1
0
mirror of https://github.com/laurent22/joplin.git synced 2026-01-11 00:21:45 +02:00

Compare commits

...

117 Commits

Author SHA1 Message Date
Laurent Cozic
b451a1a3ed Android 3.5.1 2025-11-29 12:44:33 +00:00
Laurent Cozic
4afac412ce Desktop release v3.5.9 2025-11-29 12:27:27 +00:00
Laurent Cozic
b79bf11680 Chore: Fixed version patching 2025-11-29 12:25:58 +00:00
Laurent Cozic
10d727f183 Desktop release 3.5.8 2025-11-29 12:14:40 +00:00
Laurent Cozic
50e2dc7749 Chore: Replaced npm version patch by yarn version patch
`npm version patch` now seems to run `npm install` too and messes up the repository
2025-11-29 12:14:05 +00:00
Laurent Cozic
5108fe5b24 Chore: lock files 2025-11-29 11:41:19 +00:00
Laurent Cozic
3536a68cfe Chore: lock files 2025-11-29 10:58:53 +00:00
Henry Heino
d94d057f1d Desktop: Plugins: Add an "importFrom" command to allow importing notes and notebooks (#13534) 2025-11-29 10:53:58 +00:00
mrjo118
8ec11bddc2 Mobile: Extend notebook selection dropdowns when the dropdown is opened (#13726) 2025-11-29 10:51:56 +00:00
mrjo118
4813c79b35 Mobile: Add the ability to search on the tag list screen (#13733) 2025-11-29 10:49:39 +00:00
renovate[bot]
7778a68764 Update dependency git to v2.50.0 (#13759)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-11-29 10:42:37 +00:00
renovate[bot]
503e748ca8 Update dependency react-native-vector-icons to v10.3.0 (#13760)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-11-29 10:42:26 +00:00
Henry Heino
b6297b609e Chore: Refactor: Make custom MultiTouchableOpacity component closer to a drop-in-replacement for TouchableOpacity (#13762) 2025-11-29 10:28:05 +00:00
Henry Heino
31d37b30b0 Docs: Fix lower half of Markdown documentation is marked as a code block (#13766) 2025-11-29 10:26:56 +00:00
Henry Heino
0ccd7e474d Desktop: Upgrade to Electron 39.2.3 (#13767) 2025-11-29 10:26:47 +00:00
renovate[bot]
046cfece32 Update dependency ldapts to v8.0.9 (#13768)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-11-29 10:26:39 +00:00
bwat47
0280bb80b9 Desktop,Mobile: Hide backslash escapes when "Markdown editor: Render markup in editor" is enabled (#13773) 2025-11-29 10:05:13 +00:00
Henry Heino
8a61f4ec54 Mobile: Rich Text Editor: Support inserting code blocks (#13776) 2025-11-29 10:04:28 +00:00
Henry Heino
d7dd16aac1 Desktop: OneNote importer: Change source label from ZIP to ONE (#13778) 2025-11-29 10:04:13 +00:00
Henry Heino
e1ed573c33 Chore: Mobile plugin IPC: Fix possible error format issue (#13780) 2025-11-29 10:03:36 +00:00
Henry Heino
b6c8347549 Chore: Renderer: Convert resourceId to a string in a safer way (#13781) 2025-11-29 10:03:28 +00:00
Henry Heino
b150d6453d Mobile: Rich Text Editor: Improve support for ABC sheet music and Mermaid code blocks (#13784) 2025-11-29 10:03:22 +00:00
Henry Heino
9feba9345d Mobile: Rich Text Editor: Fix error when pressing enter (#13788) 2025-11-29 10:03:15 +00:00
Henry Heino
7fa3a3b545 Desktop: OneNote importer: Handle the case where an entity GUID is missing (#13789) 2025-11-29 10:02:57 +00:00
Henry Heino
fed2438bc3 Docs: OneNote import: Update import documentation (#13790)
Co-authored-by: Linkosed <linkosed@users.noreply.github.com>
2025-11-29 09:58:22 +00:00
Henry Heino
31cb404854 Desktop: Fixes #13745: Prevent cut events from being merged with other actions in the undo history (#13791) 2025-11-29 09:57:46 +00:00
Henry Heino
dba3a3f68f Desktop: Add loading indicator to the sync status screen (#13796) 2025-11-29 09:55:00 +00:00
Henry Heino
14f8f51cd1 Desktop: Accessibility: Disable the loading animation when 'reduce motion' is enabled (#13797) 2025-11-29 09:54:47 +00:00
Henry Heino
2240cf77b5 Chore: Server: Debug: Add debug populateDatabase API (#13800) 2025-11-29 09:54:05 +00:00
Milo Ivir
599f7a24ce All: Translation: Update hr_HR.po (#13769) 2025-11-26 17:33:21 -05:00
Henry Heino
f177563c4a Chore: Mobile: Fix test warnings (#13798) 2025-11-26 22:11:50 +00:00
Laurent Cozic
a0bdc1fa9b Doc: Update donate links 2025-11-22 16:11:16 +00:00
Joplin Bot
f566e5c336 Doc: Auto-update documentation
Auto-updated using release-website.sh
2025-11-22 12:45:34 +00:00
renovate[bot]
87d07eff4a Update dependency ldapts to v8 (#13765)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-11-22 12:41:50 +00:00
Laurent Cozic
7a31f1f156 Desktop release v3.5.7 2025-11-21 19:32:11 +00:00
Henry Heino
090c1d9706 Desktop: Accessibility: Fix last items in note actions menu cannot be accessed on small screens (#13756) 2025-11-21 19:28:33 +00:00
Henry Heino
5e2b79557c Server: Fix report service fails when there are a very large number of items to be processed (#13721) 2025-11-21 19:28:10 +00:00
Henry Heino
74fa2a6eb9 Server: Slightly improve delta performance (#13730) 2025-11-21 19:27:28 +00:00
Henry Heino
791668455e Desktop: Resolves #13464: OneNote importer: Don't stop the import process when a page fails to render (#13736) 2025-11-21 19:26:14 +00:00
renovate[bot]
91aedc5efa Update bitnamilegacy/postgresql Docker tag to v17.5.0 (#13737)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-11-21 19:26:07 +00:00
Henry Heino
6b2d9ba5ec Server: Save and query less data when creating and updating items (#13739) 2025-11-21 19:25:47 +00:00
bwat47
d8920840f2 Desktop: Fixes #13707: Fix text contrast issues with Aritim, Dracula, and Nord themes (#13740) 2025-11-21 19:24:18 +00:00
bwat47
bf571c5961 Desktop,Mobile: Add support for rendering html images when "Markdown editor: Render images" is enabled (#13743) 2025-11-21 19:19:19 +00:00
Henry Heino
a7b22edbc4 Chore: Remove unused type definition dependency (#13747) 2025-11-21 19:18:37 +00:00
Henry Heino
f4904d8155 Chore: Remove no-longer-necessary Promise polyfill (#13748) 2025-11-21 19:18:28 +00:00
Henry Heino
fab633bbb4 Cli: Fix startup failure (#13749) 2025-11-21 19:17:20 +00:00
Henry Heino
cda4073bfc Chore: Cli: Run integration tests in CI (#13750) 2025-11-21 19:17:13 +00:00
Henry Heino
903edb8fa2 Chore: Desktop: Remove unused dependency (#13752) 2025-11-21 19:16:49 +00:00
Laurent Cozic
f3409600e1 All: Allow using share permission with Joplin Server Business 2025-11-21 19:14:00 +00:00
Laurent Cozic
9f36b44842 All: Fix issue with shared notebooks and SAML sync 2025-11-21 18:21:28 +00:00
Laurent Cozic
6f41234db3 Doc: Improve SAML doc 2025-11-21 18:20:27 +00:00
Laurent Cozic
2feebf504e Doc: Update donate page 2025-11-21 16:09:39 +00:00
Laurent Cozic
3312e96b0d Doc: Update donate page 2025-11-21 15:24:11 +00:00
renovate[bot]
af5108d702 Update dependency @fortawesome/react-fontawesome to v0.2.6 (#13744)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-11-20 08:24:51 +00:00
Henry Heino
0f4877f263 Chore: Sync fuzzer: Allow generating large amounts of test data for Joplin Server (#13636)
Co-authored-by: Laurent Cozic <laurent22@users.noreply.github.com>
2025-11-18 22:53:44 +00:00
Henry Heino
46c22fffb9 Desktop,Mobile: Resolves #12959: Remove image height limit in Markdown editor (#13717) 2025-11-18 22:53:14 +00:00
renovate[bot]
ae5bc1b849 Update dependency @types/nodemailer to v6.4.19 (#13728)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-11-18 21:47:40 +00:00
Henry Heino
907da6caa9 Desktop: OneNote importer: Don't stop the import process if a style object can't be found (#13719) 2025-11-18 21:40:49 +00:00
renovate[bot]
57a4a687d1 Update dependency @fortawesome/react-fontawesome to v0.2.5 (#13723)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-11-18 16:10:04 +00:00
Henry Heino
00aecd63d4 Desktop: Resolves #1556: Support selecting multiple notebooks (#13612) 2025-11-17 22:14:28 +00:00
Henry Heino
bd569b9d8d Mobile: Rich Text Editor: Add button for creating tables (#13645) 2025-11-17 22:06:42 +00:00
Henry Heino
ad4a8aa76d Server: Improve error message when font file cannot be loaded (#13682) 2025-11-17 22:01:34 +00:00
Self Not Found
c67dcebbbe All: Fix text highlighting in basic search mode (#13703) 2025-11-17 22:01:22 +00:00
renovate[bot]
0e135adbe2 Update dependency mermaid to v11.9.0 (#13708)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Laurent Cozic <laurent22@users.noreply.github.com>
2025-11-17 21:27:33 +00:00
Laurent Cozic
43e83e7cee Chore: Improve error message when an asset cannot be removed 2025-11-16 22:53:02 +00:00
renovate[bot]
d1dcc6ced5 Update dependency @types/serviceworker to v0.0.150 (#13710)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-11-16 19:27:52 +00:00
Laurent Cozic
8425f195f8 Doc: Suggest log level in CLI install command 2025-11-16 15:54:21 +00:00
renovate[bot]
055177f726 Update dependency turndown to v7.2.1 (#13690)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Laurent Cozic <laurent22@users.noreply.github.com>
2025-11-16 09:41:21 +00:00
Liffindra Angga Zaaldian
1674df2c0f All: Translation: Update id_ID.po (#13706) 2025-11-15 10:29:06 -05:00
renovate[bot]
29fa117d36 Update dependency react-native-localize to v3.5.2 (#13705)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-11-15 14:40:27 +00:00
renovate[bot]
f08eaae7ed Update dependency @types/nodemailer to v6.4.18 (#13704)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-11-15 14:38:03 +00:00
Joplin Bot
9573bb6af7 Doc: Auto-update documentation
Auto-updated using release-website.sh
2025-11-15 12:46:15 +00:00
Henry Heino
cb6bafcac6 Chore: Update js-yaml to v4.1.1 (#13702)
Co-authored-by: Laurent Cozic <laurent22@users.noreply.github.com>
2025-11-15 11:17:18 +00:00
Henry Heino
d89aae5371 Server: Upgrade NodeJS to v24 (#13701)
Co-authored-by: Laurent Cozic <laurent22@users.noreply.github.com>
2025-11-15 11:17:06 +00:00
Henry Heino
0b0ffe06d4 Desktop: Fixes #13561: Upgrade to Electron 39 (#13567) 2025-11-15 09:21:18 +00:00
mrjo118
2ab720ff87 Desktop, Mobile: Fixes #13258: Prevent new notes from being created in trashed or missing notebooks in certain cases (#13575) 2025-11-15 09:21:00 +00:00
Henry Heino
b9b07790d7 Chore: Desktop: Editor: Don't update the global Redux state on cursor motion (#13580) 2025-11-15 09:16:36 +00:00
Laurent Cozic
3dca34952b Desktop: Move ABC rendering from plugin to main app (#13599) 2025-11-15 09:11:29 +00:00
horvatkm
5be124b54a All: Apache Tomcat WebDAV compatibility for sync (#13614) 2025-11-15 09:07:39 +00:00
mrjo118
51dd0d3fdc Chore: Fix intermittent revision test failure attempt 2 (#13622) 2025-11-15 09:06:46 +00:00
horvatkm
7955f15298 Desktop: Resolves #13625: Skip over unsupported image formats during processing paste event (#13630) 2025-11-15 09:03:27 +00:00
renovate[bot]
fdf6091006 Update dependency react-native-localize to v3.5.1 (#13651)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Laurent Cozic <laurent22@users.noreply.github.com>
2025-11-15 09:02:45 +00:00
mrjo118
bb1c5792cc Mobile: Fixes #13637: Fix incorrect zebra striping on tables in the rich text editor (#13663) 2025-11-15 09:02:36 +00:00
Henry Heino
75544c943c Mobile: Hide Markdown-editor-only buttons in the Rich Text Editor (#13664) 2025-11-15 09:02:15 +00:00
Henry Heino
db9967d4fd Server: Performance: Improve performance of requests-per-minute logger (#13670) 2025-11-15 09:02:02 +00:00
Saturn&Eric
07a66ca62c Server: Update @aws-sdk/client-s3 to v3.928.0 (#13673) 2025-11-15 09:01:49 +00:00
renovate[bot]
3e3dc4392c Update dependency esbuild to v0.25.9 (#13677)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-11-15 09:01:14 +00:00
Henry Heino
57504a1795 Server: Database: Adjust connection pool configuration, make connection pool size configurable (#13681) 2025-11-15 09:00:24 +00:00
Henry Heino
9e9d2699b5 Server: Improve error when attempting to load certain routes that do not exist (#13683) 2025-11-15 08:58:36 +00:00
Henry Heino
4a0d9220ba Server: Fixes #13686: Fix items can be incorrectly unshared on conflicting update (#13691) 2025-11-15 08:58:16 +00:00
Henry Heino
86a7771d5b Desktop: Fixes #13694: Fix settings aren't saved before opening the SAML login screen (#13696) 2025-11-15 08:58:06 +00:00
Henry Heino
d792a6b3a9 Desktop: Fixes #13549: OneNote importer: Support converting checklists to Markdown (#13698) 2025-11-15 08:56:44 +00:00
mrjo118
e8a083b7bd Web: Fix find and replace toolbar in note editor is too squashed on small mobile screens (#13697) 2025-11-15 08:56:32 +00:00
Henry Heino
41ed6ab364 Chore: CI: Upgrade NodeJS to v24 (#13700) 2025-11-15 08:55:45 +00:00
Henry Heino
b587e9ad37 Chore: Fix CI (#13699) 2025-11-15 08:54:36 +00:00
Laurent Cozic
e3f9fafcdf Revert "Chore: Resolves #13643: Update Esperanto translation (Credit: @paleid)"
This reverts commit aef9429f21.
2025-11-14 01:08:29 +00:00
Joplin Bot
c0ba743d70 Doc: Auto-update documentation
Auto-updated using release-website.sh
2025-11-13 12:53:26 +00:00
Joplin Bot
523660006d Doc: Auto-update documentation
Auto-updated using release-website.sh
2025-11-13 06:41:07 +00:00
Laurent Cozic
aef9429f21 Chore: Resolves #13643: Update Esperanto translation (Credit: @paleid) 2025-11-12 22:29:37 +00:00
renovate[bot]
58e2bba1ed Update dependency esbuild to v0.25.9 (#13676)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-11-12 04:49:37 +00:00
renovate[bot]
cee44bcdc3 Update dependency @adobe/css-tools to v4.4.4 (#13667)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-11-11 22:51:42 +00:00
renovate[bot]
9a120bc0d5 Update dependency react-native-dropdownalert to v5.2.0 (#13657)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-11-11 22:51:20 +00:00
Henry Heino
d1415a318c Server: Performance: Improve performance of updating shared items, generating reports (#13674) 2025-11-11 22:49:24 +00:00
Jason Lewis
e626db3b8c Doc: Resolves #13665: Remind users not to use the Nextcloud desktop client for syncing. (#13666)
Co-authored-by: Laurent Cozic <laurent22@users.noreply.github.com>
2025-11-11 09:44:53 +00:00
Frank Fesevur
053bd91984 All: Translation: Update nl_NL.po (#13653) 2025-11-08 20:22:03 -05:00
Laurent Cozic
c76059cf7f Server: Optimise delta query (#13650) 2025-11-08 22:57:29 +01:00
ERYpTION
6d6bc78d53 All: Translation: Update da_DK.po (#13652) 2025-11-08 16:56:23 -05:00
renovate[bot]
8855495822 Update dependency react-native-localize to v3.5.0 (#13647)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-11-08 13:12:17 +01:00
renovate[bot]
3491fea313 Update dependency @playwright/test to v1.54.2 (#13649)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-11-08 13:12:06 +01:00
renovate[bot]
66f5e2fbc3 Update dependency @playwright/test to v1.54.0 (#13641)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-11-08 11:47:07 +01:00
renovate[bot]
3640bf8ae7 Update dependency nan to v2.23.0 (#13642)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-11-08 11:46:57 +01:00
Laurent Cozic
977edf6e5d Server: Fix slow delta queries (#13639) 2025-11-08 11:02:55 +01:00
renovate[bot]
e8f067a0b2 Update dependency @crowdin/cli to v4.9.0 (#13638)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-11-07 14:41:44 +01:00
Henry Heino
f971e2aa4c Server: Upgrade koa to v2.16.3 (#13626) 2025-11-07 10:42:15 +01:00
renovate[bot]
b15b92d161 Update dependency git to v2.49.0 (#13635)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-11-07 10:41:47 +01:00
renovate[bot]
1c5f66b5a9 Update dependency @react-native-community/datetimepicker to v8.4.4 (#13634)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-11-06 23:48:24 +00:00
Laurent Cozic
aaeb5db3c7 Server: Optimise delta sub-query (#13633) 2025-11-06 20:27:47 +01:00
276 changed files with 8979 additions and 3930 deletions

View File

@@ -6,6 +6,7 @@ _releases/
*.min.js
**/commands/index.ts
**/node_modules/
**/abcjs-basic-min.js
packages/generator-joplin/generators/app/templates/api/
Assets/
docs/
@@ -96,7 +97,7 @@ packages/onenote-converter/renderer/pkg/*
packages/app-cli/app/LinkSelector.js
packages/app-cli/app/app.js
packages/app-cli/app/base-command.js
packages/app-cli/app/cli-integration-tests.js
packages/app-cli/app/cli-integration-tests.test.js
packages/app-cli/app/command-apidoc.js
packages/app-cli/app/command-attach.js
packages/app-cli/app/command-batch.js
@@ -424,10 +425,11 @@ packages/app-desktop/gui/Sidebar/Sidebar.js
packages/app-desktop/gui/Sidebar/commands/focusElementSideBar.js
packages/app-desktop/gui/Sidebar/commands/index.js
packages/app-desktop/gui/Sidebar/hooks/useFocusHandler.js
packages/app-desktop/gui/Sidebar/hooks/useOnItemClick.js
packages/app-desktop/gui/Sidebar/hooks/useOnRenderItem.js
packages/app-desktop/gui/Sidebar/hooks/useOnRenderListWrapper.js
packages/app-desktop/gui/Sidebar/hooks/useOnSidebarKeyDownHandler.js
packages/app-desktop/gui/Sidebar/hooks/useSelectedSidebarIndex.js
packages/app-desktop/gui/Sidebar/hooks/useSelectedSidebarIndexes.js
packages/app-desktop/gui/Sidebar/hooks/useSidebarCommandHandler.js
packages/app-desktop/gui/Sidebar/hooks/useSidebarListData.js
packages/app-desktop/gui/Sidebar/hooks/utils/toggleHeader.js
@@ -468,6 +470,7 @@ packages/app-desktop/gui/WindowCommandsAndDialogs/commands/editAlarm.js
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/exportPdf.js
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/gotoAnything.js
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/hideModalMessage.js
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/importFrom.js
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/index.js
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/linkToNote.js
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/moveToFolder.js
@@ -510,6 +513,7 @@ packages/app-desktop/gui/WindowCommandsAndDialogs/commands/toggleSideBar.js
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/toggleVisiblePanes.js
packages/app-desktop/gui/WindowCommandsAndDialogs/types.js
packages/app-desktop/gui/WindowCommandsAndDialogs/utils/appDialogs.js
packages/app-desktop/gui/WindowCommandsAndDialogs/utils/showFolderPicker.js
packages/app-desktop/gui/WindowCommandsAndDialogs/utils/usePrintToCallback.js
packages/app-desktop/gui/WindowCommandsAndDialogs/utils/useSyncDialogState.js
packages/app-desktop/gui/WindowCommandsAndDialogs/utils/useWindowCommands.js
@@ -561,6 +565,7 @@ packages/app-desktop/integration-tests/util/evaluateWithRetry.js
packages/app-desktop/integration-tests/util/extendedExpect.js
packages/app-desktop/integration-tests/util/getImageSourceSize.js
packages/app-desktop/integration-tests/util/getMainWindow.js
packages/app-desktop/integration-tests/util/mockClipboard.js
packages/app-desktop/integration-tests/util/retryOnFailure.js
packages/app-desktop/integration-tests/util/setDarkMode.js
packages/app-desktop/integration-tests/util/setFilePickerResponse.js
@@ -849,6 +854,7 @@ packages/app-mobile/components/screens/NoteTagsDialog.js
packages/app-mobile/components/screens/Notes/NewNoteButton.test.js
packages/app-mobile/components/screens/Notes/NewNoteButton.js
packages/app-mobile/components/screens/Notes/Notes.js
packages/app-mobile/components/screens/SearchScreen/SearchBar.js
packages/app-mobile/components/screens/SearchScreen/SearchResults.test.js
packages/app-mobile/components/screens/SearchScreen/SearchResults.js
packages/app-mobile/components/screens/SearchScreen/index.js
@@ -1005,6 +1011,7 @@ packages/editor/CodeMirror/CodeMirrorControl.js
packages/editor/CodeMirror/configFromSettings.js
packages/editor/CodeMirror/createEditor.test.js
packages/editor/CodeMirror/createEditor.js
packages/editor/CodeMirror/editorCommands/cutOrCopyText.js
packages/editor/CodeMirror/editorCommands/duplicateLine.test.js
packages/editor/CodeMirror/editorCommands/duplicateLine.js
packages/editor/CodeMirror/editorCommands/editorCommands.js
@@ -1047,6 +1054,7 @@ packages/editor/CodeMirror/extensions/rendering/addFormattingClasses.js
packages/editor/CodeMirror/extensions/rendering/renderBlockImages.test.js
packages/editor/CodeMirror/extensions/rendering/renderBlockImages.js
packages/editor/CodeMirror/extensions/rendering/renderingExtension.js
packages/editor/CodeMirror/extensions/rendering/replaceBackslashEscapes.js
packages/editor/CodeMirror/extensions/rendering/replaceBulletLists.js
packages/editor/CodeMirror/extensions/rendering/replaceCheckboxes.js
packages/editor/CodeMirror/extensions/rendering/replaceDividers.js
@@ -1113,10 +1121,12 @@ packages/editor/ProseMirror/plugins/detailsPlugin.js
packages/editor/ProseMirror/plugins/imagePlugin.test.js
packages/editor/ProseMirror/plugins/imagePlugin.js
packages/editor/ProseMirror/plugins/inputRulesPlugin.js
packages/editor/ProseMirror/plugins/joplinEditablePlugin/createEditorDialog.js
packages/editor/ProseMirror/plugins/joplinEditablePlugin/joplinEditablePlugin.test.js
packages/editor/ProseMirror/plugins/joplinEditablePlugin/joplinEditablePlugin.js
packages/editor/ProseMirror/plugins/joplinEditablePlugin/postProcessRenderedHtml.js
packages/editor/ProseMirror/plugins/joplinEditablePlugin/showCreateEditablePrompt.test.js
packages/editor/ProseMirror/plugins/joplinEditablePlugin/showCreateEditablePrompt.js
packages/editor/ProseMirror/plugins/joplinEditablePlugin/utils/createEditorDialog.js
packages/editor/ProseMirror/plugins/joplinEditablePlugin/utils/postProcessRenderedHtml.js
packages/editor/ProseMirror/plugins/joplinEditorApiPlugin.js
packages/editor/ProseMirror/plugins/keymapPlugin.js
packages/editor/ProseMirror/plugins/linkTooltipPlugin.test.js
@@ -1131,6 +1141,7 @@ packages/editor/ProseMirror/schema.js
packages/editor/ProseMirror/styles.js
packages/editor/ProseMirror/testing/createTestEditor.js
packages/editor/ProseMirror/testing/createTestEditorWithSerializer.js
packages/editor/ProseMirror/testing/mockEditorApi.js
packages/editor/ProseMirror/types.js
packages/editor/ProseMirror/utils/SelectableNodeView.js
packages/editor/ProseMirror/utils/UndoStackSynchronizer.js
@@ -1144,6 +1155,7 @@ packages/editor/ProseMirror/utils/dom/showModal.js
packages/editor/ProseMirror/utils/extractSelectedLinesTo.test.js
packages/editor/ProseMirror/utils/extractSelectedLinesTo.js
packages/editor/ProseMirror/utils/forEachHeading.js
packages/editor/ProseMirror/utils/insertRenderedMarkdown.js
packages/editor/ProseMirror/utils/jumpToHash.js
packages/editor/ProseMirror/utils/makeLinksClickableInElement.js
packages/editor/ProseMirror/utils/postprocessEditorOutput.test.js
@@ -1361,6 +1373,7 @@ packages/lib/models/utils/getCanBeCollapsedFolderIds.js
packages/lib/models/utils/getCollator.js
packages/lib/models/utils/getConflictFolderId.js
packages/lib/models/utils/isItemId.js
packages/lib/models/utils/isJoplinServerVariant.js
packages/lib/models/utils/itemCanBeEncrypted.js
packages/lib/models/utils/onFolderDrop.test.js
packages/lib/models/utils/onFolderDrop.js
@@ -1396,6 +1409,7 @@ packages/lib/services/KeymapService_keysRegExp.js
packages/lib/services/KvStore.js
packages/lib/services/MigrationService.js
packages/lib/services/NavService.js
packages/lib/services/NotePositionService.js
packages/lib/services/PostMessageService.js
packages/lib/services/ReportService.test.js
packages/lib/services/ReportService.js
@@ -1411,6 +1425,7 @@ packages/lib/services/UndoRedoService.js
packages/lib/services/WhenClause.test.js
packages/lib/services/WhenClause.js
packages/lib/services/commands/MenuUtils.js
packages/lib/services/commands/ToolbarButtonUtils.test.js
packages/lib/services/commands/ToolbarButtonUtils.js
packages/lib/services/commands/commandsToMarkdownTable.js
packages/lib/services/commands/focusEditorIfEditorCommand.js
@@ -1779,6 +1794,7 @@ packages/renderer/MdToHtml/createEventHandlingAttrs.js
packages/renderer/MdToHtml/linkReplacement.test.js
packages/renderer/MdToHtml/linkReplacement.js
packages/renderer/MdToHtml/renderMedia.js
packages/renderer/MdToHtml/rules/abc.js
packages/renderer/MdToHtml/rules/checkbox.js
packages/renderer/MdToHtml/rules/code_inline.js
packages/renderer/MdToHtml/rules/fence.js
@@ -1821,14 +1837,18 @@ packages/tools/fuzzer/Client.js
packages/tools/fuzzer/ClientPool.js
packages/tools/fuzzer/Server.js
packages/tools/fuzzer/constants.js
packages/tools/fuzzer/doRandomAction.js
packages/tools/fuzzer/model/FolderRecord.js
packages/tools/fuzzer/sync-fuzzer.js
packages/tools/fuzzer/types.js
packages/tools/fuzzer/utils/ProgressBar.js
packages/tools/fuzzer/utils/SeededRandom.js
packages/tools/fuzzer/utils/getNumberProperty.js
packages/tools/fuzzer/utils/getProperty.js
packages/tools/fuzzer/utils/getStringProperty.js
packages/tools/fuzzer/utils/logDiffDebug.js
packages/tools/fuzzer/utils/openDebugSession.js
packages/tools/fuzzer/utils/randomString.js
packages/tools/fuzzer/utils/retryWithCount.js
packages/tools/generate-database-types.js
packages/tools/generate-images.js

View File

@@ -21,19 +21,24 @@ jobs:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
- uses: actions/setup-node@v6
with:
node-version: '18'
node-version: '24'
cache: 'yarn'
- uses: dtolnay/rust-toolchain@stable
- name: Install Yarn
run: |
corepack enable
- name: Install
run: yarn install
env:
SKIP_ONENOTE_CONVERTER_BUILD: 1
- name: Free disk space
run: |
sudo rm -rf /usr/share/dotnet || true
sudo rm -rf /opt/ghc || true
- name: Assemble Android Release
run: |

View File

@@ -9,11 +9,9 @@ jobs:
- uses: actions/checkout@v4
- uses: olegtarasov/get-tag@v2.1.4
- uses: actions/setup-node@v4
- uses: actions/setup-node@v6
with:
# We need to pin the version to 18.15, because 18.16+ fails with this error:
# https://github.com/facebook/react-native/issues/36440
node-version: '18.20.8'
node-version: '24'
cache: 'yarn'
- name: Install Yarn

View File

@@ -147,9 +147,9 @@ jobs:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
- uses: actions/setup-node@v6
with:
node-version: '18'
node-version: '24'
- name: Free disk space
if: runner.os == 'Linux'

View File

@@ -51,9 +51,9 @@ runs:
- uses: dtolnay/rust-toolchain@stable
if: ${{ runner.os != 'Windows' }}
- uses: actions/setup-node@v4
- uses: actions/setup-node@v6
with:
node-version: '18.20.8'
node-version: '24'
# Disable the cache on ARM runners. For now, we don't run "yarn install" on these
# environments and this breaks actions/setup-node.
# See https://github.com/laurent22/joplin/commit/47d0d3eb9e89153a609fb5441344da10904c6308#commitcomment-159577783.

27
.gitignore vendored
View File

@@ -69,7 +69,7 @@ docs/**/*.mustache
packages/app-cli/app/LinkSelector.js
packages/app-cli/app/app.js
packages/app-cli/app/base-command.js
packages/app-cli/app/cli-integration-tests.js
packages/app-cli/app/cli-integration-tests.test.js
packages/app-cli/app/command-apidoc.js
packages/app-cli/app/command-attach.js
packages/app-cli/app/command-batch.js
@@ -397,10 +397,11 @@ packages/app-desktop/gui/Sidebar/Sidebar.js
packages/app-desktop/gui/Sidebar/commands/focusElementSideBar.js
packages/app-desktop/gui/Sidebar/commands/index.js
packages/app-desktop/gui/Sidebar/hooks/useFocusHandler.js
packages/app-desktop/gui/Sidebar/hooks/useOnItemClick.js
packages/app-desktop/gui/Sidebar/hooks/useOnRenderItem.js
packages/app-desktop/gui/Sidebar/hooks/useOnRenderListWrapper.js
packages/app-desktop/gui/Sidebar/hooks/useOnSidebarKeyDownHandler.js
packages/app-desktop/gui/Sidebar/hooks/useSelectedSidebarIndex.js
packages/app-desktop/gui/Sidebar/hooks/useSelectedSidebarIndexes.js
packages/app-desktop/gui/Sidebar/hooks/useSidebarCommandHandler.js
packages/app-desktop/gui/Sidebar/hooks/useSidebarListData.js
packages/app-desktop/gui/Sidebar/hooks/utils/toggleHeader.js
@@ -441,6 +442,7 @@ packages/app-desktop/gui/WindowCommandsAndDialogs/commands/editAlarm.js
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/exportPdf.js
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/gotoAnything.js
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/hideModalMessage.js
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/importFrom.js
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/index.js
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/linkToNote.js
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/moveToFolder.js
@@ -483,6 +485,7 @@ packages/app-desktop/gui/WindowCommandsAndDialogs/commands/toggleSideBar.js
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/toggleVisiblePanes.js
packages/app-desktop/gui/WindowCommandsAndDialogs/types.js
packages/app-desktop/gui/WindowCommandsAndDialogs/utils/appDialogs.js
packages/app-desktop/gui/WindowCommandsAndDialogs/utils/showFolderPicker.js
packages/app-desktop/gui/WindowCommandsAndDialogs/utils/usePrintToCallback.js
packages/app-desktop/gui/WindowCommandsAndDialogs/utils/useSyncDialogState.js
packages/app-desktop/gui/WindowCommandsAndDialogs/utils/useWindowCommands.js
@@ -534,6 +537,7 @@ packages/app-desktop/integration-tests/util/evaluateWithRetry.js
packages/app-desktop/integration-tests/util/extendedExpect.js
packages/app-desktop/integration-tests/util/getImageSourceSize.js
packages/app-desktop/integration-tests/util/getMainWindow.js
packages/app-desktop/integration-tests/util/mockClipboard.js
packages/app-desktop/integration-tests/util/retryOnFailure.js
packages/app-desktop/integration-tests/util/setDarkMode.js
packages/app-desktop/integration-tests/util/setFilePickerResponse.js
@@ -822,6 +826,7 @@ packages/app-mobile/components/screens/NoteTagsDialog.js
packages/app-mobile/components/screens/Notes/NewNoteButton.test.js
packages/app-mobile/components/screens/Notes/NewNoteButton.js
packages/app-mobile/components/screens/Notes/Notes.js
packages/app-mobile/components/screens/SearchScreen/SearchBar.js
packages/app-mobile/components/screens/SearchScreen/SearchResults.test.js
packages/app-mobile/components/screens/SearchScreen/SearchResults.js
packages/app-mobile/components/screens/SearchScreen/index.js
@@ -978,6 +983,7 @@ packages/editor/CodeMirror/CodeMirrorControl.js
packages/editor/CodeMirror/configFromSettings.js
packages/editor/CodeMirror/createEditor.test.js
packages/editor/CodeMirror/createEditor.js
packages/editor/CodeMirror/editorCommands/cutOrCopyText.js
packages/editor/CodeMirror/editorCommands/duplicateLine.test.js
packages/editor/CodeMirror/editorCommands/duplicateLine.js
packages/editor/CodeMirror/editorCommands/editorCommands.js
@@ -1020,6 +1026,7 @@ packages/editor/CodeMirror/extensions/rendering/addFormattingClasses.js
packages/editor/CodeMirror/extensions/rendering/renderBlockImages.test.js
packages/editor/CodeMirror/extensions/rendering/renderBlockImages.js
packages/editor/CodeMirror/extensions/rendering/renderingExtension.js
packages/editor/CodeMirror/extensions/rendering/replaceBackslashEscapes.js
packages/editor/CodeMirror/extensions/rendering/replaceBulletLists.js
packages/editor/CodeMirror/extensions/rendering/replaceCheckboxes.js
packages/editor/CodeMirror/extensions/rendering/replaceDividers.js
@@ -1086,10 +1093,12 @@ packages/editor/ProseMirror/plugins/detailsPlugin.js
packages/editor/ProseMirror/plugins/imagePlugin.test.js
packages/editor/ProseMirror/plugins/imagePlugin.js
packages/editor/ProseMirror/plugins/inputRulesPlugin.js
packages/editor/ProseMirror/plugins/joplinEditablePlugin/createEditorDialog.js
packages/editor/ProseMirror/plugins/joplinEditablePlugin/joplinEditablePlugin.test.js
packages/editor/ProseMirror/plugins/joplinEditablePlugin/joplinEditablePlugin.js
packages/editor/ProseMirror/plugins/joplinEditablePlugin/postProcessRenderedHtml.js
packages/editor/ProseMirror/plugins/joplinEditablePlugin/showCreateEditablePrompt.test.js
packages/editor/ProseMirror/plugins/joplinEditablePlugin/showCreateEditablePrompt.js
packages/editor/ProseMirror/plugins/joplinEditablePlugin/utils/createEditorDialog.js
packages/editor/ProseMirror/plugins/joplinEditablePlugin/utils/postProcessRenderedHtml.js
packages/editor/ProseMirror/plugins/joplinEditorApiPlugin.js
packages/editor/ProseMirror/plugins/keymapPlugin.js
packages/editor/ProseMirror/plugins/linkTooltipPlugin.test.js
@@ -1104,6 +1113,7 @@ packages/editor/ProseMirror/schema.js
packages/editor/ProseMirror/styles.js
packages/editor/ProseMirror/testing/createTestEditor.js
packages/editor/ProseMirror/testing/createTestEditorWithSerializer.js
packages/editor/ProseMirror/testing/mockEditorApi.js
packages/editor/ProseMirror/types.js
packages/editor/ProseMirror/utils/SelectableNodeView.js
packages/editor/ProseMirror/utils/UndoStackSynchronizer.js
@@ -1117,6 +1127,7 @@ packages/editor/ProseMirror/utils/dom/showModal.js
packages/editor/ProseMirror/utils/extractSelectedLinesTo.test.js
packages/editor/ProseMirror/utils/extractSelectedLinesTo.js
packages/editor/ProseMirror/utils/forEachHeading.js
packages/editor/ProseMirror/utils/insertRenderedMarkdown.js
packages/editor/ProseMirror/utils/jumpToHash.js
packages/editor/ProseMirror/utils/makeLinksClickableInElement.js
packages/editor/ProseMirror/utils/postprocessEditorOutput.test.js
@@ -1334,6 +1345,7 @@ packages/lib/models/utils/getCanBeCollapsedFolderIds.js
packages/lib/models/utils/getCollator.js
packages/lib/models/utils/getConflictFolderId.js
packages/lib/models/utils/isItemId.js
packages/lib/models/utils/isJoplinServerVariant.js
packages/lib/models/utils/itemCanBeEncrypted.js
packages/lib/models/utils/onFolderDrop.test.js
packages/lib/models/utils/onFolderDrop.js
@@ -1369,6 +1381,7 @@ packages/lib/services/KeymapService_keysRegExp.js
packages/lib/services/KvStore.js
packages/lib/services/MigrationService.js
packages/lib/services/NavService.js
packages/lib/services/NotePositionService.js
packages/lib/services/PostMessageService.js
packages/lib/services/ReportService.test.js
packages/lib/services/ReportService.js
@@ -1384,6 +1397,7 @@ packages/lib/services/UndoRedoService.js
packages/lib/services/WhenClause.test.js
packages/lib/services/WhenClause.js
packages/lib/services/commands/MenuUtils.js
packages/lib/services/commands/ToolbarButtonUtils.test.js
packages/lib/services/commands/ToolbarButtonUtils.js
packages/lib/services/commands/commandsToMarkdownTable.js
packages/lib/services/commands/focusEditorIfEditorCommand.js
@@ -1752,6 +1766,7 @@ packages/renderer/MdToHtml/createEventHandlingAttrs.js
packages/renderer/MdToHtml/linkReplacement.test.js
packages/renderer/MdToHtml/linkReplacement.js
packages/renderer/MdToHtml/renderMedia.js
packages/renderer/MdToHtml/rules/abc.js
packages/renderer/MdToHtml/rules/checkbox.js
packages/renderer/MdToHtml/rules/code_inline.js
packages/renderer/MdToHtml/rules/fence.js
@@ -1794,14 +1809,18 @@ packages/tools/fuzzer/Client.js
packages/tools/fuzzer/ClientPool.js
packages/tools/fuzzer/Server.js
packages/tools/fuzzer/constants.js
packages/tools/fuzzer/doRandomAction.js
packages/tools/fuzzer/model/FolderRecord.js
packages/tools/fuzzer/sync-fuzzer.js
packages/tools/fuzzer/types.js
packages/tools/fuzzer/utils/ProgressBar.js
packages/tools/fuzzer/utils/SeededRandom.js
packages/tools/fuzzer/utils/getNumberProperty.js
packages/tools/fuzzer/utils/getProperty.js
packages/tools/fuzzer/utils/getStringProperty.js
packages/tools/fuzzer/utils/logDiffDebug.js
packages/tools/fuzzer/utils/openDebugSession.js
packages/tools/fuzzer/utils/randomString.js
packages/tools/fuzzer/utils/retryWithCount.js
packages/tools/generate-database-types.js
packages/tools/generate-images.js

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

View File

@@ -2,7 +2,7 @@
# Build stage
# =============================================================================
FROM node:18 AS builder
FROM node:24 AS builder
RUN apt-get update \
&& apt-get install -y \
@@ -58,7 +58,7 @@ RUN --mount=type=cache,target=/build/.yarn/cache --mount=type=cache,target=/buil
# from a smaller base image.
# =============================================================================
FROM node:18-slim
FROM node:24-slim
ARG user=joplin
RUN useradd --create-home --shell /bin/bash $user

View File

@@ -67,6 +67,45 @@ showHelp() {
fi
}
# Accepts two versions in symver (a.b.c).
# Echos -1 if the first version is less than the second,
# 0 if they're equal,
# 1 if the first version is greater than second.
compareVersions() {
V_MAJOR1=$(echo "$1"|cut -d. -f1)
V_MAJOR2=$(echo "$2"|cut -d. -f1)
if [[ $V_MAJOR1 -lt $V_MAJOR2 ]] ; then
echo -1
return
elif [[ $V_MAJOR1 -gt $V_MAJOR2 ]] ; then
echo 1
return
fi
V_MINOR1=$(echo "$1"|cut -d. -f2)
V_MINOR2=$(echo "$2"|cut -d. -f2)
if [[ $V_MINOR1 -lt $V_MINOR2 ]] ; then
echo -1
return
elif [[ $V_MINOR1 -gt $V_MINOR2 ]] ; then
echo 1
return
fi
V_PATCH1=$(echo "$1"|cut -d. -f3)
V_PATCH2=$(echo "$2"|cut -d. -f3)
if [[ $V_PATCH1 -lt $V_PATCH2 ]] ; then
echo -1
elif [[ $V_PATCH1 -gt $V_PATCH2 ]] ; then
echo 1
else
echo 0
fi
}
#-----------------------------------------------------
# Setup Download Helper: DL
#-----------------------------------------------------
@@ -258,6 +297,15 @@ fi
if [[ $DESKTOP =~ .*gnome.*|.*kde.*|.*xfce.*|.*mate.*|.*lxqt.*|.*unity.*|.*x-cinnamon.*|.*deepin.*|.*pantheon.*|.*lxde.*|.*i3.*|.*sway.* ]] || [[ `command -v update-desktop-database` ]]; then
DATA_HOME=${XDG_DATA_HOME:-~/.local/share}
DESKTOP_FILE_LOCATION="$DATA_HOME/applications"
# Only later versions of Joplin default to Wayland
IS_WAYLAND_BY_DEFAULT=$(compareVersions "$RELEASE_VERSION" "3.5.6")
# Joplin has a different startup WM class on Wayland and X11:
STARTUP_WM_CLASS=Joplin
if [[ $XDG_SESSION_TYPE != "x11" && $IS_WAYLAND_BY_DEFAULT == "1" ]]; then
STARTUP_WM_CLASS=@joplin/app-desktop
fi
# Only delete the desktop file if it will be replaced
rm -f "$DESKTOP_FILE_LOCATION/appimagekit-joplin.desktop"
@@ -272,7 +320,9 @@ Name=Joplin
Comment=Joplin for Desktop
Exec=env APPIMAGELAUNCHER_DISABLE=TRUE "${INSTALL_DIR}/Joplin.AppImage" ${SANDBOXPARAM} %u
Icon=joplin
StartupWMClass=Joplin
# This will be different between Wayland and X11. On Wayland, the startup
# WM class is "@joplin/app-desktop". On X11, it's "Joplin".
StartupWMClass=${STARTUP_WM_CLASS}
Type=Application
Categories=Office;
MimeType=x-scheme-handler/joplin;

View File

@@ -1,5 +1,5 @@
<!-- DONATELINKS -->
[![Donate using PayPal](https://raw.githubusercontent.com/laurent22/joplin/dev/Assets/WebsiteAssets/images/badges/Donate-PayPal-green.svg)](https://www.paypal.com/donate/?business=E8JMYD2LQ8MMA&no_recurring=0&item_name=I+rely+on+donations+to+maintain+and+improve+the+Joplin+open+source+project.+Thank+you+for+your+help+-+it+makes+a+difference%21&currency_code=EUR) [![Sponsor on GitHub](https://raw.githubusercontent.com/laurent22/joplin/dev/Assets/WebsiteAssets/images/badges/GitHub-Badge.svg)](https://github.com/sponsors/laurent22/) [![Become a patron](https://raw.githubusercontent.com/laurent22/joplin/dev/Assets/WebsiteAssets/images/badges/Patreon-Badge.svg)](https://www.patreon.com/joplin) [![Donate using IBAN](https://raw.githubusercontent.com/laurent22/joplin/dev/Assets/WebsiteAssets/images/badges/Donate-IBAN.svg)](https://joplinapp.org/donate/#donations)
[![Donate using PayPal](https://raw.githubusercontent.com/laurent22/joplin/dev/Assets/WebsiteAssets/images/badges/Donate-PayPal-green.svg)](https://www.paypal.com/donate/?hosted_button_id=WQCERTSSLCC7U) [![Sponsor on GitHub](https://raw.githubusercontent.com/laurent22/joplin/dev/Assets/WebsiteAssets/images/badges/GitHub-Badge.svg)](https://github.com/sponsors/laurent22/) [![Become a patron](https://raw.githubusercontent.com/laurent22/joplin/dev/Assets/WebsiteAssets/images/badges/Patreon-Badge.svg)](https://www.patreon.com/joplin) [![Donate using IBAN](https://raw.githubusercontent.com/laurent22/joplin/dev/Assets/WebsiteAssets/images/badges/Donate-IBAN.svg)](https://joplinapp.org/donate/#donations)
<!-- DONATELINKS -->
<img width="64" src="https://raw.githubusercontent.com/laurent22/joplin/dev/Assets/LinuxIcons/256x256.png" align="left" style="margin-right:15px"/>

View File

@@ -22,7 +22,7 @@
"version": "latest",
"excluded_platforms": ["aarch64-darwin", "x86_64-darwin"],
},
"git": "2.48.1",
"git": "2.50.0",
},
"shell": {
"init_hook": [

View File

@@ -19,7 +19,7 @@
services:
postgresql-master:
image: 'bitnamilegacy/postgresql:17.4.0'
image: 'bitnamilegacy/postgresql:17.5.0'
ports:
- '5432:5432'
environment:
@@ -36,7 +36,7 @@ services:
- POSTGRESQL_EXTRA_FLAGS=-c work_mem=100000 -c log_statement=all
postgresql-slave:
image: 'bitnamilegacy/postgresql:17.4.0'
image: 'bitnamilegacy/postgresql:17.5.0'
ports:
- '5433:5432'
depends_on:

View File

@@ -0,0 +1,270 @@
import * as fs from 'fs-extra';
import Logger, { TargetType } from '@joplin/utils/Logger';
import { dirname } from '@joplin/lib/path-utils';
const { DatabaseDriverNode } = require('@joplin/lib/database-driver-node.js');
import JoplinDatabase from '@joplin/lib/JoplinDatabase';
import BaseModel from '@joplin/lib/BaseModel';
import Folder from '@joplin/lib/models/Folder';
import Note from '@joplin/lib/models/Note';
import Setting from '@joplin/lib/models/Setting';
import { node } from 'execa';
import { splitCommandString } from '@joplin/utils';
const nodeSqlite = require('sqlite3');
const { loadKeychainServiceAndSettings } = require('@joplin/lib/services/SettingUtils');
const { default: shimInitCli } = require('./utils/shimInitCli');
const baseDir = `${dirname(__dirname)}/tests/cli-integration`;
const joplinAppPath = `${__dirname}/main.js`;
shimInitCli({ nodeSqlite, appVersion: () => require('../package.json').version, keytar: null });
require('@joplin/lib/testing/test-utils');
interface Client {
id: number;
profileDir: string;
}
function createClient(id: number): Client {
return {
id: id,
profileDir: `${baseDir}/client${id}`,
};
}
async function execCommand(client: Client, command: string) {
const result = await node(
joplinAppPath,
['--update-geolocation-disabled', '--env', 'dev', '--profile', client.profileDir, ...splitCommandString(command)],
);
if (result.exitCode !== 0) {
throw new Error(`Command failed: ${command}:\nstderr: ${result.stderr}\nstdout: ${result.stdout}`);
}
return result.stdout;
}
async function clearDatabase(db: JoplinDatabase) {
await db.transactionExecBatch(['DELETE FROM folders', 'DELETE FROM notes', 'DELETE FROM tags', 'DELETE FROM note_tags', 'DELETE FROM resources', 'DELETE FROM deleted_items']);
}
describe('cli-integration-tests', () => {
let client: Client;
let db: JoplinDatabase;
beforeAll(async () => {
await fs.remove(baseDir);
await fs.mkdir(baseDir);
client = createClient(1);
// Initialize the database by running a client command and exiting.
await execCommand(client, 'version');
const dbLogger = new Logger();
dbLogger.addTarget(TargetType.Console);
dbLogger.setLevel(Logger.LEVEL_WARN);
db = new JoplinDatabase(new DatabaseDriverNode());
db.setLogger(dbLogger);
await db.open({ name: `${client.profileDir}/database.sqlite` });
BaseModel.setDb(db);
Setting.setConstant('rootProfileDir', client.profileDir);
Setting.setConstant('profileDir', client.profileDir);
await loadKeychainServiceAndSettings([]);
});
beforeEach(async () => {
await clearDatabase(db);
});
it.each([
'version',
'help',
])('should run command %j without crashing', async (command) => {
await execCommand(client, command);
});
it('should support the \'ls\' command', async () => {
await execCommand(client, 'mkbook nb1');
await execCommand(client, 'mknote note1');
await execCommand(client, 'mknote note2');
const r = await execCommand(client, 'ls');
expect(r.indexOf('note1') >= 0).toBe(true);
expect(r.indexOf('note2') >= 0).toBe(true);
});
it('should support the \'mv\' command', async () => {
await execCommand(client, 'mkbook nb2');
await execCommand(client, 'mkbook nb1');
await execCommand(client, 'mknote n1');
await execCommand(client, 'mv n1 nb2');
const f1 = await Folder.loadByTitle('nb1');
const f2 = await Folder.loadByTitle('nb2');
let notes1 = await Note.previews(f1.id);
let notes2 = await Note.previews(f2.id);
expect(notes1.length).toBe(0);
expect(notes2.length).toBe(1);
await execCommand(client, 'mknote note1');
await execCommand(client, 'mknote note2');
await execCommand(client, 'mknote note3');
await execCommand(client, 'mknote blabla');
notes1 = await Note.previews(f1.id);
notes2 = await Note.previews(f2.id);
expect(notes1.length).toBe(4);
expect(notes2.length).toBe(1);
await execCommand(client, 'mv \'note*\' nb2');
notes2 = await Note.previews(f2.id);
notes1 = await Note.previews(f1.id);
expect(notes1.length).toBe(1);
expect(notes2.length).toBe(4);
});
it('should support the \'use\' command', async () => {
await execCommand(client, 'mkbook nb1');
await execCommand(client, 'mkbook nb2');
await execCommand(client, 'mknote n1');
await execCommand(client, 'mknote n2');
const f1 = await Folder.loadByTitle('nb1');
const f2 = await Folder.loadByTitle('nb2');
let notes1 = await Note.previews(f1.id);
let notes2 = await Note.previews(f2.id);
expect(notes1.length).toBe(0);
expect(notes2.length).toBe(2);
await execCommand(client, 'use nb1');
await execCommand(client, 'mknote note2');
await execCommand(client, 'mknote note3');
notes1 = await Note.previews(f1.id);
notes2 = await Note.previews(f2.id);
expect(notes1.length).toBe(2);
});
it('should support creating and removing folders', async () => {
await execCommand(client, 'mkbook nb1');
let folders = await Folder.all();
expect(folders.length).toBe(1);
expect(folders[0].title).toBe('nb1');
await execCommand(client, 'mkbook nb1');
folders = await Folder.all();
expect(folders.length).toBe(2);
expect(folders[0].title).toBe('nb1');
expect(folders[1].title).toBe('nb1');
await execCommand(client, 'rmbook -p -f nb1');
folders = await Folder.all();
expect(folders.length).toBe(1);
await execCommand(client, 'rmbook -p -f nb1');
folders = await Folder.all();
expect(folders.length).toBe(0);
});
it('should support creating and removing notes', async () => {
await execCommand(client, 'mkbook nb1');
await execCommand(client, 'mknote n1');
let notes = await Note.all();
expect(notes.length).toBe(1);
expect(notes[0].title).toBe('n1');
await execCommand(client, 'rmnote -p -f n1');
notes = await Note.all();
expect(notes.length).toBe(0);
await execCommand(client, 'mknote n1');
await execCommand(client, 'mknote n2');
notes = await Note.all();
expect(notes.length).toBe(2);
// Should fail to delete a non-existent note
let failed = false;
try {
await execCommand(client, 'rmnote -f \'blabla*\'');
} catch (error) {
failed = true;
}
expect(failed).toBe(true);
notes = await Note.all();
expect(notes.length).toBe(2);
await execCommand(client, 'rmnote -f -p \'n*\'');
notes = await Note.all();
expect(notes.length).toBe(0);
});
it('should support listing the contents of notes', async () => {
await execCommand(client, 'mkbook nb1');
await execCommand(client, 'mknote mynote');
const folder = await Folder.loadByTitle('nb1');
const note = await Note.loadFolderNoteByField(folder.id, 'title', 'mynote');
let r = await execCommand(client, 'cat mynote');
expect(r).toContain('mynote');
expect(r).not.toContain(note.id);
r = await execCommand(client, 'cat -v mynote');
expect(r).toContain(note.id);
});
it('should support changing settings with config', async () => {
await execCommand(client, 'config editor vim');
await Setting.reset();
await Setting.load();
expect(Setting.value('editor')).toBe('vim');
await execCommand(client, 'config editor subl');
await Setting.reset();
await Setting.load();
expect(Setting.value('editor')).toBe('subl');
const r = await execCommand(client, 'config');
expect(r.indexOf('editor') >= 0).toBe(true);
expect(r.indexOf('subl') >= 0).toBe(true);
});
it('should support copying folders with cp', async () => {
await execCommand(client, 'mkbook nb2');
await execCommand(client, 'mkbook nb1');
await execCommand(client, 'mknote n1');
await execCommand(client, 'cp n1');
const f1 = await Folder.loadByTitle('nb1');
const f2 = await Folder.loadByTitle('nb2');
let notes = await Note.previews(f1.id);
expect(notes.length).toBe(2);
await execCommand(client, 'cp n1 nb2');
const notesF1 = await Note.previews(f1.id);
expect(notesF1.length).toBe(2);
notes = await Note.previews(f2.id);
expect(notes.length).toBe(1);
expect(notes[0].title).toBe(notesF1[0].title);
});
});

View File

@@ -1,300 +0,0 @@
'use strict';
/* eslint-disable no-console */
import * as fs from 'fs-extra';
import Logger, { TargetType } from '@joplin/utils/Logger';
import { dirname } from '@joplin/lib/path-utils';
const { DatabaseDriverNode } = require('@joplin/lib/database-driver-node.js');
import JoplinDatabase from '@joplin/lib/JoplinDatabase';
import BaseModel from '@joplin/lib/BaseModel';
import Folder from '@joplin/lib/models/Folder';
import Note from '@joplin/lib/models/Note';
import Setting from '@joplin/lib/models/Setting';
const { sprintf } = require('sprintf-js');
const exec = require('child_process').exec;
const nodeSqlite = require('sqlite3');
const { loadKeychainServiceAndSettings } = require('@joplin/lib/services/SettingUtils');
const { default: shimInitCli } = require('./utils/shimInitCli');
const baseDir = `${dirname(__dirname)}/tests/cli-integration`;
const joplinAppPath = `${__dirname}/main.js`;
shimInitCli({ nodeSqlite, appVersion: () => require('../package.json').version, keytar: null });
require('@joplin/lib/testing/test-utils');
const logger = new Logger();
logger.addTarget(TargetType.Console);
logger.setLevel(Logger.LEVEL_ERROR);
const dbLogger = new Logger();
dbLogger.addTarget(TargetType.Console);
dbLogger.setLevel(Logger.LEVEL_INFO);
const db = new JoplinDatabase(new DatabaseDriverNode());
db.setLogger(dbLogger);
interface Client {
id: number;
profileDir: string;
}
function createClient(id: number): Client {
return {
id: id,
profileDir: `${baseDir}/client${id}`,
};
}
const client = createClient(1);
function execCommand(client: Client, command: string) {
const exePath = `node ${joplinAppPath}`;
const cmd = `${exePath} --update-geolocation-disabled --env dev --profile ${client.profileDir} ${command}`;
logger.info(`${client.id}: ${command}`);
return new Promise<string>((resolve, reject) => {
exec(cmd, (error: string, stdout: string, stderr: string) => {
if (error) {
logger.error(stderr);
reject(error);
} else {
resolve(stdout.trim());
}
});
});
}
function assertTrue(v: unknown) {
if (!v) throw new Error(sprintf('Expected "true", got "%s"."', v));
process.stdout.write('.');
}
function assertFalse(v: unknown) {
if (v) throw new Error(sprintf('Expected "false", got "%s"."', v));
process.stdout.write('.');
}
function assertEquals(expected: unknown, real: unknown) {
if (expected !== real) throw new Error(sprintf('Expecting "%s", got "%s"', expected, real));
process.stdout.write('.');
}
async function clearDatabase() {
await db.transactionExecBatch(['DELETE FROM folders', 'DELETE FROM notes', 'DELETE FROM tags', 'DELETE FROM note_tags', 'DELETE FROM resources', 'DELETE FROM deleted_items']);
}
const testUnits: Record<string, ()=> Promise<void>> = {};
testUnits.testFolders = async () => {
await execCommand(client, 'mkbook nb1');
let folders = await Folder.all();
assertEquals(1, folders.length);
assertEquals('nb1', folders[0].title);
await execCommand(client, 'mkbook nb1');
folders = await Folder.all();
assertEquals(2, folders.length);
assertEquals('nb1', folders[0].title);
assertEquals('nb1', folders[1].title);
await execCommand(client, 'rmbook -p -f nb1');
folders = await Folder.all();
assertEquals(1, folders.length);
await execCommand(client, 'rmbook -p -f nb1');
folders = await Folder.all();
assertEquals(0, folders.length);
};
testUnits.testNotes = async () => {
await execCommand(client, 'mkbook nb1');
await execCommand(client, 'mknote n1');
let notes = await Note.all();
assertEquals(1, notes.length);
assertEquals('n1', notes[0].title);
await execCommand(client, 'rmnote -p -f n1');
notes = await Note.all();
assertEquals(0, notes.length);
await execCommand(client, 'mknote n1');
await execCommand(client, 'mknote n2');
notes = await Note.all();
assertEquals(2, notes.length);
// Should fail to delete a non-existent note
let failed = false;
try {
await execCommand(client, 'rmnote -f \'blabla*\'');
} catch (error) {
failed = true;
}
assertEquals(failed, true);
notes = await Note.all();
assertEquals(2, notes.length);
await execCommand(client, 'rmnote -f -p \'n*\'');
notes = await Note.all();
assertEquals(0, notes.length);
};
testUnits.testCat = async () => {
await execCommand(client, 'mkbook nb1');
await execCommand(client, 'mknote mynote');
const folder = await Folder.loadByTitle('nb1');
const note = await Note.loadFolderNoteByField(folder.id, 'title', 'mynote');
let r = await execCommand(client, 'cat mynote');
assertTrue(r.indexOf('mynote') >= 0);
assertFalse(r.indexOf(note.id) >= 0);
r = await execCommand(client, 'cat -v mynote');
assertTrue(r.indexOf(note.id) >= 0);
};
testUnits.testConfig = async () => {
await execCommand(client, 'config editor vim');
await Setting.reset();
await Setting.load();
assertEquals('vim', Setting.value('editor'));
await execCommand(client, 'config editor subl');
await Setting.reset();
await Setting.load();
assertEquals('subl', Setting.value('editor'));
const r = await execCommand(client, 'config');
assertTrue(r.indexOf('editor') >= 0);
assertTrue(r.indexOf('subl') >= 0);
};
testUnits.testCp = async () => {
await execCommand(client, 'mkbook nb2');
await execCommand(client, 'mkbook nb1');
await execCommand(client, 'mknote n1');
await execCommand(client, 'cp n1');
const f1 = await Folder.loadByTitle('nb1');
const f2 = await Folder.loadByTitle('nb2');
let notes = await Note.previews(f1.id);
assertEquals(2, notes.length);
await execCommand(client, 'cp n1 nb2');
const notesF1 = await Note.previews(f1.id);
assertEquals(2, notesF1.length);
notes = await Note.previews(f2.id);
assertEquals(1, notes.length);
assertEquals(notesF1[0].title, notes[0].title);
};
testUnits.testLs = async () => {
await execCommand(client, 'mkbook nb1');
await execCommand(client, 'mknote note1');
await execCommand(client, 'mknote note2');
const r = await execCommand(client, 'ls');
assertTrue(r.indexOf('note1') >= 0);
assertTrue(r.indexOf('note2') >= 0);
};
testUnits.testMv = async () => {
await execCommand(client, 'mkbook nb2');
await execCommand(client, 'mkbook nb1');
await execCommand(client, 'mknote n1');
await execCommand(client, 'mv n1 nb2');
const f1 = await Folder.loadByTitle('nb1');
const f2 = await Folder.loadByTitle('nb2');
let notes1 = await Note.previews(f1.id);
let notes2 = await Note.previews(f2.id);
assertEquals(0, notes1.length);
assertEquals(1, notes2.length);
await execCommand(client, 'mknote note1');
await execCommand(client, 'mknote note2');
await execCommand(client, 'mknote note3');
await execCommand(client, 'mknote blabla');
notes1 = await Note.previews(f1.id);
notes2 = await Note.previews(f2.id);
assertEquals(4, notes1.length);
assertEquals(1, notes2.length);
await execCommand(client, 'mv \'note*\' nb2');
notes2 = await Note.previews(f2.id);
notes1 = await Note.previews(f1.id);
assertEquals(1, notes1.length);
assertEquals(4, notes2.length);
};
testUnits.testUse = async () => {
await execCommand(client, 'mkbook nb1');
await execCommand(client, 'mkbook nb2');
await execCommand(client, 'mknote n1');
await execCommand(client, 'mknote n2');
const f1 = await Folder.loadByTitle('nb1');
const f2 = await Folder.loadByTitle('nb2');
let notes1 = await Note.previews(f1.id);
let notes2 = await Note.previews(f2.id);
assertEquals(0, notes1.length);
assertEquals(2, notes2.length);
await execCommand(client, 'use nb1');
await execCommand(client, 'mknote note2');
await execCommand(client, 'mknote note3');
notes1 = await Note.previews(f1.id);
notes2 = await Note.previews(f2.id);
assertEquals(2, notes1.length);
assertEquals(2, notes2.length);
};
async function main() {
await fs.remove(baseDir);
logger.info(await execCommand(client, 'version'));
await db.open({ name: `${client.profileDir}/database.sqlite` });
BaseModel.setDb(db);
Setting.setConstant('rootProfileDir', client.profileDir);
Setting.setConstant('profileDir', client.profileDir);
await loadKeychainServiceAndSettings([]);
let onlyThisTest = 'testMv';
onlyThisTest = '';
for (const n in testUnits) {
if (!testUnits.hasOwnProperty(n)) continue;
if (onlyThisTest && n !== onlyThisTest) continue;
await clearDatabase();
const testName = n.substr(4).toLowerCase();
process.stdout.write(`${testName}: `);
await testUnits[n]();
console.info('');
}
}
main().catch(error => {
console.info('');
logger.error(error);
});

View File

@@ -12,7 +12,7 @@ class Command extends BaseCommand {
}
public override async action() {
this.stdout(versionInfo(require('./package.json'), {}).message);
this.stdout(versionInfo(require('../package.json'), {}).message);
}
}

View File

@@ -1,5 +1,5 @@
const { afterEachCleanUp } = require('@joplin/lib/testing/test-utils.js');
const { shimInit } = require('@joplin/lib/shim-init-node.js');
const { default: shimInitCli } = require('./app/utils/shimInitCli');
const shim = require('@joplin/lib/shim').default;
const sharp = require('sharp');
const nodeSqlite = require('sqlite3');
@@ -13,7 +13,7 @@ try {
keytar = null;
}
shimInit({ sharp, keytar, nodeSqlite });
shimInitCli({ sharp, nodeSqlite, appVersion: () => require('./package.json').version, keytar });
global.afterEach(async () => {
await afterEachCleanUp();

View File

@@ -52,7 +52,7 @@ describe('MarkupToHtml', () => {
pluginAssets: [],
};
expect(await service.render(MarkupLanguage.Html, testString, {}, {})).toMatchObject(expectedOutput);
expect(await service.render(MarkupLanguage.Markdown, testString, {}, {})).toMatchObject(expectedOutput);
expect(await service.render(MarkupLanguage.Html, testString, {}, { })).toMatchObject(expectedOutput);
expect(await service.render(MarkupLanguage.Markdown, testString, {}, { })).toMatchObject(expectedOutput);
});
});

View File

@@ -23,6 +23,7 @@ import { defaultWindowId } from '@joplin/lib/reducer';
import { msleep, Second } from '@joplin/utils/time';
import determineBaseAppDirs from '@joplin/lib/determineBaseAppDirs';
import getAppName from '@joplin/lib/getAppName';
import { execCommand } from '@joplin/utils';
interface RendererProcessQuitReply {
canClose: boolean;
@@ -810,6 +811,33 @@ export default class ElectronAppWrapper {
return this.customProtocolHandler_;
}
private async fixLinuxAccessibility_() {
if (this.electronApp().accessibilitySupportEnabled) return;
const isOrcaRunning = async () => {
if (!shim.isLinux()) return false;
try {
const matchingProcesses = await execCommand(['ps', '--no-headers', '-C', 'orca'], { quiet: true });
return matchingProcesses.trim().length > 0;
} catch (error) {
if (error.stderr || error.exitCode !== 1) {
// eslint-disable-next-line no-console -- The main logger is not available at this point.
console.error('Failed to check for and enable accessibility support:', error.stderr);
}
return false;
}
};
// Work around https://issues.chromium.org/issues/431257156 by force-enabling accessibility
// when Orca (a screen reader) is running:
if (await isOrcaRunning()) {
// eslint-disable-next-line no-console -- The main logger is not available at this point.
console.log('Linux accessibility: Enabling full accessibility support.');
this.electronApp().setAccessibilitySupportEnabled(true);
}
}
public async start() {
// Since we are doing other async things before creating the window, we might miss
// the "ready" event. So we use the function below to make sure that the app is ready.
@@ -818,6 +846,8 @@ export default class ElectronAppWrapper {
const alreadyRunning = await this.ensureSingleInstance();
if (alreadyRunning) return;
await this.fixLinuxAccessibility_();
this.customProtocolHandler_ = handleCustomProtocols();
this.createWindow();

View File

@@ -11,7 +11,7 @@ import Setting from '@joplin/lib/models/Setting';
import Note from '@joplin/lib/models/Note';
const { friendlySafeFilename } = require('@joplin/lib/path-utils');
import time from '@joplin/lib/time';
import { BrowserWindow } from 'electron';
import { BrowserWindow, BrowserWindowConstructorOptions } from 'electron';
const md5 = require('md5');
const url = require('url');
@@ -62,8 +62,10 @@ export default class InteropServiceHelper {
htmlFile = await this.exportNoteToHtmlFile(noteId, exportOptions);
const windowOptions = {
show: false,
const windowOptions: BrowserWindowConstructorOptions = {
// Work around a printing issue: As of Electron 39, if the window is initially hidden, printing crashes the app.
// This only seems to be necessary on Linux.
show: shim.isLinux(),
};
win = bridge().newBrowserWindow(windowOptions);
@@ -120,6 +122,9 @@ export default class InteropServiceHelper {
//
// 2025-05-03: Windows and MacOS also need the window.print() workaround.
// See https://github.com/electron/electron/pull/46937.
//
// 2025-10-30: window.print() now causes a crash on Linux -- switch back to the
// other method.
const applyWorkaround = true;
if (applyWorkaround) {

View File

@@ -52,7 +52,7 @@ describe('app.reducer', () => {
...createAppDefaultState({}),
backgroundWindows: {
testWindow: {
...createAppDefaultWindowState(null),
...createAppDefaultWindowState(),
windowId: 'testWindow',
visibleDialogs: {

View File

@@ -30,17 +30,6 @@ export interface NoteIdToScrollPercent {
[noteId: string]: number;
}
type RichTextEditorSelectionBookmark = unknown;
export interface EditorCursorLocations {
readonly richText?: RichTextEditorSelectionBookmark;
readonly markdown?: number;
}
export interface NoteIdToEditorCursorLocations {
[noteId: string]: EditorCursorLocations;
}
export interface VisibleDialogs {
[dialogKey: string]: boolean;
}
@@ -53,9 +42,6 @@ export interface AppWindowState extends WindowState {
devToolsVisible: boolean;
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
watchedResources: any;
lastEditorScrollPercents: NoteIdToScrollPercent;
lastEditorCursorLocations: NoteIdToEditorCursorLocations;
}
interface BackgroundWindowStates {
@@ -79,7 +65,7 @@ export interface AppState extends State, AppWindowState {
isResettingLayout: boolean;
}
export const createAppDefaultWindowState = (globalState: AppState|null): AppWindowState => {
export const createAppDefaultWindowState = (): AppWindowState => {
return {
...defaultWindowState,
visibleDialogs: {},
@@ -88,12 +74,6 @@ export const createAppDefaultWindowState = (globalState: AppState|null): AppWind
editorCodeView: true,
devToolsVisible: false,
watchedResources: {},
// Maintain the scroll and cursor location for secondary windows separate from the
// main window. This prevents scrolling in a secondary window from changing/resetting
// the default scroll position in the main window:
lastEditorCursorLocations: globalState?.lastEditorCursorLocations ?? {},
lastEditorScrollPercents: globalState?.lastEditorScrollPercents ?? {},
};
};
@@ -101,7 +81,7 @@ export const createAppDefaultWindowState = (globalState: AppState|null): AppWind
export function createAppDefaultState(resourceEditWatcherDefaultState: any): AppState {
return {
...defaultState,
...createAppDefaultWindowState(null),
...createAppDefaultWindowState(),
route: {
type: 'NAV_GO',
routeName: 'Main',
@@ -307,28 +287,6 @@ export default function(state: AppState, action: any) {
}
break;
case 'EDITOR_SCROLL_PERCENT_SET':
{
newState = { ...state };
const newPercents = { ...newState.lastEditorScrollPercents };
newPercents[action.noteId] = action.percent;
newState.lastEditorScrollPercents = newPercents;
}
break;
case 'EDITOR_CURSOR_POSITION_SET':
{
newState = { ...state };
const newCursorLocations = { ...newState.lastEditorCursorLocations };
newCursorLocations[action.noteId] = {
...(newCursorLocations[action.noteId] ?? {}),
...action.location,
};
newState.lastEditorCursorLocations = newCursorLocations;
}
break;
case 'NOTE_DEVTOOLS_TOGGLE':
newState = { ...state };
newState.devToolsVisible = !newState.devToolsVisible;

View File

@@ -280,6 +280,16 @@ class Application extends BaseApplication {
Setting.setValue('plugins.states', pluginSettings);
}
// As of Joplin 3.5.7, the ABC rendering is part of the app so we automatically disable the plugin
pluginSettings = {
...pluginSettings,
['org.joplinapp.plugins.AbcSheetMusic']: {
enabled: false,
deleted: false,
hasBeenUpdated: false,
},
};
try {
if (await shim.fsDriver().exists(Setting.value('pluginDir'))) {
await service.loadAndRunPlugins(Setting.value('pluginDir'), pluginSettings);

View File

@@ -2,7 +2,7 @@ import { CommandRuntime, CommandDeclaration, CommandContext } from '@joplin/lib/
import { _ } from '@joplin/lib/locale';
import { stateUtils } from '@joplin/lib/reducer';
import Note from '@joplin/lib/models/Note';
import { AppState, createAppDefaultWindowState } from '../app.reducer';
import { createAppDefaultWindowState } from '../app.reducer';
import Setting from '@joplin/lib/models/Setting';
export const declaration: CommandDeclaration = {
@@ -25,7 +25,7 @@ export const runtime = (): CommandRuntime => {
folderId: note.parent_id,
windowId: `window-${noteId}-${idCounter++}`,
defaultAppWindowState: {
...createAppDefaultWindowState(context.state as AppState),
...createAppDefaultWindowState(),
noteVisiblePanes: Setting.value('noteVisiblePanes'),
editorCodeView: Setting.value('editor.codeView'),
},

View File

@@ -261,7 +261,10 @@ class ConfigScreenComponent extends React.Component<any, any> {
if (settings['sync.target'] === SyncTargetRegistry.nameToId('joplinServerSaml')) {
const server = settings['sync.11.path'] as string;
const goToSamlLogin = () => {
const goToSamlLogin = async () => {
// Save settings to allow SAML auth with the correct URL.
await shared.saveSettings(this);
this.props.dispatch({
type: 'NAV_GO',
routeName: 'JoplinServerSamlLogin',

View File

@@ -106,7 +106,7 @@ const JoplinCloudScreenComponent = (props: Props) => {
<span className={state.className}>{state.errorMessage}</span>
) : null}
</p>
{state.active === 'LINK_USED' ? <div id="loading-animation" /> : null}
{state.active === 'LINK_USED' ? <div className="loading-animation" /> : null}
<JoplinCloudSignUpCallToAction />
</div>
<ButtonBar onCancelClick={() => props.dispatch({ type: 'NAV_BACK' })} />

View File

@@ -9,7 +9,6 @@ import { PluginStates, utils as pluginUtils } from '@joplin/lib/services/plugins
import shim from '@joplin/lib/shim';
import Setting from '@joplin/lib/models/Setting';
import versionInfo, { PackageInfo } from '@joplin/lib/versionInfo';
import makeDiscourseDebugUrl from '@joplin/lib/makeDiscourseDebugUrl';
import { ImportModule } from '@joplin/lib/services/interop/Module';
import InteropServiceHelper from '../InteropServiceHelper';
import { _ } from '@joplin/lib/locale';
@@ -29,6 +28,8 @@ import { EventName } from '@joplin/lib/eventManager';
import { ipcRenderer } from 'electron';
import NavService from '@joplin/lib/services/NavService';
import Logger from '@joplin/utils/Logger';
import { ImportCommandOptions } from './WindowCommandsAndDialogs/commands/importFrom';
import { FileSystemItem } from '@joplin/lib/services/interop/types';
const logger = Logger.create('MenuBar');
@@ -304,83 +305,16 @@ function useMenu(props: Props) {
void CommandService.instance().execute(commandName);
}, []);
const onImportModuleClick = useCallback(async (module: ImportModule, moduleSource: string) => {
let path = null;
if (moduleSource === 'file') {
path = await bridge().showOpenDialog({
filters: [{ name: module.description, extensions: module.fileExtensions }],
});
} else {
path = await bridge().showOpenDialog({
properties: ['openDirectory', 'createDirectory'],
});
}
if (!path || (Array.isArray(path) && !path.length)) return;
if (Array.isArray(path)) path = path[0];
const modalMessage = _('Importing from "%s" as "%s" format. Please wait...', path, module.format);
void CommandService.instance().execute('showModalMessage', modalMessage);
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
const errors: any[] = [];
const importOptions = {
path,
format: module.format,
outputFormat: module.outputFormat,
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
onProgress: (status: any) => {
const statusStrings: string[] = Object.keys(status).map((key: string) => {
return `${key}: ${status[key]}`;
});
void CommandService.instance().execute('showModalMessage', `${modalMessage}\n\n${statusStrings.join('\n')}`);
},
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
onError: (error: any) => {
errors.push(error);
console.warn(error);
},
const onImportModuleClick = useCallback(async (module: ImportModule, moduleSource: FileSystemItem) => {
const options: ImportCommandOptions = {
destinationFolderId: !module.isNoteArchive && moduleSource === 'file' ? props.selectedFolderId : null,
sourcePath: undefined, // Show a file picker
sourceType: moduleSource,
importFormat: module.format,
outputFormat: module.outputFormat,
};
const service = InteropService.instance();
try {
const result = await service.import(importOptions);
// eslint-disable-next-line no-console
console.info('Import result: ', result);
} catch (error) {
bridge().showErrorMessageBox(error.message);
}
void CommandService.instance().execute('hideModalMessage');
if (errors.length) {
const response = bridge().showErrorMessageBox('There was some errors importing the notes - check the console for more details.\n\nPlease consider sending a bug report to the forum!', {
buttons: [_('Close'), _('Send bug report')],
});
props.dispatch({ type: 'NOTE_DEVTOOLS_SET', value: true });
if (response === 1) {
const url = makeDiscourseDebugUrl(
`Error importing notes from format: ${module.format}`,
`- Input format: ${module.format}\n- Output format: ${module.outputFormat}`,
errors,
packageInfo,
PluginService.instance(),
props.pluginSettings,
);
void bridge().openExternal(url);
}
}
// eslint-disable-next-line @seiyab/react-hooks/exhaustive-deps -- Old code before rule was applied
}, [props.selectedFolderId, props.pluginSettings]);
await CommandService.instance().execute('importFrom', options);
}, [props.selectedFolderId]);
const onMenuItemClickRef = useRef(null);
onMenuItemClickRef.current = onMenuItemClick;

View File

@@ -22,12 +22,6 @@ interface MultiNoteActionsProps {
function styles_(props: MultiNoteActionsProps) {
return buildStyle('MultiNoteActions', props.themeId, (theme: ThemeStyle) => {
return {
root: {
display: 'inline-flex',
justifyContent: 'center',
paddingTop: theme.marginTop,
width: '100%',
},
itemList: {
display: 'flex',
flexDirection: 'column',
@@ -90,7 +84,7 @@ export default function MultiNoteActions(props: MultiNoteActionsProps) {
}
return (
<div style={styles.root}>
<div style={styles.root} className='multi-note-actions'>
<div style={styles.itemList}>{itemComps}</div>
</div>
);

View File

@@ -13,7 +13,7 @@ import { _ } from '@joplin/lib/locale';
import bridge from '../../../../../services/bridge';
import shim from '@joplin/lib/shim';
import { MarkupToHtml } from '@joplin/renderer';
const { clipboard } = require('electron');
import { clipboard } from 'electron';
import { reg } from '@joplin/lib/registry';
import ErrorBoundary from '../../../../ErrorBoundary';
import { EditorKeymap, EditorLanguageType, EditorSettings, SearchState, UserEventSource } from '@joplin/editor/types';
@@ -32,6 +32,7 @@ import useRefocusOnVisiblePaneChange from './utils/useRefocusOnVisiblePaneChange
import { WindowIdContext } from '../../../../NewWindowOrIFrame';
import eventManager, { EventName, ResourceChangeEvent } from '@joplin/lib/eventManager';
import useSyncEditorValue from './utils/useSyncEditorValue';
import { getGlobalSettings } from '@joplin/renderer/types';
const logger = Logger.create('CodeMirror6');
const logDebug = (message: string) => logger.debug(message);
@@ -93,41 +94,13 @@ const CodeMirror = (props: NoteBodyEditorProps, ref: ForwardedRef<NoteBodyEditor
const editorCutText = useCallback(() => {
if (editorRef.current) {
const selections = editorRef.current.getSelections();
if (selections.length > 0 && selections[0]) {
clipboard.writeText(selections[0]);
// Easy way to wipe out just the first selection
selections[0] = '';
editorRef.current.replaceSelections(selections);
} else {
const cursor = editorRef.current.getCursor();
const line = editorRef.current.getLine(cursor.line);
clipboard.writeText(`${line}\n`);
const startLine = editorRef.current.getCursor('head');
startLine.ch = 0;
const endLine = {
line: startLine.line + 1,
ch: 0,
};
editorRef.current.replaceRange('', startLine, endLine);
}
editorRef.current.cutText(text => clipboard.writeText(text));
}
}, []);
const editorCopyText = useCallback(() => {
if (editorRef.current) {
const selections = editorRef.current.getSelections();
// Handle the case when there is a selection - copy the selection to the clipboard
// When there is no selection, the selection array contains an empty string.
if (selections.length > 0 && selections[0]) {
clipboard.writeText(selections[0]);
} else {
// This is the case when there is no selection - copy the current line to the clipboard
const cursor = editorRef.current.getCursor();
const line = editorRef.current.getLine(cursor.line);
clipboard.writeText(line);
}
editorRef.current.copyText(text => clipboard.writeText(text));
}
}, []);
@@ -248,6 +221,7 @@ const CodeMirror = (props: NoteBodyEditorProps, ref: ForwardedRef<NoteBodyEditor
useCustomPdfViewer: props.useCustomPdfViewer,
noteId: props.noteId,
vendorDir: bridge().vendorDir(),
globalSettings: getGlobalSettings(Setting),
}));
if (cancelled) return;

View File

@@ -5,6 +5,8 @@ import { MarkupToHtmlHandler } from '../../../utils/types';
import { _ } from '@joplin/lib/locale';
import enableTextAreaTab, { TextAreaTabHandler } from './enableTextAreaTab';
import { MarkupToHtml } from '@joplin/renderer';
import { getGlobalSettings } from '@joplin/renderer/types';
import Setting from '@joplin/lib/models/Setting';
interface Props {
editor: Editor;
@@ -90,7 +92,7 @@ function openEditDialog(
onSubmit: async (dialogApi: any) => {
const newSource = newBlockSource(dialogApi.getData().languageInput, dialogApi.getData().codeTextArea, source);
const md = `${newSource.openCharacters}${newSource.content}${newSource.closeCharacters}`;
const result = await markupToHtml.current(MarkupToHtml.MARKUP_LANGUAGE_MARKDOWN, md, { bodyOnly: true });
const result = await markupToHtml.current(MarkupToHtml.MARKUP_LANGUAGE_MARKDOWN, md, { bodyOnly: true, globalSettings: getGlobalSettings(Setting) });
// markupToHtml will return the complete editable HTML, but we only
// want to update the inner HTML, so as not to break additional props that

View File

@@ -18,7 +18,7 @@ import { NoteEditorProps, FormNote, OnChangeEvent, AllAssetsOptions, NoteBodyEdi
import CommandService from '@joplin/lib/services/CommandService';
import Button, { ButtonLevel } from '../Button/Button';
import eventManager, { EventName } from '@joplin/lib/eventManager';
import { AppState, EditorCursorLocations } from '../../app.reducer';
import { AppState } from '../../app.reducer';
import ToolbarButtonUtils, { ToolbarButtonInfo } from '@joplin/lib/services/commands/ToolbarButtonUtils';
import { _, _n } from '@joplin/lib/locale';
import NoteTitleBar from './NoteTitle/NoteTitleBar';
@@ -58,6 +58,7 @@ import useVisiblePluginEditorViewIds from '@joplin/lib/hooks/plugins/useVisibleP
import useConnectToEditorPlugin from './utils/useConnectToEditorPlugin';
import getResourceBaseUrl from './utils/getResourceBaseUrl';
import useInitialCursorLocation from './utils/useInitialCursorLocation';
import NotePositionService, { EditorCursorLocations } from '@joplin/lib/services/NotePositionService';
const debounce = require('debounce');
@@ -333,7 +334,6 @@ function NoteEditorContent(props: NoteEditorProps) {
const { scrollWhenReadyRef, clearScrollWhenReady } = useScrollWhenReadyOptions({
noteId: formNote.id,
selectedNoteHash: props.selectedNoteHash,
lastEditorScrollPercents: props.lastEditorScrollPercents,
editorRef,
editorName: props.bodyEditor,
});
@@ -401,23 +401,14 @@ function NoteEditorContent(props: NoteEditorProps) {
}, [setShowRevisions]);
const onScroll = useCallback((event: { percent: number }) => {
props.dispatch({
type: 'EDITOR_SCROLL_PERCENT_SET',
// In callbacks of setTimeout()/setInterval(), props/state cannot be used
// to refer the current value, since they would be one or more generations old.
// For the purpose, useRef value should be used.
noteId: formNoteRef.current.id,
percent: event.percent,
});
}, [props.dispatch]);
const noteId = formNoteRef.current.id;
NotePositionService.instance().updateScrollPosition(noteId, windowId, event.percent);
}, [windowId]);
const onCursorMotion = useCallback((location: EditorCursorLocations) => {
props.dispatch({
type: 'EDITOR_CURSOR_POSITION_SET',
noteId: formNoteRef.current.id,
location,
});
}, [props.dispatch]);
const noteId = formNoteRef.current.id;
NotePositionService.instance().updateCursorPosition(noteId, windowId, location);
}, [windowId]);
function renderNoNotes(rootStyle: React.CSSProperties) {
const emptyDivStyle = {
@@ -430,7 +421,7 @@ function NoteEditorContent(props: NoteEditorProps) {
const searchMarkers = useSearchMarkers(showLocalSearch, localSearchMarkerOptions, props.searches, props.selectedSearchId, props.highlightedWords);
const initialCursorLocation = useInitialCursorLocation({
lastEditorCursorLocations: props.lastEditorCursorLocations, noteId: props.noteId,
noteId: props.noteId,
});
const markupLanguage = formNote.markup_language;
@@ -743,8 +734,6 @@ const mapStateToProps = (state: AppState, ownProps: ConnectProps) => {
watchedNoteFiles: state.watchedNoteFiles,
notesParentType: windowState.notesParentType,
selectedNoteTags: windowState.selectedNoteTags,
lastEditorScrollPercents: state.lastEditorScrollPercents,
lastEditorCursorLocations: state.lastEditorCursorLocations,
selectedNoteHash: windowState.selectedNoteHash,
searches: state.searches,
selectedSearchId: windowState.selectedSearchId,

View File

@@ -1,7 +1,15 @@
import { LinkRenderingType } from '@joplin/renderer/MdToHtml';
import { MarkupToHtmlOptions } from './types';
import { getGlobalSettings, ResourceInfos } from '@joplin/renderer/types';
import Setting from '@joplin/lib/models/Setting';
export default (override: MarkupToHtmlOptions = null): MarkupToHtmlOptions => {
interface OptionOverride {
bodyOnly: boolean;
resourceInfos?: ResourceInfos;
allowedFilePrefixes?: string[];
}
export default (override: OptionOverride = null): MarkupToHtmlOptions => {
return {
plugins: {
checkbox: {
@@ -12,6 +20,7 @@ export default (override: MarkupToHtmlOptions = null): MarkupToHtmlOptions => {
},
},
replaceResourceInternalToExternalLinks: true,
globalSettings: getGlobalSettings(Setting),
...override,
};
};

View File

@@ -98,6 +98,10 @@ export async function getResourcesFromPasteEvent(event: any) {
const formatType = format.split('/')[0];
if (formatType === 'image') {
// writeImageToFile can process only image/jpeg, image/jpg or image/png mime types
if (['image/png', 'image/jpg', 'image/jpeg'].indexOf(format) < 0) {
continue;
}
if (event) event.preventDefault();
const image = clipboard.readImage();

View File

@@ -14,7 +14,7 @@ import { ScrollbarSize } from '@joplin/lib/models/settings/builtInMetadata';
import { RefObject, SetStateAction } from 'react';
import * as React from 'react';
import { ResourceEntity, ResourceLocalStateEntity } from '@joplin/lib/services/database/types';
import { EditorCursorLocations, NoteIdToEditorCursorLocations, NoteIdToScrollPercent } from '../../../app.reducer';
import { EditorCursorLocations } from '@joplin/lib/services/NotePositionService';
export interface AllAssetsOptions {
contentMaxWidthTarget?: string;
@@ -41,8 +41,6 @@ export interface NoteEditorProps {
notesParentType: string;
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
selectedNoteTags: any[];
lastEditorScrollPercents: NoteIdToScrollPercent;
lastEditorCursorLocations: NoteIdToEditorCursorLocations;
selectedNoteHash: string;
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
searches: any[];

View File

@@ -1,17 +1,17 @@
import { useMemo } from 'react';
import { EditorCursorLocations, NoteIdToEditorCursorLocations } from '../../../app.reducer';
import { useContext, useMemo } from 'react';
import { WindowIdContext } from '../../NewWindowOrIFrame';
import NotePositionService from '@joplin/lib/services/NotePositionService';
interface Props {
lastEditorCursorLocations: NoteIdToEditorCursorLocations;
noteId: string;
}
const useInitialCursorLocation = ({ noteId, lastEditorCursorLocations }: Props) => {
const lastCursorLocation = lastEditorCursorLocations[noteId];
const useInitialCursorLocation = ({ noteId }: Props) => {
const windowId = useContext(WindowIdContext);
return useMemo((): EditorCursorLocations => {
return lastCursorLocation ?? { };
}, [lastCursorLocation]);
return useMemo(() => {
return NotePositionService.instance().getCursorPosition(noteId, windowId);
}, [noteId, windowId]);
};
export default useInitialCursorLocation;

View File

@@ -1,42 +1,43 @@
import { RefObject, useCallback, useRef } from 'react';
import { RefObject, useCallback, useContext, useRef } from 'react';
import { NoteBodyEditorRef, ScrollOptions, ScrollOptionTypes } from './types';
import usePrevious from '@joplin/lib/hooks/usePrevious';
import type { NoteIdToScrollPercent } from '../../../app.reducer';
import NotePositionService from '@joplin/lib/services/NotePositionService';
import useNowEffect from '@joplin/lib/hooks/useNowEffect';
import { WindowIdContext } from '../../NewWindowOrIFrame';
interface Props {
noteId: string;
editorName: string;
selectedNoteHash: string;
lastEditorScrollPercents: NoteIdToScrollPercent;
editorRef: RefObject<NoteBodyEditorRef>;
}
const useScrollWhenReadyOptions = ({ noteId, editorName, selectedNoteHash, lastEditorScrollPercents, editorRef }: Props) => {
const useScrollWhenReadyOptions = ({ noteId, editorName, selectedNoteHash, editorRef }: Props) => {
const scrollWhenReadyRef = useRef<ScrollOptions|null>(null);
const previousNoteId = usePrevious(noteId);
const noteIdChanged = noteId !== previousNoteId;
const previousEditor = usePrevious(editorName);
const editorChanged = editorName !== previousEditor;
const lastScrollPercentsRef = useRef<NoteIdToScrollPercent>(null);
lastScrollPercentsRef.current = lastEditorScrollPercents;
const windowId = useContext(WindowIdContext);
// This needs to be a nowEffect to prevent race conditions
useNowEffect(() => {
const editorChanged = editorName !== previousEditor;
const noteIdChanged = noteId !== previousNoteId;
if (!editorChanged && !noteIdChanged) return () => {};
const lastScrollPercent = NotePositionService.instance().getScrollPercent(noteId, windowId) || 0;
scrollWhenReadyRef.current = {
type: selectedNoteHash ? ScrollOptionTypes.Hash : ScrollOptionTypes.Percent,
value: selectedNoteHash ? selectedNoteHash : lastScrollPercent,
};
if (editorRef.current) {
editorRef.current.resetScroll();
}
const lastScrollPercent = lastScrollPercentsRef.current[noteId] || 0;
scrollWhenReadyRef.current = {
type: selectedNoteHash ? ScrollOptionTypes.Hash : ScrollOptionTypes.Percent,
value: selectedNoteHash ? selectedNoteHash : lastScrollPercent,
};
return () => {};
}, [editorChanged, noteIdChanged, noteId, selectedNoteHash, editorRef]);
}, [editorName, previousEditor, noteId, previousNoteId, selectedNoteHash, editorRef, windowId]);
const clearScrollWhenReady = useCallback(() => {
scrollWhenReadyRef.current = null;

View File

@@ -25,6 +25,8 @@ import useAsyncEffect from '@joplin/lib/hooks/useAsyncEffect';
import { ScrollbarSize } from '@joplin/lib/models/settings/builtInMetadata';
import { focus } from '@joplin/lib/utils/focusHandler';
import useDeleteHistoryClick from '@joplin/lib/components/shared/NoteRevisionViewer/useDeleteHistoryClick';
import { getGlobalSettings } from '@joplin/renderer/types';
import Setting from '@joplin/lib/models/Setting';
interface Props {
themeId: number;
@@ -72,6 +74,7 @@ const useNoteContent = (
const result = await markupToHtml(markupLanguage, noteBody, {
resources: await shared.attachedResources(noteBody),
whiteBackgroundNoteRendering: markupLanguage === MarkupLanguage.Html,
globalSettings: getGlobalSettings(Setting),
});
viewerRef.current.setHtml(result.html, {

View File

@@ -20,6 +20,7 @@ import { reg } from '@joplin/lib/registry';
import useAsyncEffect, { AsyncEffectEvent } from '@joplin/lib/hooks/useAsyncEffect';
import { ChangeEvent, Dropdown, DropdownOptions, DropdownVariant } from '../Dropdown/Dropdown';
import shim from '@joplin/lib/shim';
import { SettingsRecord } from '@joplin/lib/models/Setting';
const logger = Logger.create('ShareFolderDialog');
@@ -421,10 +422,14 @@ function ShareFolderDialog(props: Props) {
}
const mapStateToProps = (state: State) => {
const getCanUseSharePermissions = (settings: Partial<SettingsRecord>) => {
return [9, 10, 11].includes(settings['sync.target']) && !!settings['sync.10.canUseSharePermissions'];
};
return {
shares: state.shareService.shares,
shareUsers: state.shareService.shareUsers,
canUseSharePermissions: state.settings['sync.target'] === 10 && state.settings['sync.10.canUseSharePermissions'],
canUseSharePermissions: getCanUseSharePermissions(state.settings),
};
};

View File

@@ -9,7 +9,7 @@ import { useMemo, useRef, useState } from 'react';
import ItemList from '../ItemList';
import useElementHeight from '../hooks/useElementHeight';
import useSidebarListData from './hooks/useSidebarListData';
import useSelectedSidebarIndex from './hooks/useSelectedSidebarIndex';
import useSelectedSidebarIndexes from './hooks/useSelectedSidebarIndexes';
import useOnSidebarKeyDownHandler from './hooks/useOnSidebarKeyDownHandler';
import useFocusHandler from './hooks/useFocusHandler';
import useOnRenderItem from './hooks/useOnRenderItem';
@@ -26,7 +26,9 @@ interface Props {
tags: TagsWithNoteCountEntity[];
folders: FolderEntity[];
notesParentType: string;
selectedTagIds: string[];
selectedTagId: string;
selectedFolderIds: string[];
selectedFolderId: string;
selectedSmartFilterId: string;
collapsedFolderIds: string[];
@@ -37,7 +39,7 @@ interface Props {
const FolderAndTagList: React.FC<Props> = props => {
const listItems = useSidebarListData(props);
const { selectedIndex, updateSelectedIndex } = useSelectedSidebarIndex({
const { selectedIndex, selectedIndexes, updateSelectedIndex } = useSelectedSidebarIndexes({
...props,
listItems: listItems,
});
@@ -50,6 +52,7 @@ const FolderAndTagList: React.FC<Props> = props => {
const onRenderItem = useOnRenderItem({
...props,
selectedIndex,
selectedIndexes,
listItems,
containerRef: listContainerRef,
});
@@ -58,6 +61,7 @@ const FolderAndTagList: React.FC<Props> = props => {
dispatch: props.dispatch,
listItems: listItems,
selectedIndex,
selectedIndexes,
updateSelectedIndex,
collapsedFolderIds: props.collapsedFolderIds,
});
@@ -107,6 +111,8 @@ const mapStateToProps = (state: AppState) => {
tags: state.tags,
folders: state.folders,
notesParentType: mainWindowState.notesParentType,
selectedFolderIds: mainWindowState.selectedFolderIds,
selectedTagIds: mainWindowState.selectedTagIds,
selectedFolderId: mainWindowState.selectedFolderId,
selectedTagId: mainWindowState.selectedTagId,
collapsedFolderIds: state.collapsedFolderIds,

View File

@@ -0,0 +1,65 @@
import { MouseEvent } from 'react';
import { ModelType } from '@joplin/lib/BaseModel';
import { RefObject, useCallback } from 'react';
import { Dispatch } from 'redux';
import { ListItem, ListItemType } from '../types';
import shim from '@joplin/lib/shim';
export interface ItemClickEvent {
id: string;
type: ModelType;
event: MouseEvent;
}
interface Props {
itemsRef: RefObject<ListItem[]>;
selectedIndexesRef: RefObject<number[]>;
dispatch: Dispatch;
}
const listItemToId = (item: ListItem) => {
if (item.kind === ListItemType.Tag) return item.tag.id;
if (item.kind === ListItemType.Folder) return item.folder.id;
return null;
};
const useOnItemClick = ({ dispatch, selectedIndexesRef, itemsRef }: Props) => {
return useCallback(({ id, type, event }: ItemClickEvent) => {
const action = type === ModelType.Folder ? 'FOLDER_SELECT' : 'TAG_SELECT';
const selectedIndexes = selectedIndexesRef.current;
const findItemIndex = () => itemsRef.current.findIndex(item => listItemToId(item) === id);
if (event.shiftKey && selectedIndexes.length > 0) {
const index = findItemIndex();
if (index === -1) throw new Error(`No item found with ID: ${id}`);
const lastAddedIndex = selectedIndexes[selectedIndexes.length - 1];
const indexStart = Math.min(index, lastAddedIndex);
const indexStop = Math.max(index, lastAddedIndex);
const itemIds = itemsRef.current.slice(indexStart, indexStop + 1)
.map(listItemToId)
.filter(id => !!id);
dispatch({
type: `${action}_ADD`,
ids: itemIds,
});
} else if (shim.isMac() ? event.metaKey : event.ctrlKey) {
const index = findItemIndex();
// Don't allow unselecting all items: Keep at least one item selected
const canDeselect = selectedIndexes.length > 1;
const actionType = canDeselect && selectedIndexes.includes(index) ? 'REMOVE' : 'ADD';
dispatch({
type: `${action}_${actionType}`,
id: id,
});
} else {
dispatch({
type: action,
id: id,
});
}
}, [dispatch, selectedIndexesRef, itemsRef]);
};
export default useOnItemClick;

View File

@@ -1,16 +1,15 @@
import * as React from 'react';
import { DragEventHandler, MouseEventHandler, useCallback, useMemo, useRef } from 'react';
import { ItemClickListener, ItemDragListener, ListItem, ListItemType } from '../types';
import TagItem, { TagLinkClickEvent } from '../listItemComponents/TagItem';
import TagItem from '../listItemComponents/TagItem';
import { Dispatch } from 'redux';
import { clipboard } from 'electron';
import type { MenuItem as MenuItemType } from 'electron';
import { getTrashFolderId } from '@joplin/lib/services/trash';
import BaseModel, { ModelType } from '@joplin/lib/BaseModel';
import Tag from '@joplin/lib/models/Tag';
import { _ } from '@joplin/lib/locale';
import { substrWithEllipsis } from '@joplin/lib/string-utils';
import { AppState } from '../../../app.reducer';
import { store } from '@joplin/lib/reducer';
import Folder from '@joplin/lib/models/Folder';
import bridge from '../../../services/bridge';
import MenuUtils from '@joplin/lib/services/commands/MenuUtils';
@@ -18,7 +17,6 @@ import CommandService from '@joplin/lib/services/CommandService';
import { FolderEntity } from '@joplin/lib/services/database/types';
import InteropService from '@joplin/lib/services/interop/InteropService';
import InteropServiceHelper from '../../../InteropServiceHelper';
import stateToWhenClauseContext from '@joplin/lib/services/commands/stateToWhenClauseContext';
import Setting from '@joplin/lib/models/Setting';
import PerFolderSortOrderService from '../../../services/sortOrder/PerFolderSortOrderService';
import { getFolderCallbackUrl, getTagCallbackUrl } from '@joplin/lib/callbackUrlUtils';
@@ -29,12 +27,13 @@ import Logger from '@joplin/utils/Logger';
import onFolderDrop from '@joplin/lib/models/utils/onFolderDrop';
import HeaderItem from '../listItemComponents/HeaderItem';
import AllNotesItem from '../listItemComponents/AllNotesItem';
import ListItemWrapper from '../listItemComponents/ListItemWrapper';
import ListItemWrapper, { ItemSelectionState } from '../listItemComponents/ListItemWrapper';
import { focus } from '@joplin/lib/utils/focusHandler';
import shim from '@joplin/lib/shim';
import useOnItemClick from './useOnItemClick';
const Menu = bridge().Menu;
const MenuItem = bridge().MenuItem;
const MenuItem: typeof MenuItemType = bridge().MenuItem;
const logger = Logger.create('useOnRenderItem');
@@ -47,6 +46,7 @@ interface Props {
containerRef: React.RefObject<HTMLDivElement>;
selectedIndex: number;
selectedIndexes: number[];
listItems: ListItem[];
}
@@ -65,6 +65,11 @@ const focusListItem = (item: HTMLElement|null) => {
const noFocusListItem = () => {};
const folderCommandToMenuItem = (commandId: string, folderIds: string|string[]) => {
const options = Array.isArray(folderIds) ? { commandFolderIds: folderIds } : { commandFolderId: folderIds };
return new MenuItem(menuUtils.commandToStatefulMenuItem(commandId, folderIds, options));
};
const useOnRenderItem = (props: Props) => {
const pluginsRef = useRef<PluginStates>(null);
@@ -72,13 +77,6 @@ const useOnRenderItem = (props: Props) => {
const foldersRef = useRef<FolderEntity[]>(null);
foldersRef.current = props.folders;
const tagItem_click = useCallback(({ tag }: TagLinkClickEvent) => {
props.dispatch({
type: 'TAG_SELECT',
id: tag ? tag.id : null,
});
}, [props.dispatch]);
const onTagDrop_: DragEventHandler<HTMLElement> = useCallback(async event => {
const tagId = event.currentTarget.getAttribute('data-tag-id');
const dt = event.dataTransfer;
@@ -94,6 +92,24 @@ const useOnRenderItem = (props: Props) => {
}
}, []);
const selectedIndexesRef = useRef(props.selectedIndexes);
selectedIndexesRef.current = props.selectedIndexes;
const itemsRef = useRef(props.listItems);
itemsRef.current = props.listItems;
const getSelectedIds = useCallback(() => {
return selectedIndexesRef.current.map(index => {
const item = itemsRef.current[index];
if (item.kind === ListItemType.Folder) {
return item.folder.id;
} else if (item.kind === ListItemType.Tag) {
return item.tag.id;
}
return null;
}).filter(id => !!id);
}, []);
const onItemClick = useOnItemClick({ dispatch: props.dispatch, selectedIndexesRef, itemsRef });
const onItemContextMenu: ItemContextMenuListener = useCallback(async event => {
const itemId = event.currentTarget.getAttribute('data-id');
if (itemId === Folder.conflictFolderId()) return;
@@ -101,14 +117,22 @@ const useOnRenderItem = (props: Props) => {
const itemType = Number(event.currentTarget.getAttribute('data-type'));
if (!itemId || !itemType) throw new Error('No data on element');
const state: AppState = store().getState();
let itemIds = [itemId];
const itemIndex = Number(event.currentTarget.getAttribute('data-index'));
if (selectedIndexesRef.current.includes(itemIndex)) {
itemIds = getSelectedIds();
}
let deleteMessage = '';
const deleteButtonLabel = _('Remove');
if (itemType === BaseModel.TYPE_TAG) {
const tag = await Tag.load(itemId);
deleteMessage = _('Remove tag "%s" from all notes?', substrWithEllipsis(tag.title, 0, 32));
if (itemIds.length === 1) {
const tag = await Tag.load(itemId);
deleteMessage = _('Remove tag "%s" from all notes?', substrWithEllipsis(tag.title, 0, 32));
} else {
deleteMessage = _('Remove %d tags from all notes? This cannot be undone.', itemIds.length);
}
} else if (itemType === BaseModel.TYPE_SEARCH) {
deleteMessage = _('Remove this search from the sidebar?');
}
@@ -131,16 +155,13 @@ const useOnRenderItem = (props: Props) => {
const isDeleted = item ? !!item.deleted_time : false;
if (!isDeleted) {
if (itemType === BaseModel.TYPE_FOLDER && !item.encryption_applied) {
menu.append(
new MenuItem(menuUtils.commandToStatefulMenuItem('newFolder', itemId)),
);
const isDecryptedFolder = itemType === BaseModel.TYPE_FOLDER && !item.encryption_applied;
if (isDecryptedFolder && itemIds.length === 1) {
menu.append(folderCommandToMenuItem('newFolder', itemId));
}
if (itemType === BaseModel.TYPE_FOLDER) {
menu.append(
new MenuItem(menuUtils.commandToStatefulMenuItem('deleteFolder', itemId)),
);
menu.append(folderCommandToMenuItem('deleteFolder', itemIds));
} else {
menu.append(
new MenuItem({
@@ -153,7 +174,9 @@ const useOnRenderItem = (props: Props) => {
if (!ok) return;
if (itemType === BaseModel.TYPE_TAG) {
await Tag.untagAll(itemId);
for (const itemId of itemIds) {
await Tag.untagAll(itemId);
}
} else if (itemType === BaseModel.TYPE_SEARCH) {
props.dispatch({
type: 'SEARCH_DELETE',
@@ -165,15 +188,18 @@ const useOnRenderItem = (props: Props) => {
);
}
if (itemType === BaseModel.TYPE_FOLDER && !item.encryption_applied) {
if (isDecryptedFolder) {
const whenClause = CommandService.instance().currentWhenClauseContext({ commandFolderIds: itemIds });
menu.append(new MenuItem({
...menuUtils.commandToStatefulMenuItem('moveToFolder', [itemId]),
// By default, enabled is based on the selected folder. However, the right-click
// menu can be shown for unselected folders.
enabled: true,
...menuUtils.commandToStatefulMenuItem('moveToFolder', itemIds),
// By default, moveToFolder's enabled condition is based on the selected notes. However, the right-click
// menu item applies to folders. For now, use a custom condition:
enabled: !whenClause.foldersIncludeReadOnly,
}));
}
menu.append(new MenuItem(menuUtils.commandToStatefulMenuItem('openFolderDialog', { folderId: itemId })));
if (isDecryptedFolder && itemIds.length === 1) {
menu.append(new MenuItem(menuUtils.commandToStatefulMenuItem('openFolderDialog', { folderId: itemId }, { commandFolderId: itemId })));
menu.append(new MenuItem({ type: 'separator' }));
@@ -188,25 +214,17 @@ const useOnRenderItem = (props: Props) => {
new MenuItem({
label: module.fullLabel(),
click: async () => {
await InteropServiceHelper.export(props.dispatch, module, { sourceFolderIds: [itemId], plugins: pluginsRef.current });
await InteropServiceHelper.export(props.dispatch, module, { sourceFolderIds: itemIds, plugins: pluginsRef.current });
},
}),
);
}
// We don't display the "Share notebook" menu item for sub-notebooks
// that are within a shared notebook. If user wants to do this,
// they'd have to move the notebook out of the shared notebook
// first.
const whenClause = stateToWhenClauseContext(state, { commandFolderId: itemId });
if (CommandService.instance().isEnabled('showShareFolderDialog', whenClause)) {
menu.append(new MenuItem(menuUtils.commandToStatefulMenuItem('showShareFolderDialog', itemId)));
}
if (CommandService.instance().isEnabled('leaveSharedFolder', whenClause)) {
menu.append(new MenuItem(menuUtils.commandToStatefulMenuItem('leaveSharedFolder', itemId)));
}
// Only show the share/leave share actions for top-level folders
const shareFolderItem = folderCommandToMenuItem('showShareFolderDialog', itemId);
if (shareFolderItem.enabled) menu.append(shareFolderItem);
const leaveSharedFolderItem = folderCommandToMenuItem('leaveSharedFolder', itemId);
if (leaveSharedFolderItem.enabled) menu.append(leaveSharedFolderItem);
menu.append(
new MenuItem({
@@ -216,14 +234,14 @@ const useOnRenderItem = (props: Props) => {
);
if (Setting.value('notes.perFolderSortOrderEnabled')) {
menu.append(new MenuItem({
...menuUtils.commandToStatefulMenuItem('togglePerFolderSortOrder', itemId),
...menuUtils.commandToStatefulMenuItem('togglePerFolderSortOrder', itemId, { commandFolderId: itemId }),
type: 'checkbox',
checked: PerFolderSortOrderService.isSet(itemId),
}));
}
}
if (itemType === BaseModel.TYPE_FOLDER) {
if (itemType === BaseModel.TYPE_FOLDER && itemIds.length === 1) {
menu.append(
new MenuItem({
label: _('Copy external link'),
@@ -234,7 +252,7 @@ const useOnRenderItem = (props: Props) => {
);
}
if (itemType === BaseModel.TYPE_TAG) {
if (itemType === BaseModel.TYPE_TAG && itemIds.length === 1) {
menu.append(new MenuItem(
menuUtils.commandToStatefulMenuItem('renameTag', itemId),
));
@@ -253,24 +271,22 @@ const useOnRenderItem = (props: Props) => {
for (const view of pluginViews) {
const location = view.location;
if (itemType === ModelType.Tag && location === MenuItemLocation.TagContextMenu ||
itemType === ModelType.Folder && location === MenuItemLocation.FolderContextMenu
) {
if (itemType === ModelType.Tag && location === MenuItemLocation.TagContextMenu) {
menu.append(
new MenuItem(menuUtils.commandToStatefulMenuItem(view.commandName, itemId)),
);
} else if (itemType === ModelType.Folder && location === MenuItemLocation.FolderContextMenu) {
menu.append(folderCommandToMenuItem(view.commandName, itemId));
}
}
} else {
if (itemType === BaseModel.TYPE_FOLDER) {
menu.append(
new MenuItem(menuUtils.commandToStatefulMenuItem('restoreFolder', itemId)),
);
menu.append(folderCommandToMenuItem('restoreFolder', itemIds));
}
}
menu.popup({ window: bridge().activeWindow() });
}, [props.dispatch, pluginsRef]);
}, [props.dispatch, pluginsRef, getSelectedIds]);
@@ -278,10 +294,16 @@ const useOnRenderItem = (props: Props) => {
const folderId = event.currentTarget.getAttribute('data-folder-id');
if (!folderId) return;
let itemIds = [folderId];
const itemIndex = Number(event.currentTarget.getAttribute('data-index'));
if (selectedIndexesRef.current.includes(itemIndex)) {
itemIds = getSelectedIds();
}
event.dataTransfer.setDragImage(new Image(), 1, 1);
event.dataTransfer.clearData();
event.dataTransfer.setData('text/x-jop-folder-ids', JSON.stringify([folderId]));
}, []);
event.dataTransfer.setData('text/x-jop-folder-ids', JSON.stringify(itemIds));
}, [getSelectedIds]);
const onFolderDragOver_: ItemDragListener = useCallback(event => {
if (event.dataTransfer.types.indexOf('text/x-jop-note-ids') >= 0) event.preventDefault();
@@ -323,13 +345,6 @@ const useOnRenderItem = (props: Props) => {
});
}, [props.dispatch]);
const folderItem_click = useCallback((folderId: string) => {
props.dispatch({
type: 'FOLDER_SELECT',
id: folderId ? folderId : null,
});
}, [props.dispatch]);
// If at least one of the folder has an icon, then we display icons for all
// folders (those without one will get the default icon). This is so that
// visual alignment is correct for all folders, otherwise the folder tree
@@ -338,22 +353,26 @@ const useOnRenderItem = (props: Props) => {
return Folder.shouldShowFolderIcons(props.folders);
}, [props.folders]);
const selectedIndexRef = useRef(props.selectedIndex);
selectedIndexRef.current = props.selectedIndex;
const itemCount = props.listItems.length;
return useCallback((item: ListItem, index: number) => {
const selected = props.selectedIndex === index;
const primarySelected = props.selectedIndex === index;
const selected = primarySelected || props.selectedIndexes.includes(index);
const selectionState: ItemSelectionState = {
primarySelected,
selected,
multipleItemsSelected: props.selectedIndexes.length > 1,
};
const focusInList = document.hasFocus() && props.containerRef.current?.contains(document.activeElement);
const anchorRef = (focusInList && selected) ? focusListItem : noFocusListItem;
const anchorRef = (focusInList && primarySelected) ? focusListItem : noFocusListItem;
if (item.kind === ListItemType.Tag) {
const tag = item.tag;
return <TagItem
key={item.key}
anchorRef={anchorRef}
selected={selected}
onClick={tagItem_click}
selectionState={selectionState}
onClick={onItemClick}
onTagDrop={onTagDrop_}
onContextMenu={onItemContextMenu}
label={item.label}
@@ -383,7 +402,7 @@ const useOnRenderItem = (props: Props) => {
return <FolderItem
key={item.key}
anchorRef={anchorRef}
selected={selected}
selectionState={selectionState}
folderId={folder.id}
folderTitle={item.label}
folderIcon={Folder.unserializeIcon(folder.icon)}
@@ -395,7 +414,7 @@ const useOnRenderItem = (props: Props) => {
onFolderDragOver_={onFolderDragOver_}
onFolderDrop_={onFolderDrop_}
itemContextMenu={onItemContextMenu}
folderItem_click={folderItem_click}
folderItem_click={onItemClick}
onFolderToggleClick_={onFolderToggleClick_}
shareId={folder.share_id}
parentId={folder.parent_id}
@@ -408,7 +427,7 @@ const useOnRenderItem = (props: Props) => {
key={item.id}
anchorRef={anchorRef}
item={item}
isSelected={selected}
selectionState={selectionState}
onDrop={item.supportsFolderDrop ? onFolderDrop_ : null}
index={index}
itemCount={itemCount}
@@ -417,7 +436,7 @@ const useOnRenderItem = (props: Props) => {
return <AllNotesItem
key={item.key}
anchorRef={anchorRef}
selected={selected}
selectionState={selectionState}
item={item}
index={index}
itemCount={itemCount}
@@ -428,7 +447,7 @@ const useOnRenderItem = (props: Props) => {
key={item.key}
containerRef={anchorRef}
depth={1}
selected={selected}
selectionState={selectionState}
itemIndex={index}
itemCount={itemCount}
highlightOnHover={false}
@@ -442,7 +461,7 @@ const useOnRenderItem = (props: Props) => {
return exhaustivenessCheck;
}
}, [
folderItem_click,
onItemClick,
onFolderDragOver_,
onFolderDragStart_,
onFolderDrop_,
@@ -452,8 +471,8 @@ const useOnRenderItem = (props: Props) => {
props.collapsedFolderIds,
props.folders,
showFolderIcons,
tagItem_click,
props.selectedIndex,
props.selectedIndexes,
props.containerRef,
itemCount,
]);

View File

@@ -9,6 +9,7 @@ interface Props {
listItems: ListItem[];
collapsedFolderIds: string[];
selectedIndex: number;
selectedIndexes: number[];
updateSelectedIndex: SetSelectedIndexCallback;
}
@@ -68,7 +69,7 @@ const findNextTypeAheadMatch = (selectedIndex: number, query: string, listItems:
};
const useOnSidebarKeyDownHandler = (props: Props) => {
const { updateSelectedIndex, listItems, selectedIndex, collapsedFolderIds, dispatch } = props;
const { updateSelectedIndex, listItems, selectedIndex, selectedIndexes, collapsedFolderIds, dispatch } = props;
return useCallback<KeyboardEventHandler<HTMLElement>>((event) => {
const selectedItem = listItems[selectedIndex];
@@ -104,12 +105,15 @@ const useOnSidebarKeyDownHandler = (props: Props) => {
event.preventDefault();
} else if (event.code === 'Home') {
event.preventDefault();
updateSelectedIndex(0);
updateSelectedIndex(0, { extend: false });
indexChange = 0;
} else if (event.code === 'End') {
event.preventDefault();
updateSelectedIndex(listItems.length - 1);
updateSelectedIndex(listItems.length - 1, { extend: false });
indexChange = 0;
} else if (event.code === 'Escape' && selectedIndexes.length > 1) {
event.preventDefault();
updateSelectedIndex(selectedIndex, { extend: false });
} else if (event.code === 'Enter' && !event.shiftKey) {
event.preventDefault();
void CommandService.instance().execute('focusElement', 'noteList');
@@ -122,9 +126,9 @@ const useOnSidebarKeyDownHandler = (props: Props) => {
if (indexChange !== 0) {
event.preventDefault();
updateSelectedIndex(selectedIndex + indexChange);
updateSelectedIndex(selectedIndex + indexChange, { extend: event.shiftKey });
}
}, [selectedIndex, collapsedFolderIds, listItems, updateSelectedIndex, dispatch]);
}, [selectedIndex, selectedIndexes, collapsedFolderIds, listItems, updateSelectedIndex, dispatch]);
};
export default useOnSidebarKeyDownHandler;

View File

@@ -1,88 +0,0 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { ListItem, ListItemType } from '../types';
import { isFolderSelected, isTagSelected } from '@joplin/lib/components/shared/side-menu-shared';
import { Dispatch } from 'redux';
const { ALL_NOTES_FILTER_ID } = require('@joplin/lib/reserved-ids');
interface Props {
dispatch: Dispatch;
listItems: ListItem[];
notesParentType: string;
selectedTagId: string;
selectedFolderId: string;
selectedSmartFilterId: string;
}
const useSelectedSidebarIndex = (props: Props) => {
const appStateSelectedIndex = useMemo(() => {
for (let i = 0; i < props.listItems.length; i++) {
const listItem = props.listItems[i];
let selected = false;
if (listItem.kind === ListItemType.AllNotes) {
selected = props.selectedSmartFilterId === ALL_NOTES_FILTER_ID && props.notesParentType === 'SmartFilter';
} else if (listItem.kind === ListItemType.Header || listItem.kind === ListItemType.Spacer) {
selected = false;
} else if (listItem.kind === ListItemType.Folder) {
selected = isFolderSelected(listItem.folder, { selectedFolderId: props.selectedFolderId, notesParentType: props.notesParentType });
} else if (listItem.kind === ListItemType.Tag) {
selected = isTagSelected(listItem.tag, { selectedTagId: props.selectedTagId, notesParentType: props.notesParentType });
} else {
const exhaustivenessCheck: never = listItem;
return exhaustivenessCheck;
}
if (selected) {
return i;
}
}
return -1;
}, [props.listItems, props.selectedFolderId, props.selectedTagId, props.selectedSmartFilterId, props.notesParentType]);
// Not all list items correspond with selectable Joplin folders/tags, but we want to
// be able to select them anyway. This is handled with selectedIndexOverride.
//
// When selectedIndexOverride >= 0, it corresponds to the index of a selected item with no
// specific note parent item (e.g. a header).
const [selectedIndexOverride, setSelectedIndexOverride] = useState(-1);
useEffect(() => {
setSelectedIndexOverride(-1);
}, [appStateSelectedIndex]);
const updateSelectedIndex = useCallback((newIndex: number) => {
if (newIndex < 0) {
newIndex = 0;
} else if (newIndex >= props.listItems.length) {
newIndex = props.listItems.length - 1;
}
const newItem = props.listItems[newIndex];
let newOverrideIndex = -1;
if (newItem.kind === ListItemType.AllNotes) {
props.dispatch({
type: 'SMART_FILTER_SELECT',
id: ALL_NOTES_FILTER_ID,
});
} else if (newItem.kind === ListItemType.Folder) {
props.dispatch({
type: 'FOLDER_SELECT',
id: newItem.folder.id,
});
} else if (newItem.kind === ListItemType.Tag) {
props.dispatch({
type: 'TAG_SELECT',
id: newItem.tag.id,
});
} else {
newOverrideIndex = newIndex;
}
setSelectedIndexOverride(newOverrideIndex);
}, [props.listItems, props.dispatch]);
const selectedIndex = selectedIndexOverride === -1 ? appStateSelectedIndex : selectedIndexOverride;
return { selectedIndex, updateSelectedIndex };
};
export default useSelectedSidebarIndex;

View File

@@ -0,0 +1,132 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { ListItem, ListItemType } from '../types';
import { isFolderSelected, isTagSelected } from '@joplin/lib/components/shared/side-menu-shared';
import { Dispatch } from 'redux';
const { ALL_NOTES_FILTER_ID } = require('@joplin/lib/reserved-ids');
type UpdateSelectedIndexOptions = { extend: boolean };
interface Props {
dispatch: Dispatch;
listItems: ListItem[];
notesParentType: string;
selectedTagId: string;
selectedTagIds: string[];
selectedFolderId: string;
selectedFolderIds: string[];
selectedSmartFilterId: string;
}
const useSelectedSidebarIndexes = (props: Props) => {
const isIndexInSelection = useCallback((index: number) => {
const listItem = props.listItems[index];
let selected = false;
if (listItem.kind === ListItemType.AllNotes) {
selected = props.selectedSmartFilterId === ALL_NOTES_FILTER_ID && props.notesParentType === 'SmartFilter';
} else if (listItem.kind === ListItemType.Header || listItem.kind === ListItemType.Spacer) {
selected = false;
} else if (listItem.kind === ListItemType.Folder) {
selected = isFolderSelected(listItem.folder, {
selectedFolderIds: props.selectedFolderIds,
notesParentType: props.notesParentType,
});
} else if (listItem.kind === ListItemType.Tag) {
selected = isTagSelected(listItem.tag, { selectedTagIds: props.selectedTagIds, notesParentType: props.notesParentType });
} else {
const exhaustivenessCheck: never = listItem;
return exhaustivenessCheck;
}
return selected;
}, [props.listItems, props.selectedFolderIds, props.selectedTagIds, props.selectedSmartFilterId, props.notesParentType]);
const isIndexPrimarySelected = useCallback((index: number) => {
const listItem = props.listItems[index];
if (listItem.kind === ListItemType.Folder) {
return isFolderSelected(listItem.folder, {
selectedFolderIds: [props.selectedFolderId],
notesParentType: props.notesParentType,
});
} else if (listItem.kind === ListItemType.Tag) {
return isTagSelected(listItem.tag, { selectedTagIds: [props.selectedTagId], notesParentType: props.notesParentType });
} else {
return isIndexInSelection(index);
}
}, [props.listItems, isIndexInSelection, props.selectedFolderId, props.selectedTagId, props.notesParentType]);
const appStateSelectedIndexes = useMemo(() => {
const selectedIndexes = [];
for (let i = 0; i < props.listItems.length; i++) {
if (isIndexInSelection(i)) {
selectedIndexes.push(i);
}
}
return selectedIndexes;
}, [props.listItems, isIndexInSelection]);
const appStateSelectedIndex = useMemo(() => {
return props.listItems.findIndex((_item, index) => isIndexPrimarySelected(index));
}, [props.listItems, isIndexPrimarySelected]);
// The main index of all selected indexes. This is where the focus will go.
// Ignored if not included in appStateSelectedIndexes.
const [primarySelectedIndex, setPrimarySelectedIndex] = useState(0);
// Not all list items correspond with selectable Joplin folders/tags, but we want to
// be able to select them anyway. This is handled with selectedIndexOverride.
//
// When selectedIndexOverride >= 0, it corresponds to the index of a selected item with no
// specific note parent item (e.g. a header).
const [selectedIndexOverride, setSelectedIndexOverride] = useState(-1);
useEffect(() => {
setSelectedIndexOverride(-1);
setPrimarySelectedIndex(appStateSelectedIndex);
}, [appStateSelectedIndex]);
const updateSelectedIndex = useCallback((newIndex: number, options: UpdateSelectedIndexOptions) => {
if (newIndex < 0) {
newIndex = 0;
} else if (newIndex >= props.listItems.length) {
newIndex = props.listItems.length - 1;
}
const newItem = props.listItems[newIndex];
let newOverrideIndex = -1;
if (newItem.kind === ListItemType.AllNotes) {
props.dispatch({
type: 'SMART_FILTER_SELECT',
id: ALL_NOTES_FILTER_ID,
});
} else if (newItem.kind === ListItemType.Folder) {
props.dispatch({
type: options.extend ? 'FOLDER_SELECT_ADD' : 'FOLDER_SELECT',
id: newItem.folder.id,
});
} else if (newItem.kind === ListItemType.Tag) {
props.dispatch({
type: options.extend ? 'TAG_SELECT_ADD' : 'TAG_SELECT',
id: newItem.tag.id,
});
} else {
newOverrideIndex = newIndex;
}
setSelectedIndexOverride(newOverrideIndex);
setPrimarySelectedIndex(newIndex);
}, [props.listItems, props.dispatch]);
const selectedIndexes = useMemo(() => {
return selectedIndexOverride === -1 ? appStateSelectedIndexes : [selectedIndexOverride];
}, [appStateSelectedIndexes, selectedIndexOverride]);
const selectedIndex = selectedIndexes.includes(primarySelectedIndex) ? primarySelectedIndex : (selectedIndexes[0] ?? -1);
return {
selectedIndex,
selectedIndexes,
updateSelectedIndex,
};
};
export default useSelectedSidebarIndexes;

View File

@@ -9,7 +9,7 @@ import CommandService from '@joplin/lib/services/CommandService';
import PerFolderSortOrderService from '../../../services/sortOrder/PerFolderSortOrderService';
import { connect } from 'react-redux';
import EmptyExpandLink from './EmptyExpandLink';
import ListItemWrapper, { ListItemRef } from './ListItemWrapper';
import ListItemWrapper, { ItemSelectionState, ListItemRef } from './ListItemWrapper';
import { ListItem } from '../types';
const { ALL_NOTES_FILTER_ID } = require('@joplin/lib/reserved-ids');
@@ -19,7 +19,7 @@ const MenuItem = bridge().MenuItem;
interface Props {
dispatch: Dispatch;
anchorRef: ListItemRef;
selected: boolean;
selectionState: ItemSelectionState;
item: ListItem;
index: number;
itemCount: number;
@@ -53,7 +53,7 @@ const AllNotesItem: React.FC<Props> = props => {
<ListItemWrapper
containerRef={props.anchorRef}
key="allNotesHeader"
selected={props.selected}
selectionState={props.selectionState}
depth={props.item.depth}
className={'list-item-container list-item-depth-0 all-notes'}
highlightOnHover={true}
@@ -65,7 +65,7 @@ const AllNotesItem: React.FC<Props> = props => {
<StyledListItemAnchor
className="list-item"
isSpecialItem={true}
selected={props.selected}
selected={props.selectionState.selected}
onClick={onAllNotesClick_}
onContextMenu={toggleAllNotesContextMenu}
>

View File

@@ -10,8 +10,9 @@ import Folder from '@joplin/lib/models/Folder';
import { ModelType } from '@joplin/lib/BaseModel';
import { _ } from '@joplin/lib/locale';
import NoteCount from './NoteCount';
import ListItemWrapper, { ListItemRef } from './ListItemWrapper';
import ListItemWrapper, { ItemSelectionState, ListItemRef } from './ListItemWrapper';
import { useId } from 'react';
import { ItemClickEvent } from '../hooks/useOnItemClick';
const renderFolderIcon = (folderIcon: FolderIcon) => {
if (!folderIcon) {
@@ -42,17 +43,17 @@ interface FolderItemProps {
onFolderDragOver_: ItemDragListener;
onFolderDrop_: ItemDragListener;
itemContextMenu: ItemContextMenuListener;
folderItem_click: (folderId: string)=> void;
folderItem_click: (event: ItemClickEvent)=> void;
onFolderToggleClick_: ItemClickListener;
shareId: string;
selected: boolean;
selectionState: ItemSelectionState;
index: number;
itemCount: number;
}
function FolderItem(props: FolderItemProps) {
const { hasChildren, showFolderIcon, isExpanded, parentId, depth, selected, folderId, folderTitle, folderIcon, noteCount, onFolderDragStart_, onFolderDragOver_, onFolderDrop_, itemContextMenu, folderItem_click, onFolderToggleClick_, shareId } = props;
const { hasChildren, showFolderIcon, isExpanded, parentId, depth, selectionState, folderId, folderTitle, folderIcon, noteCount, onFolderDragStart_, onFolderDragOver_, onFolderDrop_, itemContextMenu, folderItem_click, onFolderToggleClick_, shareId } = props;
const shareTitle = _('Shared');
const shareIcon = shareId && !parentId ? <StyledShareIcon aria-label={shareTitle} title={shareTitle} className="fas fa-share-alt"/> : null;
@@ -73,11 +74,11 @@ function FolderItem(props: FolderItemProps) {
containerRef={props.anchorRef}
// Folders are contained within the "Notebooks" section (which has depth 0):
depth={depth + 1}
selected={selected}
selectionState={selectionState}
itemIndex={props.index}
itemCount={props.itemCount}
expanded={hasChildren ? props.isExpanded : undefined}
className={`list-item-container list-item-depth-${depth} ${selected ? 'selected' : ''}`}
className={`list-item-container list-item-depth-${depth} ${selectionState.selected ? 'selected' : ''}`}
highlightOnHover={true}
onDragStart={onFolderDragStart_}
onDragOver={onFolderDragOver_}
@@ -95,13 +96,15 @@ function FolderItem(props: FolderItemProps) {
className="list-item"
id={titleId}
isConflictFolder={folderId === Folder.conflictFolderId()}
selected={selected}
selected={selectionState.selected}
shareId={shareId}
data-folder-id={folderId}
onDoubleClick={onFolderToggleClick_}
onClick={() => {
folderItem_click(folderId);
onClick={(event: React.MouseEvent) => {
folderItem_click({
id: folderId, type: ModelType.Folder, event,
});
}}
>
{doRenderFolderIcon()}<StyledSpanFix className="title">{folderTitle}</StyledSpanFix>

View File

@@ -5,7 +5,7 @@ import { HeaderId, HeaderListItem } from '../types';
import bridge from '../../../services/bridge';
import MenuUtils from '@joplin/lib/services/commands/MenuUtils';
import CommandService from '@joplin/lib/services/CommandService';
import ListItemWrapper, { ListItemRef } from './ListItemWrapper';
import ListItemWrapper, { ItemSelectionState, ListItemRef } from './ListItemWrapper';
const Menu = bridge().Menu;
const MenuItem = bridge().MenuItem;
@@ -15,7 +15,7 @@ const menuUtils = new MenuUtils(CommandService.instance());
interface Props {
anchorRef: ListItemRef;
item: HeaderListItem;
isSelected: boolean;
selectionState: ItemSelectionState;
onDrop: React.DragEventHandler|null;
index: number;
itemCount: number;
@@ -47,7 +47,7 @@ const HeaderItem: React.FC<Props> = props => {
return (
<ListItemWrapper
containerRef={props.anchorRef}
selected={props.isSelected}
selectionState={props.selectionState}
itemIndex={props.index}
itemCount={props.itemCount}
expanded={props.item.expanded}

View File

@@ -4,9 +4,18 @@ import { useMemo } from 'react';
export type ListItemRef = React.Ref<HTMLDivElement>;
export interface ItemSelectionState {
selected: boolean;
// The item with primary selection is used for actions that support only one folder.
// Only one item can have primary selection.
primarySelected: boolean;
multipleItemsSelected: boolean;
}
interface Props {
containerRef: ListItemRef;
selected: boolean;
selectionState: ItemSelectionState;
itemIndex: number;
itemCount: number;
expanded?: boolean|undefined;
@@ -35,15 +44,17 @@ const ListItemWrapper: React.FC<Props> = props => {
} as React.CSSProperties;
}, [props.depth]);
const { selected, primarySelected, multipleItemsSelected } = props.selectionState;
return (
<div
ref={props.containerRef}
aria-posinset={props.itemIndex + 1}
aria-setsize={props.itemCount}
aria-selected={props.selected}
aria-selected={selected}
aria-expanded={props.expanded}
aria-level={props.depth}
tabIndex={props.selected ? 0 : -1}
tabIndex={primarySelected ? 0 : -1}
onContextMenu={props.onContextMenu}
onDrag={props.onDrag}
@@ -53,10 +64,17 @@ const ListItemWrapper: React.FC<Props> = props => {
draggable={props.draggable}
role='treeitem'
className={`list-item-wrapper ${props.highlightOnHover ? '-highlight-on-hover' : ''} ${props.selected ? '-selected' : ''} ${props.className ?? ''}`}
className={[
'list-item-wrapper',
props.highlightOnHover ? '-highlight-on-hover' : '',
selected ? '-selected' : '',
primarySelected && multipleItemsSelected ? '-selected-primary' : '',
props.className ?? '',
].join(' ')}
style={style}
data-folder-id={props['data-folder-id']}
data-id={props['data-id']}
data-index={props.itemIndex}
data-tag-id={props['data-tag-id']}
data-type={props['data-type']}
aria-labelledby={props['aria-labelledby']}

View File

@@ -3,28 +3,27 @@ import * as React from 'react';
import { useCallback } from 'react';
import { StyledListItemAnchor, StyledSpanFix } from '../styles';
import { TagsWithNoteCountEntity } from '@joplin/lib/services/database/types';
import BaseModel from '@joplin/lib/BaseModel';
import BaseModel, { ModelType } from '@joplin/lib/BaseModel';
import NoteCount from './NoteCount';
import EmptyExpandLink from './EmptyExpandLink';
import ListItemWrapper, { ListItemRef } from './ListItemWrapper';
export type TagLinkClickEvent = { tag: TagsWithNoteCountEntity|undefined };
import ListItemWrapper, { ItemSelectionState, ListItemRef } from './ListItemWrapper';
import { ItemClickEvent } from '../hooks/useOnItemClick';
interface Props {
anchorRef: ListItemRef;
selected: boolean;
selectionState: ItemSelectionState;
tag: TagsWithNoteCountEntity;
label: string;
onTagDrop: React.DragEventHandler<HTMLElement>;
onContextMenu: React.MouseEventHandler<HTMLElement>;
onClick: (event: TagLinkClickEvent)=> void;
onClick: (event: ItemClickEvent)=> void;
itemCount: number;
index: number;
}
const TagItem = (props: Props) => {
const { tag, selected } = props;
const { tag, selectionState } = props;
let noteCount = null;
if (Setting.value('showNoteCounts')) {
@@ -32,30 +31,31 @@ const TagItem = (props: Props) => {
noteCount = <NoteCount count={count}/>;
}
const onClickHandler = useCallback(() => {
props.onClick({ tag });
const onClickHandler: React.MouseEventHandler<HTMLElement> = useCallback((event) => {
props.onClick({ id: tag.id, type: ModelType.Tag, event });
}, [props.onClick, tag]);
return (
<ListItemWrapper
containerRef={props.anchorRef}
selected={selected}
selectionState={selectionState}
depth={1}
className={`list-item-container ${selected ? 'selected' : ''}`}
className={`list-item-container ${selectionState.selected ? 'selected' : ''}`}
highlightOnHover={true}
onDrop={props.onTagDrop}
onContextMenu={props.onContextMenu}
data-id={tag.id}
data-tag-id={tag.id}
aria-selected={selected}
data-type={ModelType.Tag}
itemIndex={props.index}
itemCount={props.itemCount}
>
<EmptyExpandLink/>
<StyledListItemAnchor
className="list-item"
selected={selected}
selected={selectionState.selected}
data-id={tag.id}
data-type={BaseModel.TYPE_TAG}
onContextMenu={props.onContextMenu}
onClick={onClickHandler}
>
<StyledSpanFix className="tag-label">{props.label}</StyledSpanFix>

View File

@@ -22,7 +22,30 @@
background: var(--joplin-selected-color2);
}
&.-highlight-on-hover:hover {
// When multiple items are selected, show an outline (similar to the focus outline) to indicate
// which folder has the primary selection.
&.-selected-primary {
--outline-color: var(--joplin-focus-outline-color-dimmed);
outline: 1px solid var(--outline-color);
// Also adjust the background color: This makes it clearer which item has primary focus,
// especially when using a dimmed outline.
background-color: color-mix(
in srgb,
var(--outline-color) 12%,
var(--joplin-selected-color2) 92%
);
// For accessibility, use a different style when actually focused. This makes it easier to
// tell where the keyboard focus is.
&:focus {
--outline-color: var(--joplin-focus-outline-color);
}
}
// Don't highlight selected items on hover -- doing so makes it
// difficult to tell whether the hovered item is selected or not.
&.-highlight-on-hover:not(.-selected):hover {
background-color: var(--joplin-background-color-hover2);
}
}

View File

@@ -57,8 +57,10 @@ export interface SpacerListItem extends ToplevelListItem {
export type ListItem = HeaderListItem|AllNotesListItem|TagListItem|FolderListItem|SpacerListItem;
export type SetSelectedIndexCallback = (newIndex: number)=> void;
interface SetSelectedIndexOptions {
extend: boolean;
}
export type SetSelectedIndexCallback = (newIndex: number, options: SetSelectedIndexOptions)=> void;
export type ItemDragListener = DragEventHandler<HTMLElement>;

View File

@@ -40,12 +40,18 @@ async function exportDebugReportClick() {
}
function StatusScreen(props: Props) {
const [loading, setLoading] = useState(false);
const [report, setReport] = useState<ReportSection[]>([]);
async function refreshScreen() {
const service = new ReportService();
const r = await service.status(Setting.value('sync.target'));
setReport(r);
setLoading(true);
try {
const service = new ReportService();
const r = await service.status(Setting.value('sync.target'));
setReport(r);
} finally {
setLoading(false);
}
}
useEffect(() => {
@@ -208,6 +214,7 @@ function StatusScreen(props: Props) {
<div style={style}>
<div style={containerStyle}>
{renderTools()}
{loading && <p><span className='loading-animation'/> {_('Loading...')}</p>}
{body}
</div>
<ButtonBar

View File

@@ -14,7 +14,7 @@ const ModalMessageOverlay: React.FC<Props> = ({ message }) => {
return <Dialog contentFillsScreen={true}>
<div className="modal-message">
<div id="loading-animation" />
<div className="loading-animation" />
<div className="text" role="status">
{lines}
</div>

View File

@@ -2,6 +2,7 @@ import { CommandRuntime, CommandDeclaration, CommandContext } from '@joplin/lib/
import { _ } from '@joplin/lib/locale';
import bridge from '../../../services/bridge';
import Folder from '@joplin/lib/models/Folder';
import { getTrashFolderId } from '@joplin/lib/services/trash';
const { substrWithEllipsis } = require('@joplin/lib/string-utils');
export const declaration: CommandDeclaration = {
@@ -11,22 +12,37 @@ export const declaration: CommandDeclaration = {
export const runtime = (): CommandRuntime => {
return {
execute: async (context: CommandContext, folderId: string = null) => {
if (folderId === null) folderId = context.state.selectedFolderId;
const folder = await Folder.load(folderId);
if (!folder) throw new Error(`No such folder: ${folderId}`);
let deleteMessage = _('Move notebook "%s" to the trash?\n\nAll notes and sub-notebooks within this notebook will also be moved to the trash.', substrWithEllipsis(folder.title, 0, 32));
if (folderId === context.state.settings['sync.10.inboxId']) {
deleteMessage = _('Delete the Inbox notebook?\n\nIf you delete the inbox notebook, any email that\'s recently been sent to it may be lost.');
execute: async (context: CommandContext, folderIds: string|string[] = null) => {
if (folderIds === null) {
folderIds = context.state.selectedFolderIds;
}
if (!Array.isArray(folderIds)) {
folderIds = [folderIds];
}
const ok = bridge().showConfirmMessageBox(deleteMessage);
folderIds = folderIds.filter(id => id !== getTrashFolderId());
if (folderIds.length === 0) {
throw new Error('Nothing to do: At least one valid folder must be specified.');
}
const folders = await Folder.loadItemsByIdsOrFail(folderIds);
const deleteMessage = [];
if (folders.length === 1) {
deleteMessage.push(_('Move notebook "%s" to the trash?\n\nAll notes and sub-notebooks within this notebook will also be moved to the trash.', substrWithEllipsis(folders[0].title, 0, 32)));
} else {
deleteMessage.push(_('Move %d notebooks to the trash?\n\nAll notes and sub-notebooks within these notebooks will also be moved to the trash.', folders.length));
}
if (folders.some(folder => folder.id === context.state.settings['sync.10.inboxId'])) {
deleteMessage.push(_('Delete the Inbox notebook?\n\nIf you delete the inbox notebook, any email that\'s recently been sent to it may be lost.'));
}
const ok = bridge().showConfirmMessageBox(deleteMessage.join('\n\n'));
if (!ok) return;
await Folder.delete(folderId, { toTrash: true, sourceDescription: 'deleteFolder command' });
await Folder.batchDelete(folderIds, { toTrash: true, sourceDescription: 'deleteFolder command' });
},
enabledCondition: '!folderIsReadOnly',
enabledCondition: '!foldersIncludeReadOnly',
};
};

View File

@@ -0,0 +1,166 @@
import CommandService, { CommandRuntime, CommandDeclaration, CommandContext } from '@joplin/lib/services/CommandService';
import InteropService from '@joplin/lib/services/interop/InteropService';
import { FileSystemItem, ImportModuleOutputFormat, ModuleType } from '@joplin/lib/services/interop/types';
import bridge from '../../../services/bridge';
import { WindowControl } from '../utils/useWindowControl';
import { _ } from '@joplin/lib/locale';
import makeDiscourseDebugUrl from '@joplin/lib/makeDiscourseDebugUrl';
import PluginService from '@joplin/lib/services/plugins/PluginService';
import Setting from '@joplin/lib/models/Setting';
import { PackageInfo } from '@joplin/lib/versionInfo';
import shim from '@joplin/lib/shim';
import { ImportModule } from '@joplin/lib/services/interop/Module';
const packageInfo: PackageInfo = require('../../../packageInfo.js');
export const declaration: CommandDeclaration = {
name: 'importFrom',
label: () => _('Import...'),
};
export interface ImportCommandOptions {
sourcePath: string|undefined;
sourceType: FileSystemItem;
destinationFolderId: string|null;
importFormat: string;
outputFormat: ImportModuleOutputFormat;
}
const findImportModule = async (commandOptions: ImportCommandOptions|null, control: WindowControl) => {
if (commandOptions) {
const module = InteropService.instance().findModuleByFormat(
ModuleType.Importer, commandOptions.importFormat, commandOptions.sourceType, commandOptions.outputFormat);
if (module) {
return module as ImportModule;
}
}
const importModules = InteropService.instance().modules().filter(module => module.type === ModuleType.Importer) as ImportModule[];
return await control.showPrompt({
label: _('Select the type of file to be imported:'),
value: '',
suggestions: importModules.map(module => {
const label = module.fullLabel();
return {
key: `${module.type}--${label}`,
value: module,
label: module.fullLabel(),
};
}),
});
};
const promptForSourcePath = async (module: ImportModule, sourceType: FileSystemItem|undefined) => {
if (!sourceType) {
if (!module.sources.includes(FileSystemItem.Directory)) {
sourceType = FileSystemItem.File;
}
if (!module.sources.includes(FileSystemItem.File)) {
sourceType = FileSystemItem.Directory;
}
}
if (sourceType === FileSystemItem.File) {
return await bridge().showOpenDialog({
filters: [{ name: module.description, extensions: module.fileExtensions }],
});
} else if (sourceType === FileSystemItem.Directory) {
return await bridge().showOpenDialog({
properties: ['openDirectory', 'createDirectory'],
});
} else {
return await bridge().showOpenDialog({
properties: ['openDirectory', 'openFile'],
});
}
};
export const runtime = (control: WindowControl): CommandRuntime => {
return {
// Since this can be run from "go to anything", partialOptions needs to support being null or empty.
execute: async (context: CommandContext, options: ImportCommandOptions|undefined) => {
const importModule = await findImportModule(options, control);
if (!importModule) return null; // E.g. if cancelled
let sourcePath = options?.sourcePath ?? await promptForSourcePath(importModule, options?.sourceType);
if (Array.isArray(sourcePath)) {
sourcePath = sourcePath[0];
}
// Handle the case where the directory picker action was cancelled
if (!sourcePath) return null;
if (!options) {
const isDirectory = await shim.fsDriver().isDirectory(sourcePath);
const importsMultipleNotes = importModule.isNoteArchive || isDirectory;
const destinationFolderId = importsMultipleNotes ? null : context.state.selectedFolderId;
const importFormat = importModule.format;
const outputFormat = importModule.outputFormat;
options = {
sourcePath,
destinationFolderId,
importFormat,
outputFormat,
sourceType: isDirectory ? FileSystemItem.Directory : FileSystemItem.File,
};
}
const modalMessage = _('Importing from "%s" as "%s" format. Please wait...', sourcePath, options.importFormat);
void CommandService.instance().execute('showModalMessage', modalMessage);
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
const errors: any[] = [];
const importOptions = {
path: sourcePath,
format: options.importFormat,
outputFormat: options.outputFormat,
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
onProgress: (status: any) => {
const statusStrings: string[] = Object.keys(status).map((key: string) => {
return `${key}: ${status[key]}`;
});
void CommandService.instance().execute('showModalMessage', `${modalMessage}\n\n${statusStrings.join('\n')}`);
},
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
onError: (error: any) => {
errors.push(error);
console.warn(error);
},
destinationFolderId: options.destinationFolderId,
};
const service = InteropService.instance();
try {
const result = await service.import(importOptions);
// eslint-disable-next-line no-console
console.info('Import result: ', result);
} catch (error) {
bridge().showErrorMessageBox(error.message);
}
void CommandService.instance().execute('hideModalMessage');
if (errors.length) {
const response = bridge().showErrorMessageBox('There were some errors importing the notes - check the console for more details.\n\nPlease consider sending a bug report to the forum!', {
buttons: [_('Close'), _('Send bug report')],
});
context.dispatch({ type: 'NOTE_DEVTOOLS_SET', value: true });
if (response === 1) {
const url = makeDiscourseDebugUrl(
`Error importing notes from format: ${options.importFormat}`,
`- Input format: ${options.importFormat}\n- Output format: ${options.outputFormat}`,
errors,
packageInfo,
PluginService.instance(),
Setting.value('plugins.states'),
);
void bridge().openExternal(url);
}
}
},
enabledCondition: '',
};
};

View File

@@ -7,6 +7,7 @@ import * as editAlarm from './editAlarm';
import * as exportPdf from './exportPdf';
import * as gotoAnything from './gotoAnything';
import * as hideModalMessage from './hideModalMessage';
import * as importFrom from './importFrom';
import * as linkToNote from './linkToNote';
import * as moveToFolder from './moveToFolder';
import * as newFolder from './newFolder';
@@ -55,6 +56,7 @@ const index: any[] = [
exportPdf,
gotoAnything,
hideModalMessage,
importFrom,
linkToNote,
moveToFolder,
newFolder,

View File

@@ -1,11 +1,12 @@
import { CommandRuntime, CommandDeclaration, CommandContext } from '@joplin/lib/services/CommandService';
import { _ } from '@joplin/lib/locale';
import Folder, { FolderEntityWithChildren } from '@joplin/lib/models/Folder';
import Folder from '@joplin/lib/models/Folder';
import Note from '@joplin/lib/models/Note';
import BaseItem from '@joplin/lib/models/BaseItem';
import { ModelType } from '@joplin/lib/BaseModel';
import Logger from '@joplin/utils/Logger';
import shim from '@joplin/lib/shim';
import showFolderPicker from '../utils/showFolderPicker';
const logger = Logger.create('commands/moveToFolder');
@@ -31,71 +32,37 @@ export const runtime = (comp: any): CommandRuntime => {
}
}
const folders = await Folder.sortFolderTree();
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
const startFolders: any[] = [];
const maxDepth = 15;
// It's okay for folders (but not notes) to have no parent folder:
if (allAreFolders) {
startFolders.push({
key: '',
value: '',
label: _('None'),
indentDepth: 0,
});
}
const addOptions = (folders: FolderEntityWithChildren[], depth: number) => {
for (let i = 0; i < folders.length; i++) {
const folder = folders[i];
// Disallow making a folder a subfolder of itself.
if (itemIdToType.has(folder.id)) {
continue;
}
startFolders.push({ key: folder.id, value: folder.id, label: folder.title, indentDepth: depth });
if (folder.children) addOptions(folder.children, (depth + 1) < maxDepth ? depth + 1 : maxDepth);
}
};
addOptions(folders, 0);
comp.setState({
promptOptions: {
label: _('Move to notebook:'),
inputType: 'dropdown',
value: '',
autocomplete: startFolders,
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
onClose: async (answer: any) => {
if (answer) {
try {
const targetFolderId = answer.value;
for (const id of itemIds) {
if (id === targetFolderId) {
continue;
}
const itemType = itemIdToType.get(id);
if (itemType === ModelType.Note) {
await Note.moveToFolder(id, targetFolderId);
} else if (itemType === ModelType.Folder) {
await Folder.moveToFolder(id, targetFolderId);
} else {
throw new Error(`Cannot move item with type ${itemType}`);
}
}
} catch (error) {
logger.error('Error moving items', error);
void shim.showMessageBox(`Error: ${error}`);
}
}
comp.setState({ promptOptions: null });
},
},
const targetFolderId = await showFolderPicker(comp, {
label: _('Move to notebook:'),
// It's okay for folders (but not notes) to have no parent folder:
allowSelectNone: allAreFolders,
// Don't allow setting a folder as its own parent
showFolder: (folder) => !itemIdToType.has(folder.id),
});
// It's important to allow the case where targetFolderId is the empty string,
// since that corresponds to the toplevel notebook.
if (targetFolderId !== null) {
try {
for (const id of itemIds) {
if (id === targetFolderId) {
continue;
}
const itemType = itemIdToType.get(id);
if (itemType === ModelType.Note) {
await Note.moveToFolder(id, targetFolderId);
} else if (itemType === ModelType.Folder) {
await Folder.moveToFolder(id, targetFolderId);
} else {
throw new Error(`Cannot move item with type ${itemType}`);
}
}
} catch (error) {
logger.error('Error moving items', error);
void shim.showMessageBox(`Error: ${error}`);
}
}
},
enabledCondition: 'someNotesSelected && !noteIsReadOnly',
};

View File

@@ -1,7 +1,7 @@
import { utils, CommandRuntime, CommandDeclaration, CommandContext } from '@joplin/lib/services/CommandService';
import { _ } from '@joplin/lib/locale';
import Setting from '@joplin/lib/models/Setting';
import Note from '@joplin/lib/models/Note';
import Folder from '@joplin/lib/models/Folder';
export const newNoteEnabledConditions = 'oneFolderSelected && !inConflictFolder && !folderIsReadOnly && !folderIsTrash';
@@ -14,7 +14,7 @@ export const declaration: CommandDeclaration = {
export const runtime = (): CommandRuntime => {
return {
execute: async (_context: CommandContext, body = '', isTodo = false) => {
const folderId = Setting.value('activeFolderId');
const folderId = await Folder.getValidActiveFolder();
if (!folderId) return;
const defaultValues = Note.previewFieldsWithDefaultValues({ includeTimestamps: false });

View File

@@ -12,13 +12,15 @@ export const declaration: CommandDeclaration = {
export const runtime = (): CommandRuntime => {
return {
execute: async (context: CommandContext, folderId: string = null) => {
if (folderId === null) folderId = context.state.selectedFolderId;
execute: async (context: CommandContext, folderIds: string|string[] = null) => {
if (folderIds === null) folderIds = context.state.selectedFolderIds;
if (!Array.isArray(folderIds)) {
folderIds = [folderIds];
}
const folder = await Folder.load(folderId);
if (!folder) throw new Error(`No such folder: ${folderId}`);
await restoreItems(ModelType.Folder, [folder]);
const folders = await Folder.loadItemsByIdsOrFail(folderIds);
await restoreItems(ModelType.Folder, folders);
},
enabledCondition: 'folderIsDeleted',
enabledCondition: 'foldersAreDeleted',
};
};

View File

@@ -26,6 +26,7 @@ export interface DialogState {
description?: string;
label?: string;
value?: string;
autocomplete?: unknown;
onClose?: (answer: unknown, buttonType: unknown)=> void;
}|null;
}

View File

@@ -0,0 +1,56 @@
import Folder, { FolderEntityWithChildren } from '@joplin/lib/models/Folder';
import { WindowControl } from './useWindowControl';
import { _ } from '@joplin/lib/locale';
import { FolderEntity } from '@joplin/lib/services/database/types';
interface FolderEntry {
key: string;
value: string;
label: string;
indentDepth: number;
}
interface Options {
label: string;
allowSelectNone: boolean;
showFolder: (entity: FolderEntity)=> boolean;
}
const showFolderPicker = async (control: WindowControl, { label, allowSelectNone, showFolder }: Options) => {
const folders = await Folder.sortFolderTree();
const startFolders: FolderEntry[] = [];
const maxDepth = 15;
if (allowSelectNone) {
startFolders.push({
key: '',
value: '',
label: _('None'),
indentDepth: 0,
});
}
const addOptions = (folders: FolderEntityWithChildren[], depth: number) => {
for (let i = 0; i < folders.length; i++) {
const folder = folders[i];
if (!showFolder(folder)) {
continue;
}
startFolders.push({ key: folder.id, value: folder.id, label: folder.title, indentDepth: depth });
if (folder.children) addOptions(folder.children, (depth + 1) < maxDepth ? depth + 1 : maxDepth);
}
};
addOptions(folders, 0);
const folderId = await control.showPrompt({
label,
value: '',
suggestions: startFolders,
});
return folderId;
};
export default showFolderPicker;

View File

@@ -5,8 +5,22 @@ import { PrintCallback } from './usePrintToCallback';
import { _ } from '@joplin/lib/locale';
import announceForAccessibility from '../../utils/announceForAccessibility';
interface PromptSuggestion<T> {
key: string;
value: T;
label: string;
indentDepth?: number;
}
interface PromptOptions<T> {
label: string;
value: string;
suggestions: PromptSuggestion<T>[];
}
export interface WindowControl {
setState: (update: Partial<DialogState>)=> void;
showPrompt: <T>(options: PromptOptions<T>)=> Promise<T>;
printTo: PrintCallback;
announcePanelVisibility(panelName: string, visible: boolean): void;
}
@@ -19,7 +33,7 @@ const useWindowControl = (setDialogState: OnSetDialogState, onPrint: PrintCallba
onPrintRef.current = onPrint;
return useMemo((): WindowControl => {
return {
const control: WindowControl = {
setState: (newPartialState: Partial<DialogState>) => {
setDialogState(oldState => ({
...oldState,
@@ -32,7 +46,29 @@ const useWindowControl = (setDialogState: OnSetDialogState, onPrint: PrintCallba
visible ? _('Panel "%s" is visible', panelName) : _('Panel %s is hidden', panelName),
);
},
showPrompt: <T> (options: PromptOptions<T>) => {
return new Promise<T>((resolve) => {
control.setState({
promptOptions: {
label: options.label,
inputType: 'dropdown',
value: options.value,
autocomplete: options.suggestions,
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Partially refactored code before rule was applied
onClose: async (answer: any) => {
if (answer) {
resolve(answer.value);
} else {
resolve(null);
}
control.setState({ promptOptions: null });
},
},
});
});
},
};
return control;
}, [setDialogState]);
};

View File

@@ -79,6 +79,9 @@ export default function useMarkupToHtml(deps: HookDependencies) {
return resourceFullPath(resources[id].item, resourceBaseUrl) + urlParameters;
},
globalSettings: {
'markdown.plugin.abc.options': Setting.value('markdown.plugin.abc.options'),
},
...options,
});

View File

@@ -18,3 +18,4 @@
@use './joplin-cloud-sign-up.scss';
@use './popup-notification-list.scss';
@use './popup-notification-item.scss';
@use './multi-note-actions.scss';

View File

@@ -0,0 +1,8 @@
.multi-note-actions {
display: inline-flex;
justify-content: center;
padding-top: var(--joplin-margin-top);
width: 100%;
overflow-y: auto;
}

View File

@@ -38,7 +38,7 @@ describe('NoteListUtils', () => {
const mockStore = {
getState: () => {
return {
...createAppDefaultWindowState(null),
...createAppDefaultWindowState(),
settings: {},
};
},

View File

@@ -212,5 +212,28 @@ test.describe('main', () => {
await electronApp.close();
});
test('should import an HTML directory', async ({ mainWindow, electronApp }) => {
const mainScreen = await new MainScreen(mainWindow).setup();
await mainScreen.waitFor();
await mainScreen.importHtmlDirectory(electronApp, join(__dirname, 'resources', 'html-import'));
const importedFolder = mainScreen.sidebar.container.getByText('html-import');
await importedFolder.click();
const importedNote1 = await mainScreen.noteList.getNoteItemByTitle('test-html-file-with-image');
await expect(importedNote1).toBeAttached();
const importedNote2 = await mainScreen.noteList.getNoteItemByTitle('test-html-file-2');
await expect(importedNote2).toBeAttached();
});
test('should import a single HTML file', async ({ mainWindow, electronApp }) => {
const mainScreen = await new MainScreen(mainWindow).setup();
await mainScreen.waitFor();
await mainScreen.importHtmlFile(electronApp, join(__dirname, 'resources', 'html-import', 'test-html-file-with-image.html'));
const importedNote = await mainScreen.noteList.getNoteItemByTitle('test-html-file-with-image');
await expect(importedNote).toBeAttached();
});
});

View File

@@ -6,6 +6,7 @@ import setFilePickerResponse from './util/setFilePickerResponse';
import activateMainMenuItem from './util/activateMainMenuItem';
import setSettingValue from './util/setSettingValue';
import { toForwardSlashes } from '@joplin/utils/path';
import mockClipboard from './util/mockClipboard';
test.describe('markdownEditor', () => {
@@ -337,5 +338,48 @@ test.describe('markdownEditor', () => {
// Should show the legacy editor
await expect(mainWindow.locator('.rli-editor .CodeMirror5')).toBeVisible();
});
test('should support the textCopy command', async ({ electronApp, mainWindow }) => {
const mainScreen = await new MainScreen(mainWindow).setup();
await mainScreen.waitFor();
await mainScreen.createNewNote('Test copy');
const noteEditor = mainScreen.noteEditor;
await noteEditor.focusCodeMirrorEditor();
await mainWindow.keyboard.type('Test content.');
const { expectClipboardToMatch } = await mockClipboard(electronApp, 'original');
await mainScreen.goToAnything.runCommand(electronApp, 'textCopy');
await expectClipboardToMatch('Test content.\n');
});
test('should support the textCut and textPaste commands', async ({ electronApp, mainWindow }) => {
const mainScreen = await new MainScreen(mainWindow).setup();
await mainScreen.waitFor();
await mainScreen.createNewNote('Test paste');
const { expectClipboardToMatch } = await mockClipboard(electronApp, 'test!');
await expectClipboardToMatch('test!');
// Should paste text using the textPaste command
const goToAnything = mainScreen.goToAnything;
await goToAnything.runCommand(electronApp, 'textPaste');
const noteEditor = mainScreen.noteEditor;
await noteEditor.expectToHaveText('test!');
// Should cut text using the textCut command
await mainScreen.createNewNote('Test cut');
await noteEditor.focusCodeMirrorEditor();
await mainWindow.keyboard.type('Test (new content!)');
await goToAnything.runCommand(electronApp, 'textCut');
await noteEditor.expectToHaveText('\n');
await expectClipboardToMatch('Test (new content!)\n');
// Should paste the content again with textPaste
await goToAnything.runCommand(electronApp, 'textPaste');
await noteEditor.expectToHaveText(/^Test \(new content!\)[\n]+/);
});
});

View File

@@ -68,9 +68,17 @@ export default class MainScreen {
await searchBar.fill(text);
}
public async importHtmlDirectory(electronApp: ElectronApplication, path: string) {
private async importFromModule_(electronApp: ElectronApplication, moduleName: string, path: string) {
await setFilePickerResponse(electronApp, [path]);
await activateMainMenuItem(electronApp, 'HTML - HTML document (Directory)', 'Import');
await activateMainMenuItem(electronApp, moduleName, 'Import');
}
public async importHtmlDirectory(electronApp: ElectronApplication, path: string) {
return this.importFromModule_(electronApp, 'HTML - HTML document (Directory)', path);
}
public async importHtmlFile(electronApp: ElectronApplication, path: string) {
return this.importFromModule_(electronApp, 'HTML - HTML document (File)', path);
}
public async pluginPanelLocator(pluginId: string) {

View File

@@ -1,6 +1,7 @@
import activateMainMenuItem from '../util/activateMainMenuItem';
import type MainScreen from './MainScreen';
import { ElectronApplication, Locator, Page } from '@playwright/test';
import expect from '../util/extendedExpect';
export default class Sidebar {
public readonly container: Locator;
@@ -42,4 +43,14 @@ export default class Sidebar {
await this.sortByDate(electronApp);
await this.sortByTitle(electronApp);
}
// Checks the indentation level of each folder. Useful for determining whether folders are subfolders.
public async expectToHaveDepths(folderToDepth: [Locator, number][]) {
for (let i = 0; i < folderToDepth.length; i++) {
const [folder, depth] = folderToDepth[i];
await expect(
folder, { message: `Folder ${i} should have depth ${depth}.` },
).toHaveJSProperty('ariaLevel', String(depth));
}
}
}

View File

@@ -0,0 +1,6 @@
<!DOCTYPE html>
<html>
<body>
<h1>Test HTML file 2!</h1>
</body>
</html>

View File

@@ -81,10 +81,12 @@ test.describe('sidebar', () => {
await folderDHeader.dragTo(folderCHeader);
// Folders should have correct initial levels
await expect(folderAHeader).toHaveJSProperty('ariaLevel', '2');
await expect(folderBHeader).toHaveJSProperty('ariaLevel', '3');
await expect(folderCHeader).toHaveJSProperty('ariaLevel', '3');
await expect(folderDHeader).toHaveJSProperty('ariaLevel', '4');
await sidebar.expectToHaveDepths([
[folderAHeader, 2],
[folderBHeader, 3],
[folderCHeader, 3],
[folderDHeader, 4],
]);
await sidebar.forceUpdateSorting(electronApp);
await folderBHeader.click();
@@ -186,4 +188,87 @@ test.describe('sidebar', () => {
await testFolderA.dblclick();
await expect(testFolderB).toBeVisible();
});
test('should be possible to select, then deselect, multiple folders with cmd-click', async ({ mainWindow, electronApp }) => {
const mainScreen = await new MainScreen(mainWindow).setup();
const sidebar = mainScreen.sidebar;
const folderA = await sidebar.createNewFolder('Folder A');
const folderB = await sidebar.createNewFolder('Folder B');
const folderC = await sidebar.createNewFolder('Folder C');
const folderD = await sidebar.createNewFolder('Folder D');
await sidebar.forceUpdateSorting(electronApp);
await folderA.click();
await folderB.click({ modifiers: ['ControlOrMeta'] });
await folderC.click({ modifiers: ['ControlOrMeta'] });
await expect(folderA).toBeSelected();
await expect(folderB).toBeSelected();
await expect(folderC).toBeSelected();
await expect(folderD).toHaveJSProperty('ariaSelected', 'false');
// Should be able to deselect up to two folders
await folderA.click({ modifiers: ['ControlOrMeta'] });
await expect(folderA).toHaveJSProperty('ariaSelected', 'false');
await folderB.click({ modifiers: ['ControlOrMeta'] });
await expect(folderB).toHaveJSProperty('ariaSelected', 'false');
// Should not be possible to deselect the last folder
await folderC.click({ modifiers: ['ControlOrMeta'] });
await expect(folderC).toBeSelected();
});
test('should be possible to move multiple folders at once with drag and drop', async ({ mainWindow, electronApp }) => {
const mainScreen = await new MainScreen(mainWindow).setup();
const sidebar = mainScreen.sidebar;
const folderA = await sidebar.createNewFolder('Folder A');
const folderB = await sidebar.createNewFolder('Folder B');
const folderC = await sidebar.createNewFolder('Folder C');
const folderD = await sidebar.createNewFolder('Folder D');
await sidebar.forceUpdateSorting(electronApp);
await folderB.click();
await folderC.click({ modifiers: ['ControlOrMeta'] });
await expect(folderB).toBeSelected();
await expect(folderC).toBeSelected();
await folderB.dragTo(folderA);
// Should have made folder B **and folder C** subfolders of testFolderA
await sidebar.expectToHaveDepths([
[folderA, 2],
[folderB, 3],
[folderC, 3],
[folderD, 2],
]);
});
test('should not move selected folders when dragging an unselected folder', async ({ mainWindow, electronApp }) => {
const mainScreen = await new MainScreen(mainWindow).setup();
const sidebar = mainScreen.sidebar;
const testFolderA = await sidebar.createNewFolder('Folder A');
const testFolderB = await sidebar.createNewFolder('Folder B');
const testFolderC = await sidebar.createNewFolder('Folder C');
await sidebar.forceUpdateSorting(electronApp);
await testFolderB.click();
await testFolderC.click({ modifiers: ['ControlOrMeta'] });
await expect(testFolderB).toBeSelected();
await expect(testFolderC).toBeSelected();
await testFolderA.dragTo(testFolderB);
await sidebar.expectToHaveDepths([
[testFolderB, 2],
[testFolderA, 3],
[testFolderC, 2],
]);
});
});

View File

@@ -54,6 +54,26 @@ const extendedExpect = expect.extend({
name: assertionName,
};
},
async toBeSelected(locator: Locator) {
let pass = true;
const assertionName = 'toBeSelected';
let resultMessage = () => `${assertionName}: Passed`;
try {
await extendedExpect(locator).toHaveJSProperty('ariaSelected', 'true');
} catch (error) {
pass = false;
resultMessage = () => error.toString();
}
return {
pass,
message: () => `${assertionName}: ${resultMessage()}`,
name: assertionName,
};
},
});
export default extendedExpect;

View File

@@ -0,0 +1,29 @@
import { ElectronApplication } from '@playwright/test';
import { expect } from './test';
import getMainWindow from './getMainWindow';
// Currently only supports mocking reading/writing text
const mockClipboard = async (electronApp: ElectronApplication, clipboardText: string) => {
const mainWindow = await getMainWindow(electronApp);
await mainWindow.evaluate(async (clipboardText) => {
const { clipboard } = require('electron');
clipboard.writeText = (text: string) => {
clipboardText = text;
};
clipboard.readText = () => {
return clipboardText;
};
}, clipboardText);
return {
expectClipboardToMatch: async (text: string) => {
await expect.poll(async () => {
return await mainWindow.evaluate(() => {
return require('electron').clipboard.readText();
});
}).toBe(text);
},
};
};
export default mockClipboard;

View File

@@ -132,10 +132,12 @@ a {
margin: 40px 20px;
}
#loading-animation {
.loading-animation {
margin-right: 20px;
width: 20px;
height: 20px;
display: inline-block;
vertical-align: middle;
border: 5px solid lightgrey;
border-top: 4px solid white;
border-radius: 50%;
@@ -144,6 +146,10 @@ a {
animation-duration: 1.2s;
animation-iteration-count: infinite;
animation-timing-function: linear;
@media (prefers-reduced-motion: reduce) {
animation-name: none;
}
}
@keyframes rotate {

View File

@@ -1,6 +1,6 @@
{
"name": "@joplin/app-desktop",
"version": "3.5.6",
"version": "3.5.9",
"description": "Joplin for Desktop",
"main": "main.bundle.js",
"private": true,
@@ -119,7 +119,8 @@
"category": "Office",
"desktop": {
"Icon": "joplin",
"MimeType": "x-scheme-handler/joplin;"
"MimeType": "x-scheme-handler/joplin;",
"StartupWMClass": "@joplin/app-desktop"
},
"target": [
"AppImage",
@@ -144,7 +145,7 @@
"@joplin/renderer": "~3.5",
"@joplin/tools": "~3.5",
"@joplin/utils": "~3.5",
"@playwright/test": "1.53.2",
"@playwright/test": "1.54.2",
"@sentry/electron": "4.24.0",
"@testing-library/react-hooks": "8.0.1",
"@types/jest": "29.5.14",
@@ -159,9 +160,8 @@
"codemirror": "5.65.9",
"color": "3.2.1",
"compare-versions": "6.1.1",
"countable": "3.0.1",
"debounce": "1.2.1",
"electron": "37.7.0",
"electron": "39.2.3",
"electron-builder": "24.13.3",
"electron-updater": "6.6.2",
"electron-window-state": "5.0.3",
@@ -179,7 +179,7 @@
"md5": "2.3.0",
"moment": "2.30.1",
"mustache": "4.2.0",
"nan": "2.22.2",
"nan": "2.23.0",
"node-notifier": "10.0.1",
"node-rsa": "1.1.1",
"pdfjs-dist": "3.11.174",

View File

@@ -25,7 +25,7 @@ async function main() {
// wrong one. However it means it will have to be manually upgraded for each
// new Electron release. Some ABI map there:
// https://github.com/electron/node-abi/tree/master/test
const forceAbiArgs = '--force-abi 138';
const forceAbiArgs = '--force-abi 142';
if (isWindows()) {
// Cannot run this in parallel, or the 64-bit version might end up

View File

@@ -89,8 +89,8 @@ android {
applicationId "net.cozic.joplin"
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 2097780
versionName "3.5.0"
versionCode 2097781
versionName "3.5.1"
ndk {
abiFilters "armeabi-v7a", "x86", "arm64-v8a", "x86_64"
}

View File

@@ -2,7 +2,7 @@ import { CommandRuntime, CommandDeclaration, CommandContext } from '@joplin/lib/
import Logger from '@joplin/utils/Logger';
import goToNote, { GotoNoteOptions } from './util/goToNote';
import Note from '@joplin/lib/models/Note';
import Setting from '@joplin/lib/models/Setting';
import Folder from '@joplin/lib/models/Folder';
const logger = Logger.create('newNoteCommand');
@@ -13,7 +13,7 @@ export const declaration: CommandDeclaration = {
export const runtime = (): CommandRuntime => {
return {
execute: async (_context: CommandContext, body = '', todo = false, options: GotoNoteOptions = null) => {
const folderId = Setting.value('activeFolderId');
const folderId = await Folder.getValidActiveFolder();
if (!folderId) {
logger.warn('Not creating new note -- no active folder ID.');
return;

View File

@@ -73,6 +73,7 @@ const useSettingButtonInfo = (setSettingsVisible: SetSettingsVisible) => {
name: 'showToolbarSettings',
tooltip: _('Settings'),
iconName: 'material cogs',
visible: true,
enabled: true,
onClick: () => setSettingsVisible(true),
title: '',

View File

@@ -20,6 +20,9 @@ const builtInCommandNames = [
EditorCommandType.ToggleBulletedList,
EditorCommandType.ToggleCheckList,
'-',
`editor.${EditorCommandType.InsertTable}`,
`editor.${EditorCommandType.InsertCodeBlock}`,
'-',
EditorCommandType.IndentLess,
EditorCommandType.IndentMore,
`editor.${EditorCommandType.SwapLineDown}`,

View File

@@ -122,6 +122,7 @@ const useStyles = (theme: Theme) => {
backgroundColor: theme.backgroundColor4,
color: theme.color4,
margin: 2,
width: 90, // Reduce the min width for mobile screens in portrait
},
buttonText: buttonTextStyle,
activeButtonText: {
@@ -343,7 +344,7 @@ export const SearchPanel = (props: SearchPanelProps) => {
);
const simpleLayout = (
<View style={{ flexDirection: 'row' }}>
<View style={{ flexDirection: 'row', flexShrink: 1 }}>
{ closeButton }
{ searchTextInput }
{ showDetailsButton }
@@ -353,7 +354,7 @@ export const SearchPanel = (props: SearchPanelProps) => {
);
const advancedLayout = (
<View style={{ flexDirection: 'column' }}>
<View style={{ flexDirection: 'column', flexShrink: 1 }}>
<View style={{ flexDirection: 'row' }}>
{ closeButton }
{ labeledSearchInput }

View File

@@ -9,16 +9,31 @@ const markdownEditorOnlyCommands = [
EditorCommandType.SwapLineDown,
].map(command => `editor.${command}`);
export const enabledCondition = (commandName: string) => {
const output = [
'!noteIsReadOnly',
];
const richTextEditorOnlyCommands = [
EditorCommandType.InsertTable,
EditorCommandType.InsertCodeBlock,
].map(command => `editor.${command}`);
export const visibleCondition = (commandName: string) => {
const output = [];
if (markdownEditorOnlyCommands.includes(commandName)) {
output.push('!richTextEditorVisible');
}
return output.filter(c => !!c).join(' && ');
if (richTextEditorOnlyCommands.includes(commandName)) {
output.push('!markdownEditorPaneVisible');
}
return output.join(' && ');
};
export const enabledCondition = (commandName: string) => {
return [
visibleCondition(commandName), '!noteIsReadOnly',
].filter(c => !!c).join('&&');
};
const headerDeclarations = () => {
@@ -98,6 +113,16 @@ const declarations: CommandDeclaration[] = [
label: () => _('Task list'),
iconName: 'material format-list-checks',
},
{
name: `editor.${EditorCommandType.InsertTable}`,
label: () => _('Table'),
iconName: 'material table',
},
{
name: `editor.${EditorCommandType.InsertCodeBlock}`,
label: () => _('Block code'),
iconName: 'material code-tags',
},
{
name: EditorCommandType.IndentLess,
label: () => _('Decrease indent level'),

View File

@@ -1,7 +1,7 @@
import CommandService, { CommandContext, CommandDeclaration } from '@joplin/lib/services/CommandService';
import { EditorControl } from '@joplin/editor/types';
import useNowEffect from '@joplin/lib/hooks/useNowEffect';
import commandDeclarations, { enabledCondition } from '../commandDeclarations';
import commandDeclarations, { enabledCondition, visibleCondition } from '../commandDeclarations';
import Logger from '@joplin/utils/Logger';
const logger = Logger.create('useEditorCommandHandler');
@@ -30,6 +30,7 @@ const commandRuntime = (declaration: CommandDeclaration, editor: EditorControl)
return await editor.execCommand(commandName, ...args);
},
enabledCondition: enabledCondition(declaration.name),
visibleCondition: visibleCondition(declaration.name),
};
};

View File

@@ -168,10 +168,10 @@ const NoteItemComponent: React.FC<Props> = memo(props => {
};
return (
<MultiTouchableOpacity
{...pressableProps}
containerProps={{
style: [selectionWrapperStyle, opacityStyle, styles.listItem],
}}
pressableProps={pressableProps}
onPress={onPress}
beforePressable={todoCheckbox}
>

View File

@@ -668,6 +668,10 @@ class ScreenHeaderComponent extends PureComponent<ScreenHeaderProps, ScreenHeade
{pluginPanelsComp}
{betaIconComp}
{togglePluginEditorButton}
{selectAllButtonComp}
{searchButtonComp}
{deleteButtonComp}
{customDeleteButtonComp}
</>;
const titleComp = createTitleComponent(hideableRightComponents);
@@ -706,10 +710,6 @@ class ScreenHeaderComponent extends PureComponent<ScreenHeaderProps, ScreenHeade
this.props.showSaveButton === true,
)}
{titleComp}
{selectAllButtonComp}
{searchButtonComp}
{deleteButtonComp}
{customDeleteButtonComp}
{restoreButtonComp}
{duplicateButtonComp}
{sortButtonComp}

View File

@@ -2,20 +2,19 @@ import * as React from 'react';
import { useCallback, useMemo, useRef } from 'react';
import { Animated, StyleSheet, Pressable, ViewProps, PressableProps } from 'react-native';
interface Props {
interface Props extends PressableProps {
// Nodes that need to change opacity but shouldn't be included in the main touchable
beforePressable: React.ReactNode;
// Children of the main pressable
children: React.ReactNode;
onPress: ()=> void;
pressableProps?: PressableProps;
containerProps?: ViewProps;
}
// A TouchableOpacity that can contain multiple pressable items still within the region that
// changes opacity
const MultiTouchableOpacity: React.FC<Props> = props => {
const MultiTouchableOpacity: React.FC<Props> = ({ beforePressable, children, onPress, containerProps = {}, ...pressableProps }) => {
// See https://blog.logrocket.com/react-native-touchable-vs-pressable-components/
// for more about animating Pressable buttons.
const fadeAnim = useRef(new Animated.Value(1)).current;
@@ -41,12 +40,12 @@ const MultiTouchableOpacity: React.FC<Props> = props => {
const button = (
<Pressable
accessibilityRole='button'
{...props.pressableProps}
onPress={props.onPress}
{...pressableProps}
onPress={onPress}
onPressIn={onPressIn}
onPressOut={onPressOut}
>
{props.children}
{children}
</Pressable>
);
@@ -56,10 +55,9 @@ const MultiTouchableOpacity: React.FC<Props> = props => {
});
}, [fadeAnim]);
const containerProps = props.containerProps ?? {};
return (
<Animated.View {...containerProps} style={[styles.container, props.containerProps.style]}>
{props.beforePressable}
<Animated.View {...containerProps} style={[styles.container, containerProps.style]}>
{beforePressable}
{button}
</Animated.View>
);

View File

@@ -166,19 +166,21 @@ describe('screens/Note', () => {
it('should show the currently selected note', async () => {
await openNewNote({ title: 'Test note (title)', body: '# Testing...' });
render(<WrappedNoteScreen />);
const { unmount } = render(<WrappedNoteScreen />);
const titleInput = await screen.findByDisplayValue('Test note (title)');
expect(titleInput).toBeVisible();
const renderedNote = await getNoteViewerDom();
expect(renderedNote.querySelector('h1')).toMatchObject({ textContent: 'Testing...' });
unmount();
});
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 { unmount } = render(<WrappedNoteScreen />);
const titleInput = await screen.findByDisplayValue('Change me!');
@@ -205,6 +207,8 @@ describe('screens/Note', () => {
await waitForNoteToMatch(noteId, { title: expectedTitle });
}
});
unmount();
});
it('changing the note body in the editor should update the note\'s body', async () => {
@@ -237,7 +241,7 @@ describe('screens/Note', () => {
it('pressing "delete" should move the note to the trash', async () => {
const noteId = await openNewNote({ title: 'To be deleted', body: '...' });
render(<WrappedNoteScreen />);
const { unmount } = render(<WrappedNoteScreen />);
await openNoteActionsMenu();
const deleteButton = await screen.findByText('Delete');
@@ -246,11 +250,13 @@ describe('screens/Note', () => {
await waitFor(async () => {
expect((await Note.load(noteId)).deleted_time).toBeGreaterThan(0);
});
unmount();
});
it('pressing "delete permanently" should permanently delete a note', async () => {
const noteId = await openNewNote({ title: 'To be deleted', body: '...', deleted_time: Date.now() });
render(<WrappedNoteScreen />);
const { unmount } = render(<WrappedNoteScreen />);
// Permanently delete note shows a confirmation dialog -- mock it.
const deleteId = 0;
@@ -264,6 +270,8 @@ describe('screens/Note', () => {
expect(await Note.load(noteId)).toBeUndefined();
});
expect(shim.showMessageBox).toHaveBeenCalled();
unmount();
});
it('delete should be disabled in a read-only note', async () => {
@@ -284,7 +292,7 @@ describe('screens/Note', () => {
),
).toBe(true);
render(<WrappedNoteScreen />);
const { unmount } = render(<WrappedNoteScreen />);
const titleInput = await screen.findByDisplayValue('Title: Read-only note');
expect(titleInput).toBeVisible();
@@ -295,6 +303,7 @@ describe('screens/Note', () => {
expect(deleteButton).toHaveProp('disabled', true);
act(() => cleanup());
unmount();
});
it.each([
@@ -317,7 +326,7 @@ describe('screens/Note', () => {
await openExistingNote(note.id);
render(<WrappedNoteScreen />);
const { unmount } = render(<WrappedNoteScreen />);
// Note should render
const titleInput = await screen.findByDisplayValue('Note 1');
@@ -339,16 +348,20 @@ describe('screens/Note', () => {
throw new Error(`Should not be testing downloadMode: ${downloadMode}.`);
}
});
unmount();
});
it('the toggleVisiblePanes command should start and stop editing', async () => {
await openNewNote({ title: 'To be edited', body: '...' });
render(<WrappedNoteScreen />);
const { unmount } = render(<WrappedNoteScreen />);
await expectToBeEditing(false);
await runEditorCommand('toggleVisiblePanes');
await expectToBeEditing(true);
await runEditorCommand('toggleVisiblePanes');
await expectToBeEditing(false);
unmount();
});
});

View File

@@ -0,0 +1,74 @@
import * as React from 'react';
import { _ } from '@joplin/lib/locale';
import { StyleSheet, TextInput, View } from 'react-native';
import { themeStyle } from '../../global-style';
import IconButton from '../../IconButton';
import { useMemo } from 'react';
interface Props {
themeId: number;
value: string;
autoFocus: boolean;
placeholder?: string;
onChangeText: (text: string)=> void;
onClearButtonPress: ()=> void;
onSubmitEditing?: ()=> void;
}
const useStyles = (themeId: number) => {
return useMemo(() => {
const theme = themeStyle(themeId);
return StyleSheet.create({
searchContainer: {
flexDirection: 'row',
alignItems: 'center',
borderWidth: 1,
borderColor: theme.dividerColor,
},
searchTextInput: {
...theme.lineInput,
paddingLeft: theme.marginLeft,
flex: 1,
backgroundColor: theme.backgroundColor,
color: theme.color,
},
clearIcon: {
...theme.icon,
color: theme.colorFaded,
paddingRight: theme.marginRight,
backgroundColor: theme.backgroundColor,
},
});
}, [themeId]);
};
const SearchBar: React.FC<Props> = ({ themeId, value, autoFocus, placeholder, onChangeText, onClearButtonPress, onSubmitEditing }) => {
const theme = themeStyle(themeId);
const styles = useStyles(themeId);
return (
<View style={styles.searchContainer}>
<TextInput
style={styles.searchTextInput}
autoFocus={autoFocus}
underlineColorAndroid="#ffffff00"
onChangeText={onChangeText}
onSubmitEditing={onSubmitEditing}
placeholder={placeholder}
placeholderTextColor={theme.colorFaded}
value={value}
selectionColor={theme.textSelectionColor}
keyboardAppearance={theme.keyboardAppearance}
/>
<IconButton
themeId={themeId}
iconStyle={styles.clearIcon}
iconName='ionicon close-circle'
onPress={onClearButtonPress}
description={_('Clear')}
/>
</View>
);
};
export default SearchBar;

View File

@@ -1,6 +1,6 @@
import * as React from 'react';
import { StyleSheet, View, TextInput } from 'react-native';
import { StyleSheet, View } from 'react-native';
import { connect } from 'react-redux';
import ScreenHeader from '../../ScreenHeader';
import { _ } from '@joplin/lib/locale';
@@ -8,10 +8,10 @@ import { ThemeStyle, themeStyle } from '../../global-style';
import { AppState } from '../../../utils/types';
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';
import { ComplexTerm } from '@joplin/lib/services/search/SearchEngine';
import SearchBar from './SearchBar';
interface Props {
themeId: number;
@@ -29,25 +29,6 @@ const useStyles = (theme: ThemeStyle, visible: boolean) => {
body: {
flex: 1,
},
searchContainer: {
flexDirection: 'row',
alignItems: 'center',
borderWidth: 1,
borderColor: theme.dividerColor,
},
searchTextInput: {
...theme.lineInput,
paddingLeft: theme.marginLeft,
flex: 1,
backgroundColor: theme.backgroundColor,
color: theme.color,
},
clearIcon: {
...theme.icon,
color: theme.colorFaded,
paddingRight: theme.marginRight,
backgroundColor: theme.backgroundColor,
},
rootStyle: visible ? theme.rootStyle : theme.hiddenRootStyle,
});
}, [theme, visible]);
@@ -118,26 +99,14 @@ const SearchScreenComponent: React.FC<Props> = props => {
showSearchButton={false}
/>
<View style={styles.body}>
<View style={styles.searchContainer}>
<TextInput
style={styles.searchTextInput}
autoFocus={props.visible}
underlineColorAndroid="#ffffff00"
onChangeText={setQuery}
onSubmitEditing={onOverridePause}
value={query}
selectionColor={theme.textSelectionColor}
keyboardAppearance={theme.keyboardAppearance}
/>
<IconButton
themeId={props.themeId}
iconStyle={styles.clearIcon}
iconName='ionicon close-circle'
onPress={clearButton_press}
description={_('Clear')}
/>
</View>
<SearchBar
themeId={props.themeId}
autoFocus={props.visible}
value={query}
onChangeText={setQuery}
onSubmitEditing={onOverridePause}
onClearButtonPress={clearButton_press}
/>
<SearchResults
query={query}
paused={paused}

View File

@@ -10,8 +10,12 @@ import { AppState } from '../../utils/types';
import { TagEntity } from '@joplin/lib/services/database/types';
import { useCallback, useMemo, useState } from 'react';
import { Dispatch } from 'redux';
import useAsyncEffect from '@joplin/lib/hooks/useAsyncEffect';
import useQueuedAsyncEffect from '@joplin/lib/hooks/useQueuedAsyncEffect';
import { getCollator, getCollatorLocale } from '@joplin/lib/models/utils/getCollator';
import SearchBar from './SearchScreen/SearchBar';
import Logger from '@joplin/utils/Logger';
const logger = Logger.create('tags');
interface Props {
dispatch: Dispatch;
@@ -46,6 +50,8 @@ const useStyles = (themeId: number) => {
const TagsScreenComponent: React.FC<Props> = props => {
const [tags, setTags] = useState<TagEntity[]>([]);
const [searchQuery, setSearchQuery] = useState('');
const [showSearch, setShowSearch] = useState(false);
const styles = useStyles(props.themeId);
const collatorLocale = getCollatorLocale();
const collator = useMemo(() => {
@@ -54,12 +60,45 @@ const TagsScreenComponent: React.FC<Props> = props => {
type TagItemPressEvent = { id: string };
useAsyncEffect(async () => {
const tags = await Tag.allWithNotes();
tags.sort((a, b) => {
return collator.compare(a.title, b.title);
});
setTags(tags);
useQueuedAsyncEffect(async (event) => {
try {
let fetchedTags: TagEntity[];
if (searchQuery.trim()) {
const searchPattern = `*${searchQuery.trim()}*`;
fetchedTags = await Tag.searchAllWithNotes({
titlePattern: searchPattern,
});
} else {
fetchedTags = await Tag.allWithNotes();
}
fetchedTags.sort((a, b) => {
return collator.compare(a.title, b.title);
});
if (!event.cancelled) {
setTags(fetchedTags);
}
} catch (error) {
logger.error('Error fetching tags', error);
if (!event.cancelled) {
setTags([]);
}
}
}, [searchQuery, collator], { interval: 200 });
const onSearchButtonPress = useCallback(() => {
setShowSearch(!showSearch);
// If the search button is pressed while the search bar is open, in addition to hiding the search bar, it should clear the search
if (showSearch) {
setSearchQuery('');
}
}, [showSearch]);
const clearButton_press = useCallback(() => {
setSearchQuery('');
}, []);
const onTagItemPress = useCallback((event: TagItemPressEvent) => {
@@ -89,7 +128,21 @@ const TagsScreenComponent: React.FC<Props> = props => {
return (
<View style={styles.rootStyle}>
<ScreenHeader title={_('Tags')} showSearchButton={false} />
<ScreenHeader
title={_('Tags')}
showSearchButton={true}
onSearchButtonPress={onSearchButtonPress}
/>
{showSearch && (
<SearchBar
themeId={props.themeId}
autoFocus={true}
placeholder={_('Search tags')}
value={searchQuery}
onChangeText={setSearchQuery}
onClearButtonPress={clearButton_press}
/>
)}
<FlatList style={{ flex: 1 }} data={tags} renderItem={onRenderItem} keyExtractor={tag => tag.id} />
</View>
);

View File

@@ -43,8 +43,7 @@ interface Props {
folders: FolderEntity[];
profileConfig: ProfileConfig;
inboxJopId: string;
selectedFolderId: string;
selectedTagId: string;
selectedFolderIds: string[];
}
const syncIconRotationValue = new Animated.Value(0);
@@ -564,7 +563,7 @@ const SideMenuContentComponent = (props: Props) => {
hasChildren={hasChildren}
depth={depth}
collapsed={props.collapsedFolderIds.includes(folder.id)}
selected={isFolderSelected(folder, { selectedFolderId: props.selectedFolderId, notesParentType: props.notesParentType })}
selected={isFolderSelected(folder, { selectedFolderIds: props.selectedFolderIds, notesParentType: props.notesParentType })}
styles={styles_}
folder={folder}
alwaysShowFolderIcons={alwaysShowFolderIcons}
@@ -730,8 +729,7 @@ export default connect((state: AppState) => {
folders: state.folders,
syncStarted: state.syncStarted,
syncReport: state.syncReport,
selectedFolderId: state.selectedFolderId,
selectedTagId: state.selectedTagId,
selectedFolderIds: state.selectedFolderIds,
notesParentType: state.notesParentType,
locale: state.settings.locale,
themeId: state.settings.theme,

View File

@@ -1,5 +1,5 @@
import { MarkupLanguage, MarkupToHtml } from '@joplin/renderer';
import type { MarkupToHtmlConverter, RenderOptions, FsDriver as RendererFsDriver, ResourceInfos } from '@joplin/renderer/types';
import type { MarkupToHtmlConverter, RenderOptions, RenderOptionsGlobalSettings, FsDriver as RendererFsDriver, ResourceInfos } from '@joplin/renderer/types';
import makeResourceModel from './utils/makeResourceModel';
import addPluginAssets from './utils/addPluginAssets';
import { ExtraContentScriptSource, ForwardedJoplinSettings, MarkupRecord } from '../types';
@@ -32,6 +32,7 @@ export interface RenderSettings {
destroyEditPopupSyntax: string;
pluginSettings: Record<string, unknown>;
globalSettings?: RenderOptionsGlobalSettings;
requestPluginSetting: (pluginId: string, settingKey: string)=> void;
readAssetBlob: (assetPath: string)=> Promise<Blob>;
}
@@ -135,6 +136,7 @@ export default class Renderer {
splitted: settings.splitted,
mapsToLine: settings.mapsToLine,
whiteBackgroundNoteRendering: markup.language === MarkupLanguage.Html,
globalSettings: settings.globalSettings,
};
const pluginSettingsCacheKey = JSON.stringify(settings.pluginSettings);
@@ -153,7 +155,7 @@ export default class Renderer {
// Adding plugin assets can be slow -- run it asynchronously.
if (settings.pluginAssetContainerSelector) {
void (async () => {
await addPluginAssets(result.pluginAssets, {
const addedCount = await addPluginAssets(result.pluginAssets, {
inlineAssets: this.setupOptions_.useTransferredFiles,
readAssetBlob: settings.readAssetBlob,
container: document.querySelector(settings.pluginAssetContainerSelector),
@@ -161,7 +163,12 @@ export default class Renderer {
});
// Some plugins require this event to be dispatched just after being added.
document.dispatchEvent(new Event('joplin-noteDidUpdate'));
// Avoid dispatching unless the plugins actually changed to avoid unnecessary
// rerenders in the background (which can cause content to flicker in the Rich
// Text Editor).
if (addedCount) {
document.dispatchEvent(new Event('joplin-noteDidUpdate'));
}
})();
}

View File

@@ -47,7 +47,7 @@ interface Options {
// Note that this function keeps track of what's been added so as not to
// add the same CSS files multiple times.
const addPluginAssets = async (assets: RenderResultPluginAsset[], options: Options) => {
if (!assets) return;
if (!assets) return 0;
const pluginAssetsContainer = options.container;
@@ -78,6 +78,7 @@ const addPluginAssets = async (assets: RenderResultPluginAsset[], options: Optio
const processedAssetIds = [];
let addedCount = 0;
for (let i = 0; i < assets.length; i++) {
const asset = assets[i];
@@ -124,6 +125,7 @@ const addPluginAssets = async (assets: RenderResultPluginAsset[], options: Optio
pluginAssetsContainer.appendChild(element);
}
addedCount++;
pluginAssetsAdded_[assetId] = {
element,
};
@@ -150,12 +152,14 @@ const addPluginAssets = async (assets: RenderResultPluginAsset[], options: Optio
} catch (error) {
// We don't throw an exception but we log it since
// it shouldn't happen
console.warn('Tried to remove an asset but got an error', error);
console.warn('Tried to remove an asset but got an error. On asset:', asset, error);
}
pluginAssetsAdded_[assetId] = null;
}
}
}
return addedCount > 0;
};
export default addPluginAssets;

View File

@@ -228,6 +228,9 @@ const useWebViewSetup = (props: Props): Result => {
return shim.fsDriver().fileAtPath(resolvedPath);
},
removeUnusedPluginAssets: options.removeUnusedPluginAssets,
globalSettings: {
'markdown.plugin.abc.options': Setting.value('markdown.plugin.abc.options'),
},
});
await transferResources(options.resources);

View File

@@ -364,6 +364,7 @@
"${PODS_CONFIGURATION_BUILD_DIR}/React-Core/React-Core_privacy.bundle",
"${PODS_CONFIGURATION_BUILD_DIR}/React-cxxreact/React-cxxreact_privacy.bundle",
"${PODS_CONFIGURATION_BUILD_DIR}/boost/boost_privacy.bundle",
"${PODS_CONFIGURATION_BUILD_DIR}/glog/glog_privacy.bundle",
"${PODS_CONFIGURATION_BUILD_DIR}/react-native-image-picker/RNImagePickerPrivacyInfo.bundle",
);
name = "[CP] Copy Pods Resources";
@@ -396,6 +397,7 @@
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/React-Core_privacy.bundle",
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/React-cxxreact_privacy.bundle",
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/boost_privacy.bundle",
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/glog_privacy.bundle",
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/RNImagePickerPrivacyInfo.bundle",
);
runOnlyForDeploymentPostprocessing = 0;

View File

@@ -1514,9 +1514,9 @@ PODS:
- Yoga
- react-native-rsa-native (2.0.5):
- React
- react-native-saf-x (3.5.0):
- react-native-saf-x (3.5.1):
- React-Core
- react-native-safe-area-context (5.4.1):
- react-native-safe-area-context (5.5.2):
- React-Core
- react-native-sqlite-storage (6.0.1):
- React-Core
@@ -1874,7 +1874,7 @@ PODS:
- React-Core
- RNCPushNotificationIOS (1.11.0):
- React-Core
- RNDateTimePicker (8.4.2):
- RNDateTimePicker (8.4.4):
- React-Core
- RNDeviceInfo (14.0.4):
- React-Core
@@ -1884,13 +1884,13 @@ PODS:
- React-Core
- RNFS (2.20.0):
- React-Core
- RNLocalize (3.4.2):
- RNLocalize (3.5.2):
- React-Core
- RNQuickAction (0.3.13):
- React
- RNSecureRandom (1.0.1):
- React
- RNShare (12.1.0):
- RNShare (12.1.2):
- DoubleConversion
- glog
- hermes-engine
@@ -1916,7 +1916,7 @@ PODS:
- Yoga
- RNSVG (15.13.0):
- React-Core
- RNVectorIcons (10.2.0):
- RNVectorIcons (10.3.0):
- DoubleConversion
- glog
- hermes-engine
@@ -2290,7 +2290,7 @@ EXTERNAL SOURCES:
SPEC CHECKSUMS:
boost: 7e761d76ca2ce687f7cc98e698152abd03a18f90
DoubleConversion: 76ab83afb40bddeeee456813d9c04f67f78771b5
DoubleConversion: cb417026b2400c8f53ae97020b2be961b59470cb
EXAV: ae28256069c4cdde93d185c007d8f68d92902c2e
EXConstants: 98bcf0f22b820f9b28f9fee55ff2daededadd2f8
Expo: b527631da3b11e085809e877b845f9e6cdd68f9c
@@ -2303,7 +2303,7 @@ SPEC CHECKSUMS:
fast_float: 06eeec4fe712a76acc9376682e4808b05ce978b6
FBLazyVector: 84b955f7b4da8b895faf5946f73748267347c975
fmt: a40bb5bd0294ea969aaaba240a927bd33d878cdd
glog: c5d68082e772fa1c511173d6b30a9de2c05a69a2
glog: 5683914934d5b6e4240e497e0f4a3b42d1854183
hermes-engine: 314be5250afa5692b57b4dd1705959e1973a8ebe
JoplinCommonShareExtension: a8b60b02704d85a7305627912c0240e94af78db7
JoplinRNShareExtension: e158a4b53ee0aa9cd3037a16221dc8adbd6f7860
@@ -2348,8 +2348,8 @@ SPEC CHECKSUMS:
react-native-netinfo: cec9c4e86083cb5b6aba0e0711f563e2fbbff187
react-native-quick-crypto: b475b71e7fa4dbf3446be55e8ad4ef2c58ac4f7f
react-native-rsa-native: a7931cdda1f73a8576a46d7f431378c5550f0c38
react-native-saf-x: 8a349c8348f43ff7c14770da4b0d618d62593346
react-native-safe-area-context: dde2052b903c11d677c320b599c3244021c34ce8
react-native-saf-x: 404f0f9a29cc6bf21d88582e054c45a11b28c22b
react-native-safe-area-context: 0f7bf11598f9a61b7ceac8dc3f59ef98697e99e1
react-native-sqlite-storage: 0c84826214baaa498796c7e46a5ccc9a82e114ed
react-native-version-info: f0b04e16111c4016749235ff6d9a757039189141
react-native-webview: 0dceb35a9d050f5fa55f7fe2d8c4d1903651eb7d
@@ -2387,17 +2387,17 @@ SPEC CHECKSUMS:
rn-fetch-blob: 25612b6d6f6e980c6f17ed98ba2f58f5696a51ca
RNCClipboard: f6679d470d0da2bce2a37b0af7b9e0bf369ecda5
RNCPushNotificationIOS: 6c4ca3388c7434e4a662b92e4dfeeee858e6f440
RNDateTimePicker: 392bdc0d6863b5de2fe9b957c82c25b6a038db29
RNDateTimePicker: 7d93eacf4bdf56350e4b7efd5cfc47639185e10c
RNDeviceInfo: d863506092aef7e7af3a1c350c913d867d795047
RNExitApp: 4432b9b7cc5ccec9f91c94e507849891282befd4
RNFileViewer: 4b5d83358214347e4ab2d4ca8d5c1c90d869e251
RNFS: 89de7d7f4c0f6bafa05343c578f61118c8282ed8
RNLocalize: 6a87f0490f1793d7a70042e4c55eb9a1ba6dd5b4
RNLocalize: 3c4d0abd777a546fa77bdb6caef85a87fb9ea349
RNQuickAction: c2c8f379e614428be0babe4d53a575739667744d
RNSecureRandom: b64d263529492a6897e236a22a2c4249aa1b53dc
RNShare: 9528acd4e374d3cb76b994b9e167d4a75cd8f452
RNShare: 6496fc1ea6e8fce76b769513b6c2852f9c3ded82
RNSVG: 295a96bc43f2baa5958d64aeec9847a1d8ca7a3d
RNVectorIcons: d53917643fddb261b22bd6d889776f336893622b
RNVectorIcons: e431ef1e6bef75d6ad0e33a83d376e6207962a9d
SocketRocket: d4aabe649be1e368d1318fdf28a022d714d65748
Yoga: c758bfb934100bb4bf9cbaccb52557cee35e8bdf
ZXingObjC: 8898711ab495761b2dbbdec76d90164a6d7e14c5

View File

@@ -29,7 +29,7 @@
"@joplin/renderer": "~3.5",
"@joplin/utils": "~3.5",
"@react-native-clipboard/clipboard": "1.16.3",
"@react-native-community/datetimepicker": "8.4.3",
"@react-native-community/datetimepicker": "8.4.4",
"@react-native-community/geolocation": "3.4.0",
"@react-native-community/netinfo": "11.4.1",
"@react-native-community/push-notification-ios": "1.11.0",
@@ -53,13 +53,13 @@
"react": "19.0.0",
"react-native": "0.79.2",
"react-native-device-info": "14.0.4",
"react-native-dropdownalert": "5.1.0",
"react-native-dropdownalert": "5.2.0",
"react-native-exit-app": "2.0.0",
"react-native-file-viewer": "2.1.5",
"react-native-fs": "2.20.0",
"react-native-get-random-values": "1.11.0",
"react-native-image-picker": "8.2.1",
"react-native-localize": "3.4.2",
"react-native-localize": "3.5.2",
"react-native-modal-datetime-picker": "18.0.0",
"react-native-paper": "5.14.5",
"react-native-popup-menu": "0.17.0",
@@ -72,7 +72,7 @@
"react-native-sqlite-storage": "6.0.1",
"react-native-svg": "15.13.0",
"react-native-url-polyfill": "2.0.0",
"react-native-vector-icons": "10.2.0",
"react-native-vector-icons": "10.3.0",
"react-native-version-info": "1.1.1",
"react-native-webview": "13.15.0",
"react-native-zip-archive": "7.0.2",
@@ -109,13 +109,13 @@
"@types/node": "18.19.130",
"@types/react": "19.0.14",
"@types/react-redux": "7.1.33",
"@types/serviceworker": "0.0.149",
"@types/serviceworker": "0.0.150",
"@types/tar-stream": "3.1.4",
"babel-jest": "29.7.0",
"babel-loader": "9.1.3",
"babel-plugin-module-resolver": "4.1.0",
"babel-plugin-react-native-web": "0.20.0",
"esbuild": "0.25.8",
"esbuild": "0.25.9",
"fast-deep-equal": "3.1.3",
"fs-extra": "11.2.0",
"gulp": "4.0.2",

View File

@@ -0,0 +1 @@
module.exports = `KCgpID0+IHsKCWxldCBpbml0RG9uZV8gPSBmYWxzZTsKCgljb25zdCBnZXRMaWJyYXJ5ID0gKCkgPT4gewoJCXJldHVybiB3aW5kb3c/LkFCQ0pTOwoJfTsKCgljb25zdCBnZXRPcHRpb25zID0gKGVsZW1lbnQpID0+IHsKCQljb25zdCBvcHRpb25zID0gZWxlbWVudC5nZXRBdHRyaWJ1dGUoJ2RhdGEtYWJjLW9wdGlvbnMnKTsKCgkJaWYgKG9wdGlvbnMpIHsKCQkJdHJ5IHsKCQkJCXJldHVybiBKU09OLnBhcnNlKG9wdGlvbnMpOwoJCQl9IGNhdGNoIChlcnJvcikgewoJCQkJY29uc29sZS5lcnJvcignQ291bGQgbm90IHBhcnNlIEFCQyBvcHRpb25zOicsIG9wdGlvbnMsIGVycm9yKTsKCQkJfQoJCX0KCgkJcmV0dXJuIHt9OwoJfTsKCgljb25zdCBpbml0aWFsaXplID0gKCkgPT4gewoJCWlmIChpbml0RG9uZV8pIHJldHVybiB0cnVlOwoKCQljb25zdCBsaWIgPSBnZXRMaWJyYXJ5KCk7CgkJaWYgKCFsaWIpIHJldHVybiBmYWxzZTsKCgkJaW5pdERvbmVfID0gdHJ1ZTsKCgkJY29uc3QgZWxlbWVudHMgPSBkb2N1bWVudC5xdWVyeVNlbGVjdG9yQWxsKCcuam9wbGluLWVkaXRhYmxlID4gLmpvcGxpbi1hYmMtbm90YXRpb24tcmVuZGVyZWQnKTsKCgkJZm9yIChjb25zdCByZW5kZXJDb250YWluZXIgb2YgZWxlbWVudHMpIHsKCQkJY29uc3QgYmxvY2sgPSByZW5kZXJDb250YWluZXIucGFyZW50RWxlbWVudDsKCQkJY29uc3Qgc291cmNlRWxlbWVudCA9IGJsb2NrLnF1ZXJ5U2VsZWN0b3IoJy5qb3BsaW4tc291cmNlJyk7CgkJCWlmICghc291cmNlRWxlbWVudCkgY29udGludWU7CgoJCQljb25zdCBvcHRpb25zID0gZ2V0T3B0aW9ucyhzb3VyY2VFbGVtZW50KTsKCQkJbGliLnJlbmRlckFiYyhyZW5kZXJDb250YWluZXIsIHNvdXJjZUVsZW1lbnQudGV4dENvbnRlbnQsIHsgLi4ub3B0aW9ucyB9KTsKCQl9CgoJCXJldHVybiB0cnVlOwoJfTsKCglkb2N1bWVudC5hZGRFdmVudExpc3RlbmVyKCdqb3BsaW4tbm90ZURpZFVwZGF0ZScsICgpID0+IHsKCQlpbml0RG9uZV8gPSBmYWxzZTsKCQlpbml0aWFsaXplKCk7Cgl9KTsKCgljb25zdCBpbml0SUlEXyA9IHNldEludGVydmFsKCgpID0+IHsKCQlpZiAoaW5pdGlhbGl6ZSgpKSBjbGVhckludGVydmFsKGluaXRJSURfKTsKCX0sIDEwMCk7CgoJZG9jdW1lbnQuYWRkRXZlbnRMaXN0ZW5lcignRE9NQ29udGVudExvYWRlZCcsICgpID0+IHsKCQlpZiAoaW5pdGlhbGl6ZSgpKSBjbGVhckludGVydmFsKGluaXRJSURfKTsKCX0pOwp9KSgpOwoK`;

File diff suppressed because one or more lines are too long

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