1
0
mirror of https://github.com/laurent22/joplin.git synced 2025-12-08 23:07:32 +02:00

Compare commits

..

255 Commits

Author SHA1 Message Date
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
Laurent Cozic
996a0894ae Chore: Fixed Postgres tool path for new Homebrew version 2025-11-06 17:50:59 +01:00
Laurent Cozic
66fa3fc808 Server: Remove query optimisation that now seems to be slower with newer versions of Postgres 2025-11-06 17:12:45 +01:00
renovate[bot]
dab55daf95 Update dependency prosemirror-model to v1.25.3 (#13623)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-11-05 02:16:07 +00:00
summoner
7f1c31e03f All: Translation: Update hu_HU.po (#13620) 2025-11-04 15:42:36 -05:00
cedecode
0a8255f091 All: Translation: Update de_DE.po (#13618) 2025-11-04 15:41:04 -05:00
Helmut K. C. Tessarek
9f3e6650a9 Update translations 2025-11-03 17:23:28 -05:00
mrjo118
4a17da3df5 All: Fixes #13531: When creating a conflict, ensure the latest note contents are used to create the conflict (#13552) 2025-11-03 20:21:05 +01:00
Henry Heino
2c4f0d4d8c Desktop: Fixes #13574: Fix crash when opening the legacy Markdown editor (#13576) 2025-11-03 20:12:39 +01:00
Henry Heino
9c1c2fb0d4 Chore: Desktop: Enable source maps for error reporting by default (#13577) 2025-11-03 20:12:24 +01:00
Henry Heino
2332e4bf62 Desktop: Fixes #13579: Rich Text Editor: Make cursor jump during editing less likely (#13581) 2025-11-03 20:11:45 +01:00
Henry Heino
a488ac1b27 Desktop: Fixes #13177: Location: Remove geoplugin.net from location providers (#13583) 2025-11-03 20:11:37 +01:00
Henry Heino
6daa41ca66 All: Fixes #13291: Improve performance of item deserialization (#13585) 2025-11-03 20:11:21 +01:00
Henry Heino
cc9517f1a2 Desktop: Resolves #13586: Preserve scroll when switching between Markdown and Rich Text Editors (#13587) 2025-11-03 20:11:12 +01:00
Henry Heino
200a471e55 Chore: OneNote importer: Remove unused dependency (#13590) 2025-11-03 12:21:03 +01:00
renovate[bot]
c21d37bd91 Update dependency @types/serviceworker to v0.0.149 (#13604)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-11-03 12:20:50 +01:00
renovate[bot]
e36cd0e60b Update dependency mermaid to v11.8.1 (#13607)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-11-02 21:16:04 +00:00
VortexP
871f55bf11 All: Translation: Update fi_FI.po (#13605) 2025-11-02 16:13:27 -05:00
renovate[bot]
22c9fed663 Update dependency mermaid to v11.8.0 (#13589)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-11-02 20:14:11 +01:00
renovate[bot]
ea362d7a82 Update dependency @electron/remote to v2.1.3 (#13594)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-11-02 20:13:54 +01:00
renovate[bot]
9ae9347f89 Update eslint (#13597)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-11-01 20:28:29 +00:00
Joplin Bot
ae8bb902f9 Doc: Auto-update documentation
Auto-updated using release-website.sh
2025-11-01 01:50:23 +00:00
renovate[bot]
90eeec23de Update eslint (#13595)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-11-01 01:49:05 +00:00
Henry Heino
474fd094c4 Chore: Update licenses.md (#13582) 2025-10-31 10:28:04 +01:00
renovate[bot]
937d8fa4f7 Update dependency react-native-share to v12.1.2 (#13570)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-10-30 12:10:11 +01:00
renovate[bot]
45c9844616 Update dependency @types/serviceworker to v0.0.148 (#13568)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-10-30 10:49:02 +01:00
Joplin Bot
12b8ef5a54 Doc: Auto-update documentation
Auto-updated using release-website.sh
2025-10-29 18:41:30 +00:00
mrjo118
18f72c224e Mobile: Fixes #13151: Reset the state of undo and redo buttons when switching editor (#13505) 2025-10-29 18:22:56 +01:00
mrjo118
7ca3aaa83f Web: Fixes #13241: Find and replace toolbar in the note editor is not sized correctly (#13559) 2025-10-29 18:21:30 +01:00
mrjo118
04b1443e5a Mobile: Make title field work with very long text (#13566) 2025-10-29 18:20:56 +01:00
mrjo118
c461741778 All: Fixes #13319: Treat unclosed quotes as fully quoted search terms, to prevent malformed match expression error (#13564) 2025-10-29 18:19:38 +01:00
renovate[bot]
2865b0a803 Update dependency follow-redirects to v1.15.11 (#13565)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-10-29 17:58:37 +01:00
Laurent Cozic
21e49be22f Doc: Fixed order of tags in spellcheck document 2025-10-29 17:56:40 +01:00
Laurent Cozic
fef761cbab Doc: Added documentation to setup Joplin Server with Keycloak to test SAML auth 2025-10-29 17:55:07 +01:00
renovate[bot]
c15a353dc2 Update dependency react-native-safe-area-context to v5.5.2 (#13496)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Laurent Cozic <laurent22@users.noreply.github.com>
2025-10-29 14:00:36 +01:00
Laurent Cozic
ffb32766c1 Desktop release v3.5.6 2025-10-29 13:45:27 +01:00
Henry Heino
038908550e Chore: Desktop: Share folder dialog: Remove duplicate "refreshShares" call (#13535) 2025-10-29 13:43:56 +01:00
Henry Heino
42f59134ae Desktop: Fixes #13549: OneNote importer: Task lists: Fix checkbox sizes and accessibility (#13558) 2025-10-29 13:43:48 +01:00
Laurent Cozic
fc0014c0b5 All: Open the connection screen when a SAML session has expired 2025-10-29 13:42:11 +01:00
Laurent Cozic
42d8df3036 Desktop, Cli, Mobile: Ensure that sync process ends up properly when Joplin Server shares cannot be accessed 2025-10-29 13:42:11 +01:00
Frank Fesevur
1fad9ca1cc All: Translation: Update nl_NL.po (#13556) 2025-10-28 17:37:04 -04:00
Laurent Cozic
ae289be77a Server: Add support for DELETE_EXPIRED_SESSIONS_SCHEDULE to prevent auto-logout when using SAML login 2025-10-28 17:37:38 +01:00
Laurent Cozic
7f6bfe9c6e Doc: Clarifies that SAML does not support the API_BASE_URL 2025-10-28 17:21:57 +01:00
Laurent Cozic
ead4001b7a Revert "Server: Fix SAML routes to prevent cookie issues on redirect (#13557)"
This reverts commit a4556bf598.
2025-10-28 17:05:27 +01:00
Laurent Cozic
7b95ef72a0 Server: Fixes #13368: Cannot login with SAML when already logged in on the browser 2025-10-28 16:59:42 +01:00
Laurent Cozic
a4556bf598 Server: Fix SAML routes to prevent cookie issues on redirect (#13557) 2025-10-28 16:58:11 +01:00
mrjo118
8d6268dc92 Chore: Fix intermittent revision test failure (#13458) 2025-10-28 11:35:07 +01:00
Henry Heino
7ffcbdf60a Server: Fixes #13490: Make server less likely to generate non-unique SSO codes (#13501) 2025-10-28 11:34:22 +01:00
mrjo118
76989ddc45 Mobile: Fixes #13120: Fix truncated buttons on tag association screen (#13502) 2025-10-28 11:33:52 +01:00
mrjo118
1db1254617 Mobile: Fixes #12957: Avoid dismissing the keyboard when tapping markdown toolbar buttons with the title in focus (#13504) 2025-10-28 11:33:36 +01:00
mrjo118
9810bffddc Mobile: Fixes #11468: Ensure note list is re-ordered after updating a note opened via a search (#13506) 2025-10-28 11:28:59 +01:00
Henry Heino
b25e18107b Desktop,Mobile,Cli: Fixes #13522: Fix "cannot add an item as a child of a read-only item" error when updating share IDs (#13523) 2025-10-28 11:28:37 +01:00
Henry Heino
edc5fe5d1b Desktop: Allow adding and removing users from a share while a sync is in progress (#13529) 2025-10-28 11:26:46 +01:00
Henry Heino
7ffb44b3a4 Desktop: Fixes #13537: Fix adding a new user to a share creates an unused E2EE key (#13538) 2025-10-28 11:23:02 +01:00
Henry Heino
32f4c33140 Desktop: Disallow unsharing a folder while sharing is in progress (#13551) 2025-10-28 11:22:13 +01:00
renovate[bot]
1a7b09c91c Update dependency koa to v2.16.2 (#13554)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-10-28 09:40:08 +00:00
renovate[bot]
e5bf8e0e58 Update dependency @types/node-fetch to v2.6.13 (#13553)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-10-28 09:38:10 +00:00
renovate[bot]
94725c533c Update dependency @react-native-community/datetimepicker to v8.4.3 (#13547)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-10-26 14:46:31 +00:00
Jozef Gaal
359c92b64f All: Translation: Update sk_SK.po (#13542) 2025-10-25 16:28:26 -04:00
renovate[bot]
8f8b8ad943 Update dependency dotenv to v16.6.1 (#13543)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-10-25 14:36:57 +00:00
renovate[bot]
dd2f329fd5 Update dependency @types/serviceworker to v0.0.147 (#13541)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-10-25 14:34:38 +00:00
mrjo118
813f594cb4 Mobile: Increase height of tag association screen to cater for a larger tag list area (#13521) 2025-10-25 14:13:15 +02:00
mrjo118
0e0ce49867 Mobile: Fixes #13108: Markdown toolbar overlaps with the gesture bar (#13533) 2025-10-25 14:12:37 +02:00
Henry Heino
e485d318b7 Desktop: Accessibility: Improve dialog keyboard handling (#13536) 2025-10-25 14:09:10 +02:00
renovate[bot]
4e82d81df1 Update dependency dotenv to v16.6.0 (#13539)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-10-25 14:04:29 +02:00
Frank Fesevur
d5dbda201b All: Translation: Update nl_NL.po (#13519) 2025-10-23 15:29:45 -04:00
Joplin Bot
831258506b Doc: Auto-update documentation
Auto-updated using release-website.sh
2025-10-23 12:52:21 +00:00
renovate[bot]
67f3329ecb Update dependency rate-limiter-flexible to v7.1.1 (#13517)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-10-23 14:32:35 +02:00
renovate[bot]
ed7e6751f0 Update dependency react-native-share to v12.1.1 (#13516)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-10-23 14:32:03 +02:00
mrjo118
35e69486d3 Mobile: Fixes #13457: Prevent toggling of multiline mode from clearing the title field on iOS (#13515) 2025-10-23 11:40:50 +02:00
Henry Heino
918c8830e0 Mobile: Fixes #13193: Fix Markdown toolbar (#13514) 2025-10-23 11:40:28 +02:00
renovate[bot]
c3b4a4b955 Update dependency rate-limiter-flexible to v7 (#13513)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-10-23 11:38:21 +02:00
Laurent Cozic
44a14fabbd Doc: Updated sponsors 2025-10-23 10:19:53 +02:00
Frank Fesevur
49399cd1fa All: Translation: Update nl_NL.po (#13510) 2025-10-22 14:39:24 -04:00
Bartolomeo
fc4cd2e942 Server: Resolves #13147: Add LOG_LEVEL env var to control logging verbosity (#13503) 2025-10-22 12:26:02 +02:00
Arman Saga
cd6e457dc5 All: Translation: Update ru_RU.po (#13507) 2025-10-22 00:31:23 -04:00
Eric Duarte
2e9bf3a4e5 All: Translation: Update ca.po and es_ES.po (#13499) 2025-10-21 18:27:12 -04:00
Eric Duarte
547ceea4b0 All: Translation: Update es_ES.po (#13498) 2025-10-21 18:11:53 -04:00
renovate[bot]
776ff5e7ea Update dependency @fortawesome/react-fontawesome to v0.2.3 (#13500)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-10-21 23:22:49 +02:00
Joplin Bot
2b3bac0d43 Doc: Auto-update documentation
Auto-updated using release-website.sh
2025-10-21 18:39:58 +00:00
Henry Heino
e48efe2e8d Desktop: OneNote importer: Resolve possible import failure related to unsupported formatting (#13495) 2025-10-21 17:19:56 +02:00
Laurent Cozic
5f6382fbc0 Merge branch 'release-3.4' into dev 2025-10-21 16:36:53 +02:00
Laurent Cozic
3d5d82081a iOS 13.4.4 2025-10-21 16:17:15 +02:00
Laurent Cozic
cff96b1306 iOS: Removed donation link since Apple is blocking the release because of this 2025-10-21 16:08:02 +02:00
Henry Heino
98c5a9c096 Desktop: Fixes #13481: Accessibility: Prevent sidebar header text from moving: Don't change the header icon on hover (#13482) 2025-10-21 00:46:52 +02:00
Henry Heino
e92430b3ed Desktop: Accessibility: Fix global keyboard shortcuts are ignored when the sidebar has focus (#13485) 2025-10-21 00:46:36 +02:00
renovate[bot]
848d1bfe64 Update dependency react-native-safe-area-context to v5.5.0 (#13487)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-10-21 00:45:52 +02:00
Henry Heino
a386283530 Docs: Update OneNote import workflow (#13494) 2025-10-21 00:45:33 +02:00
Greg Oledzki
6101031269 Chore: Replace if with it in one of the tests (#13489) 2025-10-20 21:18:07 +02:00
Henry Heino
2fc3431f46 Web: Accessibility: Fix focus indicator is invisible for sync wizard options (#13492) 2025-10-20 21:12:06 +02:00
renovate[bot]
361fa2c768 Update dependency @types/serviceworker to v0.0.146 (#13484)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-10-20 16:07:56 +00:00
Helmut K. C. Tessarek
f4a0a2466b Update translations 2025-10-19 14:42:12 -04:00
summoner
dbf225d6ad All: Translation: Update hu_HU.po (#13486) 2025-10-19 14:36:40 -04:00
Helmut K. C. Tessarek
4773a3831c fix: remove \r escape sequence from hu_HU.po 2025-10-18 15:46:49 -04:00
Mihai Vasiliu
6a19690581 All: Translation: Update ro_RO.po and ro_MD.po (#13479) 2025-10-18 15:24:06 -04:00
Arda Kılıçdağı
b7a771d58d All: Translation: Update tr_TR.po (#13478) 2025-10-18 15:23:53 -04:00
Jozef Gaal
e3daefb81a All: Translation: Update sk_SK.po (#13477) 2025-10-18 15:23:41 -04:00
Joplin Bot
b4253dace8 Doc: Auto-update documentation
Auto-updated using release-website.sh
2025-10-18 12:46:07 +00:00
renovate[bot]
fcf3be1be1 Update dependency esbuild to v0.25.8 (#13473)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-10-18 12:41:05 +01:00
renovate[bot]
99aebbad81 Update dependency mermaid to v11.7.0 (#13476)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-10-18 12:40:51 +01:00
Laurent Cozic
81b695a2a9 Chore: Exclude translation updates from changelog 2025-10-18 11:30:24 +01:00
Laurent Cozic
2dbba27357 Plugin Repo CLI v3.5.3 2025-10-18 10:12:33 +01:00
Laurent Cozic
8713cd2fd8 CLI v3.5.1 2025-10-18 10:11:50 +01:00
Laurent Cozic
d0fc4ea21b Lock file 2025-10-18 10:11:18 +01:00
Laurent Cozic
8bd62800ef Releasing sub-packages 2025-10-18 10:10:42 +01:00
Laurent Cozic
00f9e932e6 Desktop release v3.5.5 2025-10-18 09:53:34 +01:00
Helmut K. C. Tessarek
b8b55e4a55 Chore: Fix generated file line ending (#13459) 2025-10-18 09:50:37 +01:00
renovate[bot]
ef5be2ded3 Update dependency @types/node to v18.19.130 (#13463)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-10-18 09:49:19 +01:00
Henry Heino
00702dde00 Desktop: OneNote importer: Improve file header validation (#13467) 2025-10-18 09:48:51 +01:00
Henry Heino
2a6af9bed9 Desktop: Accessibility: Allow jumping to notebooks by typing the initial letter or Home/End (#13469) 2025-10-18 09:48:44 +01:00
Henry Heino
c26fe0960b Web: Show sync wizard on first start (#13470) 2025-10-18 09:48:13 +01:00
Henry Heino
ab9d36fc08 Chore: Windows: Tests: Fix Rust OneNote importer tests fail (#13471) 2025-10-18 09:48:03 +01:00
Henry Heino
28eb53bd9f Desktop: OneNote importer: Support directly importing .one files and, on Windows, .onepkg files (#13474)
Co-authored-by: Laurent Cozic <laurent22@users.noreply.github.com>
2025-10-18 09:47:35 +01:00
Laurent Cozic
3097c3e589 Desktop, Cli: Correctly import Evernote resources that do not have the encoding specified 2025-10-18 09:44:35 +01:00
renovate[bot]
08371ef718 Update dependency @types/serviceworker to v0.0.144 (#13475)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-10-18 08:19:27 +00:00
Laurent Cozic
561716efea Desktop, Cli: Fixed importing certain Evernote images that have invalid dimension attributes (#13472) 2025-10-18 09:17:22 +01:00
renovate[bot]
0d457d1bde Update dependency esbuild to v0.25.7 (#13461)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-10-17 09:18:57 +01:00
renovate[bot]
8c11f17c93 Update dependency @types/node to v18.19.120 (#13460)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-10-17 09:18:50 +01:00
renovate[bot]
f7a90ee1d2 Update dependency @types/serviceworker to v0.0.143 (#13449)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Laurent Cozic <laurent22@users.noreply.github.com>
2025-10-17 09:18:08 +01:00
Eric Duarte
8822409f7c All: Translation: Update ca.po (#13462) 2025-10-17 03:54:42 -04:00
Eric Duarte
cd3e7f485a All: Translation: Update es_ES.po (#13454) 2025-10-16 17:25:46 -04:00
summoner
8d42b01d4f All: Translation: Update hu_HU.po (#13451) 2025-10-16 17:25:34 -04:00
Henry Heino
2c37197641 Desktop: Resolves #520: Save and restore the cursor position when switching between notes (#13447) 2025-10-16 14:56:38 +01:00
Henry Heino
c2c37b3741 Desktop: Fixes #13411: Fix header links only work if the note viewer is visible (#13442) 2025-10-16 12:10:01 +01:00
Henry Heino
3e770300dc Chore: Desktop: Add tool for resolving stack traces based on source maps (#13427) 2025-10-16 12:09:02 +01:00
Henry Heino
683291d5df Chore: Transcribe: Make logic for starting transcription workers safer (#13425) 2025-10-16 12:08:52 +01:00
Henry Heino
d239035417 Mobile: Resolves #13067: Rich Text Editor: Improve table support (#13413)
Co-authored-by: Laurent Cozic <laurent22@users.noreply.github.com>
2025-10-16 12:08:44 +01:00
Henry Heino
5ef37d9de0 Desktop: Support importing .one files from OneNote 2016 (#13391) 2025-10-16 12:08:35 +01:00
mrjo118
1111bde017 All: Fixes #6517: Prevent Joplin from missing changes when syncing with file system or WebDAV (#13054) 2025-10-16 12:06:48 +01:00
Laurent Cozic
468cf00d77 Chore: Clean-up exclusion list in buildTranslation 2025-10-16 11:20:05 +01:00
Laurent Cozic
3c5b41b992 Chore: Fixed CI 2025-10-16 11:19:44 +01:00
Helmut K. C. Tessarek
5f66c51dba All: Update translations 2025-10-15 23:34:40 -04:00
k-santos
bfeaa67ec4 All: Translation: Update pt_BR.po (#13448) 2025-10-15 23:30:16 -04:00
renovate[bot]
348fd0333f Update dependency react-native-share to v12.1.0 (#13446)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-10-15 21:19:13 +01:00
Henry Heino
51c4d6d6ef Desktop: Upgrade to Electron v37.7.0 (#13445) 2025-10-15 20:39:26 +01:00
Laurent Cozic
97fa85a3f7 Desktop release v3.4.13 2025-10-02 09:35:36 +01:00
Laurent Cozic
defe36bba1 Server: Enable publish and share notebook for SAML login 2025-10-02 09:34:51 +01:00
Henry Heino
711d214741 Android: Fixes #13193: Fix Markdown toolbar buttons sometimes don't work (#13233) 2025-09-18 12:05:57 +01:00
pedr
0795c67354 All: Fixes #12249: Change default content-type for Webdav connector to application/octet-stream (#13053) 2025-09-13 14:13:27 +01:00
Laurent Cozic
e9a9f68568 Desktop release v3.4.12 2025-09-09 15:36:24 +01:00
689 changed files with 118198 additions and 93257 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/
@@ -90,13 +91,13 @@ plugin_types/
readme/
packages/react-native-vosk/lib/
packages/lib/countable/Countable.js
packages/onenote-converter/pkg/onenote_converter.js
packages/onenote-converter/renderer/pkg/*
# AUTO-GENERATED - EXCLUDED TYPESCRIPT BUILD
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
@@ -270,6 +271,7 @@ packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/v6/useEditorCommands.js
packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/v6/utils/localisation.js
packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/v6/utils/useKeymap.js
packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/v6/utils/useRefocusOnVisiblePaneChange.js
packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/v6/utils/useSyncEditorValue.js
packages/app-desktop/gui/NoteEditor/NoteBody/PlainEditor/PlainEditor.js
packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/TinyMCE.js
packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/styles/index.js
@@ -280,6 +282,7 @@ packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/utils/shouldPasteResources.
packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/utils/shouldPasteResources.js
packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/utils/types.js
packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/utils/useContextMenu.js
packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/utils/useCursorPositioning.js
packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/utils/useEditDialog.js
packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/utils/useEditDialogEventListeners.js
packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/utils/useKeyboardRefocusHandler.js
@@ -321,6 +324,7 @@ packages/app-desktop/gui/NoteEditor/utils/useEffectiveNoteId.js
packages/app-desktop/gui/NoteEditor/utils/useFolder.js
packages/app-desktop/gui/NoteEditor/utils/useFormNote.test.js
packages/app-desktop/gui/NoteEditor/utils/useFormNote.js
packages/app-desktop/gui/NoteEditor/utils/useInitialCursorLocation.js
packages/app-desktop/gui/NoteEditor/utils/useMessageHandler.js
packages/app-desktop/gui/NoteEditor/utils/useNoteSearchBar.js
packages/app-desktop/gui/NoteEditor/utils/usePluginEditorView.test.js
@@ -421,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
@@ -465,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
@@ -507,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
@@ -558,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
@@ -604,6 +612,7 @@ packages/app-desktop/tools/generateLatestArm64Yml.js
packages/app-desktop/tools/githubReleasesUtils.js
packages/app-desktop/tools/modifyReleaseAssets.js
packages/app-desktop/tools/notarizeMacApp.js
packages/app-desktop/tools/resolveSourceMap.js
packages/app-desktop/utils/7zip/getPathToExecutable7Zip.js
packages/app-desktop/utils/7zip/pathToBundled7Zip.js
packages/app-desktop/utils/checkForUpdatesUtils.test.js
@@ -697,6 +706,7 @@ packages/app-mobile/components/NoteEditor/ImageEditor/ImageEditor.js
packages/app-mobile/components/NoteEditor/ImageEditor/autosave.js
packages/app-mobile/components/NoteEditor/ImageEditor/isEditableResource.js
packages/app-mobile/components/NoteEditor/ImageEditor/promptRestoreAutosave.js
packages/app-mobile/components/NoteEditor/MarkdownEditor.test.js
packages/app-mobile/components/NoteEditor/MarkdownEditor.js
packages/app-mobile/components/NoteEditor/NoteEditor.test.js
packages/app-mobile/components/NoteEditor/NoteEditor.js
@@ -844,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
@@ -1000,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
@@ -1042,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
@@ -1098,8 +1111,9 @@ packages/editor/CodeMirror/utils/markdown/stripBlockquote.js
packages/editor/CodeMirror/utils/markdown/toggleCheckboxAt.js
packages/editor/CodeMirror/utils/setupVim.js
packages/editor/CodeMirror/vendor/announceSearchMatch.js
packages/editor/ProseMirror/commands.test.js
packages/editor/ProseMirror/commands.js
packages/editor/ProseMirror/commands/commands.test.js
packages/editor/ProseMirror/commands/commands.js
packages/editor/ProseMirror/commands/focusEditor.js
packages/editor/ProseMirror/createEditor.js
packages/editor/ProseMirror/index.js
packages/editor/ProseMirror/plugins/detailsPlugin.test.js
@@ -1107,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
@@ -1118,12 +1134,14 @@ packages/editor/ProseMirror/plugins/linkTooltipPlugin.js
packages/editor/ProseMirror/plugins/listPlugin.js
packages/editor/ProseMirror/plugins/originalMarkupPlugin.js
packages/editor/ProseMirror/plugins/searchPlugin.js
packages/editor/ProseMirror/plugins/tablePlugin.js
packages/editor/ProseMirror/plugins/utils/createExternalEditorPlugin.js
packages/editor/ProseMirror/plugins/utils/createFloatingButtonPlugin.js
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
@@ -1137,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
@@ -1147,6 +1166,12 @@ packages/editor/ProseMirror/utils/sanitizeHtml.js
packages/editor/ProseMirror/utils/selectFirstInstanceOfNode.js
packages/editor/ProseMirror/utils/trimEmptyParagraphs.js
packages/editor/ProseMirror/vendor/changedDescendants.js
packages/editor/ProseMirror/vendor/icons/addColumnRight.js
packages/editor/ProseMirror/vendor/icons/addRowBelow.js
packages/editor/ProseMirror/vendor/icons/icon.js
packages/editor/ProseMirror/vendor/icons/removeColumn.js
packages/editor/ProseMirror/vendor/icons/removeRow.js
packages/editor/ProseMirror/vendor/icons/types.js
packages/editor/ProseMirror/vendor/splitBlockAs.js
packages/editor/SelectionFormatting.js
packages/editor/events.js
@@ -1348,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
@@ -1383,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
@@ -1398,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
@@ -1415,6 +1443,7 @@ packages/lib/services/database/migrations/45.js
packages/lib/services/database/migrations/46.js
packages/lib/services/database/migrations/47.js
packages/lib/services/database/migrations/48.js
packages/lib/services/database/migrations/49.js
packages/lib/services/database/migrations/index.js
packages/lib/services/database/sqlStringToLines.js
packages/lib/services/database/types.js
@@ -1630,6 +1659,7 @@ packages/lib/services/synchronizer/Synchronizer.sharing.test.js
packages/lib/services/synchronizer/Synchronizer.tags.test.js
packages/lib/services/synchronizer/Synchronizer.tools.test.js
packages/lib/services/synchronizer/gui/useSyncTargetUpgrade.js
packages/lib/services/synchronizer/handleConflictAction.test.js
packages/lib/services/synchronizer/migrations/1.js
packages/lib/services/synchronizer/migrations/2.js
packages/lib/services/synchronizer/migrations/3.js
@@ -1764,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
@@ -1806,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.

46
.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
@@ -243,6 +243,7 @@ packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/v6/useEditorCommands.js
packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/v6/utils/localisation.js
packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/v6/utils/useKeymap.js
packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/v6/utils/useRefocusOnVisiblePaneChange.js
packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/v6/utils/useSyncEditorValue.js
packages/app-desktop/gui/NoteEditor/NoteBody/PlainEditor/PlainEditor.js
packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/TinyMCE.js
packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/styles/index.js
@@ -253,6 +254,7 @@ packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/utils/shouldPasteResources.
packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/utils/shouldPasteResources.js
packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/utils/types.js
packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/utils/useContextMenu.js
packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/utils/useCursorPositioning.js
packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/utils/useEditDialog.js
packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/utils/useEditDialogEventListeners.js
packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/utils/useKeyboardRefocusHandler.js
@@ -294,6 +296,7 @@ packages/app-desktop/gui/NoteEditor/utils/useEffectiveNoteId.js
packages/app-desktop/gui/NoteEditor/utils/useFolder.js
packages/app-desktop/gui/NoteEditor/utils/useFormNote.test.js
packages/app-desktop/gui/NoteEditor/utils/useFormNote.js
packages/app-desktop/gui/NoteEditor/utils/useInitialCursorLocation.js
packages/app-desktop/gui/NoteEditor/utils/useMessageHandler.js
packages/app-desktop/gui/NoteEditor/utils/useNoteSearchBar.js
packages/app-desktop/gui/NoteEditor/utils/usePluginEditorView.test.js
@@ -394,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
@@ -438,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
@@ -480,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
@@ -531,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
@@ -577,6 +584,7 @@ packages/app-desktop/tools/generateLatestArm64Yml.js
packages/app-desktop/tools/githubReleasesUtils.js
packages/app-desktop/tools/modifyReleaseAssets.js
packages/app-desktop/tools/notarizeMacApp.js
packages/app-desktop/tools/resolveSourceMap.js
packages/app-desktop/utils/7zip/getPathToExecutable7Zip.js
packages/app-desktop/utils/7zip/pathToBundled7Zip.js
packages/app-desktop/utils/checkForUpdatesUtils.test.js
@@ -670,6 +678,7 @@ packages/app-mobile/components/NoteEditor/ImageEditor/ImageEditor.js
packages/app-mobile/components/NoteEditor/ImageEditor/autosave.js
packages/app-mobile/components/NoteEditor/ImageEditor/isEditableResource.js
packages/app-mobile/components/NoteEditor/ImageEditor/promptRestoreAutosave.js
packages/app-mobile/components/NoteEditor/MarkdownEditor.test.js
packages/app-mobile/components/NoteEditor/MarkdownEditor.js
packages/app-mobile/components/NoteEditor/NoteEditor.test.js
packages/app-mobile/components/NoteEditor/NoteEditor.js
@@ -817,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
@@ -973,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
@@ -1015,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
@@ -1071,8 +1083,9 @@ packages/editor/CodeMirror/utils/markdown/stripBlockquote.js
packages/editor/CodeMirror/utils/markdown/toggleCheckboxAt.js
packages/editor/CodeMirror/utils/setupVim.js
packages/editor/CodeMirror/vendor/announceSearchMatch.js
packages/editor/ProseMirror/commands.test.js
packages/editor/ProseMirror/commands.js
packages/editor/ProseMirror/commands/commands.test.js
packages/editor/ProseMirror/commands/commands.js
packages/editor/ProseMirror/commands/focusEditor.js
packages/editor/ProseMirror/createEditor.js
packages/editor/ProseMirror/index.js
packages/editor/ProseMirror/plugins/detailsPlugin.test.js
@@ -1080,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
@@ -1091,12 +1106,14 @@ packages/editor/ProseMirror/plugins/linkTooltipPlugin.js
packages/editor/ProseMirror/plugins/listPlugin.js
packages/editor/ProseMirror/plugins/originalMarkupPlugin.js
packages/editor/ProseMirror/plugins/searchPlugin.js
packages/editor/ProseMirror/plugins/tablePlugin.js
packages/editor/ProseMirror/plugins/utils/createExternalEditorPlugin.js
packages/editor/ProseMirror/plugins/utils/createFloatingButtonPlugin.js
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
@@ -1110,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
@@ -1120,6 +1138,12 @@ packages/editor/ProseMirror/utils/sanitizeHtml.js
packages/editor/ProseMirror/utils/selectFirstInstanceOfNode.js
packages/editor/ProseMirror/utils/trimEmptyParagraphs.js
packages/editor/ProseMirror/vendor/changedDescendants.js
packages/editor/ProseMirror/vendor/icons/addColumnRight.js
packages/editor/ProseMirror/vendor/icons/addRowBelow.js
packages/editor/ProseMirror/vendor/icons/icon.js
packages/editor/ProseMirror/vendor/icons/removeColumn.js
packages/editor/ProseMirror/vendor/icons/removeRow.js
packages/editor/ProseMirror/vendor/icons/types.js
packages/editor/ProseMirror/vendor/splitBlockAs.js
packages/editor/SelectionFormatting.js
packages/editor/events.js
@@ -1321,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
@@ -1356,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
@@ -1371,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
@@ -1388,6 +1415,7 @@ packages/lib/services/database/migrations/45.js
packages/lib/services/database/migrations/46.js
packages/lib/services/database/migrations/47.js
packages/lib/services/database/migrations/48.js
packages/lib/services/database/migrations/49.js
packages/lib/services/database/migrations/index.js
packages/lib/services/database/sqlStringToLines.js
packages/lib/services/database/types.js
@@ -1603,6 +1631,7 @@ packages/lib/services/synchronizer/Synchronizer.sharing.test.js
packages/lib/services/synchronizer/Synchronizer.tags.test.js
packages/lib/services/synchronizer/Synchronizer.tools.test.js
packages/lib/services/synchronizer/gui/useSyncTargetUpgrade.js
packages/lib/services/synchronizer/handleConflictAction.test.js
packages/lib/services/synchronizer/migrations/1.js
packages/lib/services/synchronizer/migrations/2.js
packages/lib/services/synchronizer/migrations/3.js
@@ -1737,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
@@ -1779,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"/>
@@ -31,7 +31,7 @@ Please see the [donation page](https://github.com/laurent22/joplin/blob/dev/read
# Sponsors
<!-- SPONSORS-ORG -->
<a href="https://seirei.ne.jp"><img title="Serei Network" width="256" src="https://joplinapp.org/images/sponsors/SeireiNetwork.png"/></a> <a href="https://citricsheep.com"><img title="Citric Sheep" width="256" src="https://joplinapp.org/images/sponsors/CitricSheep.png"/></a> <a href="https://sorted.travel/?utm_source=joplinapp"><img title="Sorted Travel" width="256" src="https://joplinapp.org/images/sponsors/SortedTravel.png"/></a> <a href="https://celebian.com"><img title="Celebian" width="256" src="https://joplinapp.org/images/sponsors/Celebian.png"/></a> <a href="https://bestkru.com"><img title="BestKru" width="256" src="https://joplinapp.org/images/sponsors/BestKru.png"/></a> <a href="https://www.socialfollowers.uk/buy-tiktok-followers/"><img title="Social Followers" width="256" src="https://joplinapp.org/images/sponsors/SocialFollowers.png"/></a> <a href="https://stormlikes.com/"><img title="Stormlikes" width="256" src="https://joplinapp.org/images/sponsors/Stormlikes.png"/></a> <a href="https://route4me.com"><img title="Route4Me" width="256" src="https://joplinapp.org/images/sponsors/Route4Me.png"/></a> <a href="https://topagency.webflow.io"><img title="WebDesignAgency" width="256" src="https://joplinapp.org/images/sponsors/WebDesignAgency.png" alt="topagency"/></a> <a href="https://www.slotozilla.com/nz/no-deposit-bonus"><img title="casino without making any upfront cost" width="256" src="https://joplinapp.org/images/sponsors/Slotozilla.png" alt="casino without making any upfront cost"/></a> <a href="https://writepaper.com/"><img title="best service to write my paper for me" width="256" src="https://joplinapp.org/images/sponsors/WritePaper.png" alt="best service to write my paper for me"/></a> <a href="https://paperwriter.com/"><img title="high-quality paper writing service PaperWriter" width="256" src="https://joplinapp.org/images/sponsors/PaperWriter.png" alt="high-quality paper writing service PaperWriter"/></a> <a href="https://www.bestetf.net/"><img title="BestETF" width="256" src="https://joplinapp.org/images/sponsors/BestEtf.png" alt="BestETF"/></a> <a href="https://freespinny.io/free-spins-no-deposit/"><img title="Freespinny.io Free Spins Bonus site" width="256" src="https://joplinapp.org/images/sponsors/Freespinny.png" alt="Freespinny.io Free Spins Bonus site"/></a> <a href="https://essayshark.com"><img title="EssayShark - essay writers for hire" width="256" src="https://joplinapp.org/images/sponsors/EssayShark.png" alt="EssayShark - essay writers for hire"/></a> <a href="https://pokieslab1.com/real-money-pokies/"><img title="Australian Real Money Pokies" width="256" src="https://joplinapp.org/images/sponsors/PokiesLab.png" alt="Australian Real Money Pokies"/></a> <a href="https://pokiesman1.net/real-money-pokies/"><img title="Australian Real Money Pokies" width="256" src="https://joplinapp.org/images/sponsors/Pokiesman.png" alt="Australian Real Money Pokies"/></a> <a href="https://domyessay.com"><img title="Essay writers DoMyEssay are dedicated to providing top-notch, custom-written papers that meet your academic requirements" width="256" src="https://joplinapp.org/images/sponsors/DoMyEssay.png" alt="DoMyEssay"/></a> <a href="https://essaypro.com/"><img title="best essay writing service" width="256" src="https://joplinapp.org/images/sponsors/EssayPro.png" alt="best essay writing service"/></a> <a href="https://socialkings.online"><img title="Boost your reach and buy real followers" width="256" src="https://joplinapp.org/images/sponsors/SocialKings.png" alt="Boost your reach and buy real followers"/></a> <a href="https://uk.notgamstop.com/bonuses/free-spins-no-deposit-no-gamstop/"><img title="free spins no deposit at NotGamstop" width="256" src="https://joplinapp.org/images/sponsors/NotGamStop.jpg" alt="free spins no deposit at NotGamstop"/></a> <a href="https://www.writemyessay.com/"><img title="writing service for students WriteMyEssay" width="256" src="https://joplinapp.org/images/sponsors/WriteMyEssay.png" alt="writing service for students WriteMyEssay"/></a>
<a href="https://seirei.ne.jp"><img title="Serei Network" width="256" src="https://joplinapp.org/images/sponsors/SeireiNetwork.png"/></a> <a href="https://citricsheep.com"><img title="Citric Sheep" width="256" src="https://joplinapp.org/images/sponsors/CitricSheep.png"/></a> <a href="https://sorted.travel/?utm_source=joplinapp"><img title="Sorted Travel" width="256" src="https://joplinapp.org/images/sponsors/SortedTravel.png"/></a> <a href="https://celebian.com"><img title="Celebian" width="256" src="https://joplinapp.org/images/sponsors/Celebian.png"/></a> <a href="https://bestkru.com"><img title="BestKru" width="256" src="https://joplinapp.org/images/sponsors/BestKru.png"/></a> <a href="https://www.socialfollowers.uk/buy-tiktok-followers/"><img title="Social Followers" width="256" src="https://joplinapp.org/images/sponsors/SocialFollowers.png"/></a> <a href="https://stormlikes.com/"><img title="Stormlikes" width="256" src="https://joplinapp.org/images/sponsors/Stormlikes.png"/></a> <a href="https://route4me.com"><img title="Route4Me" width="256" src="https://joplinapp.org/images/sponsors/Route4Me.png"/></a> <a href="https://topagency.webflow.io"><img title="WebDesignAgency" width="256" src="https://joplinapp.org/images/sponsors/WebDesignAgency.png" alt="topagency"/></a> <a href="https://www.slotozilla.com/nz/no-deposit-bonus"><img title="casino without making any upfront cost" width="256" src="https://joplinapp.org/images/sponsors/Slotozilla.png" alt="casino without making any upfront cost"/></a> <a href="https://writepaper.com/"><img title="best service to write my paper for me" width="256" src="https://joplinapp.org/images/sponsors/WritePaper.png" alt="best service to write my paper for me"/></a> <a href="https://paperwriter.com/"><img title="high-quality paper writing service PaperWriter" width="256" src="https://joplinapp.org/images/sponsors/PaperWriter.png" alt="high-quality paper writing service PaperWriter"/></a> <a href="https://www.bestetf.net/"><img title="BestETF" width="256" src="https://joplinapp.org/images/sponsors/BestEtf.png" alt="BestETF"/></a> <a href="https://freespinny.io/free-spins-no-deposit/"><img title="Freespinny.io Free Spins Bonus site" width="256" src="https://joplinapp.org/images/sponsors/Freespinny.png" alt="Freespinny.io Free Spins Bonus site"/></a> <a href="https://essayshark.com"><img title="EssayShark - essay writers for hire" width="256" src="https://joplinapp.org/images/sponsors/EssayShark.png" alt="EssayShark - essay writers for hire"/></a> <a href="https://pokieslab1.com/real-money-pokies/"><img title="Australian Real Money Pokies" width="256" src="https://joplinapp.org/images/sponsors/PokiesLab.png" alt="Australian Real Money Pokies"/></a> <a href="https://pokiesman1.net/real-money-pokies/"><img title="Australian Real Money Pokies" width="256" src="https://joplinapp.org/images/sponsors/Pokiesman.png" alt="Australian Real Money Pokies"/></a> <a href="https://domyessay.com"><img title="Essay writers DoMyEssay are dedicated to providing top-notch, custom-written papers that meet your academic requirements" width="256" src="https://joplinapp.org/images/sponsors/DoMyEssay.png" alt="DoMyEssay"/></a> <a href="https://essaypro.com/"><img title="best essay writing service" width="256" src="https://joplinapp.org/images/sponsors/EssayPro.png" alt="best essay writing service"/></a> <a href="https://socialkings.online"><img title="Boost your reach and buy real followers" width="256" src="https://joplinapp.org/images/sponsors/SocialKings.png" alt="Boost your reach and buy real followers"/></a> <a href="https://uk.notgamstop.com/bonuses/free-spins-no-deposit-no-gamstop/"><img title="free spins no deposit at NotGamstop" width="256" src="https://joplinapp.org/images/sponsors/NotGamStop.jpg" alt="free spins no deposit at NotGamstop"/></a> <a href="https://www.writemyessay.com/"><img title="writing service for students WriteMyEssay" width="256" src="https://joplinapp.org/images/sponsors/WriteMyEssay.png" alt="writing service for students WriteMyEssay"/></a> <a href="https://essayservice.com/"><img title="For those in need of immediate academic assistance, EssayService offers a fast and reliable service to write my essay for me now, ensuring high-quality results within tight deadlines" width="256" src="https://joplinapp.org/images/sponsors/EssayService.png" alt="For those in need of immediate academic assistance, EssayService offers a fast and reliable service to write my essay for me now, ensuring high-quality results within tight deadlines"/></a>
<!-- SPONSORS-ORG -->
* * *

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

@@ -76,7 +76,7 @@
"cspell": "5.21.2",
"eslint": "8.57.1",
"eslint-interactive": "10.8.0",
"eslint-plugin-import": "2.31.0",
"eslint-plugin-import": "2.32.0",
"eslint-plugin-jest": "27.9.0",
"eslint-plugin-promise": "6.6.0",
"eslint-plugin-react": "7.37.5",

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

@@ -35,7 +35,7 @@
],
"owner": "Laurent Cozic"
},
"version": "3.5.0",
"version": "3.5.1",
"bin": "./main.js",
"engines": {
"node": ">=10.0.0"
@@ -73,7 +73,7 @@
"@joplin/tools": "~3.5",
"@types/fs-extra": "11.0.4",
"@types/jest": "29.5.14",
"@types/node": "18.19.119",
"@types/node": "18.19.130",
"@types/proper-lockfile": "^4.1.2",
"gulp": "4.0.2",
"jest": "29.7.0",

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

@@ -0,0 +1,8 @@
<en-note>
<div>
<en-media style="--en-viewerProps:{};" type="image/jpeg" hash="e2d4887c5a32ab1686276c7c5ae733ef" width="1.125in" />
</div>
<div>
<br />
</div>
</en-note>

View File

@@ -0,0 +1,8 @@
<en-note>
<div>
<img src=":/e2d4887c5a32ab1686276c7c5ae733ef" style="--en-viewerProps:{};" type="image/jpeg" hash="e2d4887c5a32ab1686276c7c5ae733ef" width="108" alt="attachment-image" />
</div>
<div>
<br/>
</div>
</en-note>

View File

@@ -1,6 +1,8 @@
<en-note>
<div><a href=":/21ca2b948f222a38802940ec7e2e5de3" hash="21ca2b948f222a38802940ec7e2e5de3" type="application/pdf" style="cursor:pointer;" alt="attachment-1">attachment-1</a></div>
<div>
<br>
<a href=':/21ca2b948f222a38802940ec7e2e5de3' hash="21ca2b948f222a38802940ec7e2e5de3" type="application/pdf" style="cursor:pointer;" alt="attachment-1"> attachment-1</a>
</div>
<div>
<br/>
</div>
</en-note>

View File

@@ -1,16 +1,11 @@
<en-note>
<div>
<p>For example, consider an exported Evernote list with todo checkboxes like this:</p>
<ul>
<li>
<div><input checked="checked" type="checkbox" onclick="return false;">Foo</div>
</li>
<li>
<div><input type="checkbox" onclick="return false;"><b>Bar</b></div>
</li>
<li>
<div><input type="checkbox" onclick="return false;"><i>Baz</i></div>
</li>
<li><div><input checked="checked" type="checkbox" onclick="return false;" />Foo</div></li>
<li><div><input type="checkbox" onclick="return false;" /><b>Bar</b></div></li>
<li><div><input type="checkbox" onclick="return false;" /><i>Baz</i></div></li>
</ul>
</div>
</en-note>

View File

@@ -1,19 +1,11 @@
<en-note>
<div>
<p>In Evernote a checklist is not the same as a list with checkboxes.</p>
<ul style="--en-todo:true;">
<li style="--en-checked:false;">
<input type="checkbox" onclick="return false;">
<div>One</div>
</li>
<li style="--en-checked:true;">
<input checked="checked" type="checkbox" onclick="return false;">
<div>Two</div>
</li>
<li style="--en-checked:false;">
<input type="checkbox" onclick="return false;">
<div>Three</div>
</li>
<ul STYLE="--en-todo:true;">
<li STYLE="--en-checked:false;"> <input type="checkbox" onclick="return false;" /><div>One</div></li>
<li STYLE="--en-checked:true;"> <input checked="checked" type="checkbox" onclick="return false;" /><div>Two</div>
</li><li STYLE="--en-checked:false;"> <input type="checkbox" onclick="return false;" /><div>Three</div></li>
</ul>
</div>
</en-note>

View File

@@ -1,12 +1 @@
<en-note>
<div>
<audio controls="" preload="none" style="width:480px;">
<source src=":/9168ee833d03c5ea7c730ac6673978c1" type="audio/mp4">
<p>Your browser does not support HTML5 audio.</p>
</audio>
<p><a href=":/9168ee833d03c5ea7c730ac6673978c1">audio test</a></p>
</div>
<div>
<br>
</div>
</en-note>
<en-note><div><audio controls preload="none" style="width:480px;"><source src=":/9168ee833d03c5ea7c730ac6673978c1" type="audio/mp4" /><p>Your browser does not support HTML5 audio.</p></audio><p><a href=":/9168ee833d03c5ea7c730ac6673978c1">audio test</a></p></div><div><br/></div></en-note>

View File

@@ -1,12 +1 @@
<en-note>
<div><input type="checkbox" onclick="return false;">This is a test</div>
<div><input type="checkbox" onclick="return false;">A test for <span style="font-weight: bold;">bold</span></div>
<div>
<input type="checkbox" onclick="return false;">A test for <i>italic</i>
<br>
</div>
<div>
<br>
</div>
<div><i><img src=":/89ce7da62c6b2832929a6964237e98e9" hash="89ce7da62c6b2832929a6964237e98e9" type="image/jpeg" alt=""></i></div>
</en-note>
<en-note><div><input type="checkbox" onclick="return false;" />This is a test</div><div><input type="checkbox" onclick="return false;" />A test for <span STYLE="font-weight: bold;">bold</span></div><div><input type="checkbox" onclick="return false;" />A test for <i>italic</i><br/></div><div><br/></div><div><i><img src=":/89ce7da62c6b2832929a6964237e98e9" hash="89ce7da62c6b2832929a6964237e98e9" type="image/jpeg" alt="" /></i></div></en-note>

View File

@@ -1,3 +1,3 @@
<en-note>
<h1 style="box-sizing:inherit;font-family:&quot;Guardian TextSans Web&quot;, &quot;Helvetica Neue&quot;, Helvetica, Arial, sans-serif;margin-top:0.2em;margin-bottom:0.35em;font-size:2.125em;font-weight:600;line-height:1.3;">Association Between mRNA Vaccination and COVID-19 Hospitalization and Disease Severity</h1>
<h1 STYLE="box-sizing:inherit;font-family:&quot;Guardian TextSans Web&quot;, &quot;Helvetica Neue&quot;, Helvetica, Arial, sans-serif;margin-top:0.2em;margin-bottom:0.35em;font-size:2.125em;font-weight:600;line-height:1.3;">Association Between mRNA Vaccination and COVID-19 Hospitalization and Disease Severity</h1>
</en-note>

View File

@@ -1,3 +1,5 @@
<en-note>
<div><img style="margin:0px;padding:0px;outline:0px;width:74px;height:36px;position:absolute;bottom:-5px;left:0px;transform:translate(0px, 100%);stroke-dasharray:90;transition:stroke-dashoffset 0.5s cubic-bezier(0.97, 0.16, 0.62, 0.76) 0s;stroke-dashoffset:0;" src="data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' data-evernote-id='97' class='js-evernote-checked'%3e%3cuse xlink:href='https://wordminds.com/wp-content/themes/wordminds/assets/img/hint_left.svg%23hint_left' data-evernote-id='98' class='js-evernote-checked'%3e%3c/use%3e%3c/svg%3e"></div>
<div>
<img STYLE="margin:0px;padding:0px;outline:0px;width:74px;height:36px;position:absolute;bottom:-5px;left:0px;transform:translate(0px, 100%);stroke-dasharray:90;transition:stroke-dashoffset 0.5s cubic-bezier(0.97, 0.16, 0.62, 0.76) 0s;stroke-dashoffset:0;" SRC="data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' data-evernote-id='97' class='js-evernote-checked'%3e%3cuse xlink:href='https://wordminds.com/wp-content/themes/wordminds/assets/img/hint_left.svg%23hint_left' data-evernote-id='98' class='js-evernote-checked'%3e%3c/use%3e%3c/svg%3e"/>
</div>
</en-note>

View File

@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE en-export SYSTEM "http://xml.evernote.com/pub/evernote-export4.dtd">
<en-export export-date="20230724T173816Z" application="Evernote" version="10.58.8">
<note>
<title>test.json</title>
<content><![CDATA[<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE en-note SYSTEM "http://xml.evernote.com/pub/enml2.dtd">
<en-note><en-media hash="ac91cc691d21261b222681dd38c1e4ad" type="application/json"/></en-note>
]]>
</content>
<created>20191002T075850Z</created>
<updated>20191002T075850Z</updated>
<note-attributes><latitude>48.79547119140625</latitude><longitude>9.809423921920198</longitude><altitude>398.0</altitude><author>Laurent</author><source>desktop.mac</source></note-attributes>
<resource><data>eyAidGVzdCI6IDEyMyB9</data><mime>application/json</mime><width>0</width><height>0</height><resource-attributes><file-name>test.json</file-name><attachment>false</attachment></resource-attributes></resource></note></en-export>

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

@@ -26,7 +26,7 @@ export interface AppStateDialog {
props: Record<string, any>;
}
export interface EditorScrollPercents {
export interface NoteIdToScrollPercent {
[noteId: string]: number;
}
@@ -55,7 +55,6 @@ export interface AppState extends State, AppWindowState {
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
navHistory: any[];
watchedNoteFiles: string[];
lastEditorScrollPercents: EditorScrollPercents;
focusedField: string;
layoutMoveMode: boolean;
startupPluginsLoaded: boolean;
@@ -90,7 +89,6 @@ export function createAppDefaultState(resourceEditWatcherDefaultState: any): App
},
navHistory: [],
watchedNoteFiles: [],
lastEditorScrollPercents: {},
visibleDialogs: {}, // empty object if no dialog is visible. Otherwise contains the list of visible dialogs.
focusedField: null,
layoutMoveMode: false,
@@ -289,16 +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 '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

@@ -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

@@ -7,6 +7,7 @@ import useKeyboardHandler from './DialogButtonRow/useKeyboardHandler';
export interface ButtonSpec {
name: string;
label: string;
disabled?: boolean;
}
export interface ClickEvent {
@@ -51,21 +52,29 @@ export default function DialogButtonRow(props: Props) {
if (props.onClick) props.onClick(event);
}, [props.onClick]);
const onKeyDown = useKeyboardHandler({ onOkButtonClick, onCancelButtonClick });
const okButtonShow = props.okButtonShow ?? true;
const cancelButtonShow = props.cancelButtonShow ?? true;
const canClickOk = okButtonShow && !props.okButtonDisabled;
const canClickCancel = cancelButtonShow && !props.cancelButtonDisabled;
const onKeyDown = useKeyboardHandler({
onOkButtonClick: canClickOk ? onOkButtonClick : null,
onCancelButtonClick: canClickCancel ? onCancelButtonClick : null,
});
const buttonComps = [];
if (props.customButtons) {
for (const b of props.customButtons) {
buttonComps.push(
<button key={b.name} style={buttonStyle} onClick={() => onCustomButtonClick({ buttonName: b.name })} onKeyDown={onKeyDown}>
<button key={b.name} style={buttonStyle} onClick={() => onCustomButtonClick({ buttonName: b.name })} disabled={b.disabled} onKeyDown={onKeyDown}>
{b.label}
</button>,
);
}
}
if (props.okButtonShow !== false) {
if (okButtonShow) {
buttonComps.push(
<button disabled={props.okButtonDisabled} key="ok" style={buttonStyle} onClick={onOkButtonClick} ref={props.okButtonRef} onKeyDown={onKeyDown}>
{props.okButtonLabel ? props.okButtonLabel : _('OK')}
@@ -73,7 +82,7 @@ export default function DialogButtonRow(props: Props) {
);
}
if (props.cancelButtonShow !== false) {
if (cancelButtonShow) {
buttonComps.push(
<button disabled={props.cancelButtonDisabled} key="cancel" style={{ ...buttonStyle }} onClick={onCancelButtonClick}>
{props.cancelButtonLabel ? props.cancelButtonLabel : _('Cancel')}

View File

@@ -2,11 +2,10 @@ import * as React from 'react';
import { useEffect, useState, useRef, useCallback } from 'react';
import { isInsideContainer } from '@joplin/lib/dom';
type OnButtonClick = ()=> void;
interface Props {
// eslint-disable-next-line @typescript-eslint/ban-types -- Old code before rule was applied
onOkButtonClick: Function;
// eslint-disable-next-line @typescript-eslint/ban-types -- Old code before rule was applied
onCancelButtonClick: Function;
onOkButtonClick: null|OnButtonClick;
onCancelButtonClick: null|OnButtonClick;
}
const globalKeydownHandlers: string[] = [];
@@ -48,15 +47,17 @@ export default (props: Props) => {
if (!isTopDialog() || isInSubModal(event.target)) return;
if (event.keyCode === 13) {
if (event.keyCode === 13 && props.onOkButtonClick) {
if ('nodeName' in event.target && event.target.nodeName === 'INPUT') {
const target = event.target as HTMLInputElement;
if (target.type !== 'button' && target.type !== 'checkbox') {
event.preventDefault();
props.onOkButtonClick();
}
}
} else if (event.keyCode === 27) {
} else if (event.keyCode === 27 && props.onCancelButtonClick) {
event.preventDefault();
props.onCancelButtonClick();
}
// eslint-disable-next-line @seiyab/react-hooks/exhaustive-deps -- Old code before rule was applied

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

@@ -172,7 +172,12 @@ export default function NoteContentPropertiesDialog(props: NoteContentProperties
<div style={{ ...labelCompStyle, marginTop: 10 }}>
{readTimeLabel}
</div>
<DialogButtonRow themeId={props.themeId} onClick={buttonRow_click} okButtonShow={false} cancelButtonLabel={_('Close')}/>
<DialogButtonRow
themeId={props.themeId}
onClick={buttonRow_click}
okButtonShow={false}
cancelButtonLabel={_('Close')}
/>
</Dialog>
);
}

View File

@@ -8,7 +8,36 @@ const logger = Logger.create('useEditorSearch');
// Registers a helper CodeMirror extension to be used with
// useEditorSearchHandler.
export default function useEditorSearchExtension(CodeMirror: CodeMirror5Emulation) {
interface SetMarkersOptions {
selectedIndex: number;
searchTimestamp: number;
showEditorMarkers?: boolean;
withSelection?: boolean;
}
type Keyword = { value: string };
export type OnSetMarkers = (cm: CodeMirror5Emulation, keywords: Keyword[], options: SetMarkersOptions)=> number;
// Modified from codemirror/addons/search/search.js
const searchOverlay = (query: RegExp) => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
return { token: function(stream: any) {
query.lastIndex = stream.pos;
const match = query.exec(stream.string);
if (match && match.index === stream.pos) {
stream.pos += match[0].length || 1;
return 'search-marker';
} else if (match) {
stream.pos = match.index;
} else {
stream.skipToEnd();
}
return null;
} };
};
export default function useEditorSearchExtension() {
const [markers, setMarkers] = useState([]);
const [overlay, setOverlay] = useState(null);
@@ -48,23 +77,6 @@ export default function useEditorSearchExtension(CodeMirror: CodeMirror5Emulatio
setOverlayTimeout(null);
}, [scrollbarMarks, overlay, overlayTimeout]);
// Modified from codemirror/addons/search/search.js
const searchOverlay = useCallback((query: RegExp) => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
return { token: function(stream: any) {
query.lastIndex = stream.pos;
const match = query.exec(stream.string);
if (match && match.index === stream.pos) {
stream.pos += match[0].length || 1;
return 'search-marker';
} else if (match) {
stream.pos = match.index;
} else {
stream.skipToEnd();
}
return null;
} };
}, []);
// Highlights the currently active found work
// It's possible to get tricky with this functions and just use findNext/findPrev
@@ -115,16 +127,17 @@ export default function useEditorSearchExtension(CodeMirror: CodeMirror5Emulatio
};
}, []);
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
CodeMirror?.defineExtension('setMarkers', function(keywords: any, options: any) {
const onSetMarkers: OnSetMarkers = (cm, keywords, options) => {
// Pass arguments in via options to allow the extension to work if multiple editors are open simultaneously
// See https://github.com/laurent22/joplin/issues/13399.
if (!options) {
options = { selectedIndex: 0, searchTimestamp: 0 };
}
if (options.showEditorMarkers === false) {
clearMarkers();
clearOverlay(this);
return;
clearOverlay(cm);
return 0;
}
clearMarkers();
@@ -145,7 +158,7 @@ export default function useEditorSearchExtension(CodeMirror: CodeMirror5Emulatio
const scrollTo = i === 0 && (previousKeywordValue !== keyword.value || previousIndex !== options.selectedIndex || options.searchTimestamp !== previousSearchTimestamp);
try {
const match = highlightSearch(this, searchTerm, options.selectedIndex, scrollTo, !!options.withSelection);
const match = highlightSearch(cm, searchTerm, options.selectedIndex, scrollTo, !!options.withSelection);
if (match) marks.push(match);
} catch (error) {
if (error.name !== 'SyntaxError') {
@@ -165,7 +178,7 @@ export default function useEditorSearchExtension(CodeMirror: CodeMirror5Emulatio
// SEARCHOVERLAY
// We only want to highlight all matches when there is only 1 search term
if (keywords.length !== 1 || keywords[0].value === '') {
clearOverlay(this);
clearOverlay(cm);
const prev = keywords.length > 1 ? keywords[0].value : '';
setPreviousKeywordValue(prev);
return 0;
@@ -175,22 +188,22 @@ export default function useEditorSearchExtension(CodeMirror: CodeMirror5Emulatio
// Determine the number of matches in the source, this is passed on
// to the NoteEditor component
const regexMatches = this.getValue().match(searchTerm);
const regexMatches = cm.getValue().match(searchTerm);
const nMatches = regexMatches ? regexMatches.length : 0;
// Don't bother clearing and re-calculating the overlay if the search term
// hasn't changed
if (keywords[0].value === previousKeywordValue) return nMatches;
clearOverlay(this);
clearOverlay(cm);
setPreviousKeywordValue(keywords[0].value);
// These operations are pretty slow, so we won't add use them until the user
// has finished typing, 500ms is probably enough time
const timeout = shim.setTimeout(() => {
const scrollMarks = this.showMatchesOnScrollbar?.(searchTerm, true, 'cm-search-marker-scrollbar');
const scrollMarks = cm.showMatchesOnScrollbar?.(searchTerm, true, 'cm-search-marker-scrollbar');
const overlay = searchOverlay(searchTerm);
this.addOverlay(overlay);
cm.addOverlay(overlay);
setOverlay(overlay);
setScrollbarMarks(scrollMarks);
}, 500);
@@ -199,5 +212,9 @@ export default function useEditorSearchExtension(CodeMirror: CodeMirror5Emulatio
overlayTimeoutRef.current = timeout;
return nMatches;
});
};
const onSetMarkersRef = useRef(onSetMarkers);
onSetMarkersRef.current = onSetMarkers;
return { onSetMarkersRef };
}

View File

@@ -2,6 +2,8 @@ import { RefObject, useEffect, useMemo, useRef } from 'react';
import usePrevious from '../../../../hooks/usePrevious';
import { RenderedBody } from './types';
import { SearchMarkers } from '../../../utils/useSearchMarkers';
import CodeMirror5Emulation from '@joplin/editor/CodeMirror/CodeMirror5Emulation/CodeMirror5Emulation';
import useEditorSearchExtension from './useEditorSearchExtension';
const debounce = require('debounce');
interface Props {
@@ -10,8 +12,7 @@ interface Props {
searchMarkers: any;
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
webviewRef: RefObject<any>;
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
editorRef: RefObject<any>;
editorRef: RefObject<CodeMirror5Emulation>;
noteContent: string;
renderedBody: RenderedBody;
@@ -23,6 +24,8 @@ const useEditorSearchHandler = (props: Props) => {
webviewRef, editorRef, renderedBody, noteContent, searchMarkers, showEditorMarkers,
} = props;
const { onSetMarkersRef } = useEditorSearchExtension();
const previousContent = usePrevious(noteContent);
const previousRenderedBody = usePrevious(renderedBody);
const previousSearchMarkers = usePrevious(searchMarkers);
@@ -31,15 +34,15 @@ const useEditorSearchHandler = (props: Props) => {
// Fixes https://github.com/laurent22/joplin/issues/7565
const debouncedMarkers = useMemo(() => debounce((searchMarkers: SearchMarkers) => {
if (!editorRef.current) return;
if (!onSetMarkersRef.current) return;
if (showEditorMarkersRef.current) {
const matches = editorRef.current.setMarkers(searchMarkers.keywords, searchMarkers.options);
const matches = onSetMarkersRef.current(editorRef.current, searchMarkers.keywords, searchMarkers.options);
props.setLocalSearchResultCount(matches);
} else {
editorRef.current.setMarkers(searchMarkers.keywords, { ...searchMarkers.options, showEditorMarkers: false });
onSetMarkersRef.current(editorRef.current, searchMarkers.keywords, { ...searchMarkers.options, showEditorMarkers: false });
}
}, 50), [editorRef, props.setLocalSearchResultCount]);
}, 50), [editorRef, onSetMarkersRef, props.setLocalSearchResultCount]);
useEffect(() => {
if (!searchMarkers) return () => {};
@@ -59,7 +62,7 @@ const useEditorSearchHandler = (props: Props) => {
}
return () => {};
}, [
editorRef,
onSetMarkersRef,
webviewRef,
searchMarkers,
previousSearchMarkers,
@@ -71,6 +74,10 @@ const useEditorSearchHandler = (props: Props) => {
debouncedMarkers,
]);
return {
// Returned to allow quickly setting the initial search markers just after the editor loads.
onSetInitialMarkersRef: onSetMarkersRef,
};
};
export default useEditorSearchHandler;

View File

@@ -695,7 +695,7 @@ function CodeMirror(props: NoteBodyEditorProps, ref: ForwardedRef<NoteBodyEditor
// eslint-disable-next-line @seiyab/react-hooks/exhaustive-deps -- Old code before rule was applied
}, [renderedBody, webviewReady]);
useEditorSearchHandler({
const { onSetInitialMarkersRef } = useEditorSearchHandler({
setLocalSearchResultCount: props.setLocalSearchResultCount,
searchMarkers: props.searchMarkers,
webviewRef,
@@ -737,6 +737,7 @@ function CodeMirror(props: NoteBodyEditorProps, ref: ForwardedRef<NoteBodyEditor
<Editor
value={props.content}
searchMarkers={props.searchMarkers}
onSetMarkersRef={onSetInitialMarkersRef}
ref={editorRef}
mode={props.contentMarkupLanguage === MarkupToHtml.MARKUP_LANGUAGE_HTML ? 'xml' : 'joplin-markdown'}
codeMirrorTheme={styles.editor.codeMirrorTheme}

View File

@@ -1,5 +1,5 @@
import * as React from 'react';
import { useEffect, useImperativeHandle, useState, useRef, useCallback, forwardRef } from 'react';
import { useEffect, useImperativeHandle, useState, useRef, useCallback, forwardRef, RefObject } from 'react';
import { PluginStates } from '@joplin/lib/services/plugins/reducer';
import CodeMirror from 'codemirror';
@@ -16,7 +16,7 @@ import useListIdent from './utils/useListIdent';
import useScrollUtils from './utils/useScrollUtils';
import useCursorUtils from './utils/useCursorUtils';
import useLineSorting from './utils/useLineSorting';
import useEditorSearch from '../utils/useEditorSearchExtension';
import { OnSetMarkers } from '../utils/useEditorSearchExtension';
import useJoplinMode from './utils/useJoplinMode';
import useKeymap from './utils/useKeymap';
import useExternalPlugins from './utils/useExternalPlugins';
@@ -77,6 +77,7 @@ export interface EditorProps {
value: string;
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
searchMarkers: any;
onSetMarkersRef: RefObject<OnSetMarkers>;
mode: string;
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
style: any;
@@ -119,7 +120,6 @@ function Editor(props: EditorProps, ref: any) {
useScrollUtils(CodeMirror);
useCursorUtils(CodeMirror);
useLineSorting(CodeMirror);
useEditorSearch(CodeMirror);
useJoplinMode(CodeMirror);
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
const pluginOptions: any = useExternalPlugins(CodeMirror, props.plugins);
@@ -228,7 +228,7 @@ function Editor(props: EditorProps, ref: any) {
// It's possible for searchMarkers to be available before the editor
// In these cases we set the markers asap so the user can see them as
// soon as the editor is ready
if (props.searchMarkers) { cm.setMarkers(props.searchMarkers.keywords, props.searchMarkers.options); }
if (props.searchMarkers) { props.onSetMarkersRef.current(cm, props.searchMarkers.keywords, props.searchMarkers.options); }
return () => {
// Clean up codemirror

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';
@@ -31,6 +31,8 @@ import CommandService from '@joplin/lib/services/CommandService';
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);
@@ -92,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));
}
}, []);
@@ -167,9 +141,8 @@ const CodeMirror = (props: NoteBodyEditorProps, ref: ForwardedRef<NoteBodyEditor
},
scrollTo: (options: ScrollOptions) => {
if (options.type === ScrollOptionTypes.Hash) {
if (!webviewRef.current) return;
const hash: string = options.value;
webviewRef.current.send('scrollToHash', hash);
webviewRef.current?.send('scrollToHash', hash);
editorRef.current.jumpToHash(hash);
} else if (options.type === ScrollOptionTypes.Percent) {
const percent = options.value as number;
@@ -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;
@@ -342,6 +316,7 @@ const CodeMirror = (props: NoteBodyEditorProps, ref: ForwardedRef<NoteBodyEditor
} else if (event.kind === EditorEventType.Change) {
codeMirror_change(event.value);
} else if (event.kind === EditorEventType.SelectionRangeChange) {
props.onCursorMotion({ markdown: event.from });
setSelectionRange({ from: event.from, to: event.to });
} else if (event.kind === EditorEventType.UpdateSearchDialog) {
if (lastSearchState.current?.searchText !== event.searchState.searchText) {
@@ -355,7 +330,7 @@ const CodeMirror = (props: NoteBodyEditorProps, ref: ForwardedRef<NoteBodyEditor
} else if (event.kind === EditorEventType.FollowLink) {
void CommandService.instance().execute('openItem', event.link);
}
}, [editor_scroll, codeMirror_change, props.setLocalSearch, props.setShowLocalSearch]);
}, [editor_scroll, codeMirror_change, props.setLocalSearch, props.setShowLocalSearch, props.onCursorMotion]);
const onSelectPastBeginning = useCallback(() => {
void CommandService.instance().execute('focusElement', 'noteTitle');
@@ -400,15 +375,17 @@ const CodeMirror = (props: NoteBodyEditorProps, ref: ForwardedRef<NoteBodyEditor
props.tabMovesFocus,
]);
// Update the editor's value
useEffect(() => {
// Include the noteId in the update props to give plugins access
// to the current note ID.
const updateProps = { noteId: props.noteId };
if (editorRef.current?.updateBody(props.content, updateProps)) {
editorRef.current?.clearHistory();
}
}, [props.content, props.noteId]);
const initialCursorLocationRef = useRef(0);
initialCursorLocationRef.current = props.initialCursorLocation.markdown ?? 0;
useSyncEditorValue({
content: props.content,
visiblePanes: props.visiblePanes,
onMessage: props.onMessage,
editorRef,
noteId: props.noteId,
initialCursorLocationRef,
});
const renderEditor = () => {
return (
@@ -416,6 +393,7 @@ const CodeMirror = (props: NoteBodyEditorProps, ref: ForwardedRef<NoteBodyEditor
<Editor
style={styles.editor}
initialText={props.content}
initialSelectionRef={initialCursorLocationRef}
initialNoteId={props.noteId}
ref={editorRef}
settings={editorSettings}

View File

@@ -1,5 +1,5 @@
import * as React from 'react';
import { ForwardedRef } from 'react';
import { ForwardedRef, RefObject } from 'react';
import { useEffect, useState, useRef, forwardRef, useImperativeHandle } from 'react';
import { EditorProps, LogMessageCallback, OnEventCallback, ContentScriptData } from '@joplin/editor/types';
import createEditor from '@joplin/editor/CodeMirror/createEditor';
@@ -11,7 +11,6 @@ import PluginService from '@joplin/lib/services/plugins/PluginService';
import setupVim from '@joplin/editor/CodeMirror/utils/setupVim';
import { dirname } from 'path';
import useKeymap from './utils/useKeymap';
import useEditorSearch from '../utils/useEditorSearchExtension';
import CommandService from '@joplin/lib/services/CommandService';
import { SearchMarkers } from '../../../utils/useSearchMarkers';
import localisation from './utils/localisation';
@@ -23,6 +22,7 @@ import getResourceBaseUrl from '../../../utils/getResourceBaseUrl';
interface Props extends EditorProps {
style: React.CSSProperties;
pluginStates: PluginStates;
initialSelectionRef: RefObject<number>;
onEditorPaste: (event: Event)=> void;
externalSearch: SearchMarkers;
@@ -43,8 +43,6 @@ const Editor = (props: Props, ref: ForwardedRef<CodeMirrorControl>) => {
onLogMessageRef.current = props.onLogMessage;
}, [props.onEvent, props.onLogMessage]);
useEditorSearch(editor);
useEffect(() => {
if (!editor) {
return () => {};
@@ -127,6 +125,9 @@ const Editor = (props: Props, ref: ForwardedRef<CodeMirrorControl>) => {
direction: 'unset',
},
});
const cursor = props.initialSelectionRef.current;
editor.select(cursor, cursor);
setEditor(editor);
return () => {

View File

@@ -0,0 +1,50 @@
import { useEffect, useRef, RefObject } from 'react';
import { OnMessage } from '../../../../utils/types';
import CodeMirrorControl from '@joplin/editor/CodeMirror/CodeMirrorControl';
interface Props {
content: string;
visiblePanes: string[];
onMessage: OnMessage;
editorRef: RefObject<CodeMirrorControl>;
noteId: string;
initialCursorLocationRef: RefObject<number>;
}
// Updates the editor's value as necessary
const useSyncEditorValue = ({ content, visiblePanes, onMessage, editorRef, noteId, initialCursorLocationRef }: Props) => {
const visiblePanesRef = useRef(visiblePanes);
visiblePanesRef.current = visiblePanes;
const onMessageRef = useRef(onMessage);
onMessageRef.current = onMessage;
const lastNoteIdRef = useRef(noteId);
useEffect(() => {
// Include the noteId in the update props to give plugins access
// to the current note ID.
const updateProps = { noteId: noteId };
if (editorRef.current?.updateBody(content, updateProps)) {
editorRef.current?.clearHistory();
// Only reset the cursor location when switching notes. If, for example,
// the note is updated from a secondary window, the cursor location shouldn't
// reset.
const noteChanged = lastNoteIdRef.current !== noteId;
if (noteChanged) {
const cursorLocation = initialCursorLocationRef.current;
editorRef.current?.select(cursorLocation, cursorLocation);
}
lastNoteIdRef.current = noteId;
// If the viewer isn't visible, the content should be considered rendered
// after the editor has finished updating:
if (!visiblePanesRef.current.includes('viewer')) {
onMessageRef.current({ channel: 'noteRenderComplete' });
}
}
}, [content, noteId, editorRef, initialCursorLocationRef]);
};
export default useSyncEditorValue;

View File

@@ -23,7 +23,7 @@ import { themeStyle } from '@joplin/lib/theme';
import { loadScript } from '../../../utils/loadScript';
import bridge from '../../../../services/bridge';
import { TinyMceEditorEvents } from './utils/types';
import type { Editor, EditorEvent } from 'tinymce';
import type { Bookmark, Editor, EditorEvent } from 'tinymce';
import { joplinCommandToTinyMceCommands, TinyMceCommand } from './utils/joplinCommandToTinyMceCommands';
import shouldPasteResources from './utils/shouldPasteResources';
import lightTheme from '@joplin/lib/themes/light';
@@ -47,6 +47,7 @@ import Setting from '@joplin/lib/models/Setting';
import useTextPatternsLookup, { TextPatternContext } from './utils/useTextPatternsLookup';
import { toFileProtocolPath } from '@joplin/utils/path';
import { RenderResultPluginAsset } from '@joplin/renderer/types';
import useCursorPositioning from './utils/useCursorPositioning';
const logger = Logger.create('TinyMCE');
@@ -918,8 +919,6 @@ const TinyMCE = (props: NoteBodyEditorProps, ref: Ref<NoteBodyEditorRef>) => {
editor.on('SetContent', () => {
preprocessContent();
props_onMessage.current({ channel: 'noteRenderComplete' });
});
},
});
@@ -1046,6 +1045,12 @@ const TinyMCE = (props: NoteBodyEditorProps, ref: Ref<NoteBodyEditorRef>) => {
return true;
}
const { onRestoreCursorPosition } = useCursorPositioning({
initialCursorLocation: props.initialCursorLocation.richText as Bookmark,
onCursorUpdate: props.onCursorMotion,
editor,
});
const lastNoteIdRef = useRef(props.noteId);
useEffect(() => {
if (!editor) return () => {};
@@ -1113,8 +1118,12 @@ const TinyMCE = (props: NoteBodyEditorProps, ref: Ref<NoteBodyEditorRef>) => {
// times would result in an empty note.
// https://github.com/laurent22/joplin/issues/3534
editor.undoManager.reset();
// Only restore the cursor position from the global state when switching notes.
// See https://github.com/laurent22/joplin/issues/13579
onRestoreCursorPosition();
} else {
// Restore the cursor location
// Restore the cursor location from the current note
editor.selection.bookmarkManager.moveToBookmark(bookmark);
}
@@ -1123,6 +1132,7 @@ const TinyMCE = (props: NoteBodyEditorProps, ref: Ref<NoteBodyEditorRef>) => {
resourceInfos: props.resourceInfos,
contentKey: props.contentKey,
};
props_onMessage.current({ channel: 'noteRenderComplete' });
}
const allAssetsOptions: NoteStyleOptions = {

View File

@@ -0,0 +1,52 @@
import { useCallback, useEffect, useRef } from 'react';
import { Bookmark, Editor } from 'tinymce';
import { OnCursorMotion } from '../../../utils/types';
interface Props {
initialCursorLocation: Bookmark;
editor: Editor;
onCursorUpdate: OnCursorMotion;
}
const useCursorPositioning = ({ initialCursorLocation, editor, onCursorUpdate }: Props) => {
const initialCursorLocationRef = useRef(initialCursorLocation);
initialCursorLocationRef.current = initialCursorLocation;
const appliedInitialCursorLocationRef = useRef(false);
const onRestoreCursorPosition = useCallback(() => {
if (editor) {
if (initialCursorLocationRef.current) {
editor.selection.moveToBookmark(initialCursorLocationRef.current);
}
appliedInitialCursorLocationRef.current = true;
}
}, [editor]);
useEffect(() => {
if (!editor) return () => {};
const onSelectionChange = () => {
// Wait until the initial cursor position has been set. This avoids resetting
// the initial cursor position to zero when the editor first loads.
if (!appliedInitialCursorLocationRef.current) return;
// Use an offset bookmark -- the default bookmark type is not preserved after unloading
// and reloading the editor.
const offsetBookmarkId = 2;
onCursorUpdate({
richText: editor.selection.getBookmark(offsetBookmarkId, true),
});
};
editor.on('SelectionChange', onSelectionChange);
return () => {
editor.off('SelectionChange', onSelectionChange);
};
}, [editor, onCursorUpdate, onRestoreCursorPosition]);
return { onRestoreCursorPosition };
};
export default useCursorPositioning;

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

@@ -57,6 +57,8 @@ import StatusBar from './StatusBar';
import useVisiblePluginEditorViewIds from '@joplin/lib/hooks/plugins/useVisiblePluginEditorViewIds';
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');
@@ -329,13 +331,13 @@ function NoteEditorContent(props: NoteEditorProps) {
});
}, [formNote, setFormNote, handleProvisionalFlag, props.dispatch]);
const { scrollWhenReady, clearScrollWhenReady } = useScrollWhenReadyOptions({
const { scrollWhenReadyRef, clearScrollWhenReady } = useScrollWhenReadyOptions({
noteId: formNote.id,
selectedNoteHash: props.selectedNoteHash,
lastEditorScrollPercents: props.lastEditorScrollPercents,
editorRef,
editorName: props.bodyEditor,
});
const onMessage = useMessageHandler(scrollWhenReady, clearScrollWhenReady, windowId, editorRef, setLocalSearchResultCount, props.dispatch, formNote, htmlToMarkdown, markupToHtml);
const onMessage = useMessageHandler(scrollWhenReadyRef, clearScrollWhenReady, windowId, editorRef, setLocalSearchResultCount, props.dispatch, formNote, htmlToMarkdown, markupToHtml);
useResourceUnwatcher({ noteId: formNote.id, windowId });
@@ -399,15 +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) => {
const noteId = formNoteRef.current.id;
NotePositionService.instance().updateCursorPosition(noteId, windowId, location);
}, [windowId]);
function renderNoNotes(rootStyle: React.CSSProperties) {
const emptyDivStyle = {
@@ -419,6 +420,9 @@ function NoteEditorContent(props: NoteEditorProps) {
}
const searchMarkers = useSearchMarkers(showLocalSearch, localSearchMarkerOptions, props.searches, props.selectedSearchId, props.highlightedWords);
const initialCursorLocation = useInitialCursorLocation({
noteId: props.noteId,
});
const markupLanguage = formNote.markup_language;
const editorProps: NoteBodyEditorPropsAndRef = {
@@ -432,6 +436,7 @@ function NoteEditorContent(props: NoteEditorProps) {
content: formNote.body,
contentMarkupLanguage: markupLanguage,
contentOriginalCss: formNote.originalCss,
initialCursorLocation,
resourceInfos: resourceInfos,
resourceDirectory: Setting.value('resourceDir'),
htmlToMarkdown: htmlToMarkdown,
@@ -442,6 +447,7 @@ function NoteEditorContent(props: NoteEditorProps) {
dispatch: props.dispatch,
noteToolbar: null,
onScroll: onScroll,
onCursorMotion,
setLocalSearchResultCount: setLocalSearchResultCount,
setLocalSearch: localSearch_change,
setShowLocalSearch,
@@ -728,7 +734,6 @@ const mapStateToProps = (state: AppState, ownProps: ConnectProps) => {
watchedNoteFiles: state.watchedNoteFiles,
notesParentType: windowState.notesParentType,
selectedNoteTags: windowState.selectedNoteTags,
lastEditorScrollPercents: state.lastEditorScrollPercents,
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,6 +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 } from '@joplin/lib/services/NotePositionService';
export interface AllAssetsOptions {
contentMaxWidthTarget?: string;
@@ -40,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[];
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
lastEditorScrollPercents: any;
selectedNoteHash: string;
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
searches: any[];
@@ -83,6 +82,14 @@ export interface NoteBodyEditorRef {
export { MarkupToHtmlOptions };
export type MarkupToHtmlHandler = (markupLanguage: MarkupLanguage, markup: string, options: MarkupToHtmlOptions)=> Promise<RenderResult>;
export type HtmlToMarkdownHandler = (markupLanguage: number, html: string, originalCss: string, parseOptions?: ParseOptions)=> Promise<string>;
export type OnCursorMotion = (event: EditorCursorLocations)=> void;
export interface MessageEvent {
channel: string;
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Partially refactored old code before rule was applied
args?: any[];
}
export type OnMessage = (event: MessageEvent)=> void;
export interface NoteBodyEditorProps {
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
@@ -102,12 +109,13 @@ export interface NoteBodyEditorProps {
contentKey: string;
contentMarkupLanguage: number;
contentOriginalCss: string;
initialCursorLocation: EditorCursorLocations;
onChange(event: OnChangeEvent): void;
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
onWillChange(event: any): void;
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
onMessage(event: any): void;
onMessage: OnMessage;
onScroll(event: { percent: number }): void;
onCursorMotion: OnCursorMotion;
markupToHtml: MarkupToHtmlHandler;
htmlToMarkdown: HtmlToMarkdownHandler;
allAssets: (markupLanguage: MarkupLanguage, options: AllAssetsOptions)=> Promise<RenderResultPluginAsset[]>;

View File

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

View File

@@ -1,5 +1,5 @@
import { useCallback } from 'react';
import { FormNote, HtmlToMarkdownHandler, MarkupToHtmlHandler, ScrollOptions } from './types';
import { RefObject, useCallback } from 'react';
import { FormNote, HtmlToMarkdownHandler, MarkupToHtmlHandler, ScrollOptions, MessageEvent } from './types';
import contextMenu from './contextMenu';
import CommandService from '@joplin/lib/services/CommandService';
import PostMessageService from '@joplin/lib/services/PostMessageService';
@@ -8,7 +8,7 @@ import { reg } from '@joplin/lib/registry';
import bridge from '../../../services/bridge';
export default function useMessageHandler(
scrollWhenReady: ScrollOptions|null,
scrollWhenReadyRef: RefObject<ScrollOptions|null>,
clearScrollWhenReady: ()=> void,
windowId: string,
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
@@ -21,8 +21,7 @@ export default function useMessageHandler(
htmlToMd: HtmlToMarkdownHandler,
mdToHtml: MarkupToHtmlHandler,
) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
return useCallback(async (event: any) => {
return useCallback(async (event: MessageEvent) => {
const msg = event.channel ? event.channel : '';
const args = event.args;
const arg0 = args && args.length >= 1 ? args[0] : null;
@@ -35,8 +34,8 @@ export default function useMessageHandler(
s.splice(0, 1);
reg.logger().error(s.join(':'));
} else if (msg === 'noteRenderComplete') {
if (scrollWhenReady) {
const options = { ...scrollWhenReady };
if (scrollWhenReadyRef.current) {
const options = { ...scrollWhenReadyRef.current };
clearScrollWhenReady();
editorRef.current.scrollTo(options);
}
@@ -78,5 +77,5 @@ export default function useMessageHandler(
// bridge().showErrorMessageBox(_('Unsupported link or message: %s', msg));
}
// eslint-disable-next-line @seiyab/react-hooks/exhaustive-deps -- Old code before rule was applied
}, [dispatch, setLocalSearchResultCount, scrollWhenReady, formNote]);
}, [dispatch, setLocalSearchResultCount, scrollWhenReadyRef, formNote]);
}

View File

@@ -1,41 +1,49 @@
import { RefObject, useCallback, useEffect, useRef, useState } from 'react';
import { RefObject, useCallback, useContext, useRef } from 'react';
import { NoteBodyEditorRef, ScrollOptions, ScrollOptionTypes } from './types';
import usePrevious from '@joplin/lib/hooks/usePrevious';
import type { EditorScrollPercents } 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: EditorScrollPercents;
editorRef: RefObject<NoteBodyEditorRef>;
}
const useScrollWhenReadyOptions = ({ noteId, selectedNoteHash, lastEditorScrollPercents, editorRef }: Props) => {
const [scrollWhenReady, setScrollWhenReady] = useState<ScrollOptions|null>(null);
const useScrollWhenReadyOptions = ({ noteId, editorName, selectedNoteHash, editorRef }: Props) => {
const scrollWhenReadyRef = useRef<ScrollOptions|null>(null);
const previousNoteId = usePrevious(noteId);
const lastScrollPercentsRef = useRef<EditorScrollPercents>(null);
lastScrollPercentsRef.current = lastEditorScrollPercents;
const previousEditor = usePrevious(editorName);
const windowId = useContext(WindowIdContext);
useEffect(() => {
if (noteId === previousNoteId) return;
// 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;
setScrollWhenReady({
type: selectedNoteHash ? ScrollOptionTypes.Hash : ScrollOptionTypes.Percent,
value: selectedNoteHash ? selectedNoteHash : lastScrollPercent,
});
}, [noteId, previousNoteId, selectedNoteHash, editorRef]);
return () => {};
}, [editorName, previousEditor, noteId, previousNoteId, selectedNoteHash, editorRef, windowId]);
const clearScrollWhenReady = useCallback(() => {
setScrollWhenReady(null);
scrollWhenReadyRef.current = null;
}, []);
return { scrollWhenReady, clearScrollWhenReady };
return { scrollWhenReadyRef, clearScrollWhenReady };
};
export default useScrollWhenReadyOptions;

View File

@@ -501,7 +501,12 @@ class NotePropertiesDialog extends React.Component<Props, State> {
<div role='table' aria-labelledby='note-properties-dialog-title'>
{noteComps}
</div>
<DialogButtonRow themeId={this.props.themeId} okButtonShow={!this.isReadOnly()} okButtonRef={this.okButton} onClick={this.buttonRow_click}/>
<DialogButtonRow
themeId={this.props.themeId}
okButtonShow={!this.isReadOnly()}
okButtonRef={this.okButton}
onClick={this.buttonRow_click}
/>
</Dialog>
);
}

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

@@ -71,7 +71,7 @@ async function initialize() {
panes: Setting.value('noteVisiblePanes'),
});
InteropService.instance().document = document;
InteropService.instance().domParser = new DOMParser();
InteropService.instance().xmlSerializer = new XMLSerializer();
}

View File

@@ -1,9 +1,9 @@
import * as React from 'react';
import Dialog from '../Dialog';
import DialogButtonRow, { ClickEvent, ButtonSpec } from '../DialogButtonRow';
import DialogButtonRow, { ClickEvent } from '../DialogButtonRow';
import DialogTitle from '../DialogTitle';
import { _ } from '@joplin/lib/locale';
import { useCallback, useEffect, useState } from 'react';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { FolderEntity } from '@joplin/lib/services/database/types';
import Folder from '@joplin/lib/models/Folder';
import ShareService, { ApiShare } from '@joplin/lib/services/share/ShareService';
@@ -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');
@@ -129,7 +130,6 @@ function ShareFolderDialog(props: Props) {
const [share, setShare] = useState<StateShare>(null);
const [shareUsers, setShareUsers] = useState<StateShareUser[]>([]);
const [shareState, setShareState] = useState<ShareState>(ShareState.Idle);
const [customButtons, setCustomButtons] = useState<ButtonSpec[]>([]);
const [recipientsBeingUpdated, setRecipientsBeingUpdated] = useState<Record<string, boolean>>({});
async function synchronize(event: AsyncEffectEvent = null) {
@@ -163,13 +163,6 @@ function ShareFolderDialog(props: Props) {
void ShareService.instance().refreshShareUsers(share.id);
}, [share]);
useEffect(() => {
setCustomButtons(share ? [{
name: 'unshare',
label: _('Unshare'),
}] : []);
}, [share]);
useEffect(() => {
if (!share) return;
const sus = props.shareUsers[share.id];
@@ -177,10 +170,6 @@ function ShareFolderDialog(props: Props) {
setShareUsers(sus);
}, [share, props.shareUsers]);
useEffect(() => {
void ShareService.instance().refreshShares();
}, [props.folderId]);
const permissionsFromString = (p: string): SharePermissions => {
return {
can_read: 1,
@@ -269,7 +258,7 @@ function ShareFolderDialog(props: Props) {
}, []);
function renderAddRecipient() {
const disabled = shareState !== ShareState.Idle;
const disabled = shareState !== ShareState.Idle && shareState !== ShareState.Synchronizing;
const dropdown = !props.canUseSharePermissions ? null : <Dropdown className="permission-dropdown" options={permissionOptions} value={recipientPermissions} onChange={recipientPermissions_change}/>;
@@ -395,6 +384,17 @@ function ShareFolderDialog(props: Props) {
props.onClose();
}
const customButtons = useMemo(() => {
return share ? [{
name: 'unshare',
label: _('Unshare'),
// Don't allow unsharing the folder during the "create" action. Doing so might
// be able to cause issues similar to #13518 (e.g. if the "unshare" action completes while
// the "share" action is still in progress).
disabled: shareState === ShareState.Creating || shareState === ShareState.Synchronizing,
}] : [];
}, [share, shareState]);
function renderContent() {
return (
<StyledRoot className="share-folder-dialog">
@@ -422,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,24 +353,29 @@ 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}
tag={tag}
itemCount={itemCount}
index={index}
@@ -382,9 +402,9 @@ const useOnRenderItem = (props: Props) => {
return <FolderItem
key={item.key}
anchorRef={anchorRef}
selected={selected}
selectionState={selectionState}
folderId={folder.id}
folderTitle={Folder.displayTitle(folder)}
folderTitle={item.label}
folderIcon={Folder.unserializeIcon(folder.icon)}
depth={item.depth}
isExpanded={isExpanded}
@@ -394,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}
@@ -407,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}
@@ -416,7 +436,7 @@ const useOnRenderItem = (props: Props) => {
return <AllNotesItem
key={item.key}
anchorRef={anchorRef}
selected={selected}
selectionState={selectionState}
item={item}
index={index}
itemCount={itemCount}
@@ -427,7 +447,7 @@ const useOnRenderItem = (props: Props) => {
key={item.key}
containerRef={anchorRef}
depth={1}
selected={selected}
selectionState={selectionState}
itemIndex={index}
itemCount={itemCount}
highlightOnHover={false}
@@ -441,7 +461,7 @@ const useOnRenderItem = (props: Props) => {
return exhaustivenessCheck;
}
}, [
folderItem_click,
onItemClick,
onFolderDragOver_,
onFolderDragStart_,
onFolderDrop_,
@@ -451,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;
}
@@ -49,13 +50,33 @@ const getParentOffset = (childIndex: number, listItems: ListItem[]): number|null
return null;
};
const findNextTypeAheadMatch = (selectedIndex: number, query: string, listItems: ListItem[]) => {
const matches = (item: ListItem) => {
return item.label.startsWith(query);
};
const indexBefore = listItems.slice(0, selectedIndex).findIndex(matches);
// Search in all results **after** the current. This prevents the current item from
// always being identified as the next match, if the user repeatedly presses the
// same key.
const startAfter = selectedIndex + 1;
let indexAfter = listItems.slice(startAfter).findIndex(matches);
if (indexAfter !== -1) {
indexAfter += startAfter;
}
// Prefer jumping to the next match, rather than the previous
const matchingIndex = indexAfter !== -1 ? indexAfter : indexBefore;
return matchingIndex;
};
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];
let indexChange = 0;
const ctrlAltOrMeta = event.ctrlKey || event.altKey || event.metaKey;
if (selectedItem && isToggleShortcut(event.code, selectedItem, collapsedFolderIds)) {
event.preventDefault();
@@ -82,16 +103,32 @@ const useOnSidebarKeyDownHandler = (props: Props) => {
indexChange = 1;
} else if ((event.ctrlKey || event.metaKey) && event.code === 'KeyA') { // ctrl+a or cmd+a
event.preventDefault();
} else if (event.code === 'Home') {
event.preventDefault();
updateSelectedIndex(0, { extend: false });
indexChange = 0;
} else if (event.code === 'End') {
event.preventDefault();
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');
} else if (selectedIndex && selectedIndex >= 0 && event.key.length === 1 && !ctrlAltOrMeta) {
const nextMatch = findNextTypeAheadMatch(selectedIndex, event.key, listItems);
if (nextMatch !== -1) {
indexChange = nextMatch - selectedIndex;
}
}
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

@@ -4,6 +4,8 @@ import { FolderEntity, TagsWithNoteCountEntity } from '@joplin/lib/services/data
import { buildFolderTree, renderFolders, renderTags } from '@joplin/lib/components/shared/side-menu-shared';
import { _ } from '@joplin/lib/locale';
import toggleHeader from './utils/toggleHeader';
import Folder from '@joplin/lib/models/Folder';
import Tag from '@joplin/lib/models/Tag';
interface Props {
tags: TagsWithNoteCountEntity[];
@@ -18,6 +20,7 @@ const useSidebarListData = (props: Props): ListItem[] => {
return renderTags<ListItem>(props.tags, (tag): TagListItem => {
return {
kind: ListItemType.Tag,
label: Tag.displayTitle(tag),
tag,
key: tag.id,
depth: 1,
@@ -38,6 +41,7 @@ const useSidebarListData = (props: Props): ListItem[] => {
return renderFolders<ListItem>(renderProps, (folder, hasChildren, depth): FolderListItem => {
return {
kind: ListItemType.Folder,
label: Folder.displayTitle(folder),
folder,
hasChildren,
// The toplevel headers have depth 1, so the toplevel notebook needs
@@ -65,9 +69,9 @@ const useSidebarListData = (props: Props): ListItem[] => {
hasChildren: folderItems.items.length > 0,
};
const foldersSectionContent: ListItem[] = props.folderHeaderIsExpanded ? [
{ kind: ListItemType.AllNotes, key: 'all-notes', depth: 2, hasChildren: false },
{ kind: ListItemType.AllNotes, label: _('All notes'), key: 'all-notes', depth: 2, hasChildren: false },
...folderItems.items,
{ kind: ListItemType.Spacer, key: 'after-folders-spacer', depth: 1, hasChildren: false },
{ kind: ListItemType.Spacer, label: '', key: 'after-folders-spacer', depth: 1, hasChildren: false },
] : [];
const tagsHeader: HeaderListItem = {

View File

@@ -7,10 +7,9 @@ import Setting from '@joplin/lib/models/Setting';
import MenuUtils from '@joplin/lib/services/commands/MenuUtils';
import CommandService from '@joplin/lib/services/CommandService';
import PerFolderSortOrderService from '../../../services/sortOrder/PerFolderSortOrderService';
import { _ } from '@joplin/lib/locale';
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');
@@ -20,7 +19,7 @@ const MenuItem = bridge().MenuItem;
interface Props {
dispatch: Dispatch;
anchorRef: ListItemRef;
selected: boolean;
selectionState: ItemSelectionState;
item: ListItem;
index: number;
itemCount: number;
@@ -54,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}
@@ -66,11 +65,11 @@ const AllNotesItem: React.FC<Props> = props => {
<StyledListItemAnchor
className="list-item"
isSpecialItem={true}
selected={props.selected}
selected={props.selectionState.selected}
onClick={onAllNotesClick_}
onContextMenu={toggleAllNotesContextMenu}
>
{_('All notes')}
{props.item.label}
</StyledListItemAnchor>
</ListItemWrapper>
);

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

@@ -1,11 +1,11 @@
import * as React from 'react';
import { useCallback, useState } from 'react';
import { useCallback } from 'react';
import { StyledHeader, StyledHeaderIcon, StyledHeaderLabel } from '../styles';
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;
@@ -25,8 +25,6 @@ const HeaderItem: React.FC<Props> = props => {
const item = props.item;
const onItemClick = item.onClick;
const itemId = item.id;
const [isHovered, setIsHovered] = useState(false);
const expanded = item.expanded;
const onClick: React.MouseEventHandler<HTMLElement> = useCallback(event => {
if (onItemClick) {
@@ -46,18 +44,10 @@ const HeaderItem: React.FC<Props> = props => {
}
}, [itemId]);
const handleMouseEnter = useCallback(() => {
setIsHovered(true);
}, []);
const handleMouseLeave = useCallback(() => {
setIsHovered(false);
}, []);
return (
<ListItemWrapper
containerRef={props.anchorRef}
selected={props.isSelected}
selectionState={props.selectionState}
itemIndex={props.index}
itemCount={props.itemCount}
expanded={props.item.expanded}
@@ -70,10 +60,8 @@ const HeaderItem: React.FC<Props> = props => {
>
<StyledHeader
onClick={onClick}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
>
<StyledHeaderIcon aria-hidden='true' role='img' className={isHovered ? `fas ${expanded ? 'fa-caret-down' : 'fa-caret-right'}` : item.iconName}/>
<StyledHeaderIcon aria-hidden='true' role='img' className={item.iconName}/>
<StyledHeaderLabel>{item.label}</StyledHeaderLabel>
</StyledHeader>
</ListItemWrapper>

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 Tag from '@joplin/lib/models/Tag';
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,33 +31,34 @@ 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">{Tag.displayTitle(tag)}</StyledSpanFix>
<StyledSpanFix className="tag-label">{props.label}</StyledSpanFix>
{noteCount}
</StyledListItemAnchor>
</ListItemWrapper>

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

@@ -16,6 +16,8 @@ export enum ListItemType {
interface BaseListItem {
key: string;
// Used for typeahead
label: string;
depth: number;
hasChildren: boolean;
}
@@ -26,7 +28,6 @@ interface ToplevelListItem extends BaseListItem {
export interface HeaderListItem extends ToplevelListItem {
kind: ListItemType.Header;
label: string;
expanded: boolean;
iconName: string;
id: HeaderId;
@@ -56,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

@@ -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', () => {
@@ -180,8 +181,8 @@ test.describe('markdownEditor', () => {
await expect(matches).toHaveCount(1);
// Should continue searching after switching to view-only mode
await noteEditor.toggleEditorLayoutButton.click();
await noteEditor.toggleEditorLayoutButton.click();
await noteEditor.toggleEditorLayout();
await noteEditor.toggleEditorLayout();
await expect(noteEditor.codeMirrorEditor).not.toBeVisible();
await expect(noteEditor.editorSearchInput).not.toBeVisible();
await expect(noteEditor.viewerSearchInput).toBeVisible();
@@ -194,7 +195,7 @@ test.describe('markdownEditor', () => {
await expect(matches).toHaveCount(0);
// After showing the viewer again, search should still be hidden
await noteEditor.toggleEditorLayoutButton.click();
await noteEditor.toggleEditorLayout();
await expect(noteEditor.codeMirrorEditor).toBeVisible();
await expect(noteEditor.editorSearchInput).not.toBeVisible();
});
@@ -274,5 +275,111 @@ test.describe('markdownEditor', () => {
expect(imageSize[0]).toBeGreaterThan(0);
expect(imageSize[1]).toBeGreaterThan(0);
});
test('ctrl-clicking on note links should open the linked note (when the viewer is hidden)', async ({ mainWindow }) => {
const mainScreen = await new MainScreen(mainWindow).setup();
await mainScreen.createNewNote('Original');
const noteEditor = mainScreen.noteEditor;
await noteEditor.hideViewer();
await noteEditor.focusCodeMirrorEditor();
await mainWindow.keyboard.type('# Test');
await mainWindow.keyboard.press('Enter');
await mainWindow.keyboard.type('## Test 2');
await mainWindow.keyboard.press('Enter');
await mainWindow.keyboard.type('### Test 3');
const editorContent = await noteEditor.contentLocator();
// Extract the note ID
const note1Locator = mainScreen.noteList.getNoteItemByTitle('Original');
await note1Locator.dragTo(editorContent);
const linkExpression = /\[[^\]]*\]\(:\/([a-z0-9]{32})\)/;
await noteEditor.expectToHaveText(linkExpression);
const targetNoteId = (await editorContent.textContent()).match(linkExpression)[1];
await mainScreen.createNewNote('Test note links');
// Create a new link to a header
await noteEditor.focusCodeMirrorEditor();
await mainWindow.keyboard.press('Enter');
await mainWindow.keyboard.press('Enter');
await mainWindow.keyboard.type('[link](:/');
await mainWindow.keyboard.type(targetNoteId);
await mainWindow.keyboard.type('#test-2');
await mainWindow.keyboard.type(')');
await mainWindow.keyboard.press('Enter');
// Clicking the link should navigate to note1
const link = editorContent.getByText(/\[?link\]?/);
await link.click({ modifiers: ['ControlOrMeta'] });
await expect(noteEditor.noteTitleInput).toHaveValue('Original');
await noteEditor.expectToHaveText(/^# Test/);
await expect.poll(() => editorContent.evaluate(async editor => {
const selection = getSelection();
return editor.contains(selection.anchorNode);
})).toBe(true);
// The cursor should be positioned on the linked-to header
await expect.poll(async () => {
await mainWindow.keyboard.type('[[cursor]]');
await noteEditor.expectToHaveText(/## Test 2\[\[cursor\]\]/);
return true;
}).toBe(true);
});
test('should still support the legacy Markdown editor', async ({ electronApp, mainWindow }) => {
const mainScreen = await new MainScreen(mainWindow).setup();
await mainScreen.waitFor();
await setSettingValue(electronApp, mainWindow, 'editor.legacyMarkdown', true);
await mainScreen.createNewNote('Test');
// 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

@@ -65,12 +65,20 @@ export default class NoteEditorPage {
}
}
public async expectToHaveText(content: string) {
public async expectToHaveText(expected: string|RegExp) {
// expect(...).toHaveText can fail in the Rich Text Editor (perhaps due to frame locators).
// Using expect.poll refreshes the locator on each attempt, which seems to prevent flakiness.
await expect.poll(
async () => (await this.contentLocator()).textContent(),
).toBe(content);
const expectResult = expect.poll(
// Use .innerText: textContent doesn't handle line breaks correctly in the CodeMirror
// editor.
async () => (await this.contentLocator()).innerText(),
);
// Allow `expected` to be either an exact match (a string) or a pattern
if (typeof expected === 'string') {
await expectResult.toBe(expected);
} else {
await expectResult.toMatch(expected);
}
}
public getNoteViewerFrameLocator() {
@@ -117,4 +125,14 @@ export default class NoteEditorPage {
await expect(backButton).not.toBeDisabled();
await backButton.click();
}
public async toggleEditorLayout() {
await this.toggleEditorLayoutButton.click();
}
public async hideViewer() {
await expect(this.noteViewerContainer).toBeVisible();
await this.toggleEditorLayout();
await expect(this.noteViewerContainer).not.toBeVisible();
}
}

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

@@ -30,7 +30,7 @@ test.describe('pluginApi', () => {
await mainScreen.createNewNote('First note');
const editor = mainScreen.noteEditor;
await editor.expectToHaveText('');
await editor.expectToHaveText('\n');
await mainScreen.goToAnything.runCommand(app, 'showTestDialog');
// Wait for the iframe to load

View File

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

View File

@@ -44,6 +44,28 @@ test.describe('sidebar', () => {
await expect(mainWindow.locator(':focus')).toHaveText('All notes');
});
test('should allow changing the focused folder by pressing the first character of the title', async ({ electronApp, mainWindow }) => {
const mainScreen = await new MainScreen(mainWindow).setup();
const sidebar = mainScreen.sidebar;
const folderAHeader = await sidebar.createNewFolder('1-Test A');
await expect(folderAHeader).toBeVisible();
const folderBHeader = await sidebar.createNewFolder('Folder b');
await expect(folderBHeader).toBeVisible();
await folderBHeader.click();
await sidebar.forceUpdateSorting(electronApp);
await folderBHeader.click();
await mainWindow.keyboard.type('1');
await expect(mainWindow.locator(':focus')).toHaveText('1-Test A');
await mainWindow.keyboard.type('F');
await expect(mainWindow.locator(':focus')).toHaveText('Folder b');
await mainWindow.keyboard.type('A');
await expect(mainWindow.locator(':focus')).toHaveText('All notes');
});
test('left/right arrow keys should expand/collapse notebooks', async ({ electronApp, mainWindow }) => {
const mainScreen = await new MainScreen(mainWindow).setup();
const sidebar = mainScreen.sidebar;
@@ -59,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();
@@ -164,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],
]);
});
});

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