1
0
mirror of https://github.com/laurent22/joplin.git synced 2025-08-30 20:39:46 +02:00

Compare commits

..

71 Commits

Author SHA1 Message Date
Laurent Cozic
6875fd271c Android 3.1.7 2024-11-04 20:28:06 +00:00
Laurent Cozic
a5c14c8d10 Desktop release v3.1.21 2024-11-04 20:21:49 +00:00
Henry Heino
ddd18551eb Mobile: Fixes #11197: Fix search result note hidden after powering on device (#11297) 2024-10-31 08:18:13 +00:00
Henry Heino
612d72d765 Desktop: Fixes #11274: Fix content dropped into the Markdown editor is missing a cursor preview or dropped at the wrong location (#11289) 2024-10-30 21:09:59 +00:00
Henry Heino
2974465882 Desktop: Resolves #11279: Remove left/right edge margin around editor content when disabled in settings (#11290) 2024-10-30 21:09:37 +00:00
Henry Heino
f7f4a50d35 Desktop: Custom CSS: Add cm-listItem class to lines with list items, don't add region start/end markers for items that are always single-line (#11291) 2024-10-30 21:09:23 +00:00
Henry Heino
f1e5ab8255 Desktop: Re-enable media with local file URLs in the note viewer (#11244) 2024-10-26 21:08:51 +01:00
Laurent Cozic
81993628ab Desktop release v3.1.20 2024-10-22 11:51:50 +01:00
Laurent Cozic
0b3f6a268e iOS 13.1.6 2024-10-17 23:17:57 +01:00
Laurent Cozic
a2069df3e0 Android 3.1.6 2024-10-17 23:14:16 +01:00
Laurent Cozic
1ad150c1bf Desktop release v3.1.19 2024-10-17 23:05:46 +01:00
Henry Heino
41b251d67a Linux: Move keychain support behind an off-by-default feature flag (#11227) 2024-10-17 22:58:03 +01:00
Henry Heino
2c40cec639 Chore: Desktop: Fix incorrect log tag (#11215) 2024-10-17 22:49:50 +01:00
Henry Heino
efb58c5f40 Desktop: Fix error screen shown on opening settings when an incompatible plugin is installed (#11223) 2024-10-17 22:49:29 +01:00
Henry Heino
9d8cd1d707 Desktop: Security: Open more target="_blank" links in a browser (#11212) 2024-10-15 16:38:33 +01:00
Henry Heino
591c458a4f Desktop: Security: Improve KaTeX error handling (#11207) 2024-10-15 16:37:15 +01:00
Laurent Cozic
f9b1a32ae7 Tools: Update script to test plugins 2024-10-14 17:52:31 +01:00
Henry Heino
1a195e23dd Desktop: Security: Improve Markdown viewer link handling (#11201) 2024-10-14 17:51:28 +01:00
Joplin Bot
26ae3f853e Doc: Auto-update documentation
Auto-updated using release-website.sh
2024-10-12 00:47:59 +00:00
Laurent Cozic
e84e9a58e1 iOS 13.1.5 2024-10-11 23:29:52 +01:00
Laurent Cozic
3b8da5023d Tools: Always run pod install 2024-10-11 23:28:45 +01:00
Laurent Cozic
548d41d0d4 lock files 2024-10-11 23:28:29 +01:00
Laurent Cozic
d6c921249f iOS 13.1.4 2024-10-11 23:21:27 +01:00
Laurent Cozic
e044c50b03 Android 3.1.5 2024-10-11 23:16:57 +01:00
Laurent Cozic
beec74d792 Desktop release v3.1.18 2024-10-11 23:05:38 +01:00
pedr
8b4e163b28 Server: Fixes #10532: Fix PostgreSQL version check failing on Windows Server because wrong regex (#11038) 2024-10-11 22:26:01 +01:00
Henry Heino
b61467097d Mobile: Fixes #11134: Fix automatic resource download mode (#11144) 2024-10-11 22:14:18 +01:00
Matthew Moore
447e4638d1 add typecript information regarding turndown-plugin-gfm (#11153)
Co-authored-by: Henry Heino <46334387+personalizedrefrigerator@users.noreply.github.com>
2024-10-11 22:14:05 +01:00
ScriptInfra
b831525b20 Update faq.md (#11169)
Co-authored-by: Henry Heino <46334387+personalizedrefrigerator@users.noreply.github.com>
Co-authored-by: Laurent Cozic <laurent22@users.noreply.github.com>
2024-10-11 22:12:05 +01:00
Henry Heino
e05be832d5 Desktop, Mobile: Downgrade CodeMirror packages to fix various Android regressions (#11170) 2024-10-11 22:08:17 +01:00
Henry Heino
64c9c3179f Chore: Update KaTeX asset files (#11172) 2024-10-11 22:08:07 +01:00
Henry Heino
0ea61f26eb Desktop: Accessibility: Fix context menu button doesn't open the note list context menu (regression) (#11175) 2024-10-11 22:07:56 +01:00
Henry Heino
349fa426ea Mobile: Fixes #11183: Fix new note/edit buttons only work if pressed quickly (#11185) 2024-10-11 22:04:29 +01:00
Henry Heino
e3d5f0c9cf Chore: Desktop: Use SCSS instead of styled-components for plugin dialogs and toolbar buttons (#11189) 2024-10-11 22:03:41 +01:00
Joplin Bot
e63d545ed8 Doc: Auto-update documentation
Auto-updated using release-website.sh
2024-10-11 18:20:26 +00:00
Laurent Cozic
ab3058612d Doc: Add sponsor 2024-10-11 19:00:43 +01:00
Laurent Cozic
715abcce32 Plugins: Add support for joplin.settings.values and deprecate joplin.settings.value 2024-10-11 18:56:04 +01:00
renovate[bot]
f165b3f870 Update dependency @types/serviceworker to v0.0.89 (#11191) 2024-10-11 03:01:01 +00:00
renovate[bot]
8895d745e7 Update dependency glob to v10.4.5 (#11192) 2024-10-11 03:01:00 +00:00
renovate[bot]
33a9b96a31 Update dependency pm2 to v5.4.2 (#11193) 2024-10-11 03:00:52 +00:00
github-actions[bot]
d1ac3d415e @moorage has signed the CLA in laurent22/joplin#11153 2024-10-05 22:34:48 +00:00
Henry Heino
432fac8fda Chore: Fix CI (#11173) 2024-10-05 12:22:44 -07:00
renovate[bot]
0f23882d47 Update dependency nodemon to v3.1.7 (#11162) 2024-10-01 02:49:36 +00:00
renovate[bot]
693c0f22c8 Update eslint (#11163) 2024-10-01 02:49:34 +00:00
renovate[bot]
e2db7a6b61 Update types (#11164) 2024-10-01 02:49:27 +00:00
renovate[bot]
2a74f60812 Update dependency katex to v0.16.11 (#11159) 2024-10-01 02:19:59 +00:00
renovate[bot]
2419291976 Update dependency nodemon to v3.1.4 (#11160) 2024-10-01 02:19:57 +00:00
renovate[bot]
733845eb95 Update eslint (#11161) 2024-10-01 02:19:50 +00:00
Joplin Bot
b3315aeb03 Doc: Auto-update documentation
Auto-updated using release-website.sh
2024-10-01 00:52:32 +00:00
renovate[bot]
d88c522d96 Update dependency @react-native/babel-preset to v0.74.85 (#11156) 2024-09-30 02:46:33 +00:00
renovate[bot]
c0cefc30f4 Update dependency @react-native/metro-config to v0.74.85 (#11157) 2024-09-30 02:17:39 +00:00
renovate[bot]
0dc3589661 Update dependency nodemon to v3.1.2 (#11140)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-09-28 16:24:50 +01:00
Henry Heino
f64c3d5484 Desktop: Remove Math Mode from the list of plugins incompatible with the new editor (#11143) 2024-09-28 16:21:59 +01:00
Henry Heino
5fceb5a3c9 Chore: Reduce mobile note screen test flakiness (#11145) 2024-09-28 16:20:46 +01:00
renovate[bot]
916b3f6f69 Update dependency rate-limiter-flexible to v5.0.3 (#11148) 2024-09-28 11:15:33 +00:00
renovate[bot]
0c4e8eeafc Update dependency react-native-document-picker to v9.3.0 (#11141)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-09-28 11:30:30 +01:00
renovate[bot]
b27e0ff1f4 Update dependency rate-limiter-flexible to v5 (#11147)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-09-28 11:29:30 +01:00
Laurent Cozic
59ffb0f265 Update renovate.json5 2024-09-28 11:29:10 +01:00
renovate[bot]
20b4fd85c1 Update dependency react-native-safe-area-context to v4.10.7 (#11138) 2024-09-27 20:52:29 +00:00
renovate[bot]
fc2da05ba6 Update dependency stream to v0.0.3 (#11139) 2024-09-27 20:52:22 +00:00
Henry Heino
948ca605b0 Mobile,Desktop: Fixes #11135: Fix incorrect list switching behavior (#11137) 2024-09-27 21:28:56 +01:00
Henry Heino
eda2c69334 Desktop: Fixes #11129: Improve performance by allowing note list background timers to be cancelled (#11133) 2024-09-27 15:25:55 +01:00
Henry Heino
42ab9ecd95 Mobile: Fixes #11130: Fix regression: Search screen not hidden when cached for search result navigation (#11131) 2024-09-27 15:23:31 +01:00
Henry Heino
5935c9c147 Chore: Mobile: Improve note screen tests and fix CI warning (#11126) 2024-09-27 15:23:02 +01:00
Joplin Bot
90640e590e Doc: Auto-update documentation
Auto-updated using release-website.sh
2024-09-26 12:25:12 +00:00
Laurent Cozic
75b8caf816 Desktop, Mobile: Plugins: Name webview root attribute so that it can be styled 2024-09-26 11:40:13 +01:00
Laurent Cozic
3ea403d004 Desktop release v3.1.17 2024-09-26 11:36:01 +01:00
Laurent Cozic
058a559de4 Desktop: Enable again auto-updates 2024-09-26 11:35:42 +01:00
Laurent Cozic
ac43c62ce8 Chore: Disable custom protocol debug logging 2024-09-26 11:35:42 +01:00
Henry Heino
c4a7749f2a Desktop: Fixes #11105: Plugin API: Save changes made with editor.setText (#11117) 2024-09-26 11:35:32 +01:00
Joplin Bot
e6c09da639 Doc: Auto-update documentation
Auto-updated using release-website.sh
2024-09-24 18:20:50 +00:00
122 changed files with 4067 additions and 2296 deletions

View File

@@ -374,6 +374,7 @@ packages/app-desktop/gui/NoteListItem/utils/useItemElement.js
packages/app-desktop/gui/NoteListItem/utils/useItemEventHandlers.js
packages/app-desktop/gui/NoteListItem/utils/useOnContextMenu.js
packages/app-desktop/gui/NoteListItem/utils/useRenderedNote.js
packages/app-desktop/gui/NoteListItem/utils/useRootElement.test.js
packages/app-desktop/gui/NoteListItem/utils/useRootElement.js
packages/app-desktop/gui/NoteListWrapper/NoteListWrapper.js
packages/app-desktop/gui/NotePropertiesDialog.js
@@ -441,7 +442,6 @@ packages/app-desktop/gui/ToggleEditorsButton/ToggleEditorsButton.js
packages/app-desktop/gui/ToggleEditorsButton/styles/index.js
packages/app-desktop/gui/ToolbarBase.js
packages/app-desktop/gui/ToolbarButton/ToolbarButton.js
packages/app-desktop/gui/ToolbarButton/styles/index.js
packages/app-desktop/gui/ToolbarSpace.js
packages/app-desktop/gui/TrashNotification/TrashNotification.js
packages/app-desktop/gui/UpdateNotification/UpdateNotification.js
@@ -475,6 +475,7 @@ packages/app-desktop/integration-tests/models/NoteList.js
packages/app-desktop/integration-tests/models/SettingsScreen.js
packages/app-desktop/integration-tests/models/Sidebar.js
packages/app-desktop/integration-tests/noteList.spec.js
packages/app-desktop/integration-tests/pluginApi.spec.js
packages/app-desktop/integration-tests/richTextEditor.spec.js
packages/app-desktop/integration-tests/settings.spec.js
packages/app-desktop/integration-tests/sidebar.spec.js
@@ -786,6 +787,7 @@ packages/app-mobile/utils/shim-init-react/injectedJs.js
packages/app-mobile/utils/shim-init-react/shimInitShared.js
packages/app-mobile/utils/testing/createMockReduxStore.js
packages/app-mobile/utils/testing/getWebViewDomById.js
packages/app-mobile/utils/testing/getWebViewWindowById.js
packages/app-mobile/utils/types.js
packages/app-mobile/web/serviceWorker.js
packages/default-plugins/build.js

4
.gitignore vendored
View File

@@ -351,6 +351,7 @@ packages/app-desktop/gui/NoteListItem/utils/useItemElement.js
packages/app-desktop/gui/NoteListItem/utils/useItemEventHandlers.js
packages/app-desktop/gui/NoteListItem/utils/useOnContextMenu.js
packages/app-desktop/gui/NoteListItem/utils/useRenderedNote.js
packages/app-desktop/gui/NoteListItem/utils/useRootElement.test.js
packages/app-desktop/gui/NoteListItem/utils/useRootElement.js
packages/app-desktop/gui/NoteListWrapper/NoteListWrapper.js
packages/app-desktop/gui/NotePropertiesDialog.js
@@ -418,7 +419,6 @@ packages/app-desktop/gui/ToggleEditorsButton/ToggleEditorsButton.js
packages/app-desktop/gui/ToggleEditorsButton/styles/index.js
packages/app-desktop/gui/ToolbarBase.js
packages/app-desktop/gui/ToolbarButton/ToolbarButton.js
packages/app-desktop/gui/ToolbarButton/styles/index.js
packages/app-desktop/gui/ToolbarSpace.js
packages/app-desktop/gui/TrashNotification/TrashNotification.js
packages/app-desktop/gui/UpdateNotification/UpdateNotification.js
@@ -452,6 +452,7 @@ packages/app-desktop/integration-tests/models/NoteList.js
packages/app-desktop/integration-tests/models/SettingsScreen.js
packages/app-desktop/integration-tests/models/Sidebar.js
packages/app-desktop/integration-tests/noteList.spec.js
packages/app-desktop/integration-tests/pluginApi.spec.js
packages/app-desktop/integration-tests/richTextEditor.spec.js
packages/app-desktop/integration-tests/settings.spec.js
packages/app-desktop/integration-tests/sidebar.spec.js
@@ -763,6 +764,7 @@ packages/app-mobile/utils/shim-init-react/injectedJs.js
packages/app-mobile/utils/shim-init-react/shimInitShared.js
packages/app-mobile/utils/testing/createMockReduxStore.js
packages/app-mobile/utils/testing/getWebViewDomById.js
packages/app-mobile/utils/testing/getWebViewWindowById.js
packages/app-mobile/utils/types.js
packages/app-mobile/web/serviceWorker.js
packages/default-plugins/build.js

File diff suppressed because one or more lines are too long

875
.yarn/releases/yarn-3.8.3.cjs vendored Executable file

File diff suppressed because one or more lines are too long

View File

@@ -6,7 +6,7 @@ plugins:
- path: .yarn/plugins/@yarnpkg/plugin-workspace-tools.cjs
spec: "@yarnpkg/plugin-workspace-tools"
yarnPath: .yarn/releases/yarn-3.6.4.cjs
yarnPath: .yarn/releases/yarn-3.8.3.cjs
logFilters:

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

View File

@@ -31,7 +31,7 @@ Please see the [donation page](https://github.com/laurent22/joplin/blob/dev/read
# Sponsors
<!-- SPONSORS-ORG -->
<a href="https://seirei.ne.jp"><img title="Serei Network" width="256" src="https://joplinapp.org/images/sponsors/SeireiNetwork.png"/></a> <a href="https://www.hosting.de/nextcloud/?mtm_campaign=managed-nextcloud&amp;mtm_kwd=joplinapp&amp;mtm_source=joplinapp-webseite&amp;mtm_medium=banner"><img title="Hosting.de" width="256" src="https://joplinapp.org/images/sponsors/HostingDe.png"/></a> <a href="https://citricsheep.com"><img title="Citric Sheep" width="256" src="https://joplinapp.org/images/sponsors/CitricSheep.png"/></a> <a href="https://sorted.travel/?utm_source=joplinapp"><img title="Sorted Travel" width="256" src="https://joplinapp.org/images/sponsors/SortedTravel.png"/></a> <a href="https://celebian.com"><img title="Celebian" width="256" src="https://joplinapp.org/images/sponsors/Celebian.png"/></a> <a href="https://bestkru.com"><img title="BestKru" width="256" src="https://joplinapp.org/images/sponsors/BestKru.png"/></a> <a href="https://www.socialfollowers.uk/buy-tiktok-followers/"><img title="Social Followers" width="256" src="https://joplinapp.org/images/sponsors/SocialFollowers.png"/></a> <a href="https://stormlikes.com/"><img title="Stormlikes" width="256" src="https://joplinapp.org/images/sponsors/Stormlikes.png"/></a> <a href="https://route4me.com"><img title="Route4Me" width="256" src="https://joplinapp.org/images/sponsors/Route4Me.png"/></a> <a href="https://buyyoutubviews.com"><img title="BYTV" width="256" src="https://joplinapp.org/images/sponsors/BYTV.png"/></a> <a href="https://www.famegear.com"><img title="Famegear" width="256" src="https://joplinapp.org/images/sponsors/Famegear.png"/></a>
<a href="https://seirei.ne.jp"><img title="Serei Network" width="256" src="https://joplinapp.org/images/sponsors/SeireiNetwork.png"/></a> <a href="https://www.hosting.de/nextcloud/?mtm_campaign=managed-nextcloud&amp;mtm_kwd=joplinapp&amp;mtm_source=joplinapp-webseite&amp;mtm_medium=banner"><img title="Hosting.de" width="256" src="https://joplinapp.org/images/sponsors/HostingDe.png"/></a> <a href="https://citricsheep.com"><img title="Citric Sheep" width="256" src="https://joplinapp.org/images/sponsors/CitricSheep.png"/></a> <a href="https://sorted.travel/?utm_source=joplinapp"><img title="Sorted Travel" width="256" src="https://joplinapp.org/images/sponsors/SortedTravel.png"/></a> <a href="https://celebian.com"><img title="Celebian" width="256" src="https://joplinapp.org/images/sponsors/Celebian.png"/></a> <a href="https://bestkru.com"><img title="BestKru" width="256" src="https://joplinapp.org/images/sponsors/BestKru.png"/></a> <a href="https://www.socialfollowers.uk/buy-tiktok-followers/"><img title="Social Followers" width="256" src="https://joplinapp.org/images/sponsors/SocialFollowers.png"/></a> <a href="https://stormlikes.com/"><img title="Stormlikes" width="256" src="https://joplinapp.org/images/sponsors/Stormlikes.png"/></a> <a href="https://route4me.com"><img title="Route4Me" width="256" src="https://joplinapp.org/images/sponsors/Route4Me.png"/></a> <a href="https://buyyoutubviews.com"><img title="BYTV" width="256" src="https://joplinapp.org/images/sponsors/BYTV.png"/></a> <a href="https://www.famegear.com"><img title="Famegear" width="256" src="https://joplinapp.org/images/sponsors/Famegear.png"/></a> <a href="https://casinoreviews.net"><img title="Casino Reviews" width="256" src="https://joplinapp.org/images/sponsors/CasinoReviews.png"/></a>
<!-- SPONSORS-ORG -->
* * *

View File

@@ -72,38 +72,38 @@
"@crowdin/cli": "3",
"@joplin/utils": "~2.12",
"@seiyab/eslint-plugin-react-hooks": "4.5.1-beta.0",
"@typescript-eslint/eslint-plugin": "6.8.0",
"@typescript-eslint/parser": "6.8.0",
"@typescript-eslint/eslint-plugin": "6.21.0",
"@typescript-eslint/parser": "6.21.0",
"cspell": "5.21.2",
"eslint": "8.52.0",
"eslint": "8.57.0",
"eslint-interactive": "10.8.0",
"eslint-plugin-import": "2.28.1",
"eslint-plugin-jest": "27.4.3",
"eslint-plugin-promise": "6.1.1",
"eslint-plugin-react": "7.33.2",
"eslint-plugin-import": "2.29.1",
"eslint-plugin-jest": "27.9.0",
"eslint-plugin-promise": "6.2.0",
"eslint-plugin-react": "7.34.3",
"execa": "5.1.1",
"fs-extra": "11.2.0",
"glob": "10.4.2",
"glob": "10.4.5",
"gulp": "4.0.2",
"husky": "3.1.0",
"lerna": "3.22.1",
"lint-staged": "15.2.7",
"madge": "6.1.0",
"npm-package-json-lint": "7.1.0",
"typescript": "5.2.2"
"typescript": "5.4.5"
},
"dependencies": {
"@types/fs-extra": "11.0.4",
"eslint-plugin-github": "4.10.1",
"eslint-plugin-github": "4.10.2",
"http-server": "14.1.1",
"node-gyp": "9.4.1",
"nodemon": "3.0.3"
"nodemon": "3.1.7"
},
"packageManager": "yarn@3.6.4",
"packageManager": "yarn@3.8.3",
"resolutions": {
"react-native-camera@4.2.1": "patch:react-native-camera@npm%3A4.2.1#./.yarn/patches/react-native-camera-npm-4.2.1-24b2600a7e.patch",
"react-native-vosk@0.1.12": "patch:react-native-vosk@npm%3A0.1.12#./.yarn/patches/react-native-vosk-npm-0.1.12-76b1caaae8.patch",
"eslint": "patch:eslint@8.52.0#./.yarn/patches/eslint-npm-8.39.0-d92bace04d.patch",
"eslint": "patch:eslint@8.57.0#./.yarn/patches/eslint-npm-8.39.0-d92bace04d.patch",
"app-builder-lib@24.4.0": "patch:app-builder-lib@npm%3A24.4.0#./.yarn/patches/app-builder-lib-npm-24.4.0-05322ff057.patch",
"nanoid": "patch:nanoid@npm%3A3.3.7#./.yarn/patches/nanoid-npm-3.3.7-98824ba130.patch",
"pdfjs-dist": "patch:pdfjs-dist@npm%3A3.11.174#./.yarn/patches/pdfjs-dist-npm-3.11.174-67f2fee6d6.patch",

View File

@@ -72,12 +72,12 @@
"devDependencies": {
"@joplin/tools": "~3.1",
"@types/fs-extra": "11.0.4",
"@types/jest": "29.5.8",
"@types/jest": "29.5.12",
"@types/node": "18.19.39",
"@types/proper-lockfile": "^4.1.2",
"gulp": "4.0.2",
"jest": "29.7.0",
"temp": "0.9.4",
"typescript": "5.2.2"
"typescript": "5.4.5"
}
}

View File

@@ -352,4 +352,12 @@ describe('MdToHtml', () => {
expect(html).toContain('Inline</span>');
expect(html).toContain('Block</span>');
});
it('should sanitize KaTeX errors', async () => {
const markdown = '$\\a<svg>$';
const renderResult = await newTestMdToHtml().render(markdown, null, { bodyOnly: true });
// Should not contain the HTML in unsanitized form
expect(renderResult.html).not.toContain('<svg>');
});
});

View File

@@ -5,7 +5,7 @@ import type ShimType from '@joplin/lib/shim';
const shim: typeof ShimType = require('@joplin/lib/shim').default;
import { isCallbackUrl } from '@joplin/lib/callbackUrlUtils';
import { BrowserWindow, Tray, screen } from 'electron';
import { BrowserWindow, Tray, WebContents, screen } from 'electron';
import bridge from './bridge';
const url = require('url');
const path = require('path');
@@ -232,14 +232,35 @@ export default class ElectronAppWrapper {
}, 3000);
}
// will-frame-navigate is fired by clicking on a link within the BrowserWindow.
this.win_.webContents.on('will-frame-navigate', event => {
// If the link changes the URL of the browser window,
if (event.isMainFrame) {
event.preventDefault();
void bridge().openExternal(event.url);
}
});
const addWindowEventHandlers = (webContents: WebContents) => {
// will-frame-navigate is fired by clicking on a link within the BrowserWindow.
webContents.on('will-frame-navigate', event => {
// If the link changes the URL of the browser window,
if (event.isMainFrame) {
event.preventDefault();
void bridge().openExternal(event.url);
}
});
// Override calls to window.open and links with target="_blank": Open most in a browser instead
// of Electron:
webContents.setWindowOpenHandler((event) => {
if (event.url === 'about:blank') {
// Script-controlled pages: Used for opening notes in new windows
return {
action: 'allow',
};
} else if (event.url.match(/^https?:\/\//)) {
void bridge().openExternal(event.url);
}
return { action: 'deny' };
});
webContents.on('did-create-window', (event) => {
addWindowEventHandlers(event.webContents);
});
};
addWindowEventHandlers(this.win_.webContents);
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
this.win_.on('close', (event: any) => {

View File

@@ -127,6 +127,12 @@ class Application extends BaseApplication {
bridge().setLocale(Setting.value('locale'));
}
if (action.type === 'SETTING_UPDATE_ONE' && action.key === 'renderer.fileUrls' || action.type === 'SETTING_UPDATE_ALL') {
bridge().electronApp().getCustomProtocolHandler().setMediaAccessEnabled(
Setting.value('renderer.fileUrls'),
);
}
if (action.type === 'SETTING_UPDATE_ONE' && action.key === 'showTrayIcon' || action.type === 'SETTING_UPDATE_ALL') {
this.updateTray();
}

View File

@@ -234,7 +234,7 @@ export default function(props: Props) {
return (
<CellFooter>
<NeedUpgradeMessage>
{PluginService.instance().describeIncompatibility(props.manifest)}
{PluginService.instance().describeIncompatibility(item.manifest)}
</NeedUpgradeMessage>
</CellFooter>
);

View File

@@ -378,6 +378,8 @@ const CodeMirror = (props: NoteBodyEditorProps, ref: ForwardedRef<NoteBodyEditor
katexEnabled: Setting.value('markdown.plugin.katex'),
themeData: {
...styles.globalTheme,
marginLeft: 0,
marginRight: 0,
monospaceFont: Setting.value('style.editor.monospaceFontFamily'),
},
automatchBraces: Setting.value('editor.autoMatchingBraces'),

View File

@@ -1,10 +1,10 @@
import { RefObject, useMemo } from 'react';
import { CommandValue } from '../../../utils/types';
import { CommandValue, DropCommandValue } from '../../../utils/types';
import { commandAttachFileToBody } from '../../../utils/resourceHandling';
import { _ } from '@joplin/lib/locale';
import dialogs from '../../../../dialogs';
import { EditorCommandType } from '@joplin/editor/types';
import { EditorCommandType, UserEventSource } from '@joplin/editor/types';
import Logger from '@joplin/utils/Logger';
import CodeMirrorControl from '@joplin/editor/CodeMirror/CodeMirrorControl';
import { MarkupLanguage } from '@joplin/renderer';
@@ -38,13 +38,22 @@ const useEditorCommands = (props: Props) => {
};
return {
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
dropItems: async (cmd: any) => {
dropItems: async (cmd: DropCommandValue) => {
let pos = cmd.pos && editorRef.current.editor.posAtCoords({ x: cmd.pos.clientX, y: cmd.pos.clientY });
if (cmd.type === 'notes') {
editorRef.current.insertText(cmd.markdownTags.join('\n'));
const text = cmd.markdownTags.join('\n');
if ((pos ?? null) !== null) {
editorRef.current.select(pos, pos);
}
editorRef.current.insertText(text, UserEventSource.Drop);
} else if (cmd.type === 'files') {
const pos = props.selectionRange.from;
const newBody = await commandAttachFileToBody(props.editorContent, cmd.paths, { createFileURL: !!cmd.createFileURL, position: pos, markupLanguage: props.contentMarkupLanguage });
pos ??= props.selectionRange.from;
const newBody = await commandAttachFileToBody(props.editorContent, cmd.paths, {
createFileURL: !!cmd.createFileURL,
position: pos,
markupLanguage: props.contentMarkupLanguage,
});
editorRef.current.updateBody(newBody);
} else {
logger.warn('CodeMirror: unsupported drop item: ', cmd);

View File

@@ -218,15 +218,6 @@ function NoteEditor(props: NoteEditorProps) {
}
}, [handleProvisionalFlag, formNote, setFormNote, isNewNote, titleHasBeenManuallyChanged, scheduleNoteListResort, scheduleSaveNote]);
useWindowCommandHandler({
dispatch: props.dispatch,
setShowLocalSearch,
noteSearchBarRef,
editorRef,
titleInputRef,
setFormNote,
});
const onDrop = useDropHandler({ editorRef });
const onBodyChange = useCallback((event: OnChangeEvent) => onFieldChange('body', event.content, event.changeId), [onFieldChange]);
@@ -234,6 +225,15 @@ function NoteEditor(props: NoteEditorProps) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
const onTitleChange = useCallback((event: any) => onFieldChange('title', event.target.value), [onFieldChange]);
useWindowCommandHandler({
dispatch: props.dispatch,
setShowLocalSearch,
noteSearchBarRef,
editorRef,
titleInputRef,
onBodyChange,
});
// const onTitleKeydown = useCallback((event:any) => {
// const keyCode = event.keyCode;

View File

@@ -5,30 +5,6 @@ import { ChangeEvent, useCallback, useRef } from 'react';
import NoteToolbar from '../../NoteToolbar/NoteToolbar';
import { buildStyle } from '@joplin/lib/theme';
import time from '@joplin/lib/time';
import styled from 'styled-components';
const StyledRoot = styled.div`
display: flex;
flex-direction: row;
align-items: center;
padding-left: ${props => props.theme.editorPaddingLeft}px;
@media (max-width: 800px) {
flex-direction: column;
align-items: flex-start;
}
`;
const InfoGroup = styled.div`
display: flex;
flex-direction: row;
align-items: center;
@media (max-width: 800px) {
border-top: 1px solid ${props => props.theme.dividerColor};
width: 100%;
}
`;
interface Props {
themeId: number;
@@ -130,7 +106,7 @@ export default function NoteTitleBar(props: Props) {
}
return (
<StyledRoot>
<div className='note-title-wrapper'>
<input
className="title-input"
type="text"
@@ -144,10 +120,10 @@ export default function NoteTitleBar(props: Props) {
onBlur={onTitleBlur}
value={props.noteTitle}
/>
<InfoGroup>
<div className='note-title-info-group'>
{renderTitleBarDate()}
{renderNoteToolbar()}
</InfoGroup>
</StyledRoot>
</div>
</div>
);
}

View File

@@ -38,7 +38,6 @@ const incompatiblePluginIds = [
'ylc395.noteLinkSystem',
'outline',
'joplin.plugin.cmoptions',
'plugin.calebjohn.MathMode',
'com.ckant.joplin-plugin-better-code-blocks',
// cSpell:enable
];

View File

@@ -1,3 +1,5 @@
@use "./styles/warning-banner.scss";
@use "./styles/warning-banner-link.scss";
@use "./styles/note-title-info-group.scss";
@use "./styles/note-title-wrapper.scss";

View File

@@ -0,0 +1,11 @@
.note-title-info-group {
display: flex;
flex-direction: row;
align-items: center;
@media (max-width: 800px) {
border-top: 1px solid var(--joplin-divider-color);
width: 100%;
}
}

View File

@@ -0,0 +1,12 @@
.note-title-wrapper {
display: flex;
flex-direction: row;
align-items: center;
padding-left: var(--joplin-editor-padding-left);
@media (max-width: 800px) {
flex-direction: column;
align-items: flex-start;
}
}

View File

@@ -252,3 +252,19 @@ export interface CommandValue {
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
value?: any; // For TinyMCE only
}
type DropCommandBase = {
pos: {
clientX: number;
clientY: number;
}|undefined;
};
export type DropCommandValue = ({
type: 'notes';
markdownTags: string[];
}|{
type: 'files';
paths: string[];
createFileURL: boolean;
}) & DropCommandBase;

View File

@@ -1,6 +1,7 @@
import { useCallback } from 'react';
import Note from '@joplin/lib/models/Note';
import { DragEvent as ReactDragEvent } from 'react';
import { DropCommandValue } from './types';
interface HookDependencies {
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
@@ -19,6 +20,11 @@ export default function useDropHandler(dependencies: HookDependencies): DropHand
const dt = event.dataTransfer;
const createFileURL = event.altKey;
const eventPosition = {
clientX: event.clientX,
clientY: event.clientY,
};
if (dt.types.indexOf('text/x-jop-note-ids') >= 0) {
const noteIds = JSON.parse(dt.getData('text/x-jop-note-ids'));
@@ -29,12 +35,15 @@ export default function useDropHandler(dependencies: HookDependencies): DropHand
noteMarkdownTags.push(Note.markdownTag(note));
}
const props: DropCommandValue = {
type: 'notes',
pos: eventPosition,
markdownTags: noteMarkdownTags,
};
editorRef.current.execCommand({
name: 'dropItems',
value: {
type: 'notes',
markdownTags: noteMarkdownTags,
},
value: props,
});
};
void dropNotes();
@@ -51,13 +60,16 @@ export default function useDropHandler(dependencies: HookDependencies): DropHand
paths.push(file.path);
}
const props: DropCommandValue = {
type: 'files',
pos: eventPosition,
paths: paths,
createFileURL: createFileURL,
};
editorRef.current.execCommand({
name: 'dropItems',
value: {
type: 'files',
paths: paths,
createFileURL: createFileURL,
},
value: props,
});
return true;
}

View File

@@ -1,5 +1,5 @@
import { RefObject, useEffect } from 'react';
import { FormNote, NoteBodyEditorRef, ScrollOptionTypes } from './types';
import { NoteBodyEditorRef, OnChangeEvent, ScrollOptionTypes } from './types';
import editorCommandDeclarations, { enabledCondition } from '../editorCommandDeclarations';
import CommandService, { CommandDeclaration, CommandRuntime, CommandContext } from '@joplin/lib/services/CommandService';
import time from '@joplin/lib/time';
@@ -12,7 +12,7 @@ const commandsWithDependencies = [
require('../commands/pasteAsText'),
];
type SetFormNoteCallback = (callback: (prev: FormNote)=> FormNote)=> void;
type OnBodyChange = (event: OnChangeEvent)=> void;
interface HookDependencies {
// eslint-disable-next-line @typescript-eslint/ban-types -- Old code before rule was applied
@@ -23,13 +23,13 @@ interface HookDependencies {
noteSearchBarRef: any;
editorRef: RefObject<NoteBodyEditorRef>;
titleInputRef: RefObject<HTMLInputElement>;
setFormNote: SetFormNoteCallback;
onBodyChange: OnBodyChange;
}
function editorCommandRuntime(
declaration: CommandDeclaration,
editorRef: RefObject<NoteBodyEditorRef>,
setFormNote: SetFormNoteCallback,
onBodyChange: OnBodyChange,
): CommandRuntime {
return {
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
@@ -55,9 +55,7 @@ function editorCommandRuntime(
value: args[0],
});
} else if (declaration.name === 'editor.setText') {
setFormNote((prev: FormNote) => {
return { ...prev, body: args[0] };
});
onBodyChange({ content: args[0], changeId: 0 });
} else {
return editorRef.current.execCommand({
name: declaration.name,
@@ -78,11 +76,11 @@ function editorCommandRuntime(
}
export default function useWindowCommandHandler(dependencies: HookDependencies) {
const { setShowLocalSearch, noteSearchBarRef, editorRef, titleInputRef, setFormNote } = dependencies;
const { setShowLocalSearch, noteSearchBarRef, editorRef, titleInputRef, onBodyChange } = dependencies;
useEffect(() => {
for (const declaration of editorCommandDeclarations) {
CommandService.instance().registerRuntime(declaration.name, editorCommandRuntime(declaration, editorRef, setFormNote));
CommandService.instance().registerRuntime(declaration.name, editorCommandRuntime(declaration, editorRef, onBodyChange));
}
const dependencies = {
@@ -105,5 +103,5 @@ export default function useWindowCommandHandler(dependencies: HookDependencies)
CommandService.instance().unregisterRuntime(command.declaration.name);
}
};
}, [editorRef, setShowLocalSearch, noteSearchBarRef, titleInputRef, setFormNote]);
}, [editorRef, setShowLocalSearch, noteSearchBarRef, titleInputRef, onBodyChange]);
}

View File

@@ -1,5 +1,5 @@
import * as React from 'react';
import { useMemo, useRef, useEffect } from 'react';
import { useMemo, useRef, useEffect, useCallback } from 'react';
import { AppState } from '../../app.reducer';
import BaseModel, { ModelType } from '@joplin/lib/BaseModel';
import { Props } from './utils/types';
@@ -275,6 +275,12 @@ const NoteList = (props: Props) => {
return output;
}, [listRenderer.flow]);
const onContainerContextMenu = useCallback((event: React.MouseEvent) => {
const isFromKeyboard = event.button === -1;
if (event.isDefaultPrevented() || !isFromKeyboard) return;
onItemContextMenu({ itemId: activeNoteId });
}, [onItemContextMenu, activeNoteId]);
return (
<div
role='listbox'
@@ -293,6 +299,7 @@ const NoteList = (props: Props) => {
onKeyDown={onKeyDown}
onKeyUp={onKeyUp}
onDrop={onDrop}
onContextMenu={onContainerContextMenu}
>
{renderEmptyList()}
{renderFiller('top', topFillerStyle)}

View File

@@ -1,3 +1,4 @@
import * as React from 'react';
import Folder from '@joplin/lib/models/Folder';
import { NoteEntity } from '@joplin/lib/services/database/types';
import { PluginStates } from '@joplin/lib/services/plugins/reducer';
@@ -6,6 +7,13 @@ import { Dispatch } from 'redux';
import bridge from '../../../services/bridge';
import NoteListUtils from '../../utils/NoteListUtils';
interface CustomContextMenuEvent {
itemId: string;
currentTarget?: undefined;
preventDefault?: undefined;
}
type ContextMenuEvent = React.MouseEvent|CustomContextMenuEvent;
const useOnContextMenu = (
selectedNoteIds: string[],
selectedFolderId: string,
@@ -15,10 +23,14 @@ const useOnContextMenu = (
plugins: PluginStates,
customCss: string,
) => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
return useCallback((event: any) => {
const currentNoteId = event.currentTarget.getAttribute('data-id');
return useCallback((event: ContextMenuEvent) => {
let currentNoteId = event.currentTarget?.getAttribute('data-id');
if ('itemId' in event) {
currentNoteId = event.itemId;
}
if (!currentNoteId) return;
event.preventDefault?.();
let noteIds = [];
if (selectedNoteIds.indexOf(currentNoteId) < 0) {

View File

@@ -0,0 +1,55 @@
import { act, renderHook } from '@testing-library/react-hooks';
import useRootElement from './useRootElement';
describe('useRootElement', () => {
beforeEach(() => {
jest.useFakeTimers({ advanceTimers: true });
});
test('should find an element with a matching ID', async () => {
const testElement = document.createElement('div');
testElement.id = 'test-element-id';
document.body.appendChild(testElement);
const { result } = renderHook(useRootElement, {
initialProps: testElement.id,
});
await act(async () => {
await jest.advanceTimersByTimeAsync(100);
});
expect(result.current).toBe(testElement);
testElement.remove();
});
test('should redo the element search when the elementId prop changes', async () => {
const testElement = document.createElement('div');
document.body.appendChild(testElement);
const { rerender, result } = renderHook(useRootElement, {
initialProps: 'some-id-here',
});
await jest.advanceTimersByTimeAsync(100);
expect(result.current).toBe(null);
// Searching for another non-existent ID: Should not match
rerender('updated-id');
await jest.advanceTimersByTimeAsync(100);
expect(result.current).toBe(null);
// Should not match the first element if its ID is set to the original (search
// should be cancelled).
testElement.id = 'some-id-here';
await jest.advanceTimersByTimeAsync(100);
expect(result.current).toBe(null);
// Should match if the element ID changes to the updated ID.
await act(async () => {
testElement.id = 'updated-id';
await jest.advanceTimersByTimeAsync(100);
});
expect(result.current).toBe(testElement);
testElement.remove();
});
});

View File

@@ -6,7 +6,7 @@ const useRootElement = (elementId: string) => {
const [rootElement, setRootElement] = useState<HTMLDivElement>(null);
useAsyncEffect(async (event) => {
const element = await waitForElement(document, elementId);
const element = await waitForElement(document, elementId, event);
if (event.cancelled) return;
setRootElement(element);
}, [document, elementId]);

View File

@@ -29,6 +29,7 @@ export default class NoteTextViewerComponent extends React.Component<Props, any>
private webviewRef_: React.RefObject<HTMLIFrameElement>;
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
private webviewListeners_: any = null;
private removePluginAssetsCallback_: RemovePluginAssetsCallback|null = null;
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
@@ -110,7 +111,7 @@ export default class NoteTextViewerComponent extends React.Component<Props, any>
window.addEventListener('message', this.webview_message);
}
public destroyWebview() {
private destroyWebview() {
const wv = this.webviewRef_.current;
if (!wv || !this.initialized_) return;
@@ -194,14 +195,13 @@ export default class NoteTextViewerComponent extends React.Component<Props, any>
}
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
public setHtml(html: string, options: SetHtmlOptions) {
const protocolHandler = bridge().electronApp().getCustomProtocolHandler();
// Grant & remove asset access.
if (options.pluginAssets) {
this.removePluginAssetsCallback_?.();
const protocolHandler = bridge().electronApp().getCustomProtocolHandler();
const pluginAssetPaths: string[] = options.pluginAssets.map((asset) => asset.path);
const assetAccesses = pluginAssetPaths.map(
path => protocolHandler.allowReadAccessToFile(path),
@@ -216,7 +216,10 @@ export default class NoteTextViewerComponent extends React.Component<Props, any>
};
}
this.send('setHtml', html, options);
this.send('setHtml', html, {
...options,
mediaAccessKey: protocolHandler.getMediaAccessKey(),
});
}
// ----------------------------------------------------------------

View File

@@ -1,6 +1,5 @@
import * as React from 'react';
import { ToolbarButtonInfo } from '@joplin/lib/services/commands/ToolbarButtonUtils';
import { StyledIconSpan, StyledIconI } from './styles';
interface Props {
readonly themeId: number;
@@ -36,8 +35,12 @@ export default function ToolbarButton(props: Props) {
let icon = null;
const iconName = getProp(props, 'iconName');
if (iconName) {
const IconClass = isFontAwesomeIcon(iconName) ? StyledIconI : StyledIconSpan;
icon = <IconClass className={iconName} aria-label='' hasTitle={!!title} role='img'/>;
const iconProps: React.HTMLProps<HTMLDivElement> = {
'aria-label': '',
role: 'img',
className: `toolbar-icon ${title ? '-has-title' : ''} ${iconName}`,
};
icon = isFontAwesomeIcon(iconName) ? <i {...iconProps} /> : <span {...iconProps} />;
}
// Keep this for legacy compatibility but for consistency we should use "disabled" prop

View File

@@ -1,19 +0,0 @@
import { ThemeStyle } from '@joplin/lib/theme';
const styled = require('styled-components').default;
const { css } = require('styled-components');
interface IconProps {
readonly theme: ThemeStyle;
readonly hasTitle: boolean;
}
const iconStyle = css<IconProps>`
font-size: ${(props: IconProps) => props.theme.toolbarIconSize}px;
color: ${(props: IconProps) => props.theme.color3};
margin-right: ${(props: IconProps) => props.hasTitle ? 5 : 0}px;
pointer-events: none; /* Need this to get button tooltip to work */
`;
export const StyledIconI = styled.i`${iconStyle}`;
export const StyledIconSpan = styled.span`${iconStyle}`;

View File

@@ -377,6 +377,20 @@
contentElement.scrollTop = scrollTop;
}
const rewriteFileUrls = (accessKey) => {
if (!accessKey) return;
// To allow accessing local files from the viewer's non-file URL, file:// URLs are re-written
// to joplin-content:// URLs:
const mediaElements = document.querySelectorAll('video[src], audio[src], source[src], img[src]');
for (const element of mediaElements) {
if (element.src?.startsWith('file:')) {
const newUrl = element.src.replace(/^file:\/\//, 'joplin-content://file-media/');
element.src = `${newUrl}?access-key=${accessKey}`;
}
}
};
ipc.setHtml = (event) => {
const html = event.html;
@@ -388,6 +402,10 @@
contentElement.innerHTML = html;
if (html.includes('file://')) {
rewriteFileUrls(event.options.mediaAccessKey);
}
scrollmap.create(event.options.markupLineCount);
if (typeof event.options.percent !== 'number') {
restorePercentScroll(); // First, a quick treatment is applied.
@@ -733,6 +751,13 @@
}));
document.addEventListener('click', webviewLib.logEnabledEventHandler(e => {
// Links should all have custom click handlers. Allowing Electron to load custom links
// can cause security issues, particularly if these links have the same domain as the
// top-level page.
if (e.target.hasAttribute('href')) {
e.preventDefault();
}
document.querySelectorAll('.media-pdf').forEach(element => {
if(!!element.contentWindow){
element.contentWindow.postMessage({

View File

@@ -0,0 +1,4 @@
.dialog-anchor-node {
display: none;
}

View File

@@ -5,4 +5,7 @@
@use './flat-button.scss';
@use './help-text.scss';
@use './toolbar-button.scss';
@use './toolbar-icon.scss';
@use './editor-toolbar.scss';
@use './user-webview-dialog-container.scss';
@use './dialog-anchor-node.scss';

View File

@@ -0,0 +1,11 @@
.toolbar-icon {
font-size: var(--joplin-toolbar-icon-size);
color: var(--joplin-color3);
margin-right: 0px;
pointer-events: none; /* Need this to get button tooltip to work */
&.-has-title {
margin-right: 5px;
}
}

View File

@@ -0,0 +1,11 @@
.user-webview-dialog-container {
display: flex;
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: 1000;
box-sizing: border-box;
}

View File

@@ -136,50 +136,55 @@ test.describe('main', () => {
expect(fullSize[0] / resizedSize[0]).toBeCloseTo(fullSize[1] / resizedSize[1]);
});
test('clicking on an external link should try to launch a browser', async ({ electronApp, mainWindow }) => {
const mainScreen = new MainScreen(mainWindow);
await mainScreen.waitFor();
for (const target of ['', '_blank']) {
test(`clicking on an external link with target=${JSON.stringify(target)} should try to launch a browser`, async ({ electronApp, mainWindow }) => {
const mainScreen = new MainScreen(mainWindow);
await mainScreen.waitFor();
// Mock openExternal
const nextExternalUrlPromise = electronApp.evaluate(({ shell }) => {
return new Promise<string>(resolve => {
const openExternal = async (url: string) => {
resolve(url);
};
shell.openExternal = openExternal;
// Mock openExternal
const nextExternalUrlPromise = electronApp.evaluate(({ shell }) => {
return new Promise<string>(resolve => {
const openExternal = async (url: string) => {
resolve(url);
};
shell.openExternal = openExternal;
});
});
// Create a test link
const testLinkTitle = 'This is a test link!';
const linkHref = 'https://joplinapp.org/';
await mainWindow.evaluate(({ testLinkTitle, linkHref, target }) => {
const testLink = document.createElement('a');
testLink.textContent = testLinkTitle;
testLink.onclick = () => {
// We need to navigate by setting location.href -- clicking on a link
// directly within the main window (i.e. not in a PDF viewer) doesn't
// navigate.
location.href = linkHref;
};
testLink.href = '#';
// Display on top of everything
testLink.style.zIndex = '99999';
testLink.style.position = 'fixed';
testLink.style.top = '0';
testLink.style.left = '0';
if (target) {
testLink.target = target;
}
document.body.appendChild(testLink);
}, { testLinkTitle, linkHref, target });
const testLink = mainWindow.getByText(testLinkTitle);
await expect(testLink).toBeVisible();
await testLink.click({ noWaitAfter: true });
expect(await nextExternalUrlPromise).toBe(linkHref);
});
// Create a test link
const testLinkTitle = 'This is a test link!';
const linkHref = 'https://joplinapp.org/';
await mainWindow.evaluate(({ testLinkTitle, linkHref }) => {
const testLink = document.createElement('a');
testLink.textContent = testLinkTitle;
testLink.onclick = () => {
// We need to navigate by setting location.href -- clicking on a link
// directly within the main window (i.e. not in a PDF viewer) doesn't
// navigate.
location.href = linkHref;
};
testLink.href = '#';
// Display on top of everything
testLink.style.zIndex = '99999';
testLink.style.position = 'fixed';
testLink.style.top = '0';
testLink.style.left = '0';
document.body.appendChild(testLink);
}, { testLinkTitle, linkHref });
const testLink = mainWindow.getByText(testLinkTitle);
await expect(testLink).toBeVisible();
await testLink.click({ noWaitAfter: true });
expect(await nextExternalUrlPromise).toBe(linkHref);
});
}
test('should start in safe mode if profile-dir/force-safe-mode-on-next-start exists', async ({ profileDirectory }) => {
await writeFile(join(profileDirectory, 'force-safe-mode-on-next-start'), 'true', 'utf8');

View File

@@ -30,4 +30,16 @@ export default class GoToAnything {
public async expectToBeOpen() {
await expect(this.containerLocator).toBeAttached();
}
public async runCommand(electronApp: ElectronApplication, command: string) {
if (!command.startsWith(':')) {
command = `:${command}`;
}
await this.open(electronApp);
await this.inputLocator.fill(command);
await this.containerLocator.locator('.match-highlight').first().waitFor();
await this.inputLocator.press('Enter');
await this.expectToBeClosed();
}
}

View File

@@ -1,5 +1,6 @@
import { Locator, Page } from '@playwright/test';
import { expect } from '../util/test';
export default class NoteEditorPage {
public readonly codeMirrorEditor: Locator;
@@ -31,6 +32,31 @@ export default class NoteEditorPage {
return this.containerLocator.getByRole('button', { name: title });
}
public async contentLocator() {
const richTextBody = this.getRichTextFrameLocator().locator('body');
const markdownEditor = this.codeMirrorEditor;
// Work around an issue where .or doesn't work with frameLocators.
// See https://github.com/microsoft/playwright/issues/27688#issuecomment-1771403495
await Promise.race([
richTextBody.waitFor({ state: 'visible' }).catch(()=>{}),
markdownEditor.waitFor({ state: 'visible' }).catch(()=>{}),
]);
if (await richTextBody.isVisible()) {
return richTextBody;
} else {
return markdownEditor;
}
}
public async expectToHaveText(content: string) {
// expect(...).toHaveText can fail in the Rich Text Editor (perhaps due to frame locators).
// Using expect.poll refreshes the locator on each attempt, which seems to prevent flakiness.
await expect.poll(
async () => (await this.contentLocator()).textContent(),
).toBe(content);
}
public getNoteViewerFrameLocator() {
// The note viewer can change content when the note re-renders. As such,
// a new locator needs to be created after re-renders (and this can't be a
@@ -38,7 +64,7 @@ export default class NoteEditorPage {
return this.noteViewerContainer.frameLocator(':scope');
}
public getTinyMCEFrameLocator() {
public getRichTextFrameLocator() {
// We use frameLocator(':scope') to convert the richTextEditor Locator into
// a FrameLocator. (:scope selects the locator itself).
// https://playwright.dev/docs/api/class-framelocator
@@ -53,4 +79,10 @@ export default class NoteEditorPage {
await this.noteTitleInput.waitFor();
await this.toggleEditorsButton.waitFor();
}
public async goBack() {
const backButton = this.toolbarButtonLocator('Back');
await expect(backButton).not.toBeDisabled();
await backButton.click();
}
}

View File

@@ -0,0 +1,32 @@
import { test } from './util/test';
import MainScreen from './models/MainScreen';
test.describe('pluginApi', () => {
for (const richTextEditor of [false, true]) {
test(`the editor.setText command should update the current note (use RTE: ${richTextEditor})`, async ({ startAppWithPlugins }) => {
const { app, mainWindow } = await startAppWithPlugins(['resources/test-plugins/execCommand.js']);
const mainScreen = new MainScreen(mainWindow);
await mainScreen.createNewNote('First note');
const editor = mainScreen.noteEditor;
await editor.focusCodeMirrorEditor();
await mainWindow.keyboard.type('This content should be overwritten.');
if (richTextEditor) {
await editor.toggleEditorsButton.click();
await editor.richTextEditor.click();
}
await mainScreen.goToAnything.runCommand(app, 'testUpdateEditorText');
await editor.expectToHaveText('PASS');
// Should still have the same text after switching notes:
await mainScreen.createNewNote('Second note');
await editor.goBack();
await editor.expectToHaveText('PASS');
});
}
});

View File

@@ -0,0 +1,31 @@
// Allows referencing the Joplin global:
/* eslint-disable no-undef */
// Allows the `joplin-manifest` block comment:
/* eslint-disable multiline-comment-style */
/* joplin-manifest:
{
"id": "org.joplinapp.plugins.example.execCommand",
"manifest_version": 1,
"app_min_version": "3.1",
"name": "JS Bundle test",
"description": "JS Bundle Test plugin",
"version": "1.0.0",
"author": "",
"homepage_url": "https://joplinapp.org"
}
*/
joplin.plugins.register({
onStart: async function() {
await joplin.commands.register({
name: 'testUpdateEditorText',
label: 'Test setting the editor\'s text with editor.setText',
iconName: 'fas fa-drum',
execute: async () => {
await joplin.commands.execute('editor.setText', 'PASS');
},
});
},
});

View File

@@ -38,7 +38,7 @@ test.describe('richTextEditor', () => {
await editor.richTextEditor.waitFor();
// Edit the note to cause the original content to update
await editor.getTinyMCEFrameLocator().locator('a').click();
await editor.getRichTextFrameLocator().locator('a').click();
await mainWindow.keyboard.type('Test...');
await editor.toggleEditorsButton.click();
@@ -70,7 +70,7 @@ test.describe('richTextEditor', () => {
// Click on the attached file URL
const openPathResult = waitForNextOpenPath(electronApp);
const targetLink = editor.getTinyMCEFrameLocator().getByRole('link', { name: basename(pathToAttach) });
const targetLink = editor.getRichTextFrameLocator().getByRole('link', { name: basename(pathToAttach) });
if (process.platform === 'darwin') {
await targetLink.click({ modifiers: ['Meta'] });
} else {

View File

@@ -6,10 +6,12 @@ import createStartupArgs from './createStartupArgs';
import firstNonDevToolsWindow from './firstNonDevToolsWindow';
type StartWithPluginsResult = { app: ElectronApplication; mainWindow: Page };
type JoplinFixtures = {
profileDirectory: string;
electronApp: ElectronApplication;
startAppWithPlugins: (pluginPaths: string[])=> Promise<StartWithPluginsResult>;
startupPluginsLoaded: Promise<void>;
mainWindow: Page;
};
@@ -17,6 +19,20 @@ type JoplinFixtures = {
// A custom fixture that loads an electron app. See
// https://playwright.dev/docs/test-fixtures
const getAndResizeMainWindow = async (electronApp: ElectronApplication) => {
const mainWindow = await firstNonDevToolsWindow(electronApp);
// Setting the viewport size helps keep test environments consistent.
await mainWindow.setViewportSize({
width: 1200,
height: 800,
});
return mainWindow;
};
const testDir = dirname(__dirname);
export const test = base.extend<JoplinFixtures>({
// Playwright fails if we don't use the object destructuring
// pattern in the first argument.
@@ -25,7 +41,7 @@ export const test = base.extend<JoplinFixtures>({
//
// eslint-disable-next-line no-empty-pattern
profileDirectory: async ({ }, use) => {
const profilePath = resolve(join(dirname(__dirname), 'test-profile'));
const profilePath = resolve(join(testDir, 'test-profile'));
const profileSubdir = join(profilePath, uuid.createNano());
await mkdirp(profileSubdir);
@@ -44,6 +60,34 @@ export const test = base.extend<JoplinFixtures>({
await electronApp.close();
},
startAppWithPlugins: async ({ profileDirectory }, use) => {
const startupArgs = createStartupArgs(profileDirectory);
let electronApp: ElectronApplication;
await use(async (pluginPaths: string[]) => {
if (electronApp) {
throw new Error('Electron app already created');
}
electronApp = await electron.launch({
args: [
...startupArgs,
'--dev-plugins',
pluginPaths.map(path => resolve(testDir, path)).join(','),
],
});
return {
app: electronApp,
mainWindow: await getAndResizeMainWindow(electronApp),
};
});
if (electronApp) {
await electronApp.firstWindow();
await electronApp.close();
}
},
startupPluginsLoaded: async ({ electronApp }, use) => {
const startupPluginsLoadedPromise = electronApp.evaluate(({ ipcMain }) => {
return new Promise<void>(resolve => {
@@ -55,15 +99,7 @@ export const test = base.extend<JoplinFixtures>({
},
mainWindow: async ({ electronApp }, use) => {
const mainWindow = await firstNonDevToolsWindow(electronApp);
// Setting the viewport size helps keep test environments consistent.
await mainWindow.setViewportSize({
width: 1200,
height: 800,
});
await use(mainWindow);
await use(await getAndResizeMainWindow(electronApp));
},
});

View File

@@ -24,9 +24,9 @@ jest.mock('@electron/remote', () => {
// Import after mocking problematic libraries
const { afterEachCleanUp, afterAllCleanUp } = require('@joplin/lib/testing/test-utils.js');
const React = require('react');
shimInit({ nodeSqlite: sqlite3 });
shimInit({ nodeSqlite: sqlite3, React });
afterEach(async () => {
await afterEachCleanUp();

View File

@@ -1,6 +1,6 @@
{
"name": "@joplin/app-desktop",
"version": "3.1.16",
"version": "3.1.21",
"description": "Joplin for Desktop",
"main": "main.js",
"private": true,
@@ -124,12 +124,12 @@
"homepage": "https://github.com/laurent22/joplin#readme",
"devDependencies": {
"7zip-bin": "5.2.0",
"@electron/rebuild": "3.3.0",
"@electron/rebuild": "3.6.0",
"@joplin/default-plugins": "~3.1",
"@joplin/tools": "~3.1",
"@playwright/test": "1.44.1",
"@testing-library/react-hooks": "8.0.1",
"@types/jest": "29.5.8",
"@types/jest": "29.5.12",
"@types/node": "18.19.39",
"@types/react": "18.3.3",
"@types/react-dom": "18.3.0",
@@ -139,19 +139,19 @@
"axios": "^1.7.7",
"electron": "29.4.5",
"electron-builder": "24.13.3",
"glob": "10.4.2",
"glob": "10.4.5",
"gulp": "4.0.2",
"jest": "29.7.0",
"jest-environment-jsdom": "29.7.0",
"js-sha512": "0.9.0",
"nan": "2.19.0",
"react-test-renderer": "18.3.1",
"ts-jest": "29.1.1",
"ts-jest": "29.1.5",
"ts-node": "10.9.2",
"typescript": "5.2.2"
"typescript": "5.4.5"
},
"dependencies": {
"@electron/notarize": "2.1.0",
"@electron/notarize": "2.3.2",
"@electron/remote": "2.1.2",
"@fortawesome/fontawesome-free": "5.15.4",
"@joeattardi/emoji-button": "4.6.4",
@@ -159,7 +159,7 @@
"@joplin/lib": "~3.1",
"@joplin/renderer": "~3.1",
"@joplin/utils": "~3.1",
"@sentry/electron": "4.17.0",
"@sentry/electron": "4.24.0",
"@types/mustache": "4.2.5",
"async-mutex": "0.5.0",
"codemirror": "5.65.9",

View File

@@ -543,11 +543,22 @@ class DialogComponent extends React.PureComponent<Props, State> {
const resultId = getResultId(item);
const isSelected = resultId === this.state.selectedItemId;
const rowStyle = isSelected ? style.rowSelected : style.row;
const wrapKeywordMatches = (unescapedContent: string) => {
return surroundKeywords(
this.state.keywords,
unescapedContent,
`<span class="match-highlight" style="font-weight: bold; color: ${theme.searchMarkerColor}; background-color: ${theme.searchMarkerBackgroundColor}">`,
'</span>',
{ escapeHtml: true },
);
};
const titleHtml = item.fragments
? `<span style="font-weight: bold; color: ${theme.color};">${item.title}</span>`
: surroundKeywords(this.state.keywords, item.title, `<span style="font-weight: bold; color: ${theme.searchMarkerColor}; background-color: ${theme.searchMarkerBackgroundColor}">`, '</span>', { escapeHtml: true });
: wrapKeywordMatches(item.title);
const fragmentsHtml = !item.fragments ? null : surroundKeywords(this.state.keywords, item.fragments, `<span style="color: ${theme.searchMarkerColor}; background-color: ${theme.searchMarkerBackgroundColor}">`, '</span>', { escapeHtml: true });
const fragmentsHtml = !item.fragments ? null : wrapKeywordMatches(item.fragments);
const folderIcon = <i style={{ fontSize: theme.fontSize, marginRight: 2 }} className="fa fa-book" role='img' aria-label={_('Notebook')} />;
const pathComp = !item.path ? null : <div style={style.rowPath}>{folderIcon} {item.path}</div>;

View File

@@ -1,5 +1,5 @@
import * as React from 'react';
import { useRef, useImperativeHandle, forwardRef, useEffect } from 'react';
import { useRef, useImperativeHandle, forwardRef, useEffect, useMemo } from 'react';
import useViewIsReady from './hooks/useViewIsReady';
import useThemeCss from './hooks/useThemeCss';
import useContentSize from './hooks/useContentSize';
@@ -8,14 +8,10 @@ import useHtmlLoader from './hooks/useHtmlLoader';
import useWebviewToPluginMessages from './hooks/useWebviewToPluginMessages';
import useScriptLoader from './hooks/useScriptLoader';
import Logger from '@joplin/utils/Logger';
import styled from 'styled-components';
import { focus } from '@joplin/lib/utils/focusHandler';
const logger = Logger.create('UserWebview');
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
type StyleProps = any;
export interface Props {
html: string;
scripts: string[];
@@ -36,15 +32,6 @@ export interface Props {
onReady?: Function;
}
const StyledFrame = styled.iframe<{ fitToContent: boolean; borderBottom: boolean }>`
padding: 0;
margin: 0;
width: ${(props: StyleProps) => props.fitToContent ? `${props.width}px` : '100%'};
height: ${(props: StyleProps) => props.fitToContent ? `${props.height}px` : '100%'};
border: none;
border-bottom: ${(props: StyleProps) => props.borderBottom ? `1px solid ${props.theme.dividerColor}` : 'none'};
`;
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
function serializeForm(form: any) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
@@ -153,15 +140,18 @@ function UserWebview(props: Props, ref: any) {
cssFilePath,
);
return <StyledFrame
const style = useMemo(() => ({
'--content-width': `${contentSize.width}px`,
'--content-height': `${contentSize.height}px`,
} as React.CSSProperties), [contentSize.width, contentSize.height]);
return <iframe
id={props.viewId}
width={contentSize.width}
height={contentSize.height}
fitToContent={props.fitToContent}
style={style}
className={`plugin-user-webview ${props.fitToContent ? '-fit-to-content' : ''} ${props.borderBottom ? '-border-bottom' : ''}`}
ref={viewRef}
src="services/plugins/UserWebviewIndex.html"
borderBottom={props.borderBottom}
></StyledFrame>;
></iframe>;
}
export default forwardRef(UserWebview);

View File

@@ -7,18 +7,12 @@ import UserWebview, { Props as UserWebviewProps } from './UserWebview';
import UserWebviewDialogButtonBar from './UserWebviewDialogButtonBar';
import { focus } from '@joplin/lib/utils/focusHandler';
import Dialog from '../../gui/Dialog';
const styled = require('styled-components').default;
interface Props extends UserWebviewProps {
buttons: ButtonSpec[];
fitToContent: boolean;
}
const UserWebViewWrapper = styled.div`
display: flex;
flex: 1;
`;
function defaultButtons(): ButtonSpec[] {
return [
{
@@ -84,7 +78,7 @@ export default function UserWebviewDialog(props: Props) {
return (
<Dialog className={`user-webview-dialog ${props.fitToContent ? '-fit' : ''}`}>
<UserWebViewWrapper>
<div className='user-dialog-wrapper'>
<UserWebview
ref={webviewRef}
html={props.html}
@@ -98,7 +92,7 @@ export default function UserWebviewDialog(props: Props) {
onDismiss={onDismiss}
onReady={onReady}
/>
</UserWebViewWrapper>
</div>
<UserWebviewDialogButtonBar buttons={buttons}/>
</Dialog>
);

View File

@@ -5,20 +5,10 @@ import { ButtonSpec } from '@joplin/lib/services/plugins/api/types';
const styled = require('styled-components').default;
const { space } = require('styled-system');
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
type StyleProps = any;
interface Props {
buttons: ButtonSpec[];
}
const StyledRoot = styled.div`
display: flex;
width: 100%;
box-sizing: border-box;
justify-content: flex-end;
padding-top: ${(props: StyleProps) => props.theme.mainPadding}px;
`;
const StyledButton = styled(Button)`${space}`;
@@ -48,8 +38,8 @@ export default function UserWebviewDialogButtonBar(props: Props) {
}
return (
<StyledRoot>
<div className='user-dialog-button-bar'>
{renderButtons()}
</StyledRoot>
</div>
);
}

View File

@@ -51,6 +51,7 @@ const webviewApi = {
docReady(() => {
const rootElement = document.createElement('div');
rootElement.setAttribute('id', 'joplin-plugin-content-root');
document.getElementsByTagName('body')[0].appendChild(rootElement);
const contentElement = document.createElement('div');

View File

@@ -0,0 +1,3 @@
@use './plugin-user-webview.scss';
@use './user-dialog-wrapper.scss';
@use './user-dialog-button-bar.scss';

View File

@@ -0,0 +1,17 @@
.plugin-user-webview {
padding: 0;
margin: 0;
border: none;
width: 100%;
height: 100%;
&.-border-bottom {
border-bottom: 1px solid var(--joplin-divider-color);
}
&.-fit-to-content {
width: var(--content-width);
height: var(--content-height);
}
}

View File

@@ -0,0 +1,8 @@
.user-dialog-button-bar {
display: flex;
width: 100%;
box-sizing: border-box;
justify-content: flex-end;
padding-top: var(--joplin-main-padding);
}

View File

@@ -0,0 +1,5 @@
.user-dialog-wrapper {
display: flex;
flex: 1;
}

View File

@@ -11,6 +11,7 @@
@use 'gui/UpdateNotification/style.scss' as update-notification;
@use 'gui/TrashNotification/style.scss' as trash-notification;
@use 'gui/Sidebar/style.scss' as sidebar-styles;
@use 'gui/styles/index.scss';
@use 'gui/NoteEditor/style.scss';
@use 'gui/NoteEditor/style.scss' as note-editor-styles;
@use 'services/plugins/styles/index.scss' as plugins-styles;
@use 'gui/styles/index.scss' as gui-styles;
@use 'main.scss' as main;

View File

@@ -16,9 +16,13 @@ if [[ $NEED_COMPILING == 1 ]]; then
echo "Copying from: $PLUGIN_PATH"
echo "To: $TEMP_PLUGIN_PATH"
rsync -a --delete "$PLUGIN_PATH/" "$TEMP_PLUGIN_PATH/"
rsync -a --exclude "cache/" --exclude "node_modules" --delete "$PLUGIN_PATH/" "$TEMP_PLUGIN_PATH/"
NODE_OPTIONS=--openssl-legacy-provider npm install --prefix="$TEMP_PLUGIN_PATH" && yarn start --dev-plugins "$TEMP_PLUGIN_PATH"
cd "$TEMP_PLUGIN_PATH/"
NODE_OPTIONS=--openssl-legacy-provider npm install
cd "$SCRIPT_DIR"
yarn start --dev-plugins "$TEMP_PLUGIN_PATH"
else
yarn start --dev-plugins "$PLUGIN_PATH"
fi

View File

@@ -42,21 +42,29 @@ const setUpProtocolHandler = () => {
return { protocolHandler, onRequestListener };
};
interface ExpectBlockedOptions {
host?: string;
}
// Although none of the paths in this test suite point to real files, file paths must be in
// a certain format on Windows to avoid invalid path exceptions.
const toPlatformPath = (path: string) => process.platform === 'win32' ? `C:/${path}` : path;
const expectPathToBeBlocked = async (onRequestListener: ProtocolHandler, filePath: string) => {
const url = `joplin-content://note-viewer/${toPlatformPath(filePath)}`;
await expect(
async () => await onRequestListener(new Request(url)),
).rejects.toThrowError('Read access not granted for URL');
const toAccessUrl = (path: string, { host = 'note-viewer' }: ExpectBlockedOptions = {}) => {
return `joplin-content://${host}/${toPlatformPath(path)}`;
};
const expectPathToBeUnblocked = async (onRequestListener: ProtocolHandler, filePath: string) => {
const expectPathToBeBlocked = async (onRequestListener: ProtocolHandler, filePath: string, options?: ExpectBlockedOptions) => {
const url = toAccessUrl(filePath, options);
await expect(
async () => await onRequestListener(new Request(url)),
).rejects.toThrow(/Read access not granted for URL|Invalid or missing media access key|Media access denied/);
};
const expectPathToBeUnblocked = async (onRequestListener: ProtocolHandler, filePath: string, options?: ExpectBlockedOptions) => {
const url = toAccessUrl(filePath, options);
const handleRequestResult = await onRequestListener(
new Request(`joplin-content://note-viewer/${toPlatformPath(filePath)}`),
new Request(url),
);
expect(handleRequestResult.body).toBeTruthy();
};
@@ -107,6 +115,34 @@ describe('handleCustomProtocols', () => {
await expectPathToBeBlocked(onRequestListener, '/test/path/a.txt');
});
test('should only allow access to file-media/ URLs when given the correct access key', async () => {
const { protocolHandler, onRequestListener } = setUpProtocolHandler();
const expectBlocked = (path: string) => {
return expectPathToBeBlocked(onRequestListener, path, { host: 'file-media' });
};
const expectUnblocked = (path: string) => {
return expectPathToBeUnblocked(onRequestListener, path, { host: 'file-media' });
};
fetchMock.mockImplementation(async (_url: string) => {
return new Response('', { headers: { 'Content-Type': 'image/jpeg' } });
});
const testPath = join(supportDir, 'photo.jpg');
await expectBlocked(testPath);
await expectBlocked(`${testPath}?access-key=wrongKey`);
await expectBlocked(`${testPath}?access-key=false`);
protocolHandler.setMediaAccessEnabled(true);
const key = protocolHandler.getMediaAccessKey();
await expectUnblocked(`${testPath}?access-key=${key}`);
await expectBlocked(`${testPath}?access-key=null`);
protocolHandler.setMediaAccessEnabled(false);
await expectBlocked(`${testPath}?access-key=${key}`);
});
test('should allow requesting part of a file', async () => {
const { protocolHandler, onRequestListener } = setUpProtocolHandler();

View File

@@ -7,10 +7,20 @@ import { LoggerWrapper } from '@joplin/utils/Logger';
import * as fs from 'fs-extra';
import { createReadStream } from 'fs';
import { fromFilename } from '@joplin/lib/mime-utils';
import { createSecureRandom } from '@joplin/lib/uuid';
export interface AccessController {
remove(): void;
}
export interface CustomProtocolHandler {
// note-viewer/ URLs
allowReadAccessToDirectory(path: string): void;
allowReadAccessToFile(path: string): { remove(): void };
allowReadAccessToFile(path: string): AccessController;
// file-media/ URLs
setMediaAccessEnabled(enabled: boolean): void;
getMediaAccessKey(): string;
}
@@ -125,8 +135,16 @@ const handleRangeRequest = async (request: Request, targetPath: string) => {
// TODO: Use Logger.create (doesn't work for now because Logger is only initialized
// in the main process.)
const handleCustomProtocols = (logger: LoggerWrapper): CustomProtocolHandler => {
logger = {
...logger,
debug: () => {},
};
// Allow-listed files/directories for joplin-content://note-viewer/
const readableDirectories: string[] = [];
const readableFiles = new Map<string, number>();
// Access for joplin-content://file-media/
let mediaAccessKey: string|false = false;
// See also the protocol.handle example: https://www.electronjs.org/docs/latest/api/protocol#protocolhandlescheme-handler
protocol.handle(contentProtocolName, async request => {
@@ -142,10 +160,9 @@ const handleCustomProtocols = (logger: LoggerWrapper): CustomProtocolHandler =>
pathname = resolve(appBundleDirectory, pathname);
const allowedHosts = ['note-viewer'];
let canRead = false;
if (allowedHosts.includes(host)) {
let mediaOnly = true;
if (host === 'note-viewer') {
if (readableFiles.has(pathname)) {
canRead = true;
} else {
@@ -156,6 +173,20 @@ const handleCustomProtocols = (logger: LoggerWrapper): CustomProtocolHandler =>
}
}
}
mediaOnly = false;
} else if (host === 'file-media') {
if (!mediaAccessKey) {
throw new Error('Media access denied. This must be enabled with .setMediaAccessEnabled');
}
canRead = true;
mediaOnly = true;
const accessKey = url.searchParams.get('access-key');
if (accessKey !== mediaAccessKey) {
throw new Error(`Invalid or missing media access key (was ${accessKey}). An allow-listed ?access-key= parameter must be provided.`);
}
} else {
throw new Error(`Invalid URL ${request.url}`);
}
@@ -168,12 +199,26 @@ const handleCustomProtocols = (logger: LoggerWrapper): CustomProtocolHandler =>
logger.debug('protocol handler: Fetch file URL', asFileUrl);
const rangeHeader = request.headers.get('Range');
let response;
if (!rangeHeader) {
const response = await net.fetch(asFileUrl);
return response;
response = await net.fetch(asFileUrl);
} else {
return handleRangeRequest(request, pathname);
response = await handleRangeRequest(request, pathname);
}
if (mediaOnly) {
// Tells the browser to avoid MIME confusion attacks. See
// https://blog.mozilla.org/security/2016/08/26/mitigating-mime-confusion-attacks-in-firefox/
response.headers.set('X-Content-Type-Options', 'nosniff');
// This is an extra check to prevent loading text/html and arbitrary non-media content from the URL.
const contentType = response.headers.get('Content-Type');
if (!contentType || !contentType.match(/^(image|video|audio)\//)) {
throw new Error(`Attempted to access non-media file from ${request.url}, which is media-only. Content type was ${contentType}.`);
}
}
return response;
});
const appBundleDirectory = dirname(dirname(__dirname));
@@ -205,6 +250,18 @@ const handleCustomProtocols = (logger: LoggerWrapper): CustomProtocolHandler =>
},
};
},
setMediaAccessEnabled: (enabled: boolean) => {
if (enabled) {
mediaAccessKey ||= createSecureRandom();
} else {
mediaAccessKey = false;
}
},
// Allows access to all local media files, provided a matching ?access-key=<key> is added
// to the request URL.
getMediaAccessKey: () => {
return mediaAccessKey || null;
},
};
};

View File

@@ -79,8 +79,8 @@ android {
applicationId "net.cozic.joplin"
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 2097752
versionName "3.1.4"
versionCode 2097755
versionName "3.1.7"
ndk {
abiFilters "armeabi-v7a", "x86", "arm64-v8a", "x86_64"
}

View File

@@ -93,7 +93,7 @@ const ExtendedWebView = (props: Props, ref: Ref<WebViewControl>) => {
}, [dom]);
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- HACK: Allow wrapper testing logic to access the DOM.
const additionalProps: any = { document: dom?.window?.document };
const additionalProps: any = { window: dom?.window };
return (
<View style={props.style} testID={props.testID} {...additionalProps}/>
);

View File

@@ -569,6 +569,7 @@ function NoteEditor(props: Props, ref: any) {
}}>
<ExtendedWebView
webviewInstanceId='NoteEditor'
testID='NoteEditor'
scrollEnabled={true}
ref={webviewRef}
html={html}

View File

@@ -139,6 +139,7 @@ const MenuComponent: React.FC<Props> = props => {
style={styles.menuContentScroller}
aria-modal={true}
accessibilityViewIsModal={true}
testID={`menu-content-${refocusCounter ? 'refocusing' : ''}`}
>{menuOptionComponents}</ScrollView>
</MenuOptions>
</Menu>

View File

@@ -100,6 +100,9 @@ const FloatingActionButton = (props: ActionButtonProps) => {
onStateChange={onMenuToggled}
actions={actions}
onPress={props.mainButton?.onPress ?? defaultOnPress}
// The long press delay is too short by default (and we don't use the long press event). See https://github.com/laurent22/joplin/issues/11183.
// Increase to a large value:
delayLongPress={10_000}
visible={true}
/>;
const mainMenu = isWeb ? (

View File

@@ -30,6 +30,7 @@ export type ThemeStyle = BaseTheme & typeof baseStyle & {
headerStyle: TextStyle;
headerWrapperStyle: ViewStyle;
rootStyle: ViewStyle;
hiddenRootStyle: ViewStyle;
keyboardAppearance: 'light'|'dark';
};
@@ -87,6 +88,11 @@ function extraStyles(theme: BaseTheme) {
backgroundColor: theme.backgroundColor,
};
const hiddenRootStyle: ViewStyle = {
...rootStyle,
flex: 0.001, // This is a bit of a hack but it seems to work fine - it makes the component invisible but without unmounting it
};
return {
marginRight: baseStyle.margin,
marginLeft: baseStyle.margin,
@@ -101,6 +107,7 @@ function extraStyles(theme: BaseTheme) {
headerStyle,
headerWrapperStyle,
rootStyle,
hiddenRootStyle,
keyboardAppearance: theme.appearance,
color5: theme.color5 ?? theme.backgroundColor4,

View File

@@ -1,13 +1,14 @@
import * as React from 'react';
import { describe, it, beforeEach } from '@jest/globals';
import { fireEvent, render, screen, userEvent, waitFor } from '@testing-library/react-native';
import { act, fireEvent, render, screen, userEvent, waitFor } from '@testing-library/react-native';
import '@testing-library/jest-native/extend-expect';
import { Provider } from 'react-redux';
import NoteScreen from './Note';
import { MenuProvider } from 'react-native-popup-menu';
import { runWithFakeTimers, setupDatabaseAndSynchronizer, switchClient, simulateReadOnlyShareEnv } from '@joplin/lib/testing/test-utils';
import { setupDatabaseAndSynchronizer, switchClient, simulateReadOnlyShareEnv, supportDir, synchronizerStart, resourceFetcher, runWithFakeTimers } from '@joplin/lib/testing/test-utils';
import { waitFor as waitForWithRealTimers } from '@joplin/lib/testing/test-utils';
import Note from '@joplin/lib/models/Note';
import { AppState } from '../../utils/types';
import { Store } from 'redux';
@@ -24,6 +25,10 @@ import { getDisplayParentId } from '@joplin/lib/services/trash';
import { itemIsReadOnlySync, ItemSlice } from '@joplin/lib/models/utils/readOnly';
import { LayoutChangeEvent } from 'react-native';
import shim from '@joplin/lib/shim';
import getWebViewWindowById from '../../utils/testing/getWebViewWindowById';
import CodeMirrorControl from '@joplin/editor/CodeMirror/CodeMirrorControl';
import Setting from '@joplin/lib/models/Setting';
import Resource from '@joplin/lib/models/Resource';
interface WrapperProps {
}
@@ -44,12 +49,29 @@ const getNoteViewerDom = async () => {
return await getWebViewDomById('NoteBodyViewer');
};
const openNewNote = async (noteProperties: NoteEntity) => {
const note = await Note.save({
parent_id: (await Folder.defaultFolder()).id,
...noteProperties,
const getNoteEditorControl = async () => {
const noteEditor = await getWebViewWindowById('NoteEditor');
const getEditorControl = () => {
if ('cm' in noteEditor.window && noteEditor.window.cm) {
return noteEditor.window.cm as CodeMirrorControl;
}
return null;
};
await waitFor(async () => {
expect(getEditorControl()).toBeTruthy();
});
return getEditorControl();
};
const waitForNoteToMatch = async (noteId: string, note: Partial<NoteEntity>) => {
await act(() => waitForWithRealTimers(async () => {
const loadedNote = await Note.load(noteId);
expect(loadedNote).toMatchObject(note);
}));
};
const openExistingNote = async (noteId: string) => {
const note = await Note.load(noteId);
const displayParentId = getDisplayParentId(note, await Folder.load(note.parent_id));
store.dispatch({
@@ -62,6 +84,17 @@ const openNewNote = async (noteProperties: NoteEntity) => {
id: note.id,
folderId: displayParentId,
});
};
const openNewNote = async (noteProperties: NoteEntity) => {
const note = await Note.save({
parent_id: (await Folder.defaultFolder()).id,
...noteProperties,
});
await openExistingNote(note.id);
await waitForNoteToMatch(note.id, { parent_id: note.parent_id, title: note.title, body: note.body });
return note.id;
};
@@ -80,11 +113,33 @@ const openNoteActionsMenu = async () => {
cursor = cursor.parent;
}
await runWithFakeTimers(() => userEvent.press(actionMenuButton));
// Wrap in act(...) -- this tells the test library that component state is intended to update (prevents
// warnings).
await act(async () => {
await runWithFakeTimers(async () => {
await userEvent.press(actionMenuButton);
});
// State can update until the menu content is marked as in the process of refocusing (part of the
// menu transition).
await waitFor(async () => {
expect(await screen.findByTestId('menu-content-refocusing')).toBeVisible();
});
});
};
describe('Note', () => {
const openEditor = async () => {
const editButton = await screen.findByLabelText('Edit');
fireEvent.press(editButton);
await waitFor(() => {
expect(screen.queryByLabelText('Edit')).toBeNull();
});
};
describe('screens/Note', () => {
beforeEach(async () => {
await setupDatabaseAndSynchronizer(1);
await setupDatabaseAndSynchronizer(0);
await switchClient(0);
@@ -113,21 +168,64 @@ describe('Note', () => {
it('changing the note title input should update the note\'s title', async () => {
const noteId = await openNewNote({ title: 'Change me!', body: 'Unchanged body' });
render(<WrappedNoteScreen />);
const titleInput = await screen.findByDisplayValue('Change me!');
// We need to use fake timers while using userEvent to avoid warnings:
await runWithFakeTimers(async () => {
const user = userEvent.setup();
await user.clear(titleInput);
await user.type(titleInput, 'New title');
});
await waitFor(async () => {
expect(await Note.load(noteId)).toMatchObject({ title: 'New title', body: 'Unchanged body' });
const user = userEvent.setup();
await user.clear(titleInput);
await user.type(titleInput, 'New title');
await waitForNoteToMatch(noteId, { title: 'New title', body: 'Unchanged body' });
// Use fake timers to allow advancing timers without pausing the test
await runWithFakeTimers(async () => {
let expectedTitle = 'New title';
for (let i = 0; i <= 10; i++) {
for (const chunk of ['!', ' test', '!!!', ' Testing']) {
jest.advanceTimersByTime(i % 5);
await user.type(titleInput, chunk);
expectedTitle += chunk;
// Don't verify after each input event -- this allows the save action queue to fill.
if (i % 4 === 0) {
await waitForNoteToMatch(noteId, { title: expectedTitle });
}
}
await waitForNoteToMatch(noteId, { title: expectedTitle });
}
});
});
it('changing the note body in the editor should update the note\'s body', async () => {
const defaultBody = 'Change me!';
const noteId = await openNewNote({ title: 'Unchanged title', body: defaultBody });
const noteScreen = render(<WrappedNoteScreen />);
await act(async () => await runWithFakeTimers(async () => {
await openEditor();
const editor = await getNoteEditorControl();
editor.select(defaultBody.length, defaultBody.length);
editor.insertText(' Testing!!!');
await waitForNoteToMatch(noteId, { body: 'Change me! Testing!!!' });
editor.insertText(' This is a test.');
await waitForNoteToMatch(noteId, { body: 'Change me! Testing!!! This is a test.' });
// should also save changes made shortly before unmounting
editor.insertText(' Test!');
// TODO: Decreasing this below 100 causes the test to fail.
// See issue #11125.
await jest.advanceTimersByTimeAsync(450);
noteScreen.unmount();
await waitForNoteToMatch(noteId, { body: 'Change me! Testing!!! This is a test. Test!' });
}));
});
it('pressing "delete" should move the note to the trash', async () => {
const noteId = await openNewNote({ title: 'To be deleted', body: '...' });
render(<WrappedNoteScreen />);
@@ -189,4 +287,48 @@ describe('Note', () => {
cleanup();
});
it.each([
'auto',
'manual',
])('should correctly auto-download or not auto-download resources in %j mode', async (downloadMode) => {
let note = await Note.save({ title: 'Note 1', parent_id: (await Folder.defaultFolder()).id });
note = await shim.attachFileToNote(note, `${supportDir}/photo.jpg`);
await synchronizerStart();
await switchClient(1);
Setting.setValue('sync.resourceDownloadMode', downloadMode);
await synchronizerStart();
// Before opening the note, the resource should not be marked for download
const allResources = await Resource.all();
expect(allResources.length).toBe(1);
const resource = allResources[0];
expect(await Resource.localState(resource)).toMatchObject({ fetch_status: Resource.FETCH_STATUS_IDLE });
await openExistingNote(note.id);
render(<WrappedNoteScreen />);
// Note should render
const titleInput = await screen.findByDisplayValue('Note 1');
expect(titleInput).toBeVisible();
// Wrap in act() -- the component may update in the background during this.
await act(async () => {
await resourceFetcher().waitForAllFinished();
// After opening the note, the resource should be marked for download only in automatic mode
if (downloadMode === 'auto') {
await waitFor(async () => {
expect(await Resource.localState(resource.id)).toMatchObject({ fetch_status: Resource.FETCH_STATUS_DONE });
});
} else if (downloadMode === 'manual') {
// In manual mode, should not mark for download
expect(await Resource.localState(resource)).toMatchObject({ fetch_status: Resource.FETCH_STATUS_IDLE });
} else {
throw new Error(`Should not be testing downloadMode: ${downloadMode}.`);
}
});
});
});

View File

@@ -493,11 +493,6 @@ class NoteScreenComponent extends BaseScreenComponent<Props, State> implements B
this.undoRedoService_ = new UndoRedoService();
this.undoRedoService_.on('stackChange', this.undoRedoService_stackChange);
if (this.state.note && this.state.note.body && Setting.value('sync.resourceDownloadMode') === 'auto') {
const resourceIds = await Note.linkedResourceIds(this.state.note.body);
await ResourceFetcher.instance().markForDownload(resourceIds);
}
// Although it is async, we don't wait for the answer so that if permission
// has already been granted, it doesn't slow down opening the note. If it hasn't
// been granted, the popup will open anyway.
@@ -509,8 +504,12 @@ class NoteScreenComponent extends BaseScreenComponent<Props, State> implements B
void ResourceFetcher.instance().markForDownload(event.resourceId);
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
public componentDidUpdate(prevProps: any, prevState: any) {
public async markAllAttachedResourcesForDownload() {
const resourceIds = await Note.linkedResourceIds(this.state.note.body);
await ResourceFetcher.instance().markForDownload(resourceIds);
}
public componentDidUpdate(prevProps: Props, prevState: State) {
if (this.doFocusUpdate_) {
this.doFocusUpdate_ = false;
this.scheduleFocusUpdate();
@@ -528,6 +527,11 @@ class NoteScreenComponent extends BaseScreenComponent<Props, State> implements B
void promptRestoreAutosave((drawingData: string) => {
void this.attachNewDrawing(drawingData);
});
// Handle automatic resource downloading
if (this.state.note?.body && Setting.value('sync.resourceDownloadMode') === 'auto') {
void this.markAllAttachedResourcesForDownload();
}
}
// Disable opening/closing the side menu with touch gestures

View File

@@ -167,6 +167,8 @@ class NotesScreenComponent extends BaseScreenComponent<Props, State> {
});
if (source === props.notesSource) return;
// For now, search refresh is handled by the search screen.
if (props.notesParentType === 'Search') return;
let notes: NoteEntity[] = [];
if (props.notesParentType === 'Folder') {
@@ -234,14 +236,7 @@ class NotesScreenComponent extends BaseScreenComponent<Props, State> {
const parent = this.parentItem();
const theme = themeStyle(this.props.themeId);
const rootStyle = {
flex: 1,
backgroundColor: theme.backgroundColor,
};
if (!this.props.visible) {
rootStyle.flex = 0.001; // This is a bit of a hack but it seems to work fine - it makes the component invisible but without unmounting it
}
const rootStyle = this.props.visible ? theme.rootStyle : theme.hiddenRootStyle;
const title = parent ? parent.title : null;
if (!parent) {

View File

@@ -10,6 +10,7 @@ import { Dispatch } from 'redux';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import IconButton from '../../IconButton';
import SearchResults from './SearchResults';
import AccessibleView from '../../accessibility/AccessibleView';
interface Props {
themeId: number;
@@ -21,7 +22,7 @@ interface Props {
ftsEnabled: number;
}
const useStyles = (theme: ThemeStyle) => {
const useStyles = (theme: ThemeStyle, visible: boolean) => {
return useMemo(() => {
return StyleSheet.create({
body: {
@@ -46,13 +47,14 @@ const useStyles = (theme: ThemeStyle) => {
paddingRight: theme.marginRight,
backgroundColor: theme.backgroundColor,
},
rootStyle: visible ? theme.rootStyle : theme.hiddenRootStyle,
});
}, [theme]);
}, [theme, visible]);
};
const SearchScreenComponent: React.FC<Props> = props => {
const theme = themeStyle(props.themeId);
const styles = useStyles(theme);
const styles = useStyles(theme, props.visible);
const [query, setQuery] = useState(props.query);
@@ -79,7 +81,7 @@ const SearchScreenComponent: React.FC<Props> = props => {
}, [props.dispatch]);
return (
<View style={theme.rootStyle}>
<AccessibleView style={styles.rootStyle} inert={!props.visible}>
<ScreenHeader
title={_('Search')}
folderPickerOptions={{
@@ -115,7 +117,7 @@ const SearchScreenComponent: React.FC<Props> = props => {
onHighlightedWordsChange={onHighlightedWordsChange}
/>
</View>
</View>
</AccessibleView>
);
};

View File

@@ -404,6 +404,7 @@
);
inputPaths = (
"${PODS_ROOT}/Target Support Files/Pods-Joplin/Pods-Joplin-resources.sh",
"${PODS_CONFIGURATION_BUILD_DIR}/RNDeviceInfo/RNDeviceInfoPrivacyInfo.bundle",
"${PODS_ROOT}/../../node_modules/react-native-vector-icons/Fonts/AntDesign.ttf",
"${PODS_ROOT}/../../node_modules/react-native-vector-icons/Fonts/Entypo.ttf",
"${PODS_ROOT}/../../node_modules/react-native-vector-icons/Fonts/EvilIcons.ttf",
@@ -427,6 +428,7 @@
);
name = "[CP] Copy Pods Resources";
outputPaths = (
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/RNDeviceInfoPrivacyInfo.bundle",
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/AntDesign.ttf",
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/Entypo.ttf",
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/EvilIcons.ttf",
@@ -503,13 +505,13 @@
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_ENTITLEMENTS = Joplin/Joplin.entitlements;
CURRENT_PROJECT_VERSION = 124;
CURRENT_PROJECT_VERSION = 127;
DEVELOPMENT_TEAM = A9BXAFS6CT;
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Joplin/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 13.4;
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
MARKETING_VERSION = 13.1.3;
MARKETING_VERSION = 13.1.6;
OTHER_LDFLAGS = (
"$(inherited)",
"-ObjC",
@@ -534,12 +536,12 @@
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_ENTITLEMENTS = Joplin/Joplin.entitlements;
CURRENT_PROJECT_VERSION = 124;
CURRENT_PROJECT_VERSION = 127;
DEVELOPMENT_TEAM = A9BXAFS6CT;
INFOPLIST_FILE = Joplin/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 13.4;
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
MARKETING_VERSION = 13.1.3;
MARKETING_VERSION = 13.1.6;
OTHER_LDFLAGS = (
"$(inherited)",
"-ObjC",
@@ -724,14 +726,14 @@
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 124;
CURRENT_PROJECT_VERSION = 127;
DEBUG_INFORMATION_FORMAT = dwarf;
DEVELOPMENT_TEAM = A9BXAFS6CT;
GCC_C_LANGUAGE_STANDARD = gnu11;
INFOPLIST_FILE = ShareExtension/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 13.4;
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @executable_path/../../Frameworks";
MARKETING_VERSION = 13.1.3;
MARKETING_VERSION = 13.1.6;
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
MTL_FAST_MATH = YES;
OTHER_LDFLAGS = (
@@ -762,14 +764,14 @@
CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements;
CODE_SIGN_STYLE = Automatic;
COPY_PHASE_STRIP = NO;
CURRENT_PROJECT_VERSION = 124;
CURRENT_PROJECT_VERSION = 127;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
DEVELOPMENT_TEAM = A9BXAFS6CT;
GCC_C_LANGUAGE_STANDARD = gnu11;
INFOPLIST_FILE = ShareExtension/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 13.4;
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @executable_path/../../Frameworks";
MARKETING_VERSION = 13.1.3;
MARKETING_VERSION = 13.1.6;
MTL_FAST_MATH = YES;
OTHER_LDFLAGS = (
"$(inherited)",

View File

@@ -949,7 +949,7 @@ PODS:
- React-Core
- react-native-camera/RN (4.2.1):
- React-Core
- react-native-document-picker (9.1.1):
- react-native-document-picker (9.3.0):
- React-Core
- react-native-fingerprint-scanner (6.0.0):
- React
@@ -997,15 +997,15 @@ PODS:
- ReactCommon/turbomodule/bridging
- ReactCommon/turbomodule/core
- Yoga
- react-native-image-resizer (3.0.9):
- react-native-image-resizer (3.0.10):
- React-Core
- react-native-netinfo (11.3.1):
- react-native-netinfo (11.3.2):
- React-Core
- react-native-rsa-native (2.0.5):
- React
- react-native-saf-x (3.1.0):
- React-Core
- react-native-safe-area-context (4.10.1):
- react-native-safe-area-context (4.10.7):
- React-Core
- react-native-slider (4.4.4):
- DoubleConversion
@@ -1032,7 +1032,7 @@ PODS:
- React-Core
- react-native-version-info (1.1.1):
- React-Core
- react-native-webview (13.8.6):
- react-native-webview (13.10.4):
- DoubleConversion
- glog
- hermes-engine
@@ -1284,13 +1284,13 @@ PODS:
- React-utils (= 0.74.1)
- rn-fetch-blob (0.12.0):
- React-Core
- RNCClipboard (1.13.2):
- RNCClipboard (1.14.1):
- React-Core
- RNCPushNotificationIOS (1.11.0):
- React-Core
- RNDateTimePicker (8.0.0):
- RNDateTimePicker (8.0.1):
- React-Core
- RNDeviceInfo (10.13.1):
- RNDeviceInfo (10.14.0):
- React-Core
- RNExitApp (2.0.0):
- React-Core
@@ -1298,13 +1298,13 @@ PODS:
- React-Core
- RNFS (2.20.0):
- React-Core
- RNLocalize (3.0.6):
- RNLocalize (3.1.0):
- React-Core
- RNQuickAction (0.3.13):
- React
- RNSecureRandom (1.0.1):
- React
- RNShare (10.0.2):
- RNShare (10.2.1):
- React-Core
- RNVectorIcons (10.1.0):
- DoubleConversion
@@ -1327,11 +1327,11 @@ PODS:
- ReactCommon/turbomodule/bridging
- ReactCommon/turbomodule/core
- Yoga
- RNZipArchive (6.1.0):
- RNZipArchive (6.1.2):
- React-Core
- RNZipArchive/Core (= 6.1.0)
- RNZipArchive/Core (= 6.1.2)
- SSZipArchive (~> 2.2)
- RNZipArchive/Core (6.1.0):
- RNZipArchive/Core (6.1.2):
- React-Core
- SSZipArchive (~> 2.2)
- SocketRocket (0.7.0)
@@ -1643,20 +1643,20 @@ SPEC CHECKSUMS:
React-Mapbuffer: 11029dcd47c5c9e057a4092ab9c2a8d10a496a33
react-native-alarm-notification: 43183613222c563c071f2c726624f9f6f06e605d
react-native-camera: 3eae183c1d111103963f3dd913b65d01aef8110f
react-native-document-picker: 3599b238843369026201d2ef466df53f77ae0452
react-native-document-picker: 5b97e24a7f1a1e4a50a72c540a043f32d29a70a2
react-native-fingerprint-scanner: ac6656f18c8e45a7459302b84da41a44ad96dbbe
react-native-geolocation: fe0562c94eb0b6334f266aea717448dfd9b08cd0
react-native-get-random-values: 21325b2244dfa6b58878f51f9aa42821e7ba3d06
react-native-image-picker: d3a65af2538ac5407e5329e50f057fb2456f15f8
react-native-image-resizer: 669454edae94399b11e49c840e4da14482302293
react-native-netinfo: bdb108d340cdb41875c9ced535977cac6d2ff321
react-native-image-resizer: fd0c333eca55147bd55c5e054cac95dcd0da6814
react-native-netinfo: 076df4f9b07f6670acf4ce9a75aac8d34c2e2ccc
react-native-rsa-native: 12132eb627797529fdb1f0d22fd0f8f9678df64a
react-native-saf-x: 7dfb7e614512c82dba2dea3401509e1c44f3d1f9
react-native-safe-area-context: dcab599c527c2d7de2d76507a523d20a0b83823d
react-native-safe-area-context: 422017db8bcabbada9ad607d010996c56713234c
react-native-slider: 03f213d3ffbf919b16298c7896c1b60101d8e137
react-native-sqlite-storage: f6d515e1c446d1e6d026aa5352908a25d4de3261
react-native-version-info: a106f23009ac0db4ee00de39574eb546682579b9
react-native-webview: 05bae3a03a1e4f59568dfc05286c0ebf8954106c
react-native-webview: 596fb33d67a3cde5a74bf1f6b4c28d3543477fdd
React-nativeconfig: b0073a590774e8b35192fead188a36d1dca23dec
React-NativeModulesApple: df46ff3e3de5b842b30b4ca8a6caae6d7c8ab09f
React-perflogger: 3d31e0d1e8ad891e43a09ac70b7b17a79773003a
@@ -1681,19 +1681,19 @@ SPEC CHECKSUMS:
React-utils: 3285151c9d1e3a28a9586571fc81d521678c196d
ReactCommon: f42444e384d82ab89184aed5d6f3142748b54768
rn-fetch-blob: f065bb7ab7fb48dd002629f8bdcb0336602d3cba
RNCClipboard: 60fed4b71560d7bfe40e9d35dea9762b024da86d
RNCClipboard: 0a720adef5ec193aa0e3de24c3977222c7e52a37
RNCPushNotificationIOS: 64218f3c776c03d7408284a819b2abfda1834bc8
RNDateTimePicker: cd42eda5f315fc320f0b359413bd598957f7e601
RNDeviceInfo: 4f9c7cfd6b9db1b05eb919620a001cf35b536423
RNDateTimePicker: b6a9b35a785ecbe12b4e7d6de5439d0aa4614146
RNDeviceInfo: 59344c19152c4b2b32283005f9737c5c64b42fba
RNExitApp: 00036cabe7bacbb413d276d5520bf74ba39afa6a
RNFileViewer: ce7ca3ac370e18554d35d6355cffd7c30437c592
RNFS: 4ac0f0ea233904cb798630b3c077808c06931688
RNLocalize: 4222a3756cdbe2dc9a5bdf445765a4d2572107cb
RNLocalize: e8694475db034bf601e17bd3dfa8986565e769eb
RNQuickAction: 6d404a869dc872cde841ad3147416a670d13fa93
RNSecureRandom: 07efbdf2cd99efe13497433668e54acd7df49fef
RNShare: 859ff710211285676b0bcedd156c12437ea1d564
RNShare: 0fad69ae2d71de9d1f7b9a43acf876886a6cb99c
RNVectorIcons: 2a2f79274248390b80684ea3c4400bd374a15c90
RNZipArchive: ef9451b849c45a29509bf44e65b788829ab07801
RNZipArchive: 6d736ee4e286dbbd9d81206b7a4da355596ca04a
SocketRocket: abac6f5de4d4d62d24e11868d7a2f427e0ef940d
SSZipArchive: fe6a26b2a54d5a0890f2567b5cc6de5caa600aef
Yoga: 348f8b538c3ed4423eb58a8e5730feec50bce372

View File

@@ -6,10 +6,10 @@
<array>
<dict>
<key>NSPrivacyAccessedAPIType</key>
<string>NSPrivacyAccessedAPICategoryFileTimestamp</string>
<string>NSPrivacyAccessedAPICategorySystemBootTime</string>
<key>NSPrivacyAccessedAPITypeReasons</key>
<array>
<string>C617.1</string>
<string>35F9.1</string>
</array>
</dict>
<dict>
@@ -22,10 +22,10 @@
</dict>
<dict>
<key>NSPrivacyAccessedAPIType</key>
<string>NSPrivacyAccessedAPICategorySystemBootTime</string>
<string>NSPrivacyAccessedAPICategoryFileTimestamp</string>
<key>NSPrivacyAccessedAPITypeReasons</key>
<array>
<string>35F9.1</string>
<string>C617.1</string>
</array>
</dict>
<dict>
@@ -33,6 +33,7 @@
<string>NSPrivacyAccessedAPICategoryDiskSpace</string>
<key>NSPrivacyAccessedAPITypeReasons</key>
<array>
<string>85F4.1</string>
<string>E174.1</string>
</array>
</dict>

View File

@@ -49,7 +49,7 @@
"react-native-camera": "4.2.1",
"react-native-device-info": "10.14.0",
"react-native-dialogbox": "0.6.10",
"react-native-document-picker": "9.2.0",
"react-native-document-picker": "9.3.0",
"react-native-dropdownalert": "5.1.0",
"react-native-exit-app": "2.0.0",
"react-native-file-viewer": "2.1.5",
@@ -63,7 +63,7 @@
"react-native-popup-menu": "0.16.1",
"react-native-quick-actions": "0.3.13",
"react-native-rsa-native": "2.0.5",
"react-native-safe-area-context": "4.10.5",
"react-native-safe-area-context": "4.10.7",
"react-native-securerandom": "1.0.1",
"react-native-share": "10.2.1",
"react-native-sqlite-storage": "6.0.1",
@@ -76,7 +76,7 @@
"react-redux": "8.1.3",
"redux": "4.2.1",
"rn-fetch-blob": "0.12.0",
"stream": "0.0.2",
"stream": "0.0.3",
"stream-browserify": "3.0.0",
"string-natural-compare": "3.0.1",
"tar-stream": "3.1.7",
@@ -90,18 +90,18 @@
"@babel/runtime": "7.24.7",
"@joplin/tools": "~3.1",
"@js-draw/material-icons": "1.20.3",
"@react-native/babel-preset": "0.74.84",
"@react-native/metro-config": "0.74.84",
"@react-native/babel-preset": "0.74.85",
"@react-native/metro-config": "0.74.85",
"@sqlite.org/sqlite-wasm": "3.46.0-build2",
"@testing-library/jest-native": "5.4.3",
"@testing-library/react-native": "12.3.3",
"@tsconfig/react-native": "2.0.2",
"@types/fs-extra": "11.0.4",
"@types/jest": "29.5.8",
"@types/jest": "29.5.12",
"@types/react": "18.3.3",
"@types/react-native": "0.70.6",
"@types/react-redux": "7.1.33",
"@types/serviceworker": "0.0.88",
"@types/serviceworker": "0.0.89",
"@types/tar-stream": "3.1.3",
"babel-jest": "29.7.0",
"babel-loader": "9.1.3",
@@ -114,7 +114,7 @@
"jetifier": "2.0.0",
"js-draw": "1.20.3",
"jsdom": "24.1.0",
"nodemon": "3.0.3",
"nodemon": "3.1.7",
"punycode": "2.3.1",
"react-dom": "18.3.1",
"react-native-web": "0.19.12",
@@ -122,10 +122,10 @@
"sharp": "0.33.4",
"sqlite3": "5.1.6",
"timers-browserify": "2.0.12",
"ts-jest": "29.1.1",
"ts-jest": "29.1.5",
"ts-loader": "9.5.1",
"ts-node": "10.9.2",
"typescript": "5.2.2",
"typescript": "5.4.5",
"uglify-js": "3.17.4",
"url-loader": "4.1.1",
"webpack": "5.84.0",

View File

@@ -1,5 +1,5 @@
module.exports = {
hash:"d7216e17c44aad8a0b1c3cf2e01a0135", files: {
hash:"addedbac5508e231800fe0f97c326075", files: {
'highlight.js/atom-one-dark-reasonable.css': { data: require('./highlight.js/atom-one-dark-reasonable.css.base64.js'), mime: 'text/css', encoding: 'base64' },
'highlight.js/atom-one-light.css': { data: require('./highlight.js/atom-one-light.css.base64.js'), mime: 'text/css', encoding: 'base64' },
'katex/fonts/KaTeX_AMS-Regular.woff2': { data: require('./katex/fonts/KaTeX_AMS-Regular.woff2.base64.js'), mime: 'application/octet-stream', encoding: 'base64' },

View File

@@ -1 +1 @@
module.exports = {"hash":"d7216e17c44aad8a0b1c3cf2e01a0135","files":["highlight.js/atom-one-dark-reasonable.css","highlight.js/atom-one-light.css","katex/fonts/KaTeX_AMS-Regular.woff2","katex/fonts/KaTeX_Caligraphic-Bold.woff2","katex/fonts/KaTeX_Caligraphic-Regular.woff2","katex/fonts/KaTeX_Fraktur-Bold.woff2","katex/fonts/KaTeX_Fraktur-Regular.woff2","katex/fonts/KaTeX_Main-Bold.woff2","katex/fonts/KaTeX_Main-BoldItalic.woff2","katex/fonts/KaTeX_Main-Italic.woff2","katex/fonts/KaTeX_Main-Regular.woff2","katex/fonts/KaTeX_Math-BoldItalic.woff2","katex/fonts/KaTeX_Math-Italic.woff2","katex/fonts/KaTeX_SansSerif-Bold.woff2","katex/fonts/KaTeX_SansSerif-Italic.woff2","katex/fonts/KaTeX_SansSerif-Regular.woff2","katex/fonts/KaTeX_Script-Regular.woff2","katex/fonts/KaTeX_Size1-Regular.woff2","katex/fonts/KaTeX_Size2-Regular.woff2","katex/fonts/KaTeX_Size3-Regular.woff2","katex/fonts/KaTeX_Size4-Regular.woff2","katex/fonts/KaTeX_Typewriter-Regular.woff2","katex/katex.css","mermaid/mermaid.min.js","mermaid/mermaid_render.js"]}
module.exports = {"hash":"addedbac5508e231800fe0f97c326075","files":["highlight.js/atom-one-dark-reasonable.css","highlight.js/atom-one-light.css","katex/fonts/KaTeX_AMS-Regular.woff2","katex/fonts/KaTeX_Caligraphic-Bold.woff2","katex/fonts/KaTeX_Caligraphic-Regular.woff2","katex/fonts/KaTeX_Fraktur-Bold.woff2","katex/fonts/KaTeX_Fraktur-Regular.woff2","katex/fonts/KaTeX_Main-Bold.woff2","katex/fonts/KaTeX_Main-BoldItalic.woff2","katex/fonts/KaTeX_Main-Italic.woff2","katex/fonts/KaTeX_Main-Regular.woff2","katex/fonts/KaTeX_Math-BoldItalic.woff2","katex/fonts/KaTeX_Math-Italic.woff2","katex/fonts/KaTeX_SansSerif-Bold.woff2","katex/fonts/KaTeX_SansSerif-Italic.woff2","katex/fonts/KaTeX_SansSerif-Regular.woff2","katex/fonts/KaTeX_Script-Regular.woff2","katex/fonts/KaTeX_Size1-Regular.woff2","katex/fonts/KaTeX_Size2-Regular.woff2","katex/fonts/KaTeX_Size3-Regular.woff2","katex/fonts/KaTeX_Size4-Regular.woff2","katex/fonts/KaTeX_Typewriter-Regular.woff2","katex/katex.css","mermaid/mermaid.min.js","mermaid/mermaid_render.js"]}

File diff suppressed because one or more lines are too long

View File

@@ -308,6 +308,10 @@ const appReducer = (state = appDefaultState, action: any) => {
newState.selectedNoteHash = '';
if (action.routeName === 'Search') {
newState.notesParentType = 'Search';
}
if ('noteId' in action) {
newState.selectedNoteIds = action.noteId ? [action.noteId] : [];
}
@@ -344,6 +348,8 @@ const appReducer = (state = appDefaultState, action: any) => {
newState.route = action;
newState.historyCanGoBack = !!navHistory.length;
logger.debug('Navigated to route:', newState.route?.routeName, 'with notesParentType:', newState.notesParentType);
}
break;

View File

@@ -3,15 +3,18 @@ const execa = require('execa');
module.exports = async function() {
if (process.platform !== 'darwin') return Promise.resolve();
if (!process.env.RUN_POD_INSTALL) {
// We almost never need to run `pod install` either because it has
// already been done, or because we are not building the iOS app, yet
// it's taking most of the build time (3 min out of the 5 min needed to
// build the entire monorepo). If it needs to be ran, XCode will tell us
// anyway.
console.warn('**Not** running `pod install` - set `RUN_POD_INSTALL` to `1` to do so');
return Promise.resolve();
}
// 2024-10-11: Seems running `pod install` is not so slow anymore, and at least not the
// bottleneck when running `yarn install` so we should run it every time.
// if (!process.env.RUN_POD_INSTALL) {
// // We almost never need to run `pod install` either because it has
// // already been done, or because we are not building the iOS app, yet
// // it's taking most of the build time (3 min out of the 5 min needed to
// // build the entire monorepo). If it needs to be ran, XCode will tell us
// // anyway.
// console.warn('**Not** running `pod install` - set `RUN_POD_INSTALL` to `1` to do so');
// return Promise.resolve();
// }
try {
const promise = execa('pod', ['install'], { cwd: `${__dirname}/../ios` });

View File

@@ -1,15 +1,7 @@
import { screen, waitFor } from '@testing-library/react-native';
import getWebViewWindowById from './getWebViewWindowById';
const getWebViewDomById = async (id: string): Promise<Document> => {
const webviewContent = await screen.findByTestId(id);
expect(webviewContent).toBeVisible();
await waitFor(() => {
expect(!!webviewContent.props.document).toBe(true);
});
// Return the composite ExtendedWebView component
// See https://callstack.github.io/react-native-testing-library/docs/advanced/testing-env#tree-navigation
return webviewContent.props.document;
return (await getWebViewWindowById(id)).document;
};
export default getWebViewDomById;

View File

@@ -0,0 +1,15 @@
import { screen, waitFor } from '@testing-library/react-native';
const getWebViewWindowById = async (id: string): Promise<Window> => {
const webviewContent = await screen.findByTestId(id);
expect(webviewContent).toBeVisible();
await waitFor(() => {
expect(!!webviewContent.props.window).toBe(true);
});
const webviewWindow = webviewContent.props.window;
return webviewWindow;
};
export default getWebViewWindowById;

View File

@@ -15,7 +15,7 @@
"devDependencies": {
"@types/yargs": "17.0.32",
"ts-node": "10.9.2",
"typescript": "5.2.2"
"typescript": "5.4.5"
},
"dependencies": {
"@joplin/utils": "~3.1",

View File

@@ -6,6 +6,7 @@ import { classHighlighter } from '@lezer/highlight';
import {
EditorView, drawSelection, highlightSpecialChars, ViewUpdate, Command, rectangularSelection,
dropCursor,
} from '@codemirror/view';
import { history, undoDepth, redoDepth, standardKeymap } from '@codemirror/commands';
@@ -253,6 +254,8 @@ const createEditor = (
// Apply styles to entire lines (block-display decorations)
decoratorExtension,
dropCursor(),
biDirectionalTextExtension,
// Adds additional CSS classes to tokens (the default CSS classes are

View File

@@ -84,6 +84,18 @@ const tableDelimiterDecoration = Decoration.line({
attributes: { class: 'cm-tableDelimiter' },
});
const orderedListDecoration = Decoration.line({
attributes: { class: 'cm-orderedList' },
});
const unorderedListDecoration = Decoration.line({
attributes: { class: 'cm-unorderedList' },
});
const listItemDecoration = Decoration.line({
attributes: { class: 'cm-listItem' },
});
const horizontalRuleDecoration = Decoration.mark({
attributes: { class: 'cm-hr' },
});
@@ -97,6 +109,10 @@ const nodeNameToLineDecoration: Record<string, Decoration> = {
'CodeBlock': codeBlockDecoration,
'BlockMath': mathBlockDecoration,
'Blockquote': blockQuoteDecoration,
'OrderedList': orderedListDecoration,
'BulletList': unorderedListDecoration,
'ListItem': listItemDecoration,
'SetextHeading1': header1LineDecoration,
'ATXHeading1': header1LineDecoration,
@@ -122,6 +138,14 @@ const nodeNameToMarkDecoration: Record<string, Decoration> = {
'TaskMarker': taskMarkerDecoration,
};
const multilineNodes = {
'FencedCode': true,
'CodeBlock': true,
'BlockMath': true,
'Blockquote': true,
'OrderedList': true,
'BulletList': true,
};
type DecorationDescription = { pos: number; length: number; decoration: Decoration };
@@ -179,8 +203,8 @@ const computeDecorations = (view: EditorView) => {
addDecorationToRange(viewFrom, viewTo, decoration);
}
// Only block decorations will have differing first and last lines
if (blockDecorated) {
// Only certain block decorations will have differing first and last lines
if (blockDecorated && multilineNodes.hasOwnProperty(node.name)) {
// Allow different styles for the first, last lines in a block.
if (viewFrom === node.from) {
addDecorationToLines(viewFrom, viewFrom, regionStartDecoration);

View File

@@ -232,4 +232,19 @@ describe('markdownCommands.toggleList', () => {
);
expect(editor.state.selection.main.from).toBe(preSubListText.length);
});
it('should not treat a list of IP addresses as a numbered list', async () => {
const initialDocText = '192.168.1.1. This\n127.0.0.1. is\n0.0.0.0. a list';
const editor = await createTestEditor(
initialDocText,
EditorSelection.range(0, initialDocText.length),
[],
);
toggleList(ListType.UnorderedList)(editor);
expect(editor.state.doc.toString()).toBe(
'- 192.168.1.1. This\n- 127.0.0.1. is\n- 0.0.0.0. a list',
);
});
});

View File

@@ -132,9 +132,9 @@ export const toggleList = (listType: ListType): Command => {
// RegExps for different list types. The regular expressions MUST
// be mutually exclusive.
// `(?!\[[ xX]+\])` means "not followed by [x] or [ ]".
const bulletedRegex = /^\s*([-*])\s(?!\[[ xX]+\])/;
const checklistRegex = /^\s*[-*]\s\[[ xX]+\]\s?/;
const numberedRegex = /^\s*\d+\.\s?/;
const bulletedRegex = /^\s*([-*])\s(?!\[[ xX]+\]\s)/;
const checklistRegex = /^\s*[-*]\s\[[ xX]+\]\s/;
const numberedRegex = /^\s*\d+\.\s/;
const listRegexes: Record<ListType, RegExp> = {
[ListType.OrderedList]: numberedRegex,

View File

@@ -79,6 +79,10 @@ const createTheme = (theme: EditorTheme): Extension[] => {
// be at least this specific.
const selectionBackgroundSelector = '&.cm-focused > .cm-scroller > .cm-selectionLayer .cm-selectionBackground';
// Matches the editor only when there are no gutters (e.g. line numbers) added by
// plugins
const editorNoGuttersSelector = '&:not(:has(> .cm-scroller > .cm-gutters))';
const baseHeadingStyle = {
fontWeight: 'bold',
fontFamily: theme.fontFamily,
@@ -180,6 +184,12 @@ const createTheme = (theme: EditorTheme): Extension[] => {
marginRight: 'auto',
} : undefined,
// Allows editor content to be left-aligned with the toolbar on desktop.
// See https://github.com/laurent22/joplin/issues/11279
[`${editorNoGuttersSelector} .cm-line`]: theme.isDesktop ? {
paddingLeft: 0,
} : undefined,
// Override the default URL style when the URL is within a link
'& .tok-url.tok-link, & .tok-link.tok-meta, & .tok-link.tok-string': {
opacity: 0.6,

View File

@@ -16,30 +16,30 @@
"devDependencies": {
"@joplin/lib": "~3.1",
"@testing-library/react-hooks": "8.0.1",
"@types/jest": "29.5.8",
"@types/jest": "29.5.12",
"@types/react": "18.3.3",
"@types/react-redux": "7.1.33",
"@types/styled-components": "5.1.32",
"jest": "29.7.0",
"jest-environment-jsdom": "29.7.0",
"ts-jest": "29.1.1",
"typescript": "5.2.2"
"ts-jest": "29.1.5",
"typescript": "5.4.5"
},
"dependencies": {
"@codemirror/autocomplete": "6.18.0",
"@codemirror/commands": "6.6.1",
"@codemirror/lang-html": "6.4.9",
"@codemirror/lang-markdown": "6.2.5",
"@codemirror/language": "6.10.2",
"@codemirror/autocomplete": "6.13.0",
"@codemirror/commands": "6.3.3",
"@codemirror/lang-html": "6.4.8",
"@codemirror/lang-markdown": "6.2.4",
"@codemirror/language": "6.10.1",
"@codemirror/language-data": "6.3.1",
"@codemirror/legacy-modes": "6.4.1",
"@codemirror/lint": "6.8.1",
"@codemirror/legacy-modes": "6.3.3",
"@codemirror/lint": "6.5.0",
"@codemirror/search": "6.5.6",
"@codemirror/state": "6.4.1",
"@codemirror/view": "6.33.0",
"@codemirror/view": "6.26.3",
"@lezer/common": "1.2.1",
"@lezer/highlight": "1.2.1",
"@lezer/markdown": "1.3.1",
"@lezer/highlight": "1.2.0",
"@lezer/markdown": "1.2.0",
"@replit/codemirror-vim": "6.2.0"
}
}

View File

@@ -90,6 +90,7 @@ export interface ContentScriptData {
// Intended to correspond with https://codemirror.net/docs/ref/#state.Transaction%5EuserEvent
export enum UserEventSource {
Paste = 'input.paste',
Drop = 'input.drop',
}
export interface EditorControl {

View File

@@ -45,16 +45,16 @@
"entities": "2.2.0"
},
"devDependencies": {
"@types/jest": "29.5.8",
"@types/jest": "29.5.12",
"@types/node": "18.19.39",
"@typescript-eslint/eslint-plugin": "6.8.0",
"@typescript-eslint/parser": "6.8.0",
"@typescript-eslint/eslint-plugin": "6.21.0",
"@typescript-eslint/parser": "6.21.0",
"coveralls": "3.1.1",
"eslint": "8.52.0",
"eslint": "8.57.0",
"jest": "29.7.0",
"prettier": "3.0.3",
"ts-jest": "29.1.1",
"typescript": "5.2.2"
"prettier": "3.3.2",
"ts-jest": "29.1.5",
"typescript": "5.4.5"
},
"jest": {
"preset": "ts-jest",

View File

@@ -7,13 +7,15 @@ export const isInsideContainer = (node: any, className: string): boolean => {
return false;
};
interface CancelEvent { cancelled: boolean }
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
export const waitForElement = async (parent: any, id: string): Promise<any> => {
export const waitForElement = async (parent: any, id: string, cancelEvent?: CancelEvent): Promise<any> => {
return new Promise((resolve, reject) => {
const iid = setInterval(() => {
try {
const element = parent.getElementById(id);
if (element) {
if (element || cancelEvent?.cancelled) {
clearInterval(iid);
resolve(element);
}

View File

@@ -926,6 +926,18 @@ const builtInMetadata = (Setting: typeof SettingType) => {
'markdown.plugin.insert': { storage: SettingStorage.File, isGlobal: true, value: false, type: SettingItemType.Bool, section: 'markdownPlugins', public: true, appTypes: [AppType.Mobile, AppType.Desktop], label: () => `${_('Enable ++insert++ syntax')}${wysiwygYes}` },
'markdown.plugin.multitable': { storage: SettingStorage.File, isGlobal: true, value: false, type: SettingItemType.Bool, section: 'markdownPlugins', public: true, appTypes: [AppType.Mobile, AppType.Desktop], label: () => `${_('Enable multimarkdown table extension')}${wysiwygNo}` },
// For now, applies only to the Markdown viewer
'renderer.fileUrls': {
storage: SettingStorage.File,
isGlobal: true,
value: false,
type: SettingItemType.Bool,
section: 'markdownPlugins',
public: true,
appTypes: [AppType.Desktop],
label: () => `${_('Enable file:// URLs for images and videos')}${wysiwygYes}`,
},
// Tray icon (called AppIndicator) doesn't work in Ubuntu
// http://www.webupd8.org/2017/04/fix-appindicator-not-working-for.html
// Might be fixed in Electron 18.x but no non-beta release yet. So for now
@@ -1127,7 +1139,7 @@ const builtInMetadata = (Setting: typeof SettingType) => {
},
autoUpdateEnabled: { value: false, type: SettingItemType.Bool, storage: SettingStorage.File, isGlobal: true, section: 'application', public: false, appTypes: [AppType.Desktop], label: () => _('Automatically check for updates') },
autoUpdateEnabled: { value: true, type: SettingItemType.Bool, storage: SettingStorage.File, isGlobal: true, section: 'application', public: platform !== 'linux', appTypes: [AppType.Desktop], label: () => _('Automatically check for updates') },
'autoUpdate.includePreReleases': { value: false, type: SettingItemType.Bool, section: 'application', storage: SettingStorage.File, isGlobal: true, public: true, appTypes: [AppType.Desktop], label: () => _('Get pre-releases when checking for updates'), description: () => _('See the pre-release page for more details: %s', 'https://joplinapp.org/help/about/prereleases') },
'autoUploadCrashDumps': {
@@ -1576,6 +1588,20 @@ const builtInMetadata = (Setting: typeof SettingType) => {
isGlobal: true,
},
'featureFlag.linuxKeychain': {
value: false,
type: SettingItemType.Bool,
public: true,
storage: SettingStorage.File,
appTypes: [AppType.Desktop],
label: () => 'Enable keychain support',
description: () => 'This is an experimental setting to enable keychain support on Linux',
show: () => shim.isLinux(),
section: 'general',
isGlobal: true,
advanced: true,
},
// 'featureFlag.syncAccurateTimestamps': {
// value: false,

View File

@@ -18,7 +18,7 @@
"devDependencies": {
"@testing-library/react-hooks": "8.0.1",
"@types/fs-extra": "11.0.4",
"@types/jest": "29.5.8",
"@types/jest": "29.5.12",
"@types/js-yaml": "4.0.9",
"@types/markdown-it": "13.0.8",
"@types/mustache": "4.2.5",
@@ -34,7 +34,7 @@
"react-test-renderer": "18.3.1",
"sharp": "0.33.4",
"tesseract.js": "5.1.0",
"typescript": "5.2.2"
"typescript": "5.4.5"
},
"dependencies": {
"@adobe/css-tools": "4.3.3",

View File

@@ -34,6 +34,13 @@ export async function loadKeychainServiceAndSettings(keychainServiceDrivers: Key
Setting.setKeychainService(KeychainService.instance());
await Setting.load();
// Using Linux with the keychain has been observed to cause all secure settings to be lost
// on Fedora 40 + GNOME. (This may have been related to running multiple Joplin instances).
// For now, make saving to the keychain opt-in until more feedback is received.
if (shim.isLinux() && !Setting.value('featureFlag.linuxKeychain')) {
KeychainService.instance().readOnly = true;
}
// This is part of the migration to the new sync target info. It needs to be
// set as early as possible since it's used to tell if E2EE is enabled, it
// contains the master keys, etc. Once it has been set, it becomes a noop

View File

@@ -4,7 +4,7 @@ import Logger from '@joplin/utils/Logger';
import KvStore from '../KvStore';
import Setting from '../../models/Setting';
const logger = Logger.create('KeychainServiceDriver.node');
const logger = Logger.create('KeychainServiceDriver.electron');
const canUseSafeStorage = () => {
return !!shim.electronBridge?.()?.safeStorage?.isEncryptionAvailable();

View File

@@ -147,6 +147,20 @@ export default class JoplinSettings {
}
/**
* Gets setting values (only applies to setting you registered from your plugin)
*/
public async values(keys: string[] | string): Promise<Record<string, unknown>> {
if (typeof keys === 'string') keys = [keys];
const output: Record<string, unknown> = {};
for (const key of keys) {
output[key] = Setting.value(getPluginNamespacedSettingKey(this.plugin_.id, key));
}
return output;
}
/**
* @deprecated Use joplin.settings.values()
*
* Gets a setting value (only applies to setting you registered from your plugin)
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied

View File

@@ -286,6 +286,7 @@ async function switchClient(id: number, options: any = null) {
BaseItem.encryptionService_ = encryptionServices_[id];
Resource.encryptionService_ = encryptionServices_[id];
BaseItem.revisionService_ = revisionServices_[id];
ResourceFetcher.instance_ = resourceFetchers_[id];
await Setting.reset();
Setting.settingFilename = settingFilename(id);
@@ -1092,12 +1093,41 @@ export const mockMobilePlatform = (platform: string) => {
};
};
// Waits for callback to not throw. Similar to react-native-testing-library's waitFor, but works better
// with Joplin's mix of real and fake Jest timers.
const realSetTimeout = setTimeout;
export const waitFor = async (callback: ()=> Promise<void>) => {
const timeout = 10_000;
const startTime = performance.now();
let passed = false;
let lastError: Error|null = null;
while (!passed && performance.now() - startTime < timeout) {
try {
await callback();
passed = true;
lastError = null;
} catch (error) {
lastError = error;
await new Promise<void>(resolve => {
realSetTimeout(() => resolve(), 10);
});
}
}
if (lastError) {
throw lastError;
}
};
export const runWithFakeTimers = async (callback: ()=> Promise<void>) => {
if (typeof jest === 'undefined') {
throw new Error('Fake timers are only supported in jest.');
}
jest.useFakeTimers();
// advanceTimers: Needed by Joplin's database driver
jest.useFakeTimers({ advanceTimers: true });
// The shim.setTimeout and similar functions need to be changed to
// use fake timers.

View File

@@ -2,7 +2,7 @@ import replaceUnsupportedCharacters from './replaceUnsupportedCharacters';
describe('replaceUnsupportedCharacters', () => {
test('should replace NULL characters', () => {
expect(replaceUnsupportedCharacters('Test\x00...')).toBe('Test...');
expect(replaceUnsupportedCharacters('\x00Test\x00...')).toBe('�Test�...');
expect(replaceUnsupportedCharacters('Test\x00...')).toBe('Test\uFFFD...');
expect(replaceUnsupportedCharacters('\x00Test\x00...')).toBe('\uFFFDTest\uFFFD...');
});
});

View File

@@ -1,9 +1,13 @@
import Logger from '@joplin/utils/Logger';
import { _ } from './locale';
import Setting from './models/Setting';
import { reg } from './registry';
import KeychainService from './services/keychain/KeychainService';
import { Plugins } from './services/plugins/PluginService';
import shim from './shim';
const logger = Logger.create('versionInfo');
export interface PackageInfo {
name: string;
version: string;
@@ -70,15 +74,21 @@ export default function versionInfo(packageInfo: PackageInfo, plugins: Plugins)
copyrightText.replace('YYYY', `${now.getFullYear()}`),
];
let keychainSupported = false;
try {
// To allow old keys to be read, certain apps allow read-only keychain access:
keychainSupported = Setting.value('keychain.supported') >= 1 && !KeychainService.instance().readOnly;
} catch (error) {
logger.error('Failed to determine if keychain is supported', error);
}
const body = [
_('%s %s (%s, %s)', p.name, p.version, Setting.value('env'), shim.platformName()),
'',
_('Client ID: %s', Setting.value('clientId')),
_('Sync Version: %s', Setting.value('syncVersion')),
_('Profile Version: %s', reg.db().version()),
// The portable app temporarily supports read-only keychain access (but disallows
// write).
_('Keychain Supported: %s', (Setting.value('keychain.supported') >= 1 && !shim.isPortable()) ? _('Yes') : _('No')),
_('Keychain Supported: %s', keychainSupported ? _('Yes') : _('No')),
];
if (gitInfo) {

View File

@@ -19,7 +19,7 @@
"author": "Joplin",
"license": "AGPL-3.0-or-later",
"devDependencies": {
"@types/jest": "29.5.8",
"@types/jest": "29.5.12",
"@types/pdfjs-dist": "2.10.378",
"@types/react": "18.3.3",
"@types/react-dom": "18.3.0",
@@ -29,9 +29,9 @@
"jest": "29.7.0",
"jest-environment-jsdom": "29.7.0",
"style-loader": "3.3.4",
"ts-jest": "29.1.1",
"ts-jest": "29.1.5",
"ts-loader": "9.5.1",
"typescript": "5.2.2",
"typescript": "5.4.5",
"webpack": "5.74.0",
"webpack-cli": "4.10.0"
},

View File

@@ -29,11 +29,11 @@
},
"devDependencies": {
"@types/fs-extra": "11.0.4",
"@types/jest": "29.5.8",
"@types/jest": "29.5.12",
"@types/node": "18.19.39",
"jest": "29.7.0",
"source-map-loader": "4.0.2",
"typescript": "5.2.2",
"typescript": "5.4.5",
"webpack": "5.65.0",
"webpack-cli": "4.10.0"
},

View File

@@ -48,7 +48,7 @@
"@types/react-native": "0.64.19",
"react": "18.3.1",
"react-native": "0.70.6",
"typescript": "5.2.2"
"typescript": "5.4.5"
},
"peerDependencies": {
"react": "*",

Some files were not shown because too many files have changed in this diff Show More