Compare commits

..
Author SHA1 Message Date
Laurent Cozic e3f38c6f1f Desktop release v3.7.16 2026-09-05 20:41:51 +01:00
Laurent Cozic e05c11b17a Chore: Pin macOS M1 build OS to prevent failure on recent macOS image 2026-09-05 20:41:40 +01:00
Laurent Cozic 949d10476f Android 3.7.8 2026-09-05 11:10:15 +01:00
Laurent Cozic c397df5e4c Desktop release v3.7.15 2026-09-05 10:58:57 +01:00
Laurent Cozic 2727aab2da iOS 13.7.4 2026-09-05 09:34:13 +01:00
Laurent Cozic e40b2f50f7 Chore: Update translations 2026-09-05 09:31:17 +01:00
summoner 6da783abe5 All: Translation: Update hu_HU.po (#16408) 2026-09-05 09:28:08 +01:00
Henry Heino 845dc16402 Android: Fix additional space present below new note menu on some devices (#16361) 2026-09-01 09:51:35 +01:00
Laurent Cozic fd3a2fb727 Update translations 2026-09-01 01:52:25 +01:00
Laurent Cozic 7d37bbe29c Update translations 2026-09-01 01:51:16 +01:00
Laurent Cozic 205c284fb8 Desktop release v3.7.14 2026-08-30 12:48:11 +01:00
Henry Heino a79a2b4de7 Desktop: Resolves #16350: External editor: Add additional logic to avoid overwriting a note with older content (#16352) 2026-08-30 12:47:56 +01:00
Joplin Bot b7beb0bd06 Doc: Auto-update documentation
Auto-updated using release-website.sh
2026-08-28 21:56:10 +00:00
Laurent Cozic 5a4a222972 iOS 13.7.3 2026-08-28 19:17:22 +01:00
Laurent Cozic 6d0f6c023a Android 3.7.7 2026-08-28 19:16:28 +01:00
Laurent Cozic e51093d736 Desktop release v3.7.13 2026-08-28 18:52:49 +01:00
Henry Heino f488dad27a Chore: Tests: Silence expected handleCallbackUrl warnings (#16343) 2026-08-28 18:51:20 +01:00
Aissa Benfodda 44751ef56b Desktop, Cli: Resolves #15223: Add support to import notes from Obsidian (#16136) 2026-08-28 18:43:12 +01:00
renovate[bot] bf69aedf7c chore(deps): update dependency @rollup/plugin-commonjs to v29.0.3 (#16331)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-08-28 17:42:26 +00:00
Henry Heino 683240968b Desktop: Fixes #15890: Limit maximum size of stored OCR text (#16325) 2026-08-28 09:49:09 +01:00
mrjo118 9bc4af486a Desktop, Mobile: Fixes #16155: Prevent conflicts initially showing alongside the original note in the notebook when created (#16330) 2026-08-28 09:48:55 +01:00
Henry Heino 7fba0fda68 Desktop: Fixes #16317: Markdown editor: Disable table editor when opting out of in-editor rendering (#16333) 2026-08-28 09:47:55 +01:00
Henry Heino 3dc1f5fef6 Chore: Tests: Silence expected warning in AsyncActionQueue tests (#16334) 2026-08-28 09:47:43 +01:00
Henry Heino 8b97347f3a Chore: Tests: Increase timeout in useSearchMarkers test (#16335) 2026-08-28 09:46:57 +01:00
mrjo118 bfe78ca8a0 Desktop: Improve refresh for whiteboards and ensure undo stack is cleared upon note refresh (#16337) 2026-08-28 09:46:48 +01:00
Henry Heino 3899ca9ce5 Chore: Desktop: Refactor end-to-end tests to reduce dependence on a particular default editor state (#16339) 2026-08-28 09:45:41 +01:00
Henry Heino 098b1edaa5 Desktop: Resolves #16120: Increase semantic search minimum relevance (#16340) 2026-08-28 09:45:11 +01:00
mrjo118 4998ec221b Desktop: Fixes #16234: Fix position does not always re-center when switching to a whiteboard (#16341) 2026-08-28 09:43:45 +01:00
mrjo118 3156f23fa2 Desktop: Fixes #16202: Prevent password checking loop when verifying the encryption password (#16267) 2026-08-27 10:01:45 +01:00
Henry Heino 347915c4ba Mobile, Desktop, Cli: Resolves #16251: Sync: Enable new notebook sharing key format (#16305) 2026-08-27 10:01:15 +01:00
Henry Heino 8f98933f14 Desktop: Sharing: Improve error message when public key format is unsupported (#16306) 2026-08-27 10:00:17 +01:00
Henry Heino 9b75d9e4d8 Desktop: Rich Text Editor: Fix error while saving changes (#16307) 2026-08-27 09:59:56 +01:00
Henry Heino dff722d6d8 Desktop: Fixes #12811: Rich Text Editor: Save note changes after changing formatting (#16308) 2026-08-27 09:59:41 +01:00
mrjo118 e6dc8d8222 Desktop: Prevent ability to link to note or set tags for a trashed note after switching to all notes (#16320) 2026-08-27 09:57:40 +01:00
Henry Heino 97fb8d5ec2 Mobile: Fixes #16312: Improve shadow colors in dark mode (#16326) 2026-08-27 09:52:24 +01:00
renovate[bot] 1adc40ab30 fix(deps): update dependency expo to v54.0.35 (#16329)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-08-27 06:47:03 +01:00
Laurent Cozic 8e9ffe3386 Chore: Add ESLint rule to enforce describe() titles match test file names (#16319) 2026-08-26 21:01:34 +01:00
renovate[bot] b6889d3e7d fix(deps): update dependency dompurify to v3.4.7 (#16313)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-08-25 20:54:35 +01:00
renovate[bot] c174faf77d fix(deps): update dependency react-native-paper to v5.15.3 (#16302)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-08-24 18:44:45 +00:00
Ivan 079bf7e48d Server: Fixes #15226: Fix SQLite database getting permanently locked (#16244) 2026-08-24 18:22:01 +01:00
renovate[bot] 0141c60d01 fix(deps): update dependency dompurify to v3.4.6 (#16300)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-08-24 16:17:52 +01:00
Laurent Cozic fc76a884f3 Desktop: Fixes #15278: Fix video/audio in Markdown viewer continuing to play after hiding it or switching notes (#16299) 2026-08-24 14:20:15 +01:00
Henry Heino 2a83642dbf Desktop, Mobile: Fixes #16285: Editor: Avoid crash when editing certain partially-loaded documents (#16286) 2026-08-24 11:27:24 +01:00
Henry Heino b21a55e073 Desktop, Mobile: Fixes #15770: HTML notes: Fix certain content incorrectly renders as linked text (#16289) 2026-08-24 11:23:24 +01:00
renovate[bot] b8919220a7 fix(deps): update dependency dayjs to v1.11.21 (#16298)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-08-24 10:31:37 +01:00
Laurent Cozic f034ad244b Desktop: Fix share notebook error message disappearing after half a second 2026-08-23 22:58:28 +01:00
renovate[bot] 73064ec468 fix(deps): update dependency prosemirror-search to v1.1.1 (#16293)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-08-23 17:31:32 +00:00
Henry Heino 94911a86ff Desktop, Mobile: Markdown editor: Support opening same-note links to elements with id="..." attributes (#16287) 2026-08-22 22:04:24 +01:00
Henry Heino 92eed39ff7 Chore: Mobile: Deduplicate ProseMirror dependencies (#16283) 2026-08-22 19:49:54 +01:00
Joplin Bot 55b1a88b88 Doc: Auto-update documentation
Auto-updated using release-website.sh
2026-08-22 18:38:51 +00:00
Keshav 50444adc23 Api, Cli, Desktop: Resolves #16054: Add fail-closed guardrails for locked notes (#16098) 2026-08-22 18:47:02 +01:00
Laurent Cozic 0fa387b463 Plugin Repo CLI v3.7.3 2026-08-22 11:55:21 +01:00
Laurent Cozic 61c58955d3 Tools: Fix plugin-repo-cli to load its self-contained bundle at runtime 2026-08-22 11:54:42 +01:00
Laurent Cozic 14dc994e58 Plugin Repo CLI v3.7.2 2026-08-22 11:50:44 +01:00
Laurent Cozic a8ea99fa59 Tools: Move bundled workspace packages to devDependencies in plugin-repo-cli 2026-08-22 11:50:05 +01:00
Henry HeinoandLaurent Cozic 6845e9d1ee Desktop: Resolves #6088: Highlight global search matches in the Rich Text Editor (#11361)
Co-authored-by: Laurent Cozic <laurent22@users.noreply.github.com>
2026-08-22 11:46:46 +01:00
Henry Heino 99f0779db5 Chore: Tests: Mobile: Fix "failed to set timeout" warning in note screen tests (#16259) 2026-08-22 11:46:38 +01:00
mrjo118 6a64879e05 Desktop: Fixes #16250: Fix exiting having no visible effect when on the settings screen during a long sync (#16278) 2026-08-22 11:42:07 +01:00
mrjo118 67e6870ec9 Desktop: Fixes #15258: Show a warning icon when a sync completes with errors (#16279) 2026-08-22 11:34:58 +01:00
Laurent Cozic 7fb9ec93e4 Desktop: Strip style elements from whiteboard card content (#16275) 2026-08-22 10:36:36 +01:00
Laurent Cozic 27ae5c0d40 Desktop: Restrict Web Clipper pairing to extension origins (#16276) 2026-08-22 10:36:25 +01:00
Laurent Cozic 8353a82fc0 Desktop: Validate x-callback-url response targets (#16269) 2026-08-22 10:35:38 +01:00
Laurent Cozic 7766eefa11 Server: Scope password-reset tokens to their intended purpose (#16274) 2026-08-21 22:54:19 +01:00
Laurent Cozic 1a0a50afc9 Server: Enforce session and CSRF checks on the application authorisation POST (#16270) 2026-08-21 22:00:03 +01:00
Aissa Benfodda eade70040f Mobile: Fixes #15920: Mobile: When toggling a checkbox which is not at the cursor position, it will scroll away from the checkbox back to the cursor position (#16173) 2026-08-21 14:44:04 +01:00
Henry Heino d36432dd0a Desktop: Improve chat connection check (#16256) 2026-08-21 14:42:37 +01:00
Henry Heino 49756e09f1 Chore: Server: Fix truncateUserDataTables debug command only deletes old changes (#16257) 2026-08-21 14:31:36 +01:00
Henry Heino 7e4bdb64b1 Chore: Embedding tests: Silence expected warnings (#16258) 2026-08-21 14:31:18 +01:00
Joplin Bot dc4e0b464e Doc: Auto-update documentation
Auto-updated using release-website.sh
2026-08-21 01:25:16 +00:00
Laurent Cozic b070bfed1f Server: Display JSB banner on larger Joplin Server instances (#16255) 2026-08-20 20:26:26 +01:00
Laurent Cozic 1aa705b97a Merge branch 'release-3.6' into dev 2026-08-20 20:06:07 +01:00
Laurent Cozic 05ae012ea3 iOS 13.6.11 2026-08-20 19:25:58 +01:00
Laurent Cozic 36ef85f044 Android 3.6.22 2026-08-20 19:25:36 +01:00
Laurent Cozic e1cf9245a5 Desktop release v3.6.16 2026-08-20 19:09:23 +01:00
Henry Heino 011a15374c Desktop, Mobile: Fixes #16070: Fix Markdown editor selection issues (#16126) 2026-08-20 19:06:43 +01:00
Henry Heino ae5855a85f Mobile, Desktop, Cli: Resolves #15753: Support syncing with Joplin v3.7 (#15889) 2026-08-20 19:06:22 +01:00
Laurent Cozic 8e7b1d763b Doc: Improve JSB copy 2026-08-20 17:51:02 +01:00
Laurent Cozic fbd3a423a3 Doc: Improve JSB copy 2026-08-20 17:48:54 +01:00
Laurent Cozic ec583f9c8a Doc: Display JSB equivalent prices per month 2026-08-20 17:46:12 +01:00
Laurent Cozic 941418a271 Doc: Improve CFA on Plans page 2026-08-20 17:37:59 +01:00
Laurent Cozic d22ebdb0a1 Doc: Mention JSB in Joplin Cloud doc 2026-08-20 17:27:09 +01:00
Laurent Cozic c1efee1049 Doc: Mention JSB license in Joplin Server README file 2026-08-20 17:27:09 +01:00
Laurent Cozic f1246530a4 Doc: Replace Tally links with JSB license page 2026-08-20 17:27:08 +01:00
Laurent Cozic 85a61eee2d Desktop: By default, show only the editor when opening the app for the first time (#16254) 2026-08-20 17:24:02 +01:00
Laurent Cozic a349c8e081 Doc: Update URL when toggling between plans in Plans page 2026-08-20 16:35:40 +01:00
Laurent Cozic 66546b587a Doc: Update warrant canary 2026-08-20 13:56:12 +01:00
Laurent Cozic 774aea1a38 Plugin Repo CLI v3.7.1 2026-08-20 13:29:01 +01:00
Aissa Benfodda 7588c2b722 Desktop: Resolves #16156: add support for unpublishing notebooks (#16237) 2026-08-20 11:24:32 +01:00
Henry Heino 09e7e72273 Desktop: Fixes #16015: Fix sync wizard extended options are partially offscreen (#16247) 2026-08-20 11:22:37 +01:00
Henry Heino 9db5a8df36 Desktop, Cli, Mobile: Fixes #16153: Support unpublishing notes published by other users (#16246) 2026-08-20 11:22:24 +01:00
Henry Heino ce36c14098 Chore: Desktop: Add performance logging for the embedding indexer (#16249) 2026-08-20 11:21:37 +01:00
mrjo118 c1013461e0 Desktop, Mobile: Implement readonly state for mobile RTE and disable the MDE interactive table editor for readonly notes (#16231) 2026-08-19 15:27:46 +01:00
Henry Heino cdee3c8901 Desktop: Fixes #16007: Fix toggling the chat panel clears undo/redo history (#16236) 2026-08-19 11:25:08 +01:00
mrjo118 eb800dc54b Desktop: Fixes #15947: Disable link to note button when note is readonly (#16238) 2026-08-19 11:24:53 +01:00
Joplin Bot ce449a5a50 Doc: Auto-update documentation
Auto-updated using release-website.sh
2026-08-18 18:48:56 +00:00
Laurent Cozic a36c689473 Android 3.7.6 2026-08-18 17:09:29 +01:00
VortexPandLaurent Cozic 715c1d5514 All: Translation: Update fi_FI.po (#16075)
Co-authored-by: Laurent Cozic <laurent22@users.noreply.github.com>
2026-08-18 15:17:30 +01:00
mrjo118 d6dcd36c25 Desktop, Mobile, Cli: Fix note screen is not properly refreshed when updated by the sync, when E2EE is enabled (#16179) 2026-08-18 15:15:09 +01:00
mrjo118 3b969d7f65 Mobile: Fix empty navigation when navigating to settings from the conflicts folder after it was removed (#16197) 2026-08-18 15:14:49 +01:00
mrjo118 d73c9ca2dd Desktop, Mobile, Cli: Prevent race which allows changes to be lost, during a long delta step (#16208) 2026-08-18 15:14:12 +01:00
mrjo118 7e66fbff34 Desktop: Fixes #16205: Fix regression which causes text to be lost if switching to another note immediately after typing in the Rich Text editor (#16213) 2026-08-18 15:13:34 +01:00
Henry Heino 561ab70c5e Desktop: Fixes #16219: Fix semantic search availability check (#16220) 2026-08-18 15:13:05 +01:00
renovate[bot] 8cc7c95d67 fix(deps): update dependency follow-redirects to v1.16.0 (#16226)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-08-18 14:48:49 +01:00
renovate[bot] 9b71467e3b fix(deps): update dependency dompurify to v3.4.5 (#16228)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-08-18 14:48:07 +01:00
Joplin Bot ca3482a8b3 Doc: Auto-update documentation
Auto-updated using release-website.sh
2026-08-18 12:53:45 +00:00
Henry Heino 36b7709072 Desktop: Sync: Apply changes to proxy settings immediately (#16222) 2026-08-18 11:46:46 +01:00
mrjo118 34ded63b0c Desktop, Mobile, Cli: Fixes #16194: Prevent incrementing the master key updated_time when the key has not actually changed (#16223) 2026-08-18 11:45:57 +01:00
renovate[bot] 7fcb0eeadd fix(deps): update dependency dompurify to v3.4.0 (#16218)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-08-18 11:35:26 +01:00
renovate[bot] 7ec75ea70c fix(deps): update dependency ldapts to v8.1.8 (#16224)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-08-18 11:35:06 +01:00
renovate[bot] 55afc8622b fix(deps): update dependency react-native-svg to v15.15.5 (#16225)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-08-18 11:34:57 +01:00
renovate[bot] 629acbf89e chore(deps): update dependency webpack-dev-server to v5.2.4 (#16221)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-08-18 04:28:02 +00:00
renovate[bot] a310163922 fix(deps): update dependency react-native-quick-crypto to v1.1.4 (#16217)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-08-18 01:29:21 +00:00
Joplin Bot a43e772c85 Doc: Auto-update documentation
Auto-updated using release-website.sh
2026-08-17 18:47:52 +00:00
Laurent Cozic bed73cd7c1 Desktop release v3.7.12 2026-08-17 19:19:17 +01:00
Laurent Cozic d4897e7f73 Desktop release v3.7.11 2026-08-17 19:12:06 +01:00
mrjo118 4afeb59450 Desktop: Prevent the remote version of a note being overwritten if typing while the sync changes the open note (#16206) 2026-08-17 19:08:07 +01:00
mrjo118 21e73968e5 Mobile: Prevent the remote version of a note being overwritten if typing while the sync changes the open note (#16193) 2026-08-17 19:01:04 +01:00
Clarisse Deschamps 7ad86544cf Doc: Fixed french translation in Welcome message (#16195) 2026-08-17 18:55:52 +01:00
Henry Heino d640f36ab1 Desktop: Fixes #16016: Chat panel: Allow closing the chat panel in small windows (#16189) 2026-08-17 18:53:42 +01:00
Henry Heino b46a96e03c Desktop: Chat panel: Fix OpenAI, Anthropic compatibility (#16188) 2026-08-17 18:52:55 +01:00
taw00 0f3d81a983 Doc: Fixes #16186: missing word added (#16187) 2026-08-17 18:52:46 +01:00
Henry Heino 628e6bd6f6 Desktop: Fixes #16167: Fix conflict created when syncing a just-published note (#16182) 2026-08-17 18:52:31 +01:00
Aissa Benfodda 925b83eafe Server: Resolves #16132: Publishing an old note may display an error "Missing property deleted_time" (#16175) 2026-08-17 18:45:34 +01:00
renovate[bot]andLaurent Cozic 82afd4a5f3 fix(deps): update dependency react-native-paper to v5.15.2 (#16158)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Laurent Cozic <laurent22@users.noreply.github.com>
2026-08-17 18:43:01 +01:00
Sriram Varun KumarandLaurent Cozic 6255497969 Desktop: Resolves #16134: Add data loader and word-level diff for Conflict resolution UI (#16135)
Co-authored-by: Laurent Cozic <laurent22@users.noreply.github.com>
2026-08-17 18:42:22 +01:00
Akshaj Rawat 942d0be0fa Tools: Resolves #16012: Add GitHub authentication and issue submission to publish script (#16022) 2026-08-17 18:39:24 +01:00
Laurent Cozic 5d894d43c0 Doc: Update sponsors 2026-08-17 18:25:55 +01:00
Joplin Bot 2f9c215bf8 Doc: Auto-update documentation
Auto-updated using release-website.sh
2026-08-16 06:48:01 +00:00
Joplin Bot 0b07849f4d Doc: Auto-update documentation
Auto-updated using release-website.sh
2026-08-16 01:25:27 +00:00
Jonatan Nyberg 6233b510ff All: Translation: Update sv.po (#16200) 2026-08-14 08:11:16 -04:00
James Carroll 45b7feb683 All: Translation: Update zh_CN.po and ZH_TW.po (#16203) 2026-08-14 08:10:10 -04:00
Joplin Bot 2654b33620 Doc: Auto-update documentation
Auto-updated using release-website.sh
2026-08-10 07:49:42 +00:00
Joplin Bot eeb420fbdd Doc: Auto-update documentation
Auto-updated using release-website.sh
2026-08-10 01:55:19 +00:00
renovate[bot] 4942bccd1e chore(deps): update dependency @docusaurus/plugin-sitemap to v3.10.1 (#16161)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-08-07 23:45:18 +02:00
mrjo118 8b70b5f6e9 Mobile: Make the editor state update, if the current note is updated by the sync (#16150) 2026-08-07 23:44:57 +02:00
Henry HeinoandLaurent Cozic aed0d55398 Desktop: Resolves #16148: Evernote import: Support importing cross-notebook links in some cases (#16151)
Co-authored-by: Laurent Cozic <laurent22@users.noreply.github.com>
2026-08-07 23:44:38 +02:00
Henry Heino 39169894dd Chore: Website: Fix build (#16164) 2026-08-07 23:44:16 +02:00
renovate[bot] 5d86c83e84 fix(deps): update dependency tar to v7.5.15 (#16159)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-08-07 01:26:10 +00:00
Laurent Cozic 6cc0b97f64 Merge branch 'mrjo118-fix-long-press-on-web' into dev 2026-08-06 23:02:04 +02:00
Laurent Cozic dd145c39eb Merge branch 'fix-long-press-on-web' of https://github.com/mrjo118/joplin into mrjo118-fix-long-press-on-web 2026-08-06 23:01:41 +02:00
Keshav fbd88155dc Mobile, Desktop, Cli: Fixes #16145: Release the save mutex when a save fails validation (#16147) 2026-08-06 22:56:10 +02:00
Henry Heino 20e55c9937 Chore: Fix generated files precommit check (#16154) 2026-08-06 22:55:31 +02:00
renovate[bot] 5d63d9c77b chore(deps): update dependency @docusaurus/plugin-sitemap to v3.10.0 (#16149)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-08-06 22:54:33 +02:00
Henry Heino 6e8fc6cd75 Desktop: MCP: Return correct API response type (#16144) 2026-08-06 22:54:09 +02:00
Henry Heino 391cdf7f15 Desktop: Chat panel: Support reading images (#16141) 2026-08-06 22:53:44 +02:00
Henry Heino 1c9ed87941 Desktop: Resolves #15824: Enable semantic search in the sidebar and go-to-anything (#16118) 2026-08-06 22:42:56 +02:00
Aissa BenfoddaandLaurent Cozic 64343897ca Desktop: Resolves #16065: Mark AI features as Beta (#16097)
Co-authored-by: Laurent Cozic <laurent22@users.noreply.github.com>
2026-08-06 22:40:05 +02:00
mrjo118 b36592577d Mobile: Fixes #15923: Prevent scrolling the cursor into view on the markdown editor when unnecessary to do so (#16091) 2026-08-06 22:36:11 +02:00
Joplin Bot ca36776224 Doc: Auto-update documentation
Auto-updated using release-website.sh
2026-08-06 08:44:34 +00:00
Joplin Bot 9fa7278c74 Doc: Auto-update documentation
Auto-updated using release-website.sh
2026-08-06 02:41:04 +00:00
Joplin Bot 346a1f1e38 Doc: Auto-update documentation
Auto-updated using release-website.sh
2026-08-05 22:43:38 +00:00
Laurent Cozic 150ecb8976 Doc: Update sponsors 2026-08-06 00:32:22 +02:00
mrjo118 a94f981ca9 Add comments 2026-08-04 23:19:33 +01:00
renovate[bot] c153ad8033 fix(deps): update dependency fs-extra to v11.3.5 (#16142)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-08-04 22:09:57 +00:00
mrjo118 03efac1c17 Fix long press on the note list cancelling multi select immediately after switching on Web 2026-08-04 21:12:45 +01:00
renovate[bot] 747a1876fb fix(deps): update dependency tar to v7.5.14 (#16133)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-08-04 06:43:09 +00:00
Stephan Paternotte 8d1ba56e2f All: Translation: Update nl_NL.po (#16125) 2026-08-03 09:52:56 -04:00
Henry Heino 45c3cf6fcf Mobile: Implement redesigned new note menu (#16082) 2026-08-03 00:09:03 +02:00
8d8f76fe24 chore(deps): update dependency @playwright/test to v1.59.1 (#16102)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Henry Heino <46334387+personalizedrefrigerator@users.noreply.github.com>
Co-authored-by: Laurent Cozic <laurent22@users.noreply.github.com>
2026-08-01 19:23:42 +02:00
renovate[bot]andLaurent Cozic c5c23d6a52 chore(deps): update dependency @types/serviceworker to v0.0.197 (#16105)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Laurent Cozic <laurent22@users.noreply.github.com>
2026-08-01 19:23:35 +02:00
renovate[bot] 7270b9f12b chore(deps): update dependency sass to v1.99.0 (#16115)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-08-01 19:23:23 +02:00
Laurent Cozic 3745d33038 Doc: Added section describing what semantic search is good at 2026-08-01 18:51:26 +02:00
Joplin Bot cb1f473bc6 Doc: Auto-update documentation
Auto-updated using release-website.sh
2026-08-01 02:55:33 +00:00
renovate[bot] 03389b59a8 fix(deps): update dependency lodash to v4.18.1 (#16111)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-07-31 23:33:59 +02:00
Henry Heino 5ab92fe5b7 Chore: Fixes #15841: Fix "makeInlineReplaceExtension" CI failures (#16112) 2026-07-31 21:19:30 +02:00
Henry Heino 1704b5cf7d Web, Android: Resolves #16107: Dialogs: Bold and add margin below titles (#16113) 2026-07-31 20:55:12 +02:00
Keshav 5395a8d02a Desktop: Fixes #16099: Skip the note lock session effect on the first render (#16100) 2026-07-31 20:14:01 +02:00
renovate[bot] 69b5c6ab03 fix(deps): update dependency knex to v3.2.10 (#16108)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-07-31 16:54:32 +00:00
Joplin Bot 4fdfb5d90e Doc: Auto-update documentation
Auto-updated using release-website.sh
2026-07-31 14:15:50 +00:00
renovate[bot] bba1231448 fix(deps): update dependency dotenv to v17.4.2 (#16106)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-07-31 15:38:11 +02:00
Laurent Cozic 140ff4552a Desktop: Add support for translating Markdown editor table context menu items 2026-07-31 12:33:11 +02:00
Laurent Cozic f7998114ae iOS 13.7.2 2026-07-31 12:17:58 +02:00
Laurent Cozic 15756bba1a Desktop: Improve rendering of Markdown editor table context menu 2026-07-31 12:17:58 +02:00
Akshaj Rawat 1fba990153 Tools: Resolves #15974: Added new publish plugin command in plugin-repo-cli (#15981) 2026-07-31 11:36:32 +02:00
Henry Heino 1d7c5c0a44 Mobile: Implement redesigned notebook long-press menu (#16027) 2026-07-31 11:31:51 +02:00
Henry Heino d7c29c1d1a Android: Add shadows to the new note and note actions menus (#16067) 2026-07-31 11:31:31 +02:00
Henry Heino d167ae507c Desktop: Plugin API: Fix panels hidden on startup are incorrectly shown in some cases (#16072) 2026-07-31 11:31:14 +02:00
mrjo118 8bb852e639 Mobile: Fixes #15265: Fix blank screen being shown when navigating back after tag changes and other edge cases (#16080) 2026-07-31 11:30:49 +02:00
mrjo118 b0ce2d06d3 Mobile: Make the startup location the first entry in the navigation history instead of 'All Notes' (#16083) 2026-07-31 11:30:32 +02:00
Henry Heino a23e78825d Mobile: Fix inconsistent button order in profile switcher, tag screen, and deletion confirmation dialogs (#16076) 2026-07-31 11:28:26 +02:00
Henry Heino cec9c97ef2 Mobile: Remove single-word titles from error/warning dialogs (#16096) 2026-07-31 11:19:36 +02:00
renovate[bot] 69a7fd19f1 fix(deps): update dependency dotenv to v17.4.0 (#16103)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-07-31 11:12:39 +02:00
renovate[bot] 6ca3d744ef fix(deps): update dependency nanoid to v5.1.11 (#16101)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-07-31 00:56:42 +02:00
renovate[bot] 0b8466addd fix(deps): update dependency uuid to v11.1.1 (#16068)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-07-30 21:04:12 +00:00
renovate[bot] b7031641f3 chore(deps): update dependency @axe-core/playwright to v4.11.3 (#16088)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-07-30 21:02:19 +00:00
Henry Heino 710f7983b3 Chore: Retry failing Markdown replacement tests in CI (#16077) 2026-07-30 15:16:01 +02:00
ReZa e359f48ccc All: Translation: Update fa.po (#16087) 2026-07-29 11:56:10 -04:00
renovate[bot] 3c313a5b42 chore(deps): update dependency @types/serviceworker to v0.0.196 (#16085)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-07-29 11:00:24 +00:00
Henry Heino 37feb9a6d0 Chore: Testing: Retry plugin install/uninstall tests (#16079) 2026-07-29 01:53:40 +01:00
Henry Heino f233b8780b Chore: Testing: Fix chat panel test failure (#16078) 2026-07-29 01:53:29 +01:00
Henry Heino ac40404d94 Chore: Fixes #16073: CI: Fix desktop end-to-end test failures caused by missing initial notebook (#16074) 2026-07-29 01:50:25 +01:00
Joplin Bot 4d3dff767b Doc: Auto-update documentation
Auto-updated using release-website.sh
2026-07-28 19:33:09 +00:00
renovate[bot] ac53434542 fix(deps): update dependency tar-stream to v3.1.9 (#16062)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-07-28 16:54:30 +00:00
renovate[bot] 58460dea63 chore(deps): update dependency axios to v1.14.0 (#16064)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-07-28 17:52:15 +01:00
renovate[bot] e14393b118 chore(deps): update dependency @crowdin/cli to v4.14.2 (#16063)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-07-28 17:51:07 +01:00
Joplin Bot 980e1f4b06 Doc: Auto-update documentation
Auto-updated using release-website.sh
2026-07-28 14:14:45 +00:00
Laurent Cozic b83c75d9d5 Tools: Ignore test target in iOS deployment target check 2026-07-28 14:06:37 +01:00
Laurent Cozic 28c14d1825 Android 3.7.4 2026-07-28 14:01:46 +01:00
Laurent Cozic 0d5d4c4902 Desktop release v3.7.10 2026-07-28 13:30:03 +01:00
Laurent Cozic d14c25f43f Chore: Fix intermittent makeInlineReplaceExtension test flake on CI 2026-07-28 13:27:54 +01:00
Henry HeinoandJGCarroll 7990ec79db Desktop: Fixes #15030: Upgrade electron-builder (#15043)
Co-authored-by: JGCarroll <JGCarroll@users.noreply.github.com>
2026-07-28 13:21:49 +01:00
Keshav 94955ea573 Desktop: Resolves #15872: Add note lock UI (#15961) 2026-07-28 13:21:37 +01:00
Henry Heino b6eb739680 Chore: Desktop: AI chat panel: Allow enabling existing MCP tools for use in the chat panel (#15999) 2026-07-28 12:57:16 +01:00
Laurent Cozic 6caf347ee0 Desktop: Add getCurrentNote and createNote x-callback-url commands (#16041) 2026-07-28 12:55:14 +01:00
Henry Heino eeef83b081 Mobile: Add shadow to note actions menu (#16053) 2026-07-28 12:53:35 +01:00
Henry Heino 15c78bca60 iOS: Improve drag-to-dismiss behavior for drawer menus (#16055) 2026-07-28 12:53:22 +01:00
mrjo118 aae1a4ceaa Mobile: Consider tags and smart filters when opening the last open location on app start (#16058) 2026-07-28 12:53:12 +01:00
Keshavandmrjo118 b307898b47 Mobile: Resolves #15853: Add note lock configuration section (#15963)
Co-authored-by: mrjo118 <jo.118@hotmail.com>
2026-07-27 11:05:41 +01:00
Laurent Cozic c2c38fb7a0 Chore: Absorb random CI install-network flakes (#16045) 2026-07-26 21:45:35 +01:00
Laurent Cozic c5913bc3d5 Chore: Chore: Add temporary diagnostic for makeInlineReplaceExtension.test.ts flake 2026-07-26 21:45:27 +01:00
mrjo118 bf7689a6c4 Mobile: Fixes #15968: Prevent update to note via the api from refreshing the editor if the change is for a different note (#15985) 2026-07-26 19:06:43 +01:00
renovate[bot] 19c0c808bd fix(deps): update dependency knex to v3.2.9 (#16044)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-07-26 19:03:10 +01:00
renovate[bot] ee39f12a64 fix(deps): update dependency expo to v54.0.34 (#16043)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-07-26 17:35:22 +00:00
Aissa Benfodda 51c6d4f809 Desktop: Resolves #15788: Selection sometimes misses markdown starting formatting characters (#15858) 2026-07-26 15:52:56 +01:00
Aissa Benfodda 2fa45a5a05 Mobile: Fixes #15972: New note list design does not include the padding in the touch target to open notes (#15987) 2026-07-26 15:27:00 +01:00
Henry Heino 7d90db0bf6 Desktop: Fixes #15874: AI chat panel: Fix changing the note body causes a cache miss (#15969) 2026-07-26 15:24:42 +01:00
mrjo118 f606699717 Mobile: Fixes #15968: Avoid reverting to the default editor state when the editor reloads the same note (#15986) 2026-07-26 15:21:53 +01:00
mrjo118 995699230a Desktop, Mobile: Fixes #16009: Upgrade CodeMirror View to v6.43.6 (#16017) 2026-07-26 15:20:39 +01:00
Sriram Varun Kumar e855fe6e4a Desktop: Resolves #16039: Add hidden feature flag for conflict resolution (#16040) 2026-07-26 15:17:44 +01:00
renovate[bot] 322219593c fix(deps): update dependency knex to v3.2.7 (#16042)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-07-26 15:16:55 +01:00
renovate[bot] 4adbae2c88 chore(deps): update dependency moment to v2.30.1 (#16035)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-07-25 00:20:51 +01:00
Henry Heino 87f82bb8fb Desktop: Chat panel: Fix rate limit handling (#16031) 2026-07-24 08:54:20 +01:00
renovate[bot] 8cd4502472 fix(deps): update dependency abcjs to v6.6.3 (#16028)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-07-24 01:10:33 +00:00
Henry Heino 1408b87794 Chore: Server: Update home page for users that sync with an external server (#16026) 2026-07-23 20:07:56 +01:00
Henry Heino 966f600d30 Chore: Server: Make the default application name match the built image name (#16025) 2026-07-23 20:07:26 +01:00
renovate[bot] 5df269104e chore(deps): update dependency python to v3.14.3 (#16014)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-07-22 17:04:35 +01:00
Aissa Benfodda 97e00c6f66 Mobile: Fixes #15973: New note list design does not look good in multi select mode (#15990) 2026-07-22 14:54:36 +01:00
renovate[bot] ed8ce18b0f chore(deps): update dependency @crowdin/cli to v4.14.2 (#16010)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-07-22 14:53:04 +01:00
renovate[bot] 6f32d26911 fix(deps): update dependency @react-native-documents/picker to v12.0.1 (#16011)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-07-22 14:52:48 +01:00
535 changed files with 197966 additions and 84197 deletions

No files matched your search

+9
View File
@@ -0,0 +1,9 @@
# Environment variables injected by Yarn into every install/build-script run.
# See the injectEnvironmentFiles setting: https://yarnpkg.com/configuration/yarnrc#injectEnvironmentFiles
# onnxruntime-node bundles the CPU runtime we use, but its postinstall also
# downloads the CUDA/GPU execution provider from nuget.org on Linux x64. Joplin
# only does CPU inference, so that download is unused - and it randomly times
# out and fails the whole `yarn install`. Skip it. This is a no-op on
# macOS/Windows, where onnxruntime-node downloads nothing anyway.
ONNXRUNTIME_NODE_INSTALL=skip
+3 -1
View File
@@ -45,7 +45,9 @@ jobs:
- uses: ./.github/workflows/shared/install-yarn
- name: Install
run: yarn install
run: |
source "${GITHUB_WORKSPACE}/.github/scripts/retry.sh"
retry yarn install
env:
SKIP_ONENOTE_CONVERTER_BUILD: 1
+3 -2
View File
@@ -5,7 +5,7 @@ jobs:
Main:
# We always process desktop release tags, because they also publish the release
if: github.repository == 'laurent22/joplin'
runs-on: macos-latest
runs-on: macos-15
steps:
- uses: actions/checkout@v6
@@ -53,7 +53,8 @@ jobs:
run: |
export npm_config_arch=arm64
export npm_config_target_arch=arm64
yarn install
source "${GITHUB_WORKSPACE}/.github/scripts/retry.sh"
retry yarn install
cd packages/app-desktop
npm pkg set 'build.mac.artifactName'='${productName}-${version}-${arch}.${ext}'
+2 -1
View File
@@ -178,7 +178,8 @@ jobs:
echo "RUNNER_ARCH=$RUNNER_ARCH"
echo "DOCKER_IMAGE_PLATFORM=$DOCKER_IMAGE_PLATFORM"
yarn install
source "${GITHUB_WORKSPACE}/.github/scripts/retry.sh"
retry yarn install
yarn buildServerDocker --docker-file Dockerfile.server --platform $DOCKER_IMAGE_PLATFORM --tag-name server-v0.0.0 --repository joplin/server
# Basic test to ensure that the created build is valid. It should exit with
+6 -2
View File
@@ -25,7 +25,9 @@ jobs:
Install-WindowsDeps
- name: Build
if: runner.os != 'Windows'
run: yarn install
run: |
source "${GITHUB_WORKSPACE}/.github/scripts/retry.sh"
retry yarn install
env:
# The onenote-converter package uses Rust, which isn't installed on all CI
# runners. Since the onenote-converter doesn't have UI tests, it can be excluded
@@ -51,7 +53,9 @@ jobs:
- name: Setup build environment
uses: ./.github/workflows/shared/setup-build-environment
- name: Build
run: yarn install
run: |
source "${GITHUB_WORKSPACE}/.github/scripts/retry.sh"
retry yarn install
env:
SKIP_ONENOTE_CONVERTER_BUILD: 1
- name: Run mobile UI tests
+74 -21
View File
@@ -71,6 +71,8 @@ docs/**/*.mustache
!.yarn/sdks
!.yarn/versions
packages/generator-joplin/generators/app/templates/script/publish/dist/
# AUTO-GENERATED - EXCLUDED TYPESCRIPT BUILD
packages/app-cli/app/LinkSelector.js
packages/app-cli/app/ResourceServer.js
@@ -83,6 +85,7 @@ packages/app-cli/app/cli-utils.js
packages/app-cli/app/command-apidoc.js
packages/app-cli/app/command-attach.js
packages/app-cli/app/command-batch.js
packages/app-cli/app/command-cat.test.js
packages/app-cli/app/command-cat.js
packages/app-cli/app/command-clear.js
packages/app-cli/app/command-config.js
@@ -91,6 +94,7 @@ packages/app-cli/app/command-done.test.js
packages/app-cli/app/command-done.js
packages/app-cli/app/command-dump.js
packages/app-cli/app/command-e2ee.js
packages/app-cli/app/command-edit.test.js
packages/app-cli/app/command-edit.js
packages/app-cli/app/command-exit.js
packages/app-cli/app/command-export-sync-status.js
@@ -115,6 +119,7 @@ packages/app-cli/app/command-rmnote.test.js
packages/app-cli/app/command-rmnote.js
packages/app-cli/app/command-search.js
packages/app-cli/app/command-server.js
packages/app-cli/app/command-set.test.js
packages/app-cli/app/command-set.js
packages/app-cli/app/command-settingschema.js
packages/app-cli/app/command-share.test.js
@@ -247,6 +252,8 @@ packages/app-desktop/gui/KeymapConfig/utils/useCommandStatus.js
packages/app-desktop/gui/KeymapConfig/utils/useKeymap.js
packages/app-desktop/gui/MainLayoutPane.js
packages/app-desktop/gui/MainScreen.js
packages/app-desktop/gui/MainScreen/handleCallbackUrl.test.js
packages/app-desktop/gui/MainScreen/handleCallbackUrl.js
packages/app-desktop/gui/MasterPasswordDialog/Dialog.js
packages/app-desktop/gui/MenuBar.js
packages/app-desktop/gui/MultiNoteActions.js
@@ -299,6 +306,7 @@ 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/useHighlightedSearchTerms.js
packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/utils/useKeyboardRefocusHandler.js
packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/utils/useLinkTooltips.js
packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/utils/useScroll.js
@@ -315,10 +323,13 @@ packages/app-desktop/gui/NoteEditor/NoteBody/WhiteboardEditor/nodes/GroupNode.js
packages/app-desktop/gui/NoteEditor/NoteBody/WhiteboardEditor/nodes/LinkNode.js
packages/app-desktop/gui/NoteEditor/NoteBody/WhiteboardEditor/nodes/TextNode.js
packages/app-desktop/gui/NoteEditor/NoteBody/WhiteboardEditor/nodes/handlePositions.js
packages/app-desktop/gui/NoteEditor/NoteBody/WhiteboardEditor/nodes/stripUnsafeStyles.test.js
packages/app-desktop/gui/NoteEditor/NoteBody/WhiteboardEditor/nodes/stripUnsafeStyles.js
packages/app-desktop/gui/NoteEditor/NoteBody/WhiteboardEditor/nodes/useCardWheel.js
packages/app-desktop/gui/NoteEditor/NoteBody/WhiteboardEditor/useCheckboxToggle.test.js
packages/app-desktop/gui/NoteEditor/NoteBody/WhiteboardEditor/useCheckboxToggle.js
packages/app-desktop/gui/NoteEditor/NoteEditor.js
packages/app-desktop/gui/NoteEditor/NoteLockPanel/NoteLockPanel.js
packages/app-desktop/gui/NoteEditor/NoteTitle/NoteTitleBar.js
packages/app-desktop/gui/NoteEditor/StatusBar.js
packages/app-desktop/gui/NoteEditor/WarningBanner/BannerContent.js
@@ -361,6 +372,7 @@ packages/app-desktop/gui/NoteEditor/utils/usePluginServiceRegistration.js
packages/app-desktop/gui/NoteEditor/utils/useResourceUnwatcher.js
packages/app-desktop/gui/NoteEditor/utils/useScheduleSaveCallbacks.js
packages/app-desktop/gui/NoteEditor/utils/useScrollWhenReadyOptions.js
packages/app-desktop/gui/NoteEditor/utils/useSearchMarkers.test.js
packages/app-desktop/gui/NoteEditor/utils/useSearchMarkers.js
packages/app-desktop/gui/NoteEditor/utils/useWindowCommandHandler.js
packages/app-desktop/gui/NoteList/NoteList2.js
@@ -408,6 +420,7 @@ packages/app-desktop/gui/NoteListItem/utils/useRenderedNote.js
packages/app-desktop/gui/NoteListItem/utils/useRootElement.test.js
packages/app-desktop/gui/NoteListItem/utils/useRootElement.js
packages/app-desktop/gui/NoteListWrapper/NoteListWrapper.js
packages/app-desktop/gui/NoteLockUnlockDialog/Dialog.js
packages/app-desktop/gui/NotePropertiesDialog.js
packages/app-desktop/gui/NoteRevisionViewer.js
packages/app-desktop/gui/NoteSearchBar.js
@@ -502,10 +515,11 @@ packages/app-desktop/gui/WindowCommandsAndDialogs/WindowCommandsAndDialogs.js
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/addNoteToWhiteboard.js
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/addProfile.js
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/commandPalette.js
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/copyNoteExternalLink.js
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/deleteFolder.js
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/disableNoteEncryption.js
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/duplicateNote.js
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/editAlarm.js
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/enableNoteEncryption.js
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/exportPdf.js
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/globalRedo.js
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/globalUndo.js
@@ -514,6 +528,7 @@ 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/lockEncryptedNotes.js
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/moveToFolder.js
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/newFolder.js
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/newNote.test.js
@@ -587,15 +602,20 @@ packages/app-desktop/gui/utils/convertToScreenCoordinates.js
packages/app-desktop/gui/utils/dragAndDrop.js
packages/app-desktop/gui/utils/loadScript.js
packages/app-desktop/gulpfile.js
packages/app-desktop/integration-tests/chatPanel.spec.js
packages/app-desktop/integration-tests/goToAnything.spec.js
packages/app-desktop/integration-tests/main.spec.js
packages/app-desktop/integration-tests/markdownEditor.spec.js
packages/app-desktop/integration-tests/models/ChangeAppLayoutScreen.js
packages/app-desktop/integration-tests/models/ChatPanel.js
packages/app-desktop/integration-tests/models/EditorCodeDialog.js
packages/app-desktop/integration-tests/models/GoToAnything.js
packages/app-desktop/integration-tests/models/MainScreen.js
packages/app-desktop/integration-tests/models/NoteEditorScreen.js
packages/app-desktop/integration-tests/models/NoteEditor/BaseEditor.js
packages/app-desktop/integration-tests/models/NoteEditor/EditorCodeDialog.js
packages/app-desktop/integration-tests/models/NoteEditor/MarkdownEditor.js
packages/app-desktop/integration-tests/models/NoteEditor/NoteEditorScreen.js
packages/app-desktop/integration-tests/models/NoteEditor/NoteViewer.js
packages/app-desktop/integration-tests/models/NoteEditor/RichTextEditor.js
packages/app-desktop/integration-tests/models/NoteList.js
packages/app-desktop/integration-tests/models/SettingsScreen.js
packages/app-desktop/integration-tests/models/Sidebar.js
@@ -695,6 +715,7 @@ packages/app-mobile/commands/util/goToNote.js
packages/app-mobile/commands/util/showResource.js
packages/app-mobile/components/BetaChip.js
packages/app-mobile/components/BottomDrawer.js
packages/app-mobile/components/BottomDrawerMenu.js
packages/app-mobile/components/CameraView/ActionButtons.js
packages/app-mobile/components/CameraView/Camera/index.jest.js
packages/app-mobile/components/CameraView/Camera/index.js
@@ -856,6 +877,8 @@ packages/app-mobile/components/screens/ConfigScreen/NoteExportSection/utils/expo
packages/app-mobile/components/screens/ConfigScreen/NoteExportSection/utils/exportDebugReport.js
packages/app-mobile/components/screens/ConfigScreen/NoteExportSection/utils/exportProfile.js
packages/app-mobile/components/screens/ConfigScreen/NoteExportSection/utils/makeImportExportCacheDirectory.js
packages/app-mobile/components/screens/ConfigScreen/NoteLockConfig.test.js
packages/app-mobile/components/screens/ConfigScreen/NoteLockConfig.js
packages/app-mobile/components/screens/ConfigScreen/SectionDescription.js
packages/app-mobile/components/screens/ConfigScreen/SectionHeader.js
packages/app-mobile/components/screens/ConfigScreen/SectionSelector/SectionTab.js
@@ -1111,8 +1134,6 @@ packages/editor/CodeMirror/extensions/links/ctrlClickLinksExtension.js
packages/editor/CodeMirror/extensions/links/followLinkTooltipExtension.test.js
packages/editor/CodeMirror/extensions/links/followLinkTooltipExtension.js
packages/editor/CodeMirror/extensions/links/referenceLinksStateField.js
packages/editor/CodeMirror/extensions/links/utils/findLineMatchingLink.test.js
packages/editor/CodeMirror/extensions/links/utils/findLineMatchingLink.js
packages/editor/CodeMirror/extensions/links/utils/getUrlAtPosition.js
packages/editor/CodeMirror/extensions/links/utils/openLink.js
packages/editor/CodeMirror/extensions/markdownDecorationExtension.test.js
@@ -1143,6 +1164,8 @@ packages/editor/CodeMirror/extensions/rendering/replaceLinks.test.js
packages/editor/CodeMirror/extensions/rendering/replaceLinks.js
packages/editor/CodeMirror/extensions/rendering/types.js
packages/editor/CodeMirror/extensions/rendering/utils/makeBlockReplaceExtension.js
packages/editor/CodeMirror/extensions/rendering/utils/makeHideReplaceExtension.js
packages/editor/CodeMirror/extensions/rendering/utils/makeInlineReplaceExtension.test.js
packages/editor/CodeMirror/extensions/rendering/utils/makeInlineReplaceExtension.js
packages/editor/CodeMirror/extensions/rendering/utils/nodeIntersectsSelection.js
packages/editor/CodeMirror/extensions/searchExtension.test.js
@@ -1161,7 +1184,10 @@ packages/editor/CodeMirror/testing/forceFullParse.js
packages/editor/CodeMirror/testing/loadLanguages.js
packages/editor/CodeMirror/testing/pressReleaseKey.js
packages/editor/CodeMirror/testing/typeText.js
packages/editor/CodeMirror/testing/visibleEditorText.js
packages/editor/CodeMirror/theme.js
packages/editor/CodeMirror/utils/findPositionMatchingLink.test.js
packages/editor/CodeMirror/utils/findPositionMatchingLink.js
packages/editor/CodeMirror/utils/formatting/RegionSpec.js
packages/editor/CodeMirror/utils/formatting/computeSelectionFormatting.test.js
packages/editor/CodeMirror/utils/formatting/computeSelectionFormatting.js
@@ -1290,9 +1316,6 @@ packages/generator-joplin/generators/app/templates/api/index.js
packages/generator-joplin/generators/app/templates/api/noteListType.js
packages/generator-joplin/generators/app/templates/api/types.js
packages/generator-joplin/generators/app/templates/api_index.js
packages/generator-joplin/generators/app/templates/script/publish/steps/verifyBuild.js
packages/generator-joplin/generators/app/templates/script/publish/steps/verifyGitState.js
packages/generator-joplin/generators/app/templates/script/publish/utils/logger.js
packages/generator-joplin/generators/app/templates/src/index.js
packages/generator-joplin/tools/updateCategories.js
packages/htmlpack/index.test.js
@@ -1310,6 +1333,7 @@ packages/lib/BaseModel.test.js
packages/lib/BaseModel.js
packages/lib/BaseSyncTarget.js
packages/lib/Cache.js
packages/lib/ClipperServer.test.js
packages/lib/ClipperServer.js
packages/lib/CssUtils.js
packages/lib/EventDispatcher.test.js
@@ -1380,6 +1404,7 @@ packages/lib/components/shared/SamlShared.js
packages/lib/components/shared/ShareNoteDialog/onUnshareNoteClick.js
packages/lib/components/shared/ShareNoteDialog/types.js
packages/lib/components/shared/ShareNoteDialog/useEncryptionWarningMessage.js
packages/lib/components/shared/ShareNoteDialog/useIsPublished.js
packages/lib/components/shared/ShareNoteDialog/useOnShareLinkClick.js
packages/lib/components/shared/ShareNoteDialog/useShareStatusMessage.js
packages/lib/components/shared/SsoScreenShared.js
@@ -1394,6 +1419,7 @@ packages/lib/components/shared/config/plugins/useOnInstallHandler.js
packages/lib/components/shared/config/shouldShowMissingPasswordWarning.test.js
packages/lib/components/shared/config/shouldShowMissingPasswordWarning.js
packages/lib/components/shared/dropbox-login-shared.js
packages/lib/components/shared/note-screen-shared.test.js
packages/lib/components/shared/note-screen-shared.js
packages/lib/components/shared/reduxSharedMiddleware.js
packages/lib/components/shared/side-menu-shared.test.js
@@ -1469,6 +1495,7 @@ packages/lib/models/Alarm.js
packages/lib/models/BaseItem.test.js
packages/lib/models/BaseItem.js
packages/lib/models/ConflictNoteState.js
packages/lib/models/Folder.publishing.test.js
packages/lib/models/Folder.sharing.test.js
packages/lib/models/Folder.test.js
packages/lib/models/Folder.js
@@ -1586,13 +1613,13 @@ packages/lib/services/ai/SearchService.js
packages/lib/services/ai/SqliteVec.test.js
packages/lib/services/ai/aiSettingsTransition.test.js
packages/lib/services/ai/aiSettingsTransition.js
packages/lib/services/ai/applyNoteEdits.js
packages/lib/services/ai/availability.test.js
packages/lib/services/ai/availability.js
packages/lib/services/ai/chunker.test.js
packages/lib/services/ai/chunker.js
packages/lib/services/ai/classification.js
packages/lib/services/ai/noteChat.test.js
packages/lib/services/ai/noteChat.tools.test.js
packages/lib/services/ai/noteChat.js
packages/lib/services/ai/providers/Anthropic.test.js
packages/lib/services/ai/providers/Anthropic.js
@@ -1603,6 +1630,26 @@ packages/lib/services/ai/providers/OpenAiCompatible.js
packages/lib/services/ai/providers/TestProvider.js
packages/lib/services/ai/testing/TestEmbeddingProvider.test.js
packages/lib/services/ai/testing/TestEmbeddingProvider.js
packages/lib/services/ai/tools/ToolIndex.js
packages/lib/services/ai/tools/buildEditorTools.js
packages/lib/services/ai/tools/global/createNote.js
packages/lib/services/ai/tools/global/createNotebook.js
packages/lib/services/ai/tools/global/deleteNote.js
packages/lib/services/ai/tools/global/index.js
packages/lib/services/ai/tools/global/listNotebooks.js
packages/lib/services/ai/tools/global/listTags.js
packages/lib/services/ai/tools/global/manageTags.js
packages/lib/services/ai/tools/global/readImage.js
packages/lib/services/ai/tools/global/readNote.js
packages/lib/services/ai/tools/global/searchNotes.js
packages/lib/services/ai/tools/global/semanticSearchNotes.js
packages/lib/services/ai/tools/global/updateNote.js
packages/lib/services/ai/tools/types.js
packages/lib/services/ai/tools/utils/applyNoteEdits.test.js
packages/lib/services/ai/tools/utils/applyNoteEdits.js
packages/lib/services/ai/tools/utils/buildTool.js
packages/lib/services/ai/tools/utils/serializeToolOutput.js
packages/lib/services/ai/tools/utils/settings.js
packages/lib/services/ai/types.js
packages/lib/services/ai/utils/findFencedBlock.js
packages/lib/services/commands/MenuUtils.js
@@ -1614,6 +1661,13 @@ packages/lib/services/commands/isEditorCommand.js
packages/lib/services/commands/propsHaveChanged.js
packages/lib/services/commands/stateToWhenClauseContext.test.js
packages/lib/services/commands/stateToWhenClauseContext.js
packages/lib/services/conflict/diffNotes.test.js
packages/lib/services/conflict/diffNotes.js
packages/lib/services/conflict/isConflictResolutionEnabled.js
packages/lib/services/conflict/loadConflictData.test.js
packages/lib/services/conflict/loadConflictData.js
packages/lib/services/conflict/wordDiff.test.js
packages/lib/services/conflict/wordDiff.js
packages/lib/services/contextkey/contextkey.js
packages/lib/services/database/addMigrationFile.js
packages/lib/services/database/isSqliteSyntaxError.js
@@ -1666,12 +1720,15 @@ packages/lib/services/interop/InteropService_Exporter_Raw.js
packages/lib/services/interop/InteropService_Importer_Base.js
packages/lib/services/interop/InteropService_Importer_Custom.js
packages/lib/services/interop/InteropService_Importer_EnexToHtml.js
packages/lib/services/interop/InteropService_Importer_EnexToMd.test.js
packages/lib/services/interop/InteropService_Importer_EnexToMd.js
packages/lib/services/interop/InteropService_Importer_Jex.js
packages/lib/services/interop/InteropService_Importer_Md.test.js
packages/lib/services/interop/InteropService_Importer_Md.js
packages/lib/services/interop/InteropService_Importer_Md_frontmatter.test.js
packages/lib/services/interop/InteropService_Importer_Md_frontmatter.js
packages/lib/services/interop/InteropService_Importer_Obsidian.test.js
packages/lib/services/interop/InteropService_Importer_Obsidian.js
packages/lib/services/interop/InteropService_Importer_OneNote.postprocessHtml.test.js
packages/lib/services/interop/InteropService_Importer_OneNote.test.js
packages/lib/services/interop/InteropService_Importer_OneNote.js
@@ -1693,17 +1750,6 @@ packages/lib/services/keychain/KeychainServiceDriver.node.js
packages/lib/services/keychain/KeychainServiceDriverBase.js
packages/lib/services/mcp/McpServer.test.js
packages/lib/services/mcp/McpServer.js
packages/lib/services/mcp/registry.js
packages/lib/services/mcp/tools/createNote.js
packages/lib/services/mcp/tools/createNotebook.js
packages/lib/services/mcp/tools/deleteNote.js
packages/lib/services/mcp/tools/listNotebooks.js
packages/lib/services/mcp/tools/listTags.js
packages/lib/services/mcp/tools/manageTags.js
packages/lib/services/mcp/tools/readNote.js
packages/lib/services/mcp/tools/searchNotes.js
packages/lib/services/mcp/tools/semanticSearchNotes.js
packages/lib/services/mcp/tools/updateNote.js
packages/lib/services/mcp/types.js
packages/lib/services/noteList/checkboxPieCss.js
packages/lib/services/noteList/defaultLeftToRightListRenderer.js
@@ -1721,7 +1767,10 @@ packages/lib/services/noteLock/NoteLockService.test.js
packages/lib/services/noteLock/NoteLockService.js
packages/lib/services/noteLock/NoteLockSession.test.js
packages/lib/services/noteLock/NoteLockSession.js
packages/lib/services/noteLock/hasLockedNoteWhileSessionLocked.js
packages/lib/services/noteLock/isNoteLockEnabled.js
packages/lib/services/noteLock/setNoteLockState.test.js
packages/lib/services/noteLock/setNoteLockState.js
packages/lib/services/ocr/OcrDriverBase.js
packages/lib/services/ocr/OcrService.test.js
packages/lib/services/ocr/OcrService.js
@@ -1824,6 +1873,7 @@ packages/lib/services/rest/routes/notes.test.js
packages/lib/services/rest/routes/notes.js
packages/lib/services/rest/routes/ping.js
packages/lib/services/rest/routes/resources.js
packages/lib/services/rest/routes/revisions.test.js
packages/lib/services/rest/routes/revisions.js
packages/lib/services/rest/routes/search.js
packages/lib/services/rest/routes/tags.js
@@ -1837,6 +1887,7 @@ packages/lib/services/rest/utils/readonlyProperties.js
packages/lib/services/rest/utils/requestFields.js
packages/lib/services/rest/utils/requestPaginationOptions.js
packages/lib/services/search/SearchEngine.resources.test.js
packages/lib/services/search/SearchEngine.semantic.test.js
packages/lib/services/search/SearchEngine.test.js
packages/lib/services/search/SearchEngine.js
packages/lib/services/search/SearchEngineUtils.test.js
@@ -1917,6 +1968,7 @@ packages/lib/shim-init-node.js
packages/lib/shim.js
packages/lib/string-utils.test.js
packages/lib/string-utils.js
packages/lib/testing/ai/semanticSearch.js
packages/lib/testing/dom-test-environment.js
packages/lib/testing/plugins/createTestPlugin.js
packages/lib/testing/share/makeMockShareInvitation.js
@@ -2072,6 +2124,7 @@ packages/tools/checkIgnoredFiles.js
packages/tools/checkLibPaths.test.js
packages/tools/checkLibPaths.js
packages/tools/convertThemesToCss.js
packages/tools/eslint-rules/describeFilename.test.js
packages/tools/fuzzer/ActionRunner.js
packages/tools/fuzzer/Fuzzer.js
packages/tools/fuzzer/cli.js
@@ -2149,8 +2202,8 @@ packages/tools/updateMarkdownDoc.js
packages/tools/utils/discourse.test.js
packages/tools/utils/discourse.js
packages/tools/utils/loadSponsors.js
packages/tools/utils/parsePluralLocalizationForm.test.js
packages/tools/utils/parsePluralLocalizationForm.js
packages/tools/utils/parsePlurallLocalizationForm.test.js
packages/tools/utils/translation.js
packages/tools/validateFilenames.js
packages/tools/website/build.js
+74 -21
View File
@@ -8,6 +8,7 @@ _releases/
**/node_modules/
**/abcjs-basic-min.js
packages/generator-joplin/generators/app/templates/api/
packages/generator-joplin/generators/app/templates/script/publish/dist/
Assets/
docs/
highlight.pack.js
@@ -82,6 +83,7 @@ packages/lib/welcomeAssets.js
packages/editor/*/vendor/
packages/plugins/**/api
packages/plugins/**/dist
packages/plugin-repo-cli/dist/
packages/server/dist/
packages/utils/dist/
packages/transcribe/dist/
@@ -109,6 +111,7 @@ packages/app-cli/app/cli-utils.js
packages/app-cli/app/command-apidoc.js
packages/app-cli/app/command-attach.js
packages/app-cli/app/command-batch.js
packages/app-cli/app/command-cat.test.js
packages/app-cli/app/command-cat.js
packages/app-cli/app/command-clear.js
packages/app-cli/app/command-config.js
@@ -117,6 +120,7 @@ packages/app-cli/app/command-done.test.js
packages/app-cli/app/command-done.js
packages/app-cli/app/command-dump.js
packages/app-cli/app/command-e2ee.js
packages/app-cli/app/command-edit.test.js
packages/app-cli/app/command-edit.js
packages/app-cli/app/command-exit.js
packages/app-cli/app/command-export-sync-status.js
@@ -141,6 +145,7 @@ packages/app-cli/app/command-rmnote.test.js
packages/app-cli/app/command-rmnote.js
packages/app-cli/app/command-search.js
packages/app-cli/app/command-server.js
packages/app-cli/app/command-set.test.js
packages/app-cli/app/command-set.js
packages/app-cli/app/command-settingschema.js
packages/app-cli/app/command-share.test.js
@@ -273,6 +278,8 @@ packages/app-desktop/gui/KeymapConfig/utils/useCommandStatus.js
packages/app-desktop/gui/KeymapConfig/utils/useKeymap.js
packages/app-desktop/gui/MainLayoutPane.js
packages/app-desktop/gui/MainScreen.js
packages/app-desktop/gui/MainScreen/handleCallbackUrl.test.js
packages/app-desktop/gui/MainScreen/handleCallbackUrl.js
packages/app-desktop/gui/MasterPasswordDialog/Dialog.js
packages/app-desktop/gui/MenuBar.js
packages/app-desktop/gui/MultiNoteActions.js
@@ -325,6 +332,7 @@ 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/useHighlightedSearchTerms.js
packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/utils/useKeyboardRefocusHandler.js
packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/utils/useLinkTooltips.js
packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/utils/useScroll.js
@@ -341,10 +349,13 @@ packages/app-desktop/gui/NoteEditor/NoteBody/WhiteboardEditor/nodes/GroupNode.js
packages/app-desktop/gui/NoteEditor/NoteBody/WhiteboardEditor/nodes/LinkNode.js
packages/app-desktop/gui/NoteEditor/NoteBody/WhiteboardEditor/nodes/TextNode.js
packages/app-desktop/gui/NoteEditor/NoteBody/WhiteboardEditor/nodes/handlePositions.js
packages/app-desktop/gui/NoteEditor/NoteBody/WhiteboardEditor/nodes/stripUnsafeStyles.test.js
packages/app-desktop/gui/NoteEditor/NoteBody/WhiteboardEditor/nodes/stripUnsafeStyles.js
packages/app-desktop/gui/NoteEditor/NoteBody/WhiteboardEditor/nodes/useCardWheel.js
packages/app-desktop/gui/NoteEditor/NoteBody/WhiteboardEditor/useCheckboxToggle.test.js
packages/app-desktop/gui/NoteEditor/NoteBody/WhiteboardEditor/useCheckboxToggle.js
packages/app-desktop/gui/NoteEditor/NoteEditor.js
packages/app-desktop/gui/NoteEditor/NoteLockPanel/NoteLockPanel.js
packages/app-desktop/gui/NoteEditor/NoteTitle/NoteTitleBar.js
packages/app-desktop/gui/NoteEditor/StatusBar.js
packages/app-desktop/gui/NoteEditor/WarningBanner/BannerContent.js
@@ -387,6 +398,7 @@ packages/app-desktop/gui/NoteEditor/utils/usePluginServiceRegistration.js
packages/app-desktop/gui/NoteEditor/utils/useResourceUnwatcher.js
packages/app-desktop/gui/NoteEditor/utils/useScheduleSaveCallbacks.js
packages/app-desktop/gui/NoteEditor/utils/useScrollWhenReadyOptions.js
packages/app-desktop/gui/NoteEditor/utils/useSearchMarkers.test.js
packages/app-desktop/gui/NoteEditor/utils/useSearchMarkers.js
packages/app-desktop/gui/NoteEditor/utils/useWindowCommandHandler.js
packages/app-desktop/gui/NoteList/NoteList2.js
@@ -434,6 +446,7 @@ packages/app-desktop/gui/NoteListItem/utils/useRenderedNote.js
packages/app-desktop/gui/NoteListItem/utils/useRootElement.test.js
packages/app-desktop/gui/NoteListItem/utils/useRootElement.js
packages/app-desktop/gui/NoteListWrapper/NoteListWrapper.js
packages/app-desktop/gui/NoteLockUnlockDialog/Dialog.js
packages/app-desktop/gui/NotePropertiesDialog.js
packages/app-desktop/gui/NoteRevisionViewer.js
packages/app-desktop/gui/NoteSearchBar.js
@@ -528,10 +541,11 @@ packages/app-desktop/gui/WindowCommandsAndDialogs/WindowCommandsAndDialogs.js
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/addNoteToWhiteboard.js
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/addProfile.js
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/commandPalette.js
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/copyNoteExternalLink.js
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/deleteFolder.js
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/disableNoteEncryption.js
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/duplicateNote.js
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/editAlarm.js
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/enableNoteEncryption.js
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/exportPdf.js
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/globalRedo.js
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/globalUndo.js
@@ -540,6 +554,7 @@ 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/lockEncryptedNotes.js
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/moveToFolder.js
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/newFolder.js
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/newNote.test.js
@@ -613,15 +628,20 @@ packages/app-desktop/gui/utils/convertToScreenCoordinates.js
packages/app-desktop/gui/utils/dragAndDrop.js
packages/app-desktop/gui/utils/loadScript.js
packages/app-desktop/gulpfile.js
packages/app-desktop/integration-tests/chatPanel.spec.js
packages/app-desktop/integration-tests/goToAnything.spec.js
packages/app-desktop/integration-tests/main.spec.js
packages/app-desktop/integration-tests/markdownEditor.spec.js
packages/app-desktop/integration-tests/models/ChangeAppLayoutScreen.js
packages/app-desktop/integration-tests/models/ChatPanel.js
packages/app-desktop/integration-tests/models/EditorCodeDialog.js
packages/app-desktop/integration-tests/models/GoToAnything.js
packages/app-desktop/integration-tests/models/MainScreen.js
packages/app-desktop/integration-tests/models/NoteEditorScreen.js
packages/app-desktop/integration-tests/models/NoteEditor/BaseEditor.js
packages/app-desktop/integration-tests/models/NoteEditor/EditorCodeDialog.js
packages/app-desktop/integration-tests/models/NoteEditor/MarkdownEditor.js
packages/app-desktop/integration-tests/models/NoteEditor/NoteEditorScreen.js
packages/app-desktop/integration-tests/models/NoteEditor/NoteViewer.js
packages/app-desktop/integration-tests/models/NoteEditor/RichTextEditor.js
packages/app-desktop/integration-tests/models/NoteList.js
packages/app-desktop/integration-tests/models/SettingsScreen.js
packages/app-desktop/integration-tests/models/Sidebar.js
@@ -721,6 +741,7 @@ packages/app-mobile/commands/util/goToNote.js
packages/app-mobile/commands/util/showResource.js
packages/app-mobile/components/BetaChip.js
packages/app-mobile/components/BottomDrawer.js
packages/app-mobile/components/BottomDrawerMenu.js
packages/app-mobile/components/CameraView/ActionButtons.js
packages/app-mobile/components/CameraView/Camera/index.jest.js
packages/app-mobile/components/CameraView/Camera/index.js
@@ -882,6 +903,8 @@ packages/app-mobile/components/screens/ConfigScreen/NoteExportSection/utils/expo
packages/app-mobile/components/screens/ConfigScreen/NoteExportSection/utils/exportDebugReport.js
packages/app-mobile/components/screens/ConfigScreen/NoteExportSection/utils/exportProfile.js
packages/app-mobile/components/screens/ConfigScreen/NoteExportSection/utils/makeImportExportCacheDirectory.js
packages/app-mobile/components/screens/ConfigScreen/NoteLockConfig.test.js
packages/app-mobile/components/screens/ConfigScreen/NoteLockConfig.js
packages/app-mobile/components/screens/ConfigScreen/SectionDescription.js
packages/app-mobile/components/screens/ConfigScreen/SectionHeader.js
packages/app-mobile/components/screens/ConfigScreen/SectionSelector/SectionTab.js
@@ -1137,8 +1160,6 @@ packages/editor/CodeMirror/extensions/links/ctrlClickLinksExtension.js
packages/editor/CodeMirror/extensions/links/followLinkTooltipExtension.test.js
packages/editor/CodeMirror/extensions/links/followLinkTooltipExtension.js
packages/editor/CodeMirror/extensions/links/referenceLinksStateField.js
packages/editor/CodeMirror/extensions/links/utils/findLineMatchingLink.test.js
packages/editor/CodeMirror/extensions/links/utils/findLineMatchingLink.js
packages/editor/CodeMirror/extensions/links/utils/getUrlAtPosition.js
packages/editor/CodeMirror/extensions/links/utils/openLink.js
packages/editor/CodeMirror/extensions/markdownDecorationExtension.test.js
@@ -1169,6 +1190,8 @@ packages/editor/CodeMirror/extensions/rendering/replaceLinks.test.js
packages/editor/CodeMirror/extensions/rendering/replaceLinks.js
packages/editor/CodeMirror/extensions/rendering/types.js
packages/editor/CodeMirror/extensions/rendering/utils/makeBlockReplaceExtension.js
packages/editor/CodeMirror/extensions/rendering/utils/makeHideReplaceExtension.js
packages/editor/CodeMirror/extensions/rendering/utils/makeInlineReplaceExtension.test.js
packages/editor/CodeMirror/extensions/rendering/utils/makeInlineReplaceExtension.js
packages/editor/CodeMirror/extensions/rendering/utils/nodeIntersectsSelection.js
packages/editor/CodeMirror/extensions/searchExtension.test.js
@@ -1187,7 +1210,10 @@ packages/editor/CodeMirror/testing/forceFullParse.js
packages/editor/CodeMirror/testing/loadLanguages.js
packages/editor/CodeMirror/testing/pressReleaseKey.js
packages/editor/CodeMirror/testing/typeText.js
packages/editor/CodeMirror/testing/visibleEditorText.js
packages/editor/CodeMirror/theme.js
packages/editor/CodeMirror/utils/findPositionMatchingLink.test.js
packages/editor/CodeMirror/utils/findPositionMatchingLink.js
packages/editor/CodeMirror/utils/formatting/RegionSpec.js
packages/editor/CodeMirror/utils/formatting/computeSelectionFormatting.test.js
packages/editor/CodeMirror/utils/formatting/computeSelectionFormatting.js
@@ -1316,9 +1342,6 @@ packages/generator-joplin/generators/app/templates/api/index.js
packages/generator-joplin/generators/app/templates/api/noteListType.js
packages/generator-joplin/generators/app/templates/api/types.js
packages/generator-joplin/generators/app/templates/api_index.js
packages/generator-joplin/generators/app/templates/script/publish/steps/verifyBuild.js
packages/generator-joplin/generators/app/templates/script/publish/steps/verifyGitState.js
packages/generator-joplin/generators/app/templates/script/publish/utils/logger.js
packages/generator-joplin/generators/app/templates/src/index.js
packages/generator-joplin/tools/updateCategories.js
packages/htmlpack/index.test.js
@@ -1336,6 +1359,7 @@ packages/lib/BaseModel.test.js
packages/lib/BaseModel.js
packages/lib/BaseSyncTarget.js
packages/lib/Cache.js
packages/lib/ClipperServer.test.js
packages/lib/ClipperServer.js
packages/lib/CssUtils.js
packages/lib/EventDispatcher.test.js
@@ -1406,6 +1430,7 @@ packages/lib/components/shared/SamlShared.js
packages/lib/components/shared/ShareNoteDialog/onUnshareNoteClick.js
packages/lib/components/shared/ShareNoteDialog/types.js
packages/lib/components/shared/ShareNoteDialog/useEncryptionWarningMessage.js
packages/lib/components/shared/ShareNoteDialog/useIsPublished.js
packages/lib/components/shared/ShareNoteDialog/useOnShareLinkClick.js
packages/lib/components/shared/ShareNoteDialog/useShareStatusMessage.js
packages/lib/components/shared/SsoScreenShared.js
@@ -1420,6 +1445,7 @@ packages/lib/components/shared/config/plugins/useOnInstallHandler.js
packages/lib/components/shared/config/shouldShowMissingPasswordWarning.test.js
packages/lib/components/shared/config/shouldShowMissingPasswordWarning.js
packages/lib/components/shared/dropbox-login-shared.js
packages/lib/components/shared/note-screen-shared.test.js
packages/lib/components/shared/note-screen-shared.js
packages/lib/components/shared/reduxSharedMiddleware.js
packages/lib/components/shared/side-menu-shared.test.js
@@ -1495,6 +1521,7 @@ packages/lib/models/Alarm.js
packages/lib/models/BaseItem.test.js
packages/lib/models/BaseItem.js
packages/lib/models/ConflictNoteState.js
packages/lib/models/Folder.publishing.test.js
packages/lib/models/Folder.sharing.test.js
packages/lib/models/Folder.test.js
packages/lib/models/Folder.js
@@ -1612,13 +1639,13 @@ packages/lib/services/ai/SearchService.js
packages/lib/services/ai/SqliteVec.test.js
packages/lib/services/ai/aiSettingsTransition.test.js
packages/lib/services/ai/aiSettingsTransition.js
packages/lib/services/ai/applyNoteEdits.js
packages/lib/services/ai/availability.test.js
packages/lib/services/ai/availability.js
packages/lib/services/ai/chunker.test.js
packages/lib/services/ai/chunker.js
packages/lib/services/ai/classification.js
packages/lib/services/ai/noteChat.test.js
packages/lib/services/ai/noteChat.tools.test.js
packages/lib/services/ai/noteChat.js
packages/lib/services/ai/providers/Anthropic.test.js
packages/lib/services/ai/providers/Anthropic.js
@@ -1629,6 +1656,26 @@ packages/lib/services/ai/providers/OpenAiCompatible.js
packages/lib/services/ai/providers/TestProvider.js
packages/lib/services/ai/testing/TestEmbeddingProvider.test.js
packages/lib/services/ai/testing/TestEmbeddingProvider.js
packages/lib/services/ai/tools/ToolIndex.js
packages/lib/services/ai/tools/buildEditorTools.js
packages/lib/services/ai/tools/global/createNote.js
packages/lib/services/ai/tools/global/createNotebook.js
packages/lib/services/ai/tools/global/deleteNote.js
packages/lib/services/ai/tools/global/index.js
packages/lib/services/ai/tools/global/listNotebooks.js
packages/lib/services/ai/tools/global/listTags.js
packages/lib/services/ai/tools/global/manageTags.js
packages/lib/services/ai/tools/global/readImage.js
packages/lib/services/ai/tools/global/readNote.js
packages/lib/services/ai/tools/global/searchNotes.js
packages/lib/services/ai/tools/global/semanticSearchNotes.js
packages/lib/services/ai/tools/global/updateNote.js
packages/lib/services/ai/tools/types.js
packages/lib/services/ai/tools/utils/applyNoteEdits.test.js
packages/lib/services/ai/tools/utils/applyNoteEdits.js
packages/lib/services/ai/tools/utils/buildTool.js
packages/lib/services/ai/tools/utils/serializeToolOutput.js
packages/lib/services/ai/tools/utils/settings.js
packages/lib/services/ai/types.js
packages/lib/services/ai/utils/findFencedBlock.js
packages/lib/services/commands/MenuUtils.js
@@ -1640,6 +1687,13 @@ packages/lib/services/commands/isEditorCommand.js
packages/lib/services/commands/propsHaveChanged.js
packages/lib/services/commands/stateToWhenClauseContext.test.js
packages/lib/services/commands/stateToWhenClauseContext.js
packages/lib/services/conflict/diffNotes.test.js
packages/lib/services/conflict/diffNotes.js
packages/lib/services/conflict/isConflictResolutionEnabled.js
packages/lib/services/conflict/loadConflictData.test.js
packages/lib/services/conflict/loadConflictData.js
packages/lib/services/conflict/wordDiff.test.js
packages/lib/services/conflict/wordDiff.js
packages/lib/services/contextkey/contextkey.js
packages/lib/services/database/addMigrationFile.js
packages/lib/services/database/isSqliteSyntaxError.js
@@ -1692,12 +1746,15 @@ packages/lib/services/interop/InteropService_Exporter_Raw.js
packages/lib/services/interop/InteropService_Importer_Base.js
packages/lib/services/interop/InteropService_Importer_Custom.js
packages/lib/services/interop/InteropService_Importer_EnexToHtml.js
packages/lib/services/interop/InteropService_Importer_EnexToMd.test.js
packages/lib/services/interop/InteropService_Importer_EnexToMd.js
packages/lib/services/interop/InteropService_Importer_Jex.js
packages/lib/services/interop/InteropService_Importer_Md.test.js
packages/lib/services/interop/InteropService_Importer_Md.js
packages/lib/services/interop/InteropService_Importer_Md_frontmatter.test.js
packages/lib/services/interop/InteropService_Importer_Md_frontmatter.js
packages/lib/services/interop/InteropService_Importer_Obsidian.test.js
packages/lib/services/interop/InteropService_Importer_Obsidian.js
packages/lib/services/interop/InteropService_Importer_OneNote.postprocessHtml.test.js
packages/lib/services/interop/InteropService_Importer_OneNote.test.js
packages/lib/services/interop/InteropService_Importer_OneNote.js
@@ -1719,17 +1776,6 @@ packages/lib/services/keychain/KeychainServiceDriver.node.js
packages/lib/services/keychain/KeychainServiceDriverBase.js
packages/lib/services/mcp/McpServer.test.js
packages/lib/services/mcp/McpServer.js
packages/lib/services/mcp/registry.js
packages/lib/services/mcp/tools/createNote.js
packages/lib/services/mcp/tools/createNotebook.js
packages/lib/services/mcp/tools/deleteNote.js
packages/lib/services/mcp/tools/listNotebooks.js
packages/lib/services/mcp/tools/listTags.js
packages/lib/services/mcp/tools/manageTags.js
packages/lib/services/mcp/tools/readNote.js
packages/lib/services/mcp/tools/searchNotes.js
packages/lib/services/mcp/tools/semanticSearchNotes.js
packages/lib/services/mcp/tools/updateNote.js
packages/lib/services/mcp/types.js
packages/lib/services/noteList/checkboxPieCss.js
packages/lib/services/noteList/defaultLeftToRightListRenderer.js
@@ -1747,7 +1793,10 @@ packages/lib/services/noteLock/NoteLockService.test.js
packages/lib/services/noteLock/NoteLockService.js
packages/lib/services/noteLock/NoteLockSession.test.js
packages/lib/services/noteLock/NoteLockSession.js
packages/lib/services/noteLock/hasLockedNoteWhileSessionLocked.js
packages/lib/services/noteLock/isNoteLockEnabled.js
packages/lib/services/noteLock/setNoteLockState.test.js
packages/lib/services/noteLock/setNoteLockState.js
packages/lib/services/ocr/OcrDriverBase.js
packages/lib/services/ocr/OcrService.test.js
packages/lib/services/ocr/OcrService.js
@@ -1850,6 +1899,7 @@ packages/lib/services/rest/routes/notes.test.js
packages/lib/services/rest/routes/notes.js
packages/lib/services/rest/routes/ping.js
packages/lib/services/rest/routes/resources.js
packages/lib/services/rest/routes/revisions.test.js
packages/lib/services/rest/routes/revisions.js
packages/lib/services/rest/routes/search.js
packages/lib/services/rest/routes/tags.js
@@ -1863,6 +1913,7 @@ packages/lib/services/rest/utils/readonlyProperties.js
packages/lib/services/rest/utils/requestFields.js
packages/lib/services/rest/utils/requestPaginationOptions.js
packages/lib/services/search/SearchEngine.resources.test.js
packages/lib/services/search/SearchEngine.semantic.test.js
packages/lib/services/search/SearchEngine.test.js
packages/lib/services/search/SearchEngine.js
packages/lib/services/search/SearchEngineUtils.test.js
@@ -1943,6 +1994,7 @@ packages/lib/shim-init-node.js
packages/lib/shim.js
packages/lib/string-utils.test.js
packages/lib/string-utils.js
packages/lib/testing/ai/semanticSearch.js
packages/lib/testing/dom-test-environment.js
packages/lib/testing/plugins/createTestPlugin.js
packages/lib/testing/share/makeMockShareInvitation.js
@@ -2098,6 +2150,7 @@ packages/tools/checkIgnoredFiles.js
packages/tools/checkLibPaths.test.js
packages/tools/checkLibPaths.js
packages/tools/convertThemesToCss.js
packages/tools/eslint-rules/describeFilename.test.js
packages/tools/fuzzer/ActionRunner.js
packages/tools/fuzzer/Fuzzer.js
packages/tools/fuzzer/cli.js
@@ -2175,8 +2228,8 @@ packages/tools/updateMarkdownDoc.js
packages/tools/utils/discourse.test.js
packages/tools/utils/discourse.js
packages/tools/utils/loadSponsors.js
packages/tools/utils/parsePluralLocalizationForm.test.js
packages/tools/utils/parsePluralLocalizationForm.js
packages/tools/utils/parsePlurallLocalizationForm.test.js
packages/tools/utils/translation.js
packages/tools/validateFilenames.js
packages/tools/website/build.js
@@ -1,90 +0,0 @@
# This patch's goal is to work around an issue in the NSIS uninstaller on Windows:
# - For future uninstallers, this patch backports an upstream commit that changes how
# running copies of the app are found.
# - See https://github.com/electron-userland/electron-builder/pull/8133
# - If an existing uninstaller fails, gives an option to continue with the installation
# despite the failure.
# - Updates "uninstall failed" error messages to state that uninstallation failed (rather
# than incorrectly stating that the issue was with closing the app).
#
# See https://github.com/laurent22/joplin/pull/11541
diff --git a/templates/nsis/include/allowOnlyOneInstallerInstance.nsh b/templates/nsis/include/allowOnlyOneInstallerInstance.nsh
index fe5d45c730f36c9fe8d8cfea12e242e501b67139..97b27fce6798e30e3e631221435f09b3579e77c3 100644
--- a/templates/nsis/include/allowOnlyOneInstallerInstance.nsh
+++ b/templates/nsis/include/allowOnlyOneInstallerInstance.nsh
@@ -42,7 +42,7 @@
${nsProcess::FindProcess} "${_FILE}" ${_ERR}
!else
# find process owned by current user
- nsExec::Exec `%SYSTEMROOT%\System32\cmd.exe /c tasklist /FI "USERNAME eq %USERNAME%" /FI "IMAGENAME eq ${_FILE}" /FO csv | %SYSTEMROOT%\System32\find.exe "${_FILE}"`
+ nsExec::Exec `"$SYSDIR\cmd.exe" /c tasklist /FI "USERNAME eq %USERNAME%" /FI "IMAGENAME eq ${_FILE}" /FO csv | "$SYSDIR\find.exe" "${_FILE}"`
Pop ${_ERR}
!endif
!macroend
@@ -73,7 +73,7 @@
!ifdef INSTALL_MODE_PER_ALL_USERS
nsExec::Exec `taskkill /im "${APP_EXECUTABLE_FILENAME}" /fi "PID ne $pid"`
!else
- nsExec::Exec `%SYSTEMROOT%\System32\cmd.exe /c taskkill /im "${APP_EXECUTABLE_FILENAME}" /fi "PID ne $pid" /fi "USERNAME eq %USERNAME%"`
+ nsExec::Exec `"$SYSDIR\cmd.exe" /c taskkill /im "${APP_EXECUTABLE_FILENAME}" /fi "PID ne $pid" /fi "USERNAME eq %USERNAME%"`
!endif
# to ensure that files are not "in-use"
Sleep 300
@@ -91,7 +91,7 @@
!ifdef INSTALL_MODE_PER_ALL_USERS
nsExec::Exec `taskkill /f /im "${APP_EXECUTABLE_FILENAME}" /fi "PID ne $pid"`
!else
- nsExec::Exec `%SYSTEMROOT%\System32\cmd.exe /c taskkill /f /im "${APP_EXECUTABLE_FILENAME}" /fi "PID ne $pid" /fi "USERNAME eq %USERNAME%"`
+ nsExec::Exec `"$SYSDIR\cmd.exe" /c taskkill /f /im "${APP_EXECUTABLE_FILENAME}" /fi "PID ne $pid" /fi "USERNAME eq %USERNAME%"`
!endif
!insertmacro FIND_PROCESS "${APP_EXECUTABLE_FILENAME}" $R0
${If} $R0 == 0
diff --git a/templates/nsis/include/installUtil.nsh b/templates/nsis/include/installUtil.nsh
index 47367741632726ba0886ac516461dbe98b7aea58..675965762375925a505ca6d8bbb67507ef696c2e 100644
--- a/templates/nsis/include/installUtil.nsh
+++ b/templates/nsis/include/installUtil.nsh
@@ -126,10 +126,11 @@ Function handleUninstallResult
Return
${if} $R0 != 0
- MessageBox MB_OK|MB_ICONEXCLAMATION "$(uninstallFailed): $R0"
+ # MessageBox MB_OK|MB_ICONEXCLAMATION "$(uninstallFailed): $R0"
DetailPrint `Uninstall was not successful. Uninstaller error code: $R0.`
- SetErrorLevel 2
- Quit
+ DetailPrint `Continuing anyway. See https://github.com/laurent22/joplin/pull/11612.`
+ # SetErrorLevel 2
+ # Quit
${endif}
FunctionEnd
@@ -216,11 +217,13 @@ Function uninstallOldVersion
IntOp $R5 $R5 + 1
${if} $R5 > 5
- MessageBox MB_RETRYCANCEL|MB_ICONEXCLAMATION "$(appCannotBeClosed)" /SD IDCANCEL IDRETRY OneMoreAttempt
- Return
+ MessageBox MB_RETRYCANCEL|MB_ICONEXCLAMATION "$(appCannotBeUninstalled)" /SD IDCANCEL IDRETRY ContinueWithoutUninstall
+ Abort ; Exit early
+ ContinueWithoutUninstall:
+ Return
${endIf}
- OneMoreAttempt:
+# OneMoreAttempt: ; Commented out because unused
ExecWait '"$uninstallerFileNameTemp" /S /KEEP_APP_DATA $0 _?=$installationDir' $R0
ifErrors TryInPlace CheckResult
diff --git a/templates/nsis/messages.yml b/templates/nsis/messages.yml
index a1c2847fa48d79f835b30b48e999ccaf3c818657..6884c18d1e77dbd6be114401d23cf5caf3e0dd94 100644
--- a/templates/nsis/messages.yml
+++ b/templates/nsis/messages.yml
@@ -235,3 +235,8 @@ uninstallFailed:
sv: Det gick inte att avinstallera gamla programfiler. Försök att köra installationsprogrammet igen.
uk: Не вдалось видалити старі файли застосунку. Будь ласка, спробуйте запустити встановлювач знов.
zh_TW: 無法俺安裝舊的應用程式檔案。 請嘗試再次執行安裝程式。
+
+
+appCannotBeUninstalled:
+ en: "The old version of ${PRODUCT_NAME} could not be removed. \nClick Retry to skip this step."
+
@@ -0,0 +1,48 @@
# Patched to work around a Windows install issue: On Windows, if an existing uninstaller fails,
# this change gives an option to continue with the installation despite the failure
# (https://github.com/laurent22/joplin/pull/11541).
diff --git a/templates/nsis/include/installUtil.nsh b/templates/nsis/include/installUtil.nsh
index 47367741632726ba0886ac516461dbe98b7aea58..c40a0907688cd8b1b8d09047cb0b380e42110ea1 100644
--- a/templates/nsis/include/installUtil.nsh
+++ b/templates/nsis/include/installUtil.nsh
@@ -128,8 +128,9 @@ Function handleUninstallResult
${if} $R0 != 0
- MessageBox MB_OK|MB_ICONEXCLAMATION "$(uninstallFailed): $R0"
+ # MessageBox MB_OK|MB_ICONEXCLAMATION "$(uninstallFailed): $R0"
DetailPrint `Uninstall was not successful. Uninstaller error code: $R0.`
- SetErrorLevel 2
- Quit
+ DetailPrint `Continuing anyway. See https://github.com/laurent22/joplin/pull/11612.`
+ # SetErrorLevel 2
+ # Quit
${endif}
FunctionEnd
@@ -216,11 +217,13 @@ Function uninstallOldVersion
IntOp $R5 $R5 + 1
${if} $R5 > 5
- MessageBox MB_RETRYCANCEL|MB_ICONEXCLAMATION "$(appCannotBeClosed)" /SD IDCANCEL IDRETRY OneMoreAttempt
- Return
+ MessageBox MB_RETRYCANCEL|MB_ICONEXCLAMATION "$(appCannotBeUninstalled)" /SD IDCANCEL IDRETRY ContinueWithoutUninstall
+ Abort ; Exit early
+ ContinueWithoutUninstall:
+ Return
${endIf}
- OneMoreAttempt:
+# OneMoreAttempt: ; OneMoreAttempt is unused
ExecWait '"$uninstallerFileNameTemp" /S /KEEP_APP_DATA $0 _?=$installationDir' $R0
ifErrors TryInPlace CheckResult
diff --git a/templates/nsis/messages.yml b/templates/nsis/messages.yml
index 982e1b09e424c4f07ce7c1152fe5ce8b703f5f57..20dfa5743d18f8775ce88658014092cf977ad055 100644
--- a/templates/nsis/messages.yml
+++ b/templates/nsis/messages.yml
@@ -238,3 +238,5 @@ uninstallFailed:
appClosing:
en: "Closing running ${PRODUCT_NAME}..."
cs: "Ukončuji aplikaci ${PRODUCT_NAME}..."
+appCannotBeUninstalled:
+ en: "The old version of ${PRODUCT_NAME} could not be removed. \nClick Retry to skip this step."
\ No newline at end of file
+16 -5
View File
@@ -1212,6 +1212,13 @@ footer .bottom-links-row p {
margin-top: 2em;
}
.price-container p.pricing-table-billed-annually {
font-size: 14px;
font-style: italic;
opacity: 0.7;
margin-top: 0.5em;
}
.price-row {
display: flex;
flex-direction: row;
@@ -1304,6 +1311,15 @@ footer .bottom-links-row p {
opacity: 0.4;
}
.cfa-button {
margin-top: 10px;
}
.cfa-description {
font-style: italic;
text-align: center;
}
@media (max-width: 1400px) {
.price-row .plan-type {
font-size: 16px;
@@ -1392,8 +1408,3 @@ footer .bottom-links-row p {
:lang(zh-cn) #plans-section .faq {
display: none;
}
.cfa-button {
margin-top: 10px;
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 208 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 575 KiB

@@ -40,12 +40,16 @@
{{#pricingTable}}
<strong translate>Pricing</strong>
<table class="table">
<thead>
<tr><th translate>Users</th><th translate>Price</th></tr>
</thead>
<tbody>
{{#rows}}
<tr><td>{{condition}}</td><td>{{priceYearly}}</td></tr>
<tr><td>{{condition}}</td><td>{{priceMonthly}}</td></tr>
{{/rows}}
</tbody>
</table>
<p class="pricing-table-billed-annually">{{billedAnnually}}</p>
{{/pricingTable}}
<p class="text-center subscribe-wrapper">
@@ -54,6 +58,10 @@
{{#learnMoreUrl}}
<a id="learnMore-{{name}}" href="{{learnMoreUrl}}" class="button-link btn-white learnMoreButton cfa-button">Learn more</a>
{{/learnMoreUrl}}
{{#cfaDescription}}
<p class="cfa-description">{{cfaDescription}}</p>
{{/cfaDescription}}
</p>
{{#footnote}}<sub>(*) {{.}}</sub>{{/footnote}}
@@ -88,7 +88,7 @@
Joplin Server Business
</h1>
<p translate class="text-center sub-title">
Joplin Server Business is a synchronisation server that you can install on your own infrastructure, so that your data remains private and secure within your business.
Joplin Server Business is the self-hosted solution for businesses and organisations. Install it on your own infrastructure, keeping your data private and secure within your organisation while retaining full control over where it is stored.
</p>
</div>
</div>
@@ -237,7 +237,7 @@
});
});
const setHostingType = (type) => {
const setHostingType = (type, updateUrl = true) => {
const other = type === 'managed' ? 'self' : 'managed';
$('.toggle-button-' + type).addClass('active');
$('.toggle-button-' + type).removeClass('inactive');
@@ -246,6 +246,12 @@
$('.hosting-type-' + type).show();
$('.hosting-type-' + other).hide();
if (updateUrl) {
const url = new URL(location.href);
url.searchParams.set('hosting', type);
history.replaceState(null, '', url);
}
}
$('.toggle-button-managed').click((event) => {
@@ -261,6 +267,6 @@
});
const initialHostingType = urlQuery.get('hosting') ? urlQuery.get('hosting') : 'managed';
setHostingType(initialHostingType);
setHostingType(initialHostingType, false);
</script>
</div>
+3 -1
View File
@@ -102,13 +102,15 @@ WORKDIR /home/$user/packages/server
ENTRYPOINT ["tini", "--"]
CMD ["yarn", "start-prod"]
ARG IMAGE_NAME="Joplin Server"
ENV APP_NAME=${IMAGE_NAME}
# Build-time metadata
# https://github.com/opencontainers/image-spec/blob/master/annotations.md
ARG BUILD_DATE
ARG REVISION
ARG VERSION
ARG SOURCE
ARG IMAGE_NAME="Joplin Server"
LABEL org.opencontainers.image.created="$BUILD_DATE" \
org.opencontainers.image.title="$IMAGE_NAME" \
org.opencontainers.image.description="Docker image for Joplin Server" \
+21 -18
View File
@@ -172,24 +172,6 @@ elif [[ $ARCHITECTURE =~ .*i386.*|.*i686.* ]]; then
fi
#-----------------------------------------------------
print "Checking dependencies..."
## Check if libfuse2 is present.
if [[ $(command -v ldconfig) ]]; then
LIBFUSE=$(ldconfig -p | grep "libfuse.so.2" || echo '')
fi
if [[ $LIBFUSE == "" ]]; then
LIBFUSE=$(find /lib /usr/lib /lib64 /usr/lib64 /usr/local/lib -name "libfuse.so.2" 2>/dev/null | grep "libfuse.so.2" || echo '')
fi
if [[ $LIBFUSE == "" ]]; then
print "${COLOR_RED}Error: Can't get libfuse2 on system, please install libfuse2${COLOR_RESET}"
print "See https://joplinapp.org/help/faq/#desktop-application-will-not-launch-on-linux and https://github.com/AppImage/AppImageKit/wiki/FUSE for more information"
exit 1
fi
#-----------------------------------------------------
# Download Joplin
#-----------------------------------------------------
# Get the latest version to download
if [[ "$INCLUDE_PRE_RELEASE" == true ]]; then
RELEASE_VERSION=$($DL - "https://api.github.com/repos/laurent22/joplin/releases" | grep -Po '"tag_name": ?"v\K.*?(?=")' | sort -rV | head -1)
@@ -197,6 +179,27 @@ else
RELEASE_VERSION=$($DL - "https://api.github.com/repos/laurent22/joplin/releases/latest" | grep -Po '"tag_name": ?"v\K.*?(?=")')
fi
#-----------------------------------------------------
print "Checking dependencies..."
## Check for libfuse2 for Joplin versions lesser than 3.7.10, which transitioned to a new AppImage runtime without libfuse2 dependency
if [[ $(compareVersions "$RELEASE_VERSION" "3.7.10") -lt 0 ]]; then
if [[ $(command -v ldconfig) ]]; then
LIBFUSE=$(ldconfig -p | grep "libfuse.so.2" || echo '')
fi
if [[ $LIBFUSE == "" ]]; then
LIBFUSE=$(find /lib /usr/lib /lib64 /usr/lib64 /usr/local/lib -name "libfuse.so.2" 2>/dev/null | grep "libfuse.so.2" || echo '')
fi
if [[ $LIBFUSE == "" ]]; then
print "${COLOR_RED}Error: Can't get libfuse2 on system, please install libfuse2${COLOR_RESET}"
print "See https://joplinapp.org/help/faq/#desktop-application-will-not-launch-on-linux and https://github.com/AppImage/AppImageKit/wiki/FUSE for more information"
exit 1
fi
fi
#-----------------------------------------------------
# Download Joplin
#-----------------------------------------------------
# Check if it's in the latest version
if [[ -e "${INSTALL_DIR}/VERSION" ]]; then
CURRENT_VERSION=$(< "${INSTALL_DIR}/VERSION")
+1 -1
View File
@@ -31,7 +31,7 @@ Please see the [donation page](https://github.com/laurent22/joplin/blob/dev/read
# Sponsors
<!-- SPONSORS-ORG -->
<a href="https://seirei.ne.jp"><img title="Serei Network" width="256" src="https://joplinapp.org/images/sponsors/SeireiNetwork.png"/></a> <a href="https://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://route4me.com"><img title="Route4Me" width="256" src="https://joplinapp.org/images/sponsors/Route4Me.png"/></a> <a href="https://socialboom.io/"><img title="Buy YouTube, Instagram, TikTok &amp; Facebook Growth Services at Socialboom" width="256" src="https://joplinapp.org/images/sponsors/Socialboom.png" alt="Buy YouTube, Instagram, TikTok &amp; Facebook Growth Services at Socialboom"/></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://route4me.com"><img title="Route4Me" width="256" src="https://joplinapp.org/images/sponsors/Route4Me.png"/></a> <a href="https://socialboom.io/"><img title="Buy YouTube, Instagram, TikTok &amp; Facebook Growth Services at Socialboom" width="256" src="https://joplinapp.org/images/sponsors/Socialboom.png" alt="Buy YouTube, Instagram, TikTok &amp; Facebook Growth Services at Socialboom"/></a> <a href="https://essaypro.com/"><img title="write my essay EssayPro" width="256" src="https://joplinapp.org/images/sponsors/EssayPro.png" alt="write my essay EssayPro"/></a> <a href="https://writepaper.com/"><img title="write my paper" width="256" src="https://joplinapp.org/images/sponsors/WritePaper.png" alt="write my paper"/></a> <a href="https://mypaperhelp.com/"><img title="MyPaperHelp" width="256" src="https://joplinapp.org/images/sponsors/MyPaperHelp.png" alt="MyPaperHelp"/></a> <a href="https://essaypay.com/"><img title="EssayPay" width="256" src="https://joplinapp.org/images/sponsors/EssayPay.png" alt="EssayPay"/></a>
<!-- SPONSORS-ORG -->
* * *
+1 -1
View File
@@ -11,7 +11,7 @@
},
"nodejs": "24.12.0",
"pkg-config": "latest",
"python": "3.14.2",
"python": "3.14.3",
"bat": "latest",
"electron": {
"version": "latest",
+9
View File
@@ -14,6 +14,8 @@ const github = require('eslint-plugin-github');
const { fixupPluginRules, includeIgnoreFile } = require('@eslint/compat');
const js = require('@eslint/js');
const joplin = require('./packages/tools/eslint-rules');
const { join } = require('path');
// As of ESLint v9, .eslintignore is no longer supported and the presence of
@@ -246,6 +248,7 @@ module.exports = defineConfig([{
promise,
jest,
github: fixupPluginRules(github),
joplin,
},
}, {
files: [
@@ -257,6 +260,12 @@ module.exports = defineConfig([{
rules: {
'no-console': 'off',
},
}, {
files: ['**/*.test.ts', '**/*.test.tsx', '**/*.test.js', '**/*.test.jsx'],
rules: {
'joplin/describe-filename': ['error'],
},
}, {
// .jsx files should be linted with the default rules
files: ['**/*.jsx'],
+6 -7
View File
@@ -87,7 +87,7 @@
"eslint-plugin-promise": "7.3.0",
"eslint-plugin-react": "7.37.5",
"execa": "5.1.1",
"fs-extra": "11.3.4",
"fs-extra": "11.3.5",
"glob": "11.1.0",
"globals": "17.6.0",
"gulp": "4.0.2",
@@ -107,8 +107,8 @@
},
"packageManager": "yarn@4.16.0",
"resolutions": {
"@codemirror/view": "6.42.1",
"@codemirror/state": "6.6.0",
"@codemirror/view": "6.43.6",
"@codemirror/state": "6.7.1",
"@codemirror/language": "6.12.3",
"@codemirror/lint": "6.9.5",
"@codemirror/autocomplete": "6.20.1",
@@ -121,8 +121,6 @@
"pdfjs-dist": "patch:pdfjs-dist@npm%3A3.11.174#./.yarn/patches/pdfjs-dist-npm-3.11.174-67f2fee6d6.patch",
"chokidar@^2.0.0": "3.5.3",
"rn-fetch-blob@0.12.0": "patch:rn-fetch-blob@npm%3A0.12.0#./.yarn/patches/rn-fetch-blob-npm-0.12.0-cf02e3c544.patch",
"app-builder-lib@26.0.0-alpha.7": "patch:app-builder-lib@npm%3A26.0.0-alpha.7#./.yarn/patches/app-builder-lib-npm-26.0.0-alpha.7-e1b3dca119.patch",
"app-builder-lib@24.13.3": "patch:app-builder-lib@npm%3A24.13.3#./.yarn/patches/app-builder-lib-npm-24.13.3-86a66c0bf3.patch",
"react-native-sqlite-storage@6.0.1": "patch:react-native-sqlite-storage@npm%3A6.0.1#./.yarn/patches/react-native-sqlite-storage-npm-6.0.1-8369d747bd.patch",
"react-native-paper@5.13.1": "patch:react-native-paper@npm%3A5.13.1#./.yarn/patches/react-native-paper-npm-5.13.1-f153e542e2.patch",
"react-native-popup-menu@0.17.0": "patch:react-native-popup-menu@npm%3A0.17.0#./.yarn/patches/react-native-popup-menu-npm-0.17.0-8b745d88dd.patch",
@@ -135,7 +133,7 @@
"node-gyp@npm:^9.0.0": "11.2.0",
"node-forge@npm:^1": "1.3.2",
"node-forge@npm:^1.3.0": "1.3.2",
"moment@npm:^2.8.4": "2.29.4",
"moment@npm:^2.8.4": "2.30.1",
"depd@npm:^2.0.0": "patch:depd@npm%3A2.0.0#~/.yarn/patches/depd-npm-2.0.0-b6c51a4b43.patch",
"depd@npm:~2.0.0": "patch:depd@npm%3A2.0.0#~/.yarn/patches/depd-npm-2.0.0-b6c51a4b43.patch",
"depd@npm:~1.1.2": "patch:depd@npm%3A2.0.0#~/.yarn/patches/depd-npm-2.0.0-b6c51a4b43.patch",
@@ -147,6 +145,7 @@
"@react-native-vector-icons/fontawesome5@npm:12.3.0": "patch:@react-native-vector-icons/fontawesome5@npm%3A12.3.0#~/.yarn/patches/@react-native-vector-icons-fontawesome5-npm-12.3.0-a1ca46610f.patch",
"@react-native-vector-icons/ionicons@npm:12.3.0": "patch:@react-native-vector-icons/ionicons@npm%3A12.3.0#~/.yarn/patches/@react-native-vector-icons-ionicons-npm-12.3.0-9bd4746f3f.patch",
"@react-native-vector-icons/material-design-icons@npm:12.4.0": "patch:@react-native-vector-icons/material-design-icons@npm%3A12.4.0#~/.yarn/patches/@react-native-vector-icons-material-design-icons-npm-12.4.0-890f7f618b.patch",
"@react-native-vector-icons/material-icons@npm:12.4.0": "patch:@react-native-vector-icons/material-icons@npm%3A12.4.0#~/.yarn/patches/@react-native-vector-icons-material-icons-npm-12.4.0-94138e627b.patch"
"@react-native-vector-icons/material-icons@npm:12.4.0": "patch:@react-native-vector-icons/material-icons@npm%3A12.4.0#~/.yarn/patches/@react-native-vector-icons-material-icons-npm-12.4.0-94138e627b.patch",
"app-builder-lib@npm:26.15.6": "patch:app-builder-lib@npm%3A26.15.6#~/.yarn/patches/app-builder-lib-npm-26.15.6-010811df92.patch"
}
}
+4 -1
View File
@@ -1,5 +1,7 @@
import { _ } from '@joplin/lib/locale';
import { reg } from '@joplin/lib/registry.js';
import isNoteLockEnabled from '@joplin/lib/services/noteLock/isNoteLockEnabled';
import NoteLockNote from '@joplin/lib/services/noteLock/NoteLockNote';
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Stdout can be called with formatted strings or arbitrary values
type StdoutFn = (text: any)=> void;
@@ -18,8 +20,9 @@ export default class BaseCommand {
throw new Error('Usage not defined');
}
public encryptionCheck(item: { encryption_applied?: number } | null) {
public encryptionCheck(item: { encryption_applied?: number; is_locked?: number } | null) {
if (item && item.encryption_applied) throw new Error(_('Cannot change encrypted item'));
if (isNoteLockEnabled() && NoteLockNote.isLocked(item)) throw new Error(_('Cannot change a locked note'));
}
public description(): string {
+26
View File
@@ -0,0 +1,26 @@
import Note from '@joplin/lib/models/Note';
import Setting from '@joplin/lib/models/Setting';
import { setupDatabaseAndSynchronizer, switchClient } from '@joplin/lib/testing/test-utils';
import { setupCommandForTesting, setupApplication } from './utils/testUtils';
const Command = require('./command-cat');
describe('command-cat', () => {
beforeEach(async () => {
await setupDatabaseAndSynchronizer(1);
await switchClient(1);
await setupApplication();
});
it('should refuse to display a locked note', async () => {
Setting.setValue('featureFlag.noteLock', true);
const note = await Note.save({ title: 'hello', body: 'ciphertext', is_locked: 1, parent_id: '' });
let output = '';
const command = setupCommandForTesting(Command, (text: string) => { output += text; });
await expect(command.action({ note: note.id, options: {} })).rejects.toThrow('locked note');
expect(output).toBe('');
});
});
+3
View File
@@ -4,6 +4,8 @@ import { _ } from '@joplin/lib/locale';
import { ModelType } from '@joplin/lib/BaseModel';
import BaseItem from '@joplin/lib/models/BaseItem';
import Note from '@joplin/lib/models/Note';
import isNoteLockEnabled from '@joplin/lib/services/noteLock/isNoteLockEnabled';
import NoteLockNote from '@joplin/lib/services/noteLock/NoteLockNote';
class Command extends BaseCommand {
public override usage() {
@@ -23,6 +25,7 @@ class Command extends BaseCommand {
const item = await app().loadItem(ModelType.Note, title, { parent: app().currentFolder() });
if (!item) throw new Error(_('Cannot find "%s".', title));
if (isNoteLockEnabled() && NoteLockNote.isLocked(item)) throw new Error(_('Cannot display a locked note'));
let content = '';
+29
View File
@@ -0,0 +1,29 @@
import * as fs from 'fs-extra';
import Note from '@joplin/lib/models/Note';
import Setting from '@joplin/lib/models/Setting';
import { setupDatabaseAndSynchronizer, switchClient } from '@joplin/lib/testing/test-utils';
import { setupCommandForTesting, setupApplication } from './utils/testUtils';
const Command = require('./command-edit');
describe('command-edit', () => {
beforeEach(async () => {
await setupDatabaseAndSynchronizer(1);
await switchClient(1);
await setupApplication();
});
it('should refuse to edit a locked note without writing a temp file', async () => {
Setting.setValue('featureFlag.noteLock', true);
const note = await Note.save({ title: 'hello', body: 'ciphertext', is_locked: 1, parent_id: '' });
const tempDir = Setting.value('tempDir');
await fs.ensureDir(tempDir);
const filesBefore = await fs.readdir(tempDir);
const command = setupCommandForTesting(Command);
await expect(command.action({ note: note.id })).rejects.toThrow('locked note');
expect(await fs.readdir(tempDir)).toEqual(filesBefore);
});
});
+44
View File
@@ -0,0 +1,44 @@
import Note from '@joplin/lib/models/Note';
import Setting from '@joplin/lib/models/Setting';
import { setupDatabaseAndSynchronizer, switchClient } from '@joplin/lib/testing/test-utils';
import { setupCommandForTesting, setupApplication } from './utils/testUtils';
const Command = require('./command-set');
describe('command-set', () => {
beforeEach(async () => {
await setupDatabaseAndSynchronizer(1);
await switchClient(1);
await setupApplication();
});
// This guard lives in BaseCommand.encryptionCheck, so it also covers attach, done, edit, ren and todo.
it.each([
{ label: 'refuse to change a locked note', flagEnabled: true, expectedError: 'Cannot change a locked note', expectedTitle: 'hello' },
{ label: 'change a locked note when note lock is disabled', flagEnabled: false, expectedError: null, expectedTitle: 'renamed' },
])('should $label', async ({ flagEnabled, expectedError, expectedTitle }) => {
Setting.setValue('featureFlag.noteLock', flagEnabled);
const note = await Note.save({ title: 'hello', body: 'ciphertext', is_locked: 1, parent_id: '' });
const command = setupCommandForTesting(Command);
let error: string|null = null;
try {
await command.action({ note: note.id, name: 'title', value: 'renamed' });
} catch (e) {
error = (e as Error).message;
}
expect(error).toBe(expectedError);
expect((await Note.load(note.id)).title).toBe(expectedTitle);
});
it('should block changing the lock state of a plain note', async () => {
Setting.setValue('featureFlag.noteLock', true);
const note = await Note.save({ title: 'hello', body: 'plain', parent_id: '' });
const command = setupCommandForTesting(Command);
await expect(command.action({ note: note.id, name: 'is_locked', value: '1' })).rejects.toThrow('The note lock state cannot be changed from the command line');
expect((await Note.load(note.id)).is_locked).toBe(0);
});
});
+6
View File
@@ -4,6 +4,7 @@ import { _ } from '@joplin/lib/locale';
import { ModelType } from '@joplin/lib/BaseModel';
import Database from '@joplin/lib/database';
import Note from '@joplin/lib/models/Note';
import isNoteLockEnabled from '@joplin/lib/services/noteLock/isNoteLockEnabled';
class Command extends BaseCommand {
public override usage() {
@@ -34,6 +35,11 @@ class Command extends BaseCommand {
for (let i = 0; i < notes.length; i++) {
this.encryptionCheck(notes[i]);
// Enabling the lock here would only fail deep inside the gated save, so refuse it outright.
if (isNoteLockEnabled() && propName === 'is_locked') {
throw new Error(_('The note lock state cannot be changed from the command line'));
}
const timestamp = Date.now();
const newNote: Record<string, unknown> = {
+1 -1
View File
@@ -48,7 +48,7 @@
"chalk": "4.1.2",
"compare-version": "0.1.2",
"file-type": "16.5.4",
"fs-extra": "11.3.4",
"fs-extra": "11.3.5",
"html-entities": "1.4.0",
"keytar": "7.9.0",
"md5": "2.3.0",
@@ -0,0 +1,28 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE en-export SYSTEM "http://xml.evernote.com/pub/evernote-export4.dtd">
<en-export export-date="20260805T200517Z" application="Evernote" version="11.28.2">
<note>
<title>Note 2</title>
<created>20260805T185318Z</created>
<updated>20260805T200333Z</updated>
<note-attributes>
<author>author</author>
</note-attributes>
<content>
<![CDATA[<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE en-note SYSTEM "http://xml.evernote.com/pub/enml2.dtd"><en-note><div style="display:none;"> </div><div>This is a test.</div><div><br/></div><div><span style="--en-highlight:red;background-color: #ffe2d5;">Link to a note in another notebook</span>: <a type="inline-richlink" rev="{&quot;isCached&quot;:false}" href="https://share.evernote.com/note/3784df9c-5e9f-dd33-c246-a6e9616db99b">Test</a></div><div>Link to a note in the same notebook: <a type="inline-richlink" rev="{&quot;isCached&quot;:false}" href="https://share.evernote.com/note/471e9dd8-9436-276a-0594-6d3f5a006084">Example note</a></div></en-note> ]]>
</content>
</note>
<note>
<title>Example note</title>
<created>20260805T200313Z</created>
<updated>20260805T200320Z</updated>
<note-attributes>
<author>author</author>
</note-attributes>
<content>
<![CDATA[<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE en-note SYSTEM "http://xml.evernote.com/pub/enml2.dtd"><en-note><div style="display:none;"> </div><div>Testing!</div></en-note> ]]>
</content>
</note>
</en-export>
@@ -0,0 +1,40 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE en-export SYSTEM "http://xml.evernote.com/pub/evernote-export4.dtd">
<en-export export-date="20260805T200508Z" application="Evernote" version="11.28.2">
<note>
<title>Test</title>
<created>20260805T184526Z</created>
<updated>20260805T200439Z</updated>
<note-attributes>
<author>author</author>
</note-attributes>
<content>
<![CDATA[<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE en-note SYSTEM "http://xml.evernote.com/pub/enml2.dtd"><en-note><div style="display:none;"> </div><div><br/></div><div><a type="inline-richlink" rev="{&quot;isCached&quot;:false}" href="evernote:///view/312260475/s710/0c61536b-4d58-e7ee-1070-7a1a50d94852/e312cdf9-8314-440f-91a6-4bfca42da635">Test note</a>. <a type="inline-richlink" rev="{&quot;isCached&quot;:false}" href="https://share.evernote.com/note/471e9dd8-9436-276a-0594-6d3f5a006084">Example note</a></div></en-note> ]]>
</content>
</note>
<note>
<title>Testing</title>
<created>20260805T185202Z</created>
<updated>20260805T200413Z</updated>
<note-attributes>
<author>author</author>
</note-attributes>
<content>
<![CDATA[<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE en-note SYSTEM "http://xml.evernote.com/pub/enml2.dtd"><en-note><div style="display:none;"> </div><div><a rev="en_rl_none" href="https://share.evernote.com/note/3784df9c-5e9f-dd33-c246-a6e9616db99b">Test</a>...</div><div><br/></div><div><br/></div></en-note> ]]>
</content>
</note>
<note>
<title>Test note</title>
<created>20260805T184449Z</created>
<updated>20260805T184526Z</updated>
<note-attributes>
<author>author</author>
</note-attributes>
<content>
<![CDATA[<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE en-note SYSTEM "http://xml.evernote.com/pub/enml2.dtd"><en-note><div style="display:none;"> </div><div>This is a test note!</div><ul><li><div>Test</div></li></ul><div><br/></div><div><br/></div></en-note> ]]>
</content>
</note>
</en-export>
@@ -1 +1 @@
<a data-from-md href='#'>test</a>
<a>test</a>
+2 -1
View File
@@ -146,13 +146,14 @@ describe('app.reducer', () => {
toolCallId: 'call-1',
userDescription: '',
isError: false,
isEdit: true,
content: 'Result',
} satisfies ChatToolMessage),
});
expect(state.aiChatMessages).toMatchObject([
{ id: 'id-0', role: 'user', text: 'Test', raw: [{ role: 'user' }] },
{ id: 'id-1', role: 'assistant', text: 'Testing', editsApplied: 1, editsMissed: 0 },
{ id: 'id-1', role: 'assistant', text: 'Testing' },
]);
+1 -9
View File
@@ -13,8 +13,7 @@ export interface AiChatMessage {
id: string;
role: 'user' | 'assistant' | 'error' | 'separator';
text: string;
editsApplied?: number;
editsMissed?: number;
hide?: boolean;
// The raw message(s) corresponding to this event
raw: ChatMessage[];
@@ -317,15 +316,8 @@ export default function(state: AppState, action: any) {
state, action.windowId, 'aiChatMessages', messages => {
let lastMessage = messages[messages.length - 1];
if (lastMessage) {
const toolCall = action.toolCall;
const error = toolCall.isError;
const editsApplied = (lastMessage.editsApplied ?? 0) + (error ? 0 : 1);
const editsMissed = (lastMessage.editsMissed ?? 0) + (error ? 1 : 0);
lastMessage = {
...lastMessage,
editsApplied,
editsMissed,
raw: [
...lastMessage.raw,
action.toolCall,
+9
View File
@@ -772,6 +772,15 @@ class Application extends BaseApplication {
});
});
addTask('app/listen for note lock session events', () => {
eventManager.on(EventName.NoteLockSessionChange, (event) => {
this.dispatch({
type: 'SET_NOTE_LOCK_SESSION_UNLOCKED',
value: event.unlocked,
});
});
});
addTask('app/setupOcrService', () => this.setupOcrService());
return tasks;
@@ -3,6 +3,7 @@ import { _ } from '@joplin/lib/locale';
import { stateUtils } from '@joplin/lib/reducer';
import ExternalEditWatcher from '@joplin/lib/services/ExternalEditWatcher';
import Note from '@joplin/lib/models/Note';
import isNoteLockEnabled from '@joplin/lib/services/noteLock/isNoteLockEnabled';
import bridge from '../services/bridge';
export const declaration: CommandDeclaration = {
@@ -18,11 +19,14 @@ export const runtime = (): CommandRuntime => {
try {
const note = await Note.load(noteId);
// The enabled condition can be bypassed when the command is executed directly (e.g. via
// toggleExternalEditing): the external editor would get ciphertext, or write plaintext to disk.
if (isNoteLockEnabled() && note.is_locked) throw new Error(_('Locked notes cannot be opened in an external editor'));
void ExternalEditWatcher.instance().openAndWatch(note);
} catch (error) {
bridge().showErrorMessageBox(_('Error opening note in editor: %s', error.message));
}
},
enabledCondition: 'oneNoteSelected && !noteIsReadOnly',
enabledCondition: 'oneNoteSelected && !noteIsReadOnly && !noteIsLocked',
};
};
@@ -23,7 +23,7 @@ export const runtime = (): CommandRuntime => {
void CommandService.instance().execute('startExternalEditing', noteId);
}
},
enabledCondition: 'oneNoteSelected && !noteIsReadOnly && (!modalDialogVisible || gotoAnythingVisible)',
enabledCondition: 'oneNoteSelected && !noteIsReadOnly && !noteIsLocked && (!modalDialogVisible || gotoAnythingVisible)',
mapStateToTitle: (state: AppState) => {
const noteId = stateUtils.selectedNoteId(state);
return state.watchedNoteFiles.includes(noteId) ? _('Stop') : '';
@@ -2,17 +2,45 @@ import * as React from 'react';
import { _ } from '@joplin/lib/locale';
import { AiChatMessage } from '../../app.reducer';
import InlineMarkdownDisplay from '../InlineMarkdownDisplay';
import { ChatMessage, ChatRole } from '@joplin/lib/services/ai/types';
import { ChatMessage, ChatRole, ChatToolMessage } from '@joplin/lib/services/ai/types';
const editsSummary = (actions: ChatMessage[], applied: number, missed: number) => {
if (applied + missed === 0) return '';
if (missed === 0 && applied > 1) return _('%d edit(s) applied.', applied);
if (missed === 0) {
const toolResults = actions.filter(action => action.role === ChatRole.Tool);
return toolResults.map(result => result.userDescription).join('\n');
}
return _('%d edit(s) applied, %d could not be placed automatically.', applied, missed);
const toolResultSummary = (actions: ChatMessage[]) => {
const toolResults = actions.filter(action => action.role === ChatRole.Tool);
const editSummary = () => {
let applied = 0;
let missed = 0;
let lastEdit: ChatToolMessage|null = null;
for (const result of toolResults) {
if (!result.isEdit) continue;
if (result.isError) {
missed ++;
} else {
applied ++;
lastEdit = result;
}
}
if (applied + missed === 0) return '';
if (missed === 0 && applied > 1) return _('%d edit(s) applied.', applied);
if (missed === 0 && lastEdit) return lastEdit.userDescription;
return _('%d edit(s) applied, %d could not be placed automatically.', applied, missed);
};
return [
...toolResults
.filter(result => !result.isEdit)
.map(result => result.userDescription),
editSummary(),
]
.filter(item => !!item)
.join('\n');
};
const hasToolError = (actions: ChatMessage[]) => {
return actions.some(action => action.role === ChatRole.Tool && action.isError);
};
interface Props {
@@ -27,7 +55,7 @@ const ChatMessageItem: React.FC<Props> = ({ message }) => {
return <div className='error'>{message.text}</div>;
}
const summary = message.role === 'assistant' ? editsSummary(message.raw, message.editsApplied ?? 0, message.editsMissed ?? 0) : '';
const summary = message.role === 'assistant' ? toolResultSummary(message.raw) : '';
// Always show something in the message box:
const textContent = !message.text && !summary ? _('(no message)') : message.text;
@@ -44,7 +72,7 @@ const ChatMessageItem: React.FC<Props> = ({ message }) => {
{content}
{summary && (
<div className='meta'>
{(message.editsMissed ?? 0) > 0
{hasToolError(message.raw)
? <span className='warning'>{summary}</span>
: <span>{summary}</span>}
</div>
@@ -18,6 +18,7 @@ import JoplinError from '@joplin/lib/JoplinError';
import eventManager, { EventName, ItemChangeEvent } from '@joplin/lib/eventManager';
import { Second } from '@joplin/utils/time';
import ChatMessageItem from './ChatMessageItem';
import NavService from '@joplin/lib/services/NavService';
const logger = Logger.create('ChatPanel');
@@ -142,11 +143,13 @@ const ChatPanel: React.FC<Props> = (props) => {
lastNoteIdRef.current = props.noteId;
if (prev === null || prev === props.noteId || !props.noteId) return;
if (messagesLengthRef.current === 0) return;
const text = _('— now viewing: %s —', props.noteTitle || _('(untitled)'));
appendMessage({
id: makeId(),
role: 'separator',
text: _('— now viewing: %s —', props.noteTitle || _('(untitled)')),
raw: [],
text,
raw: [{ role: ChatRole.User, content: _('Switched notes: %s', text) }],
});
}, [props.noteId, props.noteTitle, appendMessage]);
@@ -188,7 +191,7 @@ const ChatPanel: React.FC<Props> = (props) => {
// Captured so we can roll it back on failure — otherwise a retry would
// send the prior user turn as history alongside the new prompt.
const userTurnId = makeId();
let hadSuccessfulResponse = false;
let hadVisibleResponse = false;
appendMessage({
id: userTurnId,
role: 'user',
@@ -217,6 +220,8 @@ const ChatPanel: React.FC<Props> = (props) => {
body: note.body,
title: note.title,
selection,
noteId: note.id,
folderId: note.parent_id,
};
};
@@ -236,15 +241,16 @@ const ChatPanel: React.FC<Props> = (props) => {
// System messages are not shown in the UI
if (entry.role === ChatRole.System) continue;
hadSuccessfulResponse = true;
if (entry.role === ChatRole.Tool) {
addToolResult(entry);
} else {
hadVisibleResponse ||= !entry.hide;
appendMessage({
id: makeId(),
role: entry.role,
text: entry.content,
hide: entry.hide,
raw: [entry],
});
}
@@ -295,7 +301,7 @@ const ChatPanel: React.FC<Props> = (props) => {
logger.warn('Chat failed:', error);
if (abortController.signal.aborted) return;
if (!hadSuccessfulResponse) {
if (!hadVisibleResponse) {
dispatch({ type: 'AI_CHAT_REMOVE', windowId, id: userTurnId });
setInput(text);
}
@@ -316,6 +322,10 @@ const ChatPanel: React.FC<Props> = (props) => {
dispatch({ type: 'AI_CHAT_RESET', windowId: windowId });
}, [dispatch, windowId, cancelRequest]);
const handleClose = useCallback(() => {
void CommandService.instance().executeInWindow('toggleAiChat', { windowId: windowId, args: [] });
}, [windowId]);
const handleKeyDown = useCallback((e: React.KeyboardEvent<HTMLTextAreaElement>) => {
// Don't send while an IME composition is in flight — Enter commits
// the composition for CJK / accented input.
@@ -343,15 +353,24 @@ const ChatPanel: React.FC<Props> = (props) => {
return { content, showingMessages: false };
}
const visibleMessages = messages.filter(m => !m.hide);
const content = <>
{props.aiDegraded && <AiDegradedNotice className='degraded-status' />}
<div className='messages' aria-live={hasFocus ? 'polite' : undefined}>
{messages.length === 0 && (
{visibleMessages.length === 0 && (
<div className='empty'>
{_('Ask about this note, or request changes. Select text in the editor first to scope the request to that selection.')}
<br/>
<br/>
<button
type='button'
className='link-button'
onClick={() => NavService.go('Config', { props: { defaultSection: 'ai.tools' } })}
>{_('Manage capabilities')}</button>
</div>
)}
{messages.map(m => <ChatMessageItem key={m.id} message={m}/>)}
{visibleMessages.map(m => <ChatMessageItem key={m.id} message={m}/>)}
<div ref={messagesEndRef} />
</div>
<div className='composer'>
@@ -385,13 +404,28 @@ const ChatPanel: React.FC<Props> = (props) => {
</div>
</>;
return { content, showingMessages: messages.length > 0 };
return { content, showingMessages: visibleMessages.length > 0 };
};
const sendButtonLabel = sending ? _('Stop generating') : _('Send');
const headerId = useId();
const { content, showingMessages } = renderContent();
const renderHeaderActions = () => {
if (showingMessages) {
return <button type='button' className='reset' onClick={handleReset}>{_('Reset')}</button>;
}
const closeLabel = _('Close');
return <button
type='button'
className='close'
onClick={handleClose}
title={closeLabel}
aria-label={closeLabel}
><i className='fas fa-times' role='img' aria-hidden={true}/></button>;
};
return (
<div
className='chat-panel'
@@ -400,11 +434,9 @@ const ChatPanel: React.FC<Props> = (props) => {
onFocus={onFocus}
onBlur={onBlur}
>
<div className='header'>
<div className='header chat-panel-header'>
<h1 className='title' id={headerId}>{_('AI Chat')}</h1>
{showingMessages && (
<button type='button' className='reset' onClick={handleReset}>{_('Reset')}</button>
)}
{renderHeaderActions()}
</div>
{content}
</div>
+17 -15
View File
@@ -19,23 +19,25 @@
font-weight: 600;
}
.chat-panel > .header > .title {
flex: 1;
font-size: inherit;
margin: 0;
}
.chat-panel-header {
> .title {
flex: 1;
font-size: inherit;
margin: 0;
}
.chat-panel > .header > .reset {
background: none;
border: none;
color: var(--joplin-color-faded);
cursor: pointer;
font-size: 0.85em;
padding: 2px 6px;
}
> .reset, > .close {
background: none;
border: none;
color: var(--joplin-color-faded);
cursor: pointer;
font-size: 0.85em;
padding: 2px 6px;
.chat-panel > .header > .reset:hover {
color: var(--joplin-color);
&:hover {
color: var(--joplin-color);
}
}
}
.chat-panel > .degraded-status {
@@ -7,7 +7,7 @@ import shim, { MessageBoxType } from '@joplin/lib/shim';
import dialogs from '../dialogs';
import { decryptedStatText, determineKeyPassword, dontReencryptData, enableEncryptionConfirmationMessages, onSavePasswordClick, onToggleEnabledClick, reencryptData, upgradeMasterKey, useInputPasswords, useNeedMasterPassword, usePasswordChecker, useStats, useToggleShowDisabledMasterKeys } from '@joplin/lib/components/EncryptionConfigScreen/utils';
import { MasterKeyEntity } from '@joplin/lib/services/e2ee/types';
import { getEncryptionEnabled, masterKeyEnabled, SyncInfo } from '@joplin/lib/services/synchronizer/syncInfoUtils';
import { getEncryptionEnabled, localSyncInfoSelector, masterKeyEnabled } from '@joplin/lib/services/synchronizer/syncInfoUtils';
import { getDefaultMasterKey, getMasterPasswordStatusMessage, masterPasswordIsValid, toggleAndSetupEncryption } from '@joplin/lib/services/e2ee/utils';
import Button, { ButtonLevel } from '../Button/Button';
import { useCallback, useEffect, useId, useMemo, useRef, useState } from 'react';
@@ -606,7 +606,7 @@ export const EncryptionConfigScreen = (props: Props) => {
};
const mapStateToProps = (state: AppState) => {
const syncInfo = new SyncInfo(state.settings['syncInfoCache']);
const syncInfo = localSyncInfoSelector(state);
return {
themeId: state.settings.theme,
+12 -9
View File
@@ -27,7 +27,8 @@ import EncryptionService from '@joplin/lib/services/e2ee/EncryptionService';
import { ShareInvitation } from '@joplin/lib/services/share/reducer';
import removeKeylessItems from './ResizableLayout/utils/removeKeylessItems';
import { localSyncInfoFromState } from '@joplin/lib/services/synchronizer/syncInfoUtils';
import { isCallbackUrl, parseCallbackUrl } from '@joplin/lib/callbackUrlUtils';
import { isCallbackUrl } from '@joplin/lib/callbackUrlUtils';
import executeCallbackUrl from './MainScreen/handleCallbackUrl';
import ElectronAppWrapper from '../ElectronAppWrapper';
import { showMissingMasterKeyMessage } from '@joplin/lib/services/e2ee/utils';
import { MasterKeyEntity } from '@joplin/lib/services/e2ee/types';
@@ -38,7 +39,6 @@ import TrashNotification from './TrashNotification/TrashNotification';
import UpdateNotification from './UpdateNotification/UpdateNotification';
import PluginNotification from './PluginNotification/PluginNotification';
import { Toast } from '@joplin/lib/services/plugins/api/types';
import QuitSyncDialog from './QuitSyncDialog';
import Logger from '@joplin/utils/Logger';
import checkForUpdates, { isReleaseVersion } from '../checkForUpdates';
@@ -153,20 +153,23 @@ class MainScreenComponent extends React.Component<Props, State> {
ipcRenderer.on('asynchronous-message', (_event: import('electron').IpcRendererEvent, message: string, args: { url: string }) => {
if (message === 'openCallbackUrl') {
this.openCallbackUrl(args.url);
void this.openCallbackUrl(args.url);
}
});
const initialCallbackUrl = (bridge().electronApp() as ElectronAppWrapper).initialCallbackUrl();
if (initialCallbackUrl) {
this.openCallbackUrl(initialCallbackUrl);
void this.openCallbackUrl(initialCallbackUrl);
}
}
private openCallbackUrl(url: string) {
if (!isCallbackUrl(url)) throw new Error(`Invalid callback URL: ${url}`);
const { command, params } = parseCallbackUrl(url);
void CommandService.instance().execute(command.toString(), params.id);
private async openCallbackUrl(url: string) {
try {
if (!isCallbackUrl(url)) throw new Error(`Invalid callback URL: ${url}`);
await executeCallbackUrl(url);
} catch (error) {
logger.error('Error handling callback URL:', error);
}
}
private updateLayoutPluginViews(layout: LayoutItem, plugins: PluginStates) {
@@ -182,6 +185,7 @@ class MainScreenComponent extends React.Component<Props, State> {
if (!existingItem) {
draftLayout.children.push({
key: viewId,
visible: info.view.opened,
context: {
pluginId: info.plugin.id,
},
@@ -798,7 +802,6 @@ class MainScreenComponent extends React.Component<Props, State> {
themeId={this.props.themeId}
toast={this.props.toast}
/>
<QuitSyncDialog themeId={this.props.themeId} />
{messageComp}
{layoutComp}
</div>
@@ -0,0 +1,67 @@
import { utils as commandUtils } from '@joplin/lib/services/CommandService';
import executeCallbackUrl from './handleCallbackUrl';
import { withWarningSilenced } from '@joplin/lib/testing/test-utils';
const mockOpenExternal = jest.fn();
jest.mock('../../services/bridge', () => ({
__esModule: true,
default: () => ({
openExternal: (url: string) => mockOpenExternal(url),
}),
}));
const setSelectedNoteId = (noteId: string | null) => {
// executeCallbackUrl reads the selected note id via commandUtils.store; a
// null id makes handleGetCurrentNote take its x-error branch, which routes
// the caller-supplied target through respond() without touching the DB.
commandUtils.store = {
getState: () => ({ selectedNoteIds: noteId ? [noteId] : [] }),
};
};
describe('handleCallbackUrl', () => {
beforeEach(() => {
mockOpenExternal.mockClear();
setSelectedNoteId(null);
});
test.each([
'x-custom-scheme://arbitrary-scheme-reached',
'file:///etc/passwd',
'ms-msdt://something',
'mailto:someone@example.com',
'not a url',
])('should not dispatch a callback target with a disallowed scheme (%s)', async (target) => {
await withWarningSilenced(/Rejected malformed callback|Rejected callback target with disallowed scheme/, async () => {
await executeCallbackUrl(`joplin://x-callback-url/getCurrentNote?x-error=${encodeURIComponent(target)}`);
}, { requireWarning: true });
expect(mockOpenExternal).not.toHaveBeenCalled();
});
test.each([
'http://example.com/cb',
'https://example.com/cb',
])('should dispatch a callback target with an allowed scheme (%s)', async (target) => {
await executeCallbackUrl(`joplin://x-callback-url/getCurrentNote?x-error=${encodeURIComponent(target)}`);
expect(mockOpenExternal).toHaveBeenCalledTimes(1);
expect(mockOpenExternal.mock.calls[0][0]).toContain(`${target}?`);
});
it('should not leak the raw error message on the error path', async () => {
// Force a throw inside the try block so the catch path builds the response.
commandUtils.store = {
getState: () => { throw new Error('secret path /home/victim/.config/joplin/database.sqlite'); },
};
await withWarningSilenced(/Error handling callback URL command "getCurrentNote":.*secret path \/home\/victim/, async () => {
await executeCallbackUrl(`joplin://x-callback-url/getCurrentNote?x-error=${encodeURIComponent('https://example.com/cb')}`);
}, { requireWarning: true });
expect(mockOpenExternal).toHaveBeenCalledTimes(1);
const responseUrl = mockOpenExternal.mock.calls[0][0];
expect(responseUrl).not.toContain('secret path');
expect(responseUrl).not.toContain('database.sqlite');
expect(new URL(responseUrl).searchParams.get('errorMessage')).toBe('The command could not be completed');
});
});
@@ -0,0 +1,123 @@
import { CallbackUrlCommand, getNoteCallbackUrl, parseCallbackUrl } from '@joplin/lib/callbackUrlUtils';
import CommandService, { utils as commandUtils } from '@joplin/lib/services/CommandService';
import Note from '@joplin/lib/models/Note';
import Folder from '@joplin/lib/models/Folder';
import { stateUtils } from '@joplin/lib/reducer';
import { AppState } from '../../app.reducer';
import bridge from '../../services/bridge';
import Logger from '@joplin/utils/Logger';
const logger = Logger.create('handleCallbackUrl');
// Opens the caller's x-success / x-error URL with the result appended as query params.
const respond = (target: string, params: Record<string, string> = {}) => {
if (!target) return;
// The callback target is untrusted input from the x-callback-url. Anything
// that is not file: is handed straight to shell.openExternal(), i.e. the OS
// URI dispatcher, so restrict it to schemes we're willing to invoke. This
// also covers the x-error path below, which fires on any thrown exception.
let protocol;
try {
protocol = new URL(target).protocol;
} catch (error) {
// Don't log the caller-supplied target: it's untrusted and may contain
// secrets in its query or fragment.
logger.warn('Rejected malformed callback target');
return;
}
if (protocol !== 'http:' && protocol !== 'https:') {
logger.warn(`Rejected callback target with disallowed scheme "${protocol}"`);
return;
}
const query = Object.entries(params)
.map(([key, value]) => `${encodeURIComponent(key)}=${encodeURIComponent(value)}`)
.join('&');
const fragmentIndex = target.indexOf('#');
const base = fragmentIndex === -1 ? target : target.substring(0, fragmentIndex);
const fragment = fragmentIndex === -1 ? '' : target.substring(fragmentIndex);
const separator = base.includes('?') ? '&' : '?';
const responseUrl = query ? `${base}${separator}${query}${fragment}` : target;
void bridge().openExternal(responseUrl);
};
const handleGetCurrentNote = async (params: Record<string, string>) => {
const state = commandUtils.store.getState() as AppState;
const noteId = stateUtils.selectedNoteId(state);
if (!noteId) {
respond(params['x-error'], { errorMessage: 'No note is currently selected' });
return;
}
const note = await Note.load(noteId);
if (!note) {
respond(params['x-error'], { errorMessage: 'The selected note could not be loaded' });
return;
}
respond(params['x-success'], {
title: note.title,
url: getNoteCallbackUrl(note.id),
});
};
const handleCreateNote = async (params: Record<string, string>) => {
const folder = await Folder.getValidActiveFolder();
if (!folder) {
respond(params['x-error'], { errorMessage: 'No valid notebook is available to create the note in' });
return;
}
// Not provisional: an externally-requested note should persist even if unedited.
const note = await Note.save({
...Note.previewFieldsWithDefaultValues({ includeTimestamps: false }),
title: params.title ?? '',
body: params.body ?? '',
parent_id: folder.id,
});
commandUtils.store.dispatch({ type: 'NOTE_SELECT', id: note.id });
respond(params['x-success'], {
title: note.title,
url: getNoteCallbackUrl(note.id),
});
};
// Verbs dispatched directly to CommandService by name. Kept as an explicit
// allowlist (not a catch-all) so a crafted URL can't run arbitrary commands. See 69826610a.
const openCommands: string[] = [
CallbackUrlCommand.OpenNote,
CallbackUrlCommand.OpenFolder,
CallbackUrlCommand.OpenTag,
];
const executeCallbackUrl = async (url: string) => {
const info = parseCallbackUrl(url);
if (!openCommands.includes(info.command) &&
info.command !== CallbackUrlCommand.GetCurrentNote &&
info.command !== CallbackUrlCommand.CreateNote) {
throw new Error(`Unhandled callback URL command: ${info.command}`);
}
try {
if (info.command === CallbackUrlCommand.GetCurrentNote) {
await handleGetCurrentNote(info.params);
} else if (info.command === CallbackUrlCommand.CreateNote) {
await handleCreateNote(info.params);
} else {
await CommandService.instance().execute(info.command.toString(), info.params.id);
logger.info(`Executed callback URL command: ${info.command}`);
}
} catch (error) {
logger.error(`Error handling callback URL command "${info.command}":`, error);
// Return an opaque status rather than error.message, which can leak
// profile paths and item ids to the caller-supplied x-error target.
respond(info.params['x-error'], { errorMessage: 'The command could not be completed' });
}
};
export default executeCallbackUrl;
+7 -1
View File
@@ -13,6 +13,7 @@ import versionInfo, { PackageInfo } from '@joplin/lib/versionInfo';
import { ImportModule } from '@joplin/lib/services/interop/Module';
import InteropServiceHelper from '../InteropServiceHelper';
import { _ } from '@joplin/lib/locale';
import isNoteLockEnabled from '@joplin/lib/services/noteLock/isNoteLockEnabled';
import { isContextMenuItemLocation, MenuItem, MenuItemLocation } from '@joplin/lib/services/plugins/api/types';
import SpellCheckerService from '@joplin/lib/services/spellChecker/SpellCheckerService';
import menuCommandNames from './menuCommandNames';
@@ -864,10 +865,15 @@ function useMenu(props: Props) {
menuItemDic.toggleExternalEditing,
separator(),
menuItemDic.linkToNote,
menuItemDic.copyNoteExternalLink,
menuItemDic.setTags,
menuItemDic.showShareNoteDialog,
menuItemDic.convertNoteToMarkdown,
...(isNoteLockEnabled() ? [
separator(),
menuItemDic.enableNoteEncryption,
menuItemDic.disableNoteEncryption,
menuItemDic.lockEncryptedNotes,
] : []),
separator(),
menuItemDic.showNoteProperties,
menuItemDic.showNoteContentProperties,
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useRef, useState } from 'react';
import shim from '@joplin/lib/shim';
import Logger from '@joplin/utils/Logger';
import CodeMirror5Emulation from '@joplin/editor/CodeMirror/CodeMirror5Emulation/CodeMirror5Emulation';
import { HighlightedWord } from '@joplin/lib/reducer';
const logger = Logger.create('useEditorSearch');
@@ -14,9 +15,8 @@ interface SetMarkersOptions {
showEditorMarkers?: boolean;
withSelection?: boolean;
}
type Keyword = { value: string };
export type OnSetMarkers = (cm: CodeMirror5Emulation, keywords: Keyword[], options: SetMarkersOptions)=> number;
export type OnSetMarkers = (cm: CodeMirror5Emulation, keywords: HighlightedWord[], options: SetMarkersOptions)=> number;
// Modified from codemirror/addons/search/search.js
@@ -109,11 +109,11 @@ export default function useEditorSearchExtension() {
// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Regular_Expressions#Escaping
function escapeRegExp(keyword: string) {
return keyword.replace(/[.*+\-?^${}()|[\]\\]/g, '\\$&'); // $& means the whole matched string
return keyword.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); // $& means the whole matched string
}
function getSearchTerm(keyword: Keyword) {
const value = escapeRegExp(keyword.value);
function getSearchTerm(keyword: HighlightedWord) {
const value = escapeRegExp(typeof keyword === 'string' ? keyword : keyword.value);
return new RegExp(value, 'gi');
}
@@ -139,6 +139,8 @@ export default function useEditorSearchExtension() {
clearMarkers();
const keywordValue = (keyword: HighlightedWord) => typeof keyword === 'string' ? keyword : keyword.value;
// HIGHLIGHT KEYWORDS
// When doing a global search it's possible to have multiple keywords
// This means we need to highlight each one
@@ -146,12 +148,12 @@ export default function useEditorSearchExtension() {
for (let i = 0; i < keywords.length; i++) {
const keyword = keywords[i];
if (keyword.value === '') continue;
if (keywordValue(keyword) === '') continue;
const searchTerm = getSearchTerm(keyword);
// We only want to scroll the first keyword into view in the case of a multi keyword search
const scrollTo = i === 0 && (previousKeywordValue !== keyword.value || previousIndex !== options.selectedIndex || options.searchTimestamp !== previousSearchTimestamp);
const scrollTo = i === 0 && (previousKeywordValue !== keywordValue(keyword) || previousIndex !== options.selectedIndex || options.searchTimestamp !== previousSearchTimestamp);
try {
const match = highlightSearch(cm, searchTerm, options.selectedIndex, scrollTo, !!options.withSelection);
@@ -172,15 +174,16 @@ export default function useEditorSearchExtension() {
setPreviousSearchTimestamp(options.searchTimestamp);
// SEARCHOVERLAY
// We only want to highlight all matches when there is only 1 search term
if (keywords.length !== 1 || keywords[0].value === '') {
// We only want to highlight all matches when there is only 1 main search term
const mainSearch = keywords.filter(keyword => typeof keyword === 'string' || keyword.source !== 'semantic');
if (mainSearch.length !== 1 || keywordValue(mainSearch[0]) === '') {
clearOverlay(cm);
const prev = keywords.length > 1 ? keywords[0].value : '';
const prev = keywords.length > 1 ? keywordValue(keywords[0]) : '';
setPreviousKeywordValue(prev);
return 0;
}
const searchTerm = getSearchTerm(keywords[0]);
const searchTerm = getSearchTerm(mainSearch[0]);
// Determine the number of matches in the source, this is passed on
// to the NoteEditor component
@@ -189,10 +192,10 @@ export default function useEditorSearchExtension() {
// Don't bother clearing and re-calculating the overlay if the search term
// hasn't changed
if (keywords[0].value === previousKeywordValue) return nMatches;
if (keywordValue(keywords[0]) === previousKeywordValue) return nMatches;
clearOverlay(cm);
setPreviousKeywordValue(keywords[0].value);
setPreviousKeywordValue(keywordValue(keywords[0]));
// These operations are pretty slow, so we won't add use them until the user
// has finished typing, 500ms is probably enough time
@@ -710,6 +710,14 @@ function CodeMirror(props: NoteBodyEditorProps, ref: ForwardedRef<NoteBodyEditor
// eslint-disable-next-line @seiyab/react-hooks/exhaustive-deps -- Old code before rule was applied
}, [renderedBody, webviewReady]);
// Stop media playing in the viewer once it's hidden - https://github.com/laurent22/joplin/issues/15278
const viewerVisible = props.visiblePanes.includes('viewer');
useEffect(() => {
if (!viewerVisible) {
webviewRef.current?.stopMediaPlayback();
}
}, [viewerVisible]);
const { onSetInitialMarkersRef } = useEditorSearchHandler({
setLocalSearchResultCount: props.setLocalSearchResultCount,
searchMarkers: props.searchMarkers,
@@ -297,6 +297,14 @@ const CodeMirror = (props: NoteBodyEditorProps, ref: ForwardedRef<NoteBodyEditor
useRefocusOnVisiblePaneChange({ editorRef, webviewRef, visiblePanes: props.visiblePanes });
// Stop media playing in the viewer once it's hidden - https://github.com/laurent22/joplin/issues/15278
const viewerVisible = props.visiblePanes.includes('viewer');
useEffect(() => {
if (!viewerVisible) {
webviewRef.current?.stopMediaPlayback();
}
}, [viewerVisible]);
useEditorSearchHandler({
setLocalSearchResultCount: props.setLocalSearchResultCount,
searchMarkers: props.searchMarkers,
@@ -109,7 +109,11 @@ const Editor = (props: Props, ref: ForwardedRef<CodeMirrorControl>) => {
}
const searchState = editor.getSearchState();
const externalSearchText = props.externalSearch.keywords.map(k => k.value).join(' ') || searchState.searchText;
const externalSearchText = props.externalSearch
.keywords
.filter(k => typeof k === 'string' || k.source !== 'semantic')
.map(k => typeof k === 'string' ? k : k.value)
.join(' ') || searchState.searchText;
if (externalSearchText === searchState.searchText && searchState.dialogVisible === props.useLocalSearch) {
return;
@@ -25,4 +25,17 @@ export default () => ({
'replaced $ matches': _('replaced $ matches'),
'replaced match on line $': _('replaced match on line $'),
'on line': _('on line'),
// Table editor context menu. These keys must be kept in sync with the
// state.phrase() calls in packages/editor renderTables.ts.
'Insert row above': _('Insert row above'),
'Insert row below': _('Insert row below'),
'Insert column left': _('Insert column left'),
'Insert column right': _('Insert column right'),
'Move row up': _('Move row up'),
'Move row down': _('Move row down'),
'Move column left': _('Move column left'),
'Move column right': _('Move column right'),
'Delete row': _('Delete row'),
'Delete column': _('Delete column'),
});
@@ -33,7 +33,8 @@ import { DropHandler } from '../../utils/useDropHandler';
import Logger from '@joplin/utils/Logger';
import useWebViewApi from './utils/useWebViewApi';
import useLinkTooltips from './utils/useLinkTooltips';
import { focus } from '@joplin/lib/utils/focusHandler';
import { blur, focus } from '@joplin/lib/utils/focusHandler';
import useHighlightedSearchTerms from './utils/useHighlightedSearchTerms';
const md5 = require('md5');
import { clipboard } from 'electron';
const supportedLocales = require('./supportedLocales');
@@ -186,6 +187,9 @@ const TinyMCE = (props: NoteBodyEditorProps, ref: Ref<NoteBodyEditorRef>) => {
if (!editorRef.current) return '';
return prop_htmlToMarkdownRef.current(props.contentMarkupLanguage, editorRef.current.getContent(), props.contentOriginalCss);
},
blurEditor: () => {
if (editor) blur('TinyMCE::reload', editor.getBody());
},
resetScroll: () => {
if (editor) editor.getWin().scrollTo(0, 0);
},
@@ -1199,6 +1203,8 @@ const TinyMCE = (props: NoteBodyEditorProps, ref: Ref<NoteBodyEditorRef>) => {
};
}, [editor, onEditorContentClick]);
useHighlightedSearchTerms(editor, props.searchMarkers.keywords, props.themeId);
// This is to handle dropping notes on the editor. In this case, we add an
// overlay over the editor, which makes it a valid drop target. This in
// turn makes NoteEditor get the drop event and dispatch it.
@@ -1243,6 +1249,8 @@ const TinyMCE = (props: NoteBodyEditorProps, ref: Ref<NoteBodyEditorRef>) => {
// https://github.com/facebook/react/issues/14010#issuecomment-433788147
const props_onChangeRef = useRef<NoteBodyEditorProps['onChange']>(null);
props_onChangeRef.current = props.onChange;
const editorNoteReloadTimeRequestRef = useRef(props.editorNoteReloadTimeRequest);
editorNoteReloadTimeRequestRef.current = props.editorNoteReloadTimeRequest;
const prop_htmlToMarkdownRef = useRef<HtmlToMarkdownHandler>(null);
prop_htmlToMarkdownRef.current = props.htmlToMarkdown;
@@ -1255,12 +1263,14 @@ const TinyMCE = (props: NoteBodyEditorProps, ref: Ref<NoteBodyEditorRef>) => {
if (!info) return;
nextOnChangeEventInfo.current = null;
if (info.editorNoteReloadTimeRequest !== editorNoteReloadTimeRequestRef.current) return;
resetLinkTooltips();
const contentMd = await prop_htmlToMarkdownRef.current(info.contentMarkupLanguage, info.editor.getContent(), info.contentOriginalCss);
lastOnChangeEventInfo.current.content = contentMd;
lastOnChangeEventInfo.current.resourceInfos = await attachedResources(contentMd);
if (info.editorNoteReloadTimeRequest !== editorNoteReloadTimeRequestRef.current) return;
props_onChangeRef.current({
changeId: info.changeId,
@@ -1301,6 +1311,7 @@ const TinyMCE = (props: NoteBodyEditorProps, ref: Ref<NoteBodyEditorRef>) => {
editor: editor,
contentMarkupLanguage: props.contentMarkupLanguage,
contentOriginalCss: props.contentOriginalCss,
editorNoteReloadTimeRequest: editorNoteReloadTimeRequestRef.current,
};
onChangeHandlerTimeoutRef.current = shim.setTimeout(async () => {
@@ -1547,6 +1558,8 @@ const TinyMCE = (props: NoteBodyEditorProps, ref: Ref<NoteBodyEditorRef>) => {
editor.on(TinyMceEditorEvents.JoplinChange, onChangeHandler);
editor.on(TinyMceEditorEvents.Undo, onChangeHandler);
editor.on(TinyMceEditorEvents.Redo, onChangeHandler);
editor.on(TinyMceEditorEvents.FormatApply, onChangeHandler);
editor.on(TinyMceEditorEvents.FormatRemove, onChangeHandler);
editor.on(TinyMceEditorEvents.ExecCommand, onExecCommand);
editor.on(TinyMceEditorEvents.SetAttrib, onSetAttrib);
editor.on('TableModified', onTableModified);
@@ -1564,6 +1577,8 @@ const TinyMCE = (props: NoteBodyEditorProps, ref: Ref<NoteBodyEditorRef>) => {
editor.off(TinyMceEditorEvents.JoplinChange, onChangeHandler);
editor.off(TinyMceEditorEvents.Undo, onChangeHandler);
editor.off(TinyMceEditorEvents.Redo, onChangeHandler);
editor.off(TinyMceEditorEvents.FormatApply, onChangeHandler);
editor.off(TinyMceEditorEvents.FormatRemove, onChangeHandler);
editor.off(TinyMceEditorEvents.ExecCommand, onExecCommand);
editor.off(TinyMceEditorEvents.SetAttrib, onSetAttrib);
editor.off('TableModified', onTableModified);
@@ -15,6 +15,8 @@ export enum TinyMceEditorEvents {
Redo = 'Redo',
ExecCommand = 'ExecCommand',
SetAttrib = 'SetAttrib',
FormatApply = 'FormatApply',
FormatRemove = 'FormatRemove',
}
export type DispatchDidUpdateCallback = (editor: Editor)=> void;
@@ -0,0 +1,224 @@
import { HighlightedWord } from '@joplin/lib/reducer';
import SearchEngine from '@joplin/lib/services/search/SearchEngine';
import { themeStyle } from '@joplin/lib/theme';
import { Theme } from '@joplin/lib/themes/type';
import { useEffect, useMemo } from 'react';
import { Editor, EditorEvent } from 'tinymce';
declare global {
interface Window {
Highlight: typeof Highlight;
Range: typeof Range;
CSS: typeof CSS;
}
}
const useSearchRegexes = (searchTerms: HighlightedWord[]) => {
return useMemo(() => {
return searchTerms.map((term: HighlightedWord) => {
let text;
if (typeof term === 'string') {
text = term;
} else {
text = term.value;
}
// Only highlight short matches at word boundaries (roughly matching the note viewer
// behavior)
const regexText = SearchEngine.instance().queryTermToRegex(text, { anchorToWordBoundary: text.length <= 2 });
return regexText ? new RegExp(regexText, 'ig') : null;
}).filter(regex => !!regex);
}, [searchTerms]);
};
const searchHighlightKey = 'jop-search-highlight';
const useHighlightStyleSheet = (editor: Editor, themeId: number) => {
useEffect(() => {
if (!editor) {
return () => {};
}
const theme: Theme = themeStyle(themeId);
const style = editor.dom.create('style', {}, `
/* Avoid showing highlights when the user has the find dialog open and
there are TinyMCE-provided search results */
body:not(:has(span.mce-match-marker)) {
::highlight(${searchHighlightKey}) {
background-color: ${theme.searchMarkerBackgroundColor};
color: ${theme.searchMarkerColor};
}
}
`);
editor.getDoc().head.appendChild(style);
return () => {
style.remove();
};
}, [editor, themeId]);
};
const useHighlighter = (editor: Editor, searchRegexes: RegExp[]) => {
return useMemo(() => {
if (!editor) {
return { canHighlight: false };
}
const editorWindow = editor.getWin();
const ranges: Map<Node, Range[]> = new Map();
let highlight: Highlight = undefined;
const processNode = (node: Node) => {
ranges.delete(node);
type FoundChildAndOffset = { node: Node; offset: number };
const findChildAtTextOffset = (parent: Node, offset: number): FoundChildAndOffset => {
if (offset > parent.textContent.length) return null;
if (parent.nodeName === '#text') {
return { node: parent, offset };
}
let start = 0;
for (const child of parent.childNodes) {
if (child.nodeName === '#comment') continue;
const found = findChildAtTextOffset(child, offset - start);
start += child.textContent.length;
if (found) return found;
}
return null;
};
const buildRange = (parent: Node, startIndex: number, endIndex: number) => {
const range: Range = new editorWindow.Range();
const start = findChildAtTextOffset(parent, startIndex);
const end = findChildAtTextOffset(parent, endIndex);
range.setStart(start?.node ?? parent, start?.offset ?? 0);
range.setEnd(end?.node ?? parent, end?.offset ?? parent.textContent.length);
return range;
};
// Process highlights at the paragraph level, where possible, to pick up formatting that crosses Markdown boundaries
const isLeaf = node.nodeName === '#text' || node.nodeName === 'P';
if (isLeaf) {
const childRanges = [];
for (const term of searchRegexes) {
const matches = node.textContent.matchAll(term);
for (const match of matches) {
const startIndex = match.index ?? 0;
const endIndex = (match.index ?? 0) + match[0].length;
if (startIndex !== endIndex) {
childRanges.push(buildRange(node, startIndex, endIndex));
}
}
}
if (childRanges.length > 0) {
ranges.set(node, childRanges);
}
} else {
for (const child of node.childNodes) {
processNode(child);
}
}
};
const closestParagraph = (node: Node) => node.parentElement?.closest('p');
const onNodeChanged = (node: Node) => {
highlight?.clear();
processNode(closestParagraph(node) ?? node);
highlight = new editorWindow.Highlight(...[...ranges.values()].flat());
editorWindow.CSS.highlights.set(searchHighlightKey, highlight);
};
const onNodeRemoved = (node: Node, parent: Node) => {
const removeFromRanges = (node: Node) => {
ranges.delete(node);
for (const child of node.childNodes) {
removeFromRanges(child);
}
};
removeFromRanges(node);
// Handle the case where removing a node created a match:
onNodeChanged(closestParagraph(parent) ?? parent);
};
const clearHighlights = () => {
highlight?.clear();
editorWindow.CSS.highlights.delete(searchHighlightKey);
ranges.clear();
};
return { onNodeChanged, onNodeRemoved, clearHighlights, canHighlight: searchRegexes.length > 0 };
}, [editor, searchRegexes]);
};
const useHighlightedSearchTerms = (editor: Editor, searchTerms: HighlightedWord[], themeId: number) => {
const searchRegexes = useSearchRegexes(searchTerms);
useHighlightStyleSheet(editor, themeId);
const highlighter = useHighlighter(editor, searchRegexes);
useEffect(() => {
if (!editor || !highlighter.canHighlight) {
return () => {};
}
const editorBody = editor.getBody();
type NodeChangeEvent = { element: Element };
const onNodeChange = ({ element }: EditorEvent<NodeChangeEvent>) => {
highlighter.onNodeChanged(element);
};
const onKeyUp = (_event: KeyboardEvent) => {
// Use selectedNode and not event.target -- event.target seems to always point
// to the body.
const selectedNode = editor.selection.getNode();
if (selectedNode) {
highlighter.onNodeChanged(selectedNode);
}
};
const onSetContent = () => {
highlighter.onNodeChanged(editorBody);
};
editor.on('NodeChange', onNodeChange);
editor.on('SetContent', onSetContent);
// NodeChange doesn't fire while typing, so we also need keyup
editor.on('keyup', onKeyUp);
// NodeChange also doesn't fire for certain DOM changes (e.g. when TinyMCE-provided search highlights are added/removed):
const observer = new MutationObserver((mutations) => {
for (const mutation of mutations) {
for (const node of mutation.removedNodes) {
highlighter.onNodeRemoved(node, mutation.target);
}
for (const node of mutation.addedNodes) {
highlighter.onNodeChanged(node);
}
}
});
observer.observe(editorBody, { subtree: true, childList: true });
onSetContent();
return () => {
observer.disconnect();
highlighter.clearHighlights();
editor.off('NodeChange', onNodeChange);
editor.off('keyup', onKeyUp);
editor.off('SetContent', onSetContent);
};
}, [editor, highlighter]);
};
export default useHighlightedSearchTerms;
@@ -6,7 +6,9 @@ import shim from '@joplin/lib/shim';
const useLinkTooltips = (editor: Editor|null) => {
const resetModifiedTitles = useCallback(() => {
if (!editor) return;
for (const element of editor.getDoc().querySelectorAll('a[data-joplin-original-title]')) {
const doc = editor.getDoc();
if (!doc) return;
for (const element of doc.querySelectorAll('a[data-joplin-original-title]')) {
element.setAttribute('title', element.getAttribute('data-joplin-original-title') ?? '');
element.removeAttribute('data-joplin-original-title');
}
@@ -20,6 +20,7 @@ import { WhiteboardNodeData } from '../canvasFlow';
import useCheckboxToggle from '../useCheckboxToggle';
import handlePositions from './handlePositions';
import useCardWheel from './useCardWheel';
import stripUnsafeStyles from './stripUnsafeStyles';
const logger = Logger.create('WhiteboardFileNode');
@@ -124,7 +125,7 @@ const FileNode = ({ data, selected }: NodeProps<{ id: string; type: 'wbFile'; da
const result = await ctx.markupToHtml(MarkupLanguage.Markdown, resolved.body, {
resourceInfos: linkedResources,
});
if (!cancelled) setNoteHtml(result?.html ?? '');
if (!cancelled) setNoteHtml(stripUnsafeStyles(result?.html ?? ''));
} catch {
if (!cancelled) setNoteHtml('');
}
@@ -11,6 +11,7 @@ import { WhiteboardNodeData } from '../canvasFlow';
import useCheckboxToggle from '../useCheckboxToggle';
import handlePositions from './handlePositions';
import useCardWheel from './useCardWheel';
import stripUnsafeStyles from './stripUnsafeStyles';
const useRenderedMarkdown = (md: string, ctx: WhiteboardContextValue) => {
const [html, setHtml] = useState<string>('');
@@ -26,7 +27,7 @@ const useRenderedMarkdown = (md: string, ctx: WhiteboardContextValue) => {
const result = await ctx.markupToHtml(MarkupLanguage.Markdown, md, {
resourceInfos: ctx.resourceInfos,
});
if (!cancelled) setHtml(result?.html ?? '');
if (!cancelled) setHtml(stripUnsafeStyles(result?.html ?? ''));
} catch {
if (!cancelled) setHtml('');
}
@@ -0,0 +1,48 @@
import stripUnsafeStyles from './stripUnsafeStyles';
describe('stripUnsafeStyles', () => {
test.each([
[
'removes a style element',
'<p>Hello</p><style>.rli-sideBar { outline: 3px solid magenta; }</style>',
'<p>Hello</p>',
],
[
'removes a style element containing a remote @import',
'<style>@import url(http://evil.example/beacon.css);</style><p>Hi</p>',
'<p>Hi</p>',
],
[
'removes multiple style elements',
'<style>a{}</style><p>x</p><style>b{}</style>',
'<p>x</p>',
],
[
'removes an uppercase STYLE element',
'<P>x</P><STYLE>@import url(http://evil.example/x.css);</STYLE>',
'<p>x</p>',
],
[
'removes a mixed-case Style element',
'<Style>a{}</Style><p>y</p>',
'<p>y</p>',
],
[
'keeps rich card content (images, formatting, links)',
'<p><strong>Bold</strong> <a href="https://example.com">link</a> <img src="x.png"></p>',
'<p><strong>Bold</strong> <a href="https://example.com">link</a> <img src="x.png"></p>',
],
[
'keeps inline style attributes, which cannot reach the app chrome',
'<p style="color: red">text</p>',
'<p style="color: red">text</p>',
],
])('should %s', (_label, input, expected) => {
expect(stripUnsafeStyles(input)).toBe(expected);
});
test('should return empty input unchanged', () => {
expect(stripUnsafeStyles('')).toBe('');
});
});
@@ -0,0 +1,15 @@
// Whiteboard cards render into the main document, not the isolated note-viewer
// iframe, so a note's <style> would reach the app chrome. Removing <style>
// elements also drops any @import; inline style="" attributes are kept as they
// only affect the card itself.
const stripUnsafeStyles = (html: string) => {
if (!html || !/<style/i.test(html)) return html;
const doc = new DOMParser().parseFromString(html, 'text/html');
for (const style of Array.from(doc.querySelectorAll('style'))) {
style.remove();
}
return doc.body.innerHTML;
};
export default stripUnsafeStyles;
@@ -5,7 +5,7 @@ import { flipNthCheckbox } from './useCheckboxToggle';
// `flipNthCheckbox` must count exactly the same subset so the Nth rendered
// checkbox maps to the Nth source-text checkbox.
describe('flipNthCheckbox', () => {
describe('useCheckboxToggle', () => {
test('flips an unchecked box to checked', () => {
expect(flipNthCheckbox('- [ ] todo', 0)).toBe('- [x] todo');
});
@@ -18,7 +18,7 @@ import styles_ from './styles';
import { NoteEditorProps, FormNote, OnChangeEvent, AllAssetsOptions, NoteBodyEditorRef, NoteBodyEditorPropsAndRef, NoteBodyEditorType } from './utils/types';
import CommandService from '@joplin/lib/services/CommandService';
import Button, { ButtonLevel } from '../Button/Button';
import eventManager, { EventName } from '@joplin/lib/eventManager';
import eventManager, { EventName, NoteLockNoteStateChangeEvent } from '@joplin/lib/eventManager';
import { AppState } from '../../app.reducer';
import ToolbarButtonUtils, { ToolbarButtonInfo } from '@joplin/lib/services/commands/ToolbarButtonUtils';
import { _, _n } from '@joplin/lib/locale';
@@ -28,6 +28,10 @@ import Setting from '@joplin/lib/models/Setting';
import stateToWhenClauseContext from '../../services/commands/stateToWhenClauseContext';
import ExternalEditWatcher from '@joplin/lib/services/ExternalEditWatcher';
import { itemIsReadOnly } from '@joplin/lib/models/utils/readOnly';
import NoteLockSession from '@joplin/lib/services/noteLock/NoteLockSession';
import isNoteLockEnabled from '@joplin/lib/services/noteLock/isNoteLockEnabled';
import { SyncInfo } from '@joplin/lib/services/synchronizer/syncInfoUtils';
import NoteLockPanel from './NoteLockPanel/NoteLockPanel';
import { themeStyle } from '@joplin/lib/theme';
import { substrWithEllipsis } from '@joplin/lib/string-utils';
import NoteSearchBar from '../NoteSearchBar';
@@ -62,6 +66,7 @@ import useConnectToEditorPlugin from './utils/useConnectToEditorPlugin';
import getResourceBaseUrl from './utils/getResourceBaseUrl';
import useInitialCursorLocation from './utils/useInitialCursorLocation';
import NotePositionService, { EditorCursorLocations } from '@joplin/lib/services/NotePositionService';
import { blur } from '@joplin/lib/utils/focusHandler';
const debounce = require('debounce');
@@ -80,6 +85,7 @@ function NoteEditorContent(props: NoteEditorProps) {
const [showRevisions, setShowRevisions] = useState(false);
const [titleHasBeenManuallyChanged, setTitleHasBeenManuallyChanged] = useState(false);
const [isReadOnly, setIsReadOnly] = useState<boolean>(false);
const [reloadInProgress, setReloadInProgress] = useState(false);
const editorRef = useRef<NoteBodyEditorRef|null>(null);
const titleInputRef = useRef<HTMLInputElement|null>(null);
@@ -92,11 +98,20 @@ function NoteEditorContent(props: NoteEditorProps) {
}, []);
const setFormNoteRef = useRef<OnSetFormNote>(null);
const formNoteRef = useRef<FormNote>(null);
const { saveNoteIfWillChange, scheduleSaveNote } = useScheduleSaveCallbacks({
setFormNote: setFormNoteRef, dispatch: props.dispatch, editorRef, editorId,
setFormNote: setFormNoteRef, formNote: formNoteRef, dispatch: props.dispatch, editorRef, editorId,
editorNoteReloadTimeRequest: props.editorNoteReloadTimeRequest,
});
const formNote_beforeLoad = useCallback(async (event: OnLoadEvent) => {
await saveNoteIfWillChange(event.formNote);
// The lock must wait for the pending saves of the note being left: a locked note's save
// needs the unlocked session to encrypt, so locking first would drop the last edits.
// The note being left may have no form note at all (unlock or cannot-decrypt overlay).
if (isNoteLockEnabled() && Setting.value('noteLock.lockOnNoteSwitch')) {
await event.formNote.saveActionQueue?.waitForAllDone();
NoteLockSession.instance().lock();
}
setShowRevisions(false);
}, [saveNoteIfWillChange]);
@@ -107,8 +122,37 @@ function NoteEditorContent(props: NoteEditorProps) {
const effectiveNoteId = useEffectiveNoteId(props);
const { editorPlugin, editorView } = usePluginEditorView(props.plugins);
const builtInEditorVisible = !editorPlugin;
const windowId = useContext(WindowIdContext);
const onDecryptFailedChange = useCallback((value: boolean) => {
props.dispatch({ type: 'SET_ACTIVE_NOTE_IS_UNDECRYPTABLE', value, windowId });
}, [props.dispatch, windowId]);
const onReloadInProgressChange = useCallback((value: boolean) => {
if (value) {
// TinyMCE edits inside its own document. Blurring only the outer window does
// not reliably stop input when the editor is in a secondary window.
editorRef.current?.blurEditor?.();
const { formNote, setFormNote, isNewNote, resourceInfos } = useFormNote({
// Blur only the window whose editor is being refreshed. A secondary window
// can refresh in response to a change saved by the main editor; blurring the
// global document here would incorrectly interrupt typing in the main window.
const editorWindowActiveElement = containerRef.current?.ownerDocument?.activeElement;
if (editorWindowActiveElement) {
blur('NoteEditor::reloadEditorWindow', editorWindowActiveElement);
}
}
setReloadInProgress(value);
if (!value) {
// TinyMCE can report onWillChange before a reload, then have its delayed
// onChange rejected while the reload is in progress. Since no save is queued
// in that case, clear the saving status when the reload completes.
props.dispatch({
type: 'EDITOR_NOTE_STATUS_REMOVE',
id: effectiveNoteId,
});
}
}, [effectiveNoteId, props.dispatch]);
const { formNote, setFormNote, isNewNote, resourceInfos, decryptFailed, loadBlocked } = useFormNote({
noteId: effectiveNoteId,
isProvisional: props.isProvisional,
titleInputRef: titleInputRef,
@@ -117,14 +161,48 @@ function NoteEditorContent(props: NoteEditorProps) {
onAfterLoad: formNote_afterLoad,
builtInEditorVisible,
editorId,
noteLockSessionUnlocked: props.noteLockSessionUnlocked,
onDecryptFailedChange,
onReloadInProgressChange,
editorNoteReloadTimeRequest: props.editorNoteReloadTimeRequest,
});
setFormNoteRef.current = setFormNote;
const formNoteRef = useRef<FormNote>(formNote);
formNoteRef.current = { ...formNote };
useEffect(() => {
if (!isNoteLockEnabled()) return () => {};
// Pending scheduled saves read the lock state from the form note, so it must follow an
// enable/disable triggered outside the editor (note list menu, another window) immediately.
const onLockStateChange = (event: NoteLockNoteStateChangeEvent) => {
if (formNoteRef.current.id !== event.noteId) return;
// Enabling requires an unlocked session, so capture the key the same way a decrypt
// does - a pending save can then still encrypt if the session locks before it runs.
const noteLockKey = event.isLocked && NoteLockSession.instance().isUnlocked() ? NoteLockSession.instance().decryptedKey() : null;
const newFormNote = { ...formNoteRef.current, is_locked: event.isLocked ? 1 : 0, noteLockKey, isDecrypted: event.isLocked };
setFormNote(newFormNote);
void scheduleSaveNote(newFormNote);
};
eventManager.on(EventName.NoteLockNoteStateChange, onLockStateChange);
return () => {
eventManager.off(EventName.NoteLockNoteStateChange, onLockStateChange);
};
}, [setFormNote, scheduleSaveNote]);
useAsyncEffect(async () => {
if (!isNoteLockEnabled() || props.noteLockSessionUnlocked) return;
const lockedFormNote = formNoteRef.current;
if (!lockedFormNote?.is_locked || !lockedFormNote.hasChanged) return;
// The panel replaces the editor only once the edits are written, so flush them the same way
// a note switch does before locking.
await saveNoteIfWillChange(lockedFormNote);
// The editor dispatches its pending change when it unmounts, which would be saved over the
// flushed one - clearing the change id makes onFieldChange drop it, as a note switch does.
setFormNote(prev => ({ ...prev, bodyWillChangeId: 0 }));
await lockedFormNote.saveActionQueue?.waitForAllDone();
}, [props.noteLockSessionUnlocked, saveNoteIfWillChange]);
const formNoteFolder = useFolder({ folderId: formNote.parent_id });
const windowId = useContext(WindowIdContext);
const shownEditorViewIds = useVisiblePluginEditorViewIds(props.plugins, windowId);
useConnectToEditorPlugin({
startupPluginsLoaded: props.startupPluginsLoaded,
@@ -210,6 +288,7 @@ function NoteEditorContent(props: NoteEditorProps) {
}, [formNote.title, props.onTitleChange]);
const onFieldChange = useCallback(async (field: string, value: string, changeId = 0) => {
if (reloadInProgress) return;
if (!isMountedRef.current) {
// When the component is unmounted, various actions can happen which can
// trigger onChange events, for example the textarea might be cleared.
@@ -258,7 +337,7 @@ function NoteEditorContent(props: NoteEditorProps) {
// - debounced because many calls to scheduleSaveNote can resolve at once
scheduleNoteListResort();
}
}, [handleProvisionalFlag, formNote, setFormNote, isNewNote, titleHasBeenManuallyChanged, scheduleNoteListResort, scheduleSaveNote]);
}, [reloadInProgress, handleProvisionalFlag, formNote, setFormNote, isNewNote, titleHasBeenManuallyChanged, scheduleNoteListResort, scheduleSaveNote]);
const onDrop = useDropHandler({ editorRef });
@@ -417,7 +496,16 @@ function NoteEditorContent(props: NoteEditorProps) {
return <div style={emptyDivStyle} ref={containerRef}></div>;
}
const searchMarkers = useSearchMarkers(showLocalSearch, localSearchMarkerOptions, props.searches, props.selectedSearchId, props.highlightedWords);
const searchMarkers = useSearchMarkers({
showLocalSearch,
localSearchMarkerOptions,
noteId: props.noteId,
searchResults: props.searchResults,
searchId: props.selectedSearchId,
searches: props.searches,
highlightedWords: props.highlightedWords,
noteTitle: formNote.title,
});
const initialCursorLocation = useInitialCursorLocation({
noteId: props.noteId,
});
@@ -434,13 +522,14 @@ function NoteEditorContent(props: NoteEditorProps) {
content: formNote.body,
contentMarkupLanguage: markupLanguage,
contentOriginalCss: formNote.originalCss,
editorNoteReloadTimeRequest: props.editorNoteReloadTimeRequest,
initialCursorLocation,
resourceInfos: resourceInfos,
resourceDirectory: Setting.value('resourceDir'),
htmlToMarkdown: htmlToMarkdown,
markupToHtml: markupToHtml,
allAssets: allAssets,
disabled: isReadOnly,
disabled: isReadOnly || reloadInProgress,
themeId: props.themeId,
dispatch: props.dispatch,
noteToolbar: null,
@@ -469,7 +558,9 @@ function NoteEditorContent(props: NoteEditorProps) {
useCustomPdfViewer: props.useCustomPdfViewer,
// We need it to identify the context for which media is rendered.
// It is currently used to remember pdf scroll position for each attachments of each note uniquely.
noteId: props.noteId,
// Use the form note object to ensure the editor is associated with the loaded form note until onBeforeLoad
// has flushed any pending changes from it.
noteId: formNote.id,
watchedNoteFiles: props.watchedNoteFiles,
enableHtmlToMarkdownBanner: props.enableHtmlToMarkdownBanner,
showNoteLinkIcon: props.showNoteLinkIcon,
@@ -492,7 +583,7 @@ function NoteEditorContent(props: NoteEditorProps) {
}, [noteHasWhiteboardFence, props.dispatch]);
if (useWhiteboardEditor) {
editor = <WhiteboardEditor {...editorProps}/>;
editor = <WhiteboardEditor key={formNote.id} {...editorProps}/>;
} else if (builtInEditorVisible) {
if (props.bodyEditor === 'TinyMCE') {
editor = <TinyMCE {...editorProps}/>;
@@ -660,7 +751,43 @@ function NoteEditorContent(props: NoteEditorProps) {
/>;
};
if (formNote.encryption_applied || !formNote.id || !effectiveNoteId) {
// A locked note has no form note while the session is locked (see loadNoteForForm), so the
// panel is driven by the note metadata. A loaded form note stays mounted on lock only if it
// has unsaved changes, so they are not thrown away.
const lockedNoteMetadata = isNoteLockEnabled() && effectiveNoteId ? props.notes.find(n => n.id === effectiveNoteId) : null;
if (lockedNoteMetadata?.is_locked) {
// The session is unlocked but the note content failed to decrypt (e.g. it was encrypted
// prior to a password reset) - locking the session again shows the regular unlock panel.
if (decryptFailed && props.noteLockSessionUnlocked) {
return (
<div style={styles.root} ref={containerRef}>
<NoteLockPanel
noteTitle={lockedNoteMetadata.title}
hasNoteLockKey={props.hasNoteLockKey}
dispatch={props.dispatch}
undecryptable={true}
/>
</div>
);
}
const formNoteLoaded = formNote.id === effectiveNoteId;
const showLockPanel = formNoteLoaded
? !props.noteLockSessionUnlocked && !formNote.hasChanged
: !props.noteLockSessionUnlocked || loadBlocked;
if (showLockPanel) {
return (
<div style={styles.root} ref={containerRef}>
<NoteLockPanel
noteTitle={lockedNoteMetadata.title}
hasNoteLockKey={props.hasNoteLockKey}
dispatch={props.dispatch}
/>
</div>
);
}
}
if ((!editorPlugin && reloadInProgress) || formNote.encryption_applied || !formNote.id || !effectiveNoteId) {
return renderNoNotes(styles.root);
}
@@ -672,6 +799,7 @@ function NoteEditorContent(props: NoteEditorProps) {
const note = props.notes.find(n => n.id === props.selectedNoteIds[0]);
if (!note) return null;
if (note.markup_language !== MarkupLanguage.Html) return null;
if (isNoteLockEnabled() && note.is_locked && !props.noteLockSessionUnlocked) return null;
return (
<div style={styles.resourceWatchBanner}>
@@ -700,7 +828,7 @@ function NoteEditorContent(props: NoteEditorProps) {
noteTitle={formNote.title}
noteUserUpdatedTime={formNote.user_updated_time}
onTitleChange={onTitleChange}
disabled={isReadOnly}
disabled={isReadOnly || reloadInProgress}
/>
{renderSearchInfo()}
<div style={{ display: 'flex', flex: 1, paddingLeft: theme.editorPaddingLeft, maxHeight: '100%', minHeight: '0' }}>
@@ -725,6 +853,15 @@ interface ConnectProps {
windowId: string;
}
// Memoized because mapStateToProps runs on every dispatch and SyncInfo parses the cached JSON.
let hasNoteLockKeyCache: { syncInfoCache: string; value: boolean } = null;
const hasNoteLockKey = (syncInfoCache: string) => {
if (!hasNoteLockKeyCache || hasNoteLockKeyCache.syncInfoCache !== syncInfoCache) {
hasNoteLockKeyCache = { syncInfoCache, value: !!new SyncInfo(syncInfoCache).noteLockKey };
}
return hasNoteLockKeyCache.value;
};
const mapStateToProps = (state: AppState, ownProps: ConnectProps) => {
const whenClauseContext = stateToWhenClauseContext(state, { windowId: ownProps.windowId });
const windowState = stateUtils.windowStateById(state, ownProps.windowId);
@@ -784,6 +921,9 @@ const mapStateToProps = (state: AppState, ownProps: ConnectProps) => {
enableInEditorRendering: state.settings['editor.inlineRendering'],
showNoteLinkIcon: state.settings['notes.showNoteLinkIcon'],
whiteboardForceMarkdown: windowState.whiteboardForceMarkdown ?? {},
noteLockSessionUnlocked: state.noteLockSessionUnlocked,
hasNoteLockKey: hasNoteLockKey(state.settings['syncInfoCache']),
editorNoteReloadTimeRequest: windowState.windowEditorNoteReloadTimeRequest,
};
};
@@ -0,0 +1,88 @@
import * as React from 'react';
import { useCallback, useState } from 'react';
import { Dispatch } from 'redux';
import { _ } from '@joplin/lib/locale';
import NoteLockSession from '@joplin/lib/services/noteLock/NoteLockSession';
import Button, { ButtonLevel } from '../../Button/Button';
import LabelledPasswordInput from '../../PasswordInput/LabelledPasswordInput';
interface Props {
noteTitle: string;
hasNoteLockKey: boolean;
dispatch: Dispatch;
undecryptable?: boolean;
}
export default function NoteLockPanel(props: Props) {
const [password, setPassword] = useState('');
const [unlocking, setUnlocking] = useState(false);
const [errorMessage, setErrorMessage] = useState('');
const onPasswordChange = useCallback((event: React.ChangeEvent<HTMLInputElement>) => {
setPassword(event.target.value);
}, []);
const unlock = useCallback(async () => {
if (!password || unlocking) return;
setUnlocking(true);
try {
await NoteLockSession.instance().unlock(password);
// No success handling: the unlock event swaps this panel out for the note.
} catch (error) {
// WebCrypto reports a wrong password as a generic OperationError.
setErrorMessage(error.name === 'OperationError' ? _('Invalid password') : error.message);
setUnlocking(false);
}
}, [password, unlocking]);
const onSubmit = useCallback((event: React.FormEvent) => {
event.preventDefault();
void unlock();
}, [unlock]);
const onSetUpClick = useCallback(() => {
props.dispatch({
type: 'NAV_GO',
routeName: 'Config',
props: { defaultSection: 'noteLock' },
});
}, [props.dispatch]);
const renderAction = () => {
if (props.undecryptable) {
return <p className="message">{_('This note could not be decrypted. If it was encrypted prior to a password reset, the contents are no longer recoverable.')}</p>;
}
if (!props.hasNoteLockKey) {
return (
<>
<p className="message">{_('Reading this note requires the note lock password, which has not been set up on this device yet.')}</p>
<Button level={ButtonLevel.Primary} title={_('Set up note lock')} onClick={onSetUpClick} />
</>
);
}
return (
<form className="form unlock-form" onSubmit={onSubmit}>
<p className="message">{_('This note is encrypted. Enter the note lock password to unlock encrypted notes for this session.')}</p>
<div className="field">
<LabelledPasswordInput
labelText={_('Note lock password')}
value={password}
onChange={onPasswordChange}
/>
</div>
{!!errorMessage && <p className="error" role="alert">{errorMessage}</p>}
<Button type='button' level={ButtonLevel.Primary} title={_('Unlock')} disabled={!password || unlocking} onClick={unlock} />
</form>
);
};
return (
<div className="note-lock-panel">
<i className="icon fas fa-lock" role="img" aria-label={_('Locked note')}></i>
<h2 className="title">{props.noteTitle}</h2>
{renderAction()}
</div>
);
}
@@ -0,0 +1,47 @@
.note-lock-panel {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
padding: var(--joplin-main-padding);
text-align: center;
}
.note-lock-panel > .icon {
font-size: 32px;
color: var(--joplin-color-faded);
margin-bottom: 16px;
}
.note-lock-panel > .title {
margin: 0 0 8px 0;
overflow-wrap: anywhere;
}
.note-lock-panel > .message {
max-width: 420px;
color: var(--joplin-color-faded);
margin-bottom: 16px;
}
.note-lock-panel > .unlock-form {
display: flex;
flex-direction: column;
align-items: center;
}
.note-lock-panel > .unlock-form > .message {
max-width: 420px;
color: var(--joplin-color-faded);
margin-bottom: 16px;
}
.note-lock-panel > .unlock-form > .field {
margin-bottom: 16px;
}
.note-lock-panel > .unlock-form > .error {
color: var(--joplin-color-error);
margin: 0 0 12px 0;
}
@@ -2,7 +2,6 @@ import { FormNote } from './types';
import HtmlToMd, { ParseOptions } from '@joplin/lib/HtmlToMd';
import Note from '@joplin/lib/models/Note';
import { NoteEntity } from '@joplin/lib/services/database/types';
import { MarkupToHtml } from '@joplin/renderer';
export async function htmlToMarkdown(markupLanguage: number, html: string, originalCss: string, parseOptions: ParseOptions = null): Promise<string> {
@@ -26,7 +25,7 @@ export async function htmlToMarkdown(markupLanguage: number, html: string, origi
return newBody;
}
export async function formNoteToNote(formNote: FormNote): Promise<NoteEntity> {
export async function formNoteToNote(formNote: FormNote) {
return {
id: formNote.id,
// Should also include parent_id and deleted_time so that the reducer
@@ -37,5 +36,7 @@ export async function formNoteToNote(formNote: FormNote): Promise<NoteEntity> {
is_conflict: formNote.is_conflict,
title: formNote.title,
body: formNote.body,
is_locked: formNote.is_locked,
isDecrypted: formNote.isDecrypted,
};
}
@@ -16,6 +16,8 @@ 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';
import type { DecryptedNoteLockKey } from '@joplin/lib/services/noteLock/NoteLockKey';
import { HighlightedWord, SearchEntry } from '@joplin/lib/reducer';
export interface AllAssetsOptions {
contentMaxWidthTarget?: string;
@@ -47,13 +49,12 @@ export interface NoteEditorProps {
notesParentType: string;
selectedNoteTags: TagEntity[];
selectedNoteHash: string;
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- searches: any[] is the shape in lib's reducer; tightening requires updating the reducer first
searches: any[];
searches: SearchEntry[];
selectedSearchId: string;
customCss: string;
noteVisiblePanes: string[];
watchedResources: Record<string, unknown>;
highlightedWords: string[];
highlightedWords: HighlightedWord[];
tabMovesFocus: boolean;
plugins: PluginStates;
toolbarButtonInfos: ToolbarItem[];
@@ -73,10 +74,14 @@ export interface NoteEditorProps {
enableHtmlToMarkdownBanner: boolean;
showNoteLinkIcon: boolean;
whiteboardForceMarkdown: Record<string, boolean>;
noteLockSessionUnlocked: boolean;
hasNoteLockKey: boolean;
editorNoteReloadTimeRequest: number;
}
export interface NoteBodyEditorRef {
content(): string|Promise<string>;
blurEditor?(): void;
resetScroll(): void;
scrollTo(options: ScrollOptions): void;
@@ -113,6 +118,7 @@ export interface NoteBodyEditorProps {
contentKey: string;
contentMarkupLanguage: number;
contentOriginalCss: string;
editorNoteReloadTimeRequest: number;
initialCursorLocation: EditorCursorLocations;
onChange(event: OnChangeEvent): void;
onWillChange(event: { changeId: number }): void;
@@ -168,6 +174,12 @@ export interface FormNote {
markup_language: number;
user_updated_time: number;
encryption_applied: number;
is_locked: number;
// The key captured when the locked note was decrypted; pending saves encrypt with it so they
// can complete even if the session locks before they run.
noteLockKey: DecryptedNoteLockKey|null;
// Proof for gated saves that the body is plaintext.
isDecrypted: boolean;
deleted_time: number;
hasChanged: boolean;
@@ -220,6 +232,9 @@ export function defaultFormNote(): FormNote {
hasChanged: false,
user_updated_time: 0,
encryption_applied: 0,
is_locked: 0,
noteLockKey: null,
isDecrypted: false,
};
}
@@ -1,11 +1,15 @@
import Note from '@joplin/lib/models/Note';
import Setting from '@joplin/lib/models/Setting';
import { setupDatabaseAndSynchronizer, supportDir, switchClient } from '@joplin/lib/testing/test-utils';
import { act, renderHook, waitFor } from '@testing-library/react';
import useFormNote, { HookDependencies } from './useFormNote';
import shim from '@joplin/lib/shim';
import Resource from '@joplin/lib/models/Resource';
import ItemChange from '@joplin/lib/models/ItemChange';
import { join } from 'path';
import { formNoteToNote } from '.';
import NoteLockNote from '@joplin/lib/services/noteLock/NoteLockNote';
import NoteLockSession from '@joplin/lib/services/noteLock/NoteLockSession';
const defaultFormNoteProps: HookDependencies = {
noteId: '',
@@ -16,6 +20,16 @@ const defaultFormNoteProps: HookDependencies = {
onAfterLoad: () => { },
editorId: 'editor',
builtInEditorVisible: false,
noteLockSessionUnlocked: false,
onDecryptFailedChange: () => {},
};
const deferred = <T>() => {
let resolve: (value: T)=> void;
const promise = new Promise<T>(resolvePromise => {
resolve = resolvePromise;
});
return { promise, resolve: resolve! };
};
describe('useFormNote', () => {
@@ -24,6 +38,99 @@ describe('useFormNote', () => {
await switchClient(1);
});
// The session and decryption internals are covered by the lib tests; here they are mocked to
// test only the hook's gating: ciphertext must never reach the form note.
it('should not produce a form note for a locked note while the session is locked, and decrypt it once unlocked', async () => {
Setting.setValue('featureFlag.noteLock', true);
// A direct save of a new note with is_locked keeps the raw body, standing in for ciphertext.
const testNote = await Note.save({ title: 'Locked note', body: 'ciphertext', is_locked: 1 });
const isUnlockedMock = jest.spyOn(NoteLockSession.instance(), 'isUnlocked').mockReturnValue(false);
const decryptedKeyMock = jest.spyOn(NoteLockSession.instance(), 'decryptedKey').mockReturnValue({ id: 'key-id', plainText: 'key' });
const decryptBodyMock = jest.spyOn(NoteLockNote, 'decryptBody').mockImplementation(async note => ({ ...note, body: 'secret content' }));
const onBeforeLoad = jest.fn();
const onDecryptFailedChange = jest.fn();
try {
const lockedRender = renderHook(props => useFormNote(props), {
initialProps: { ...defaultFormNoteProps, noteId: testNote.id, onBeforeLoad, onDecryptFailedChange },
});
// The blocked load produces no state change to wait for; onBeforeLoad is its last
// await, so once it has run a single flush completes the branch.
await waitFor(() => expect(onBeforeLoad).toHaveBeenCalled());
await act(async () => {});
expect(lockedRender.result.current.formNote).toMatchObject({
id: '',
body: '',
noteLockKey: null,
});
expect(lockedRender.result.current.loadBlocked).toBe(true);
expect(onDecryptFailedChange).toHaveBeenLastCalledWith(false);
isUnlockedMock.mockReturnValue(true);
lockedRender.rerender({ ...defaultFormNoteProps, noteId: testNote.id, onBeforeLoad, onDecryptFailedChange, noteLockSessionUnlocked: true });
await waitFor(() => {
expect(lockedRender.result.current.formNote).toMatchObject({
id: testNote.id,
is_locked: 1,
body: 'secret content',
noteLockKey: { id: 'key-id', plainText: 'key' },
});
});
expect(lockedRender.result.current.loadBlocked).toBe(false);
lockedRender.unmount();
Setting.setValue('featureFlag.noteLock', false);
isUnlockedMock.mockReturnValue(false);
const flagOffRender = renderHook(props => useFormNote(props), {
initialProps: { ...defaultFormNoteProps, noteId: testNote.id },
});
await waitFor(() => {
expect(flagOffRender.result.current.formNote.id).toBe(testNote.id);
});
expect(flagOffRender.result.current.formNote).toMatchObject({
body: 'ciphertext',
});
flagOffRender.unmount();
} finally {
isUnlockedMock.mockRestore();
decryptedKeyMock.mockRestore();
decryptBodyMock.mockRestore();
Setting.setValue('featureFlag.noteLock', false);
}
});
it('should report a decryption failure instead of throwing, without producing a form note', async () => {
Setting.setValue('featureFlag.noteLock', true);
const testNote = await Note.save({ title: 'Locked note', body: 'ciphertext', is_locked: 1 });
// The save's own change event would otherwise reach the hook's listener and reload the note.
await ItemChange.waitForAllSaved();
const isUnlockedMock = jest.spyOn(NoteLockSession.instance(), 'isUnlocked').mockReturnValue(true);
const decryptBodyMock = jest.spyOn(NoteLockNote, 'decryptBody').mockRejectedValue(new Error('OperationError'));
const onDecryptFailedChange = jest.fn();
try {
const render = renderHook(props => useFormNote(props), {
initialProps: { ...defaultFormNoteProps, noteId: testNote.id, noteLockSessionUnlocked: true, onDecryptFailedChange },
});
await waitFor(() => {
expect(render.result.current.decryptFailed).toBe(true);
});
expect(render.result.current.formNote).toMatchObject({
id: '',
body: '',
});
expect(render.result.current.loadBlocked).toBe(false);
expect(onDecryptFailedChange).toHaveBeenLastCalledWith(true);
render.unmount();
} finally {
isUnlockedMock.mockRestore();
decryptBodyMock.mockRestore();
Setting.setValue('featureFlag.noteLock', false);
}
});
it('should update note when decryption completes', async () => {
const testNote = await Note.save({ title: 'Test Note!' });
@@ -101,7 +208,7 @@ describe('useFormNote', () => {
is_conflict: 1,
title: testNote.title,
});
});
}, { timeout: 15_000 });
// Should preserve is_conflict after save.
expect(await formNoteToNote(formNote.result.current.formNote)).toMatchObject({
@@ -115,10 +222,12 @@ describe('useFormNote', () => {
it('should reload the note when it is changed outside of the editor', async () => {
const note = await Note.save({ title: 'Test Note!', body: '...' });
const onReloadInProgressChange = jest.fn();
const props = {
...defaultFormNoteProps,
noteId: note.id,
onReloadInProgressChange,
};
const formNote = renderHook(props => useFormNote(props), {
@@ -137,10 +246,59 @@ describe('useFormNote', () => {
await waitFor(() => {
expect(formNote.result.current.formNote.title).toBe('Modified');
});
expect(onReloadInProgressChange).not.toHaveBeenCalled();
formNote.unmount();
});
it('should force a reload when editorNoteReloadTimeRequest changes', async () => {
const note = await Note.save({ title: 'Original', body: '...' });
const onReloadInProgressChange = jest.fn();
const props = { ...defaultFormNoteProps, noteId: note.id, editorNoteReloadTimeRequest: 0, onReloadInProgressChange };
const formNote = renderHook(hookProps => useFormNote(hookProps), { initialProps: props });
await waitFor(() => expect(formNote.result.current.formNote.title).toBe('Original'));
act(() => {
formNote.result.current.setFormNote(previous => ({ ...previous, title: 'Unsaved local title', hasChanged: true }));
});
await Note.save({ id: note.id, title: 'Remote title' }, { changeId: 'test-editor' });
formNote.rerender({ ...props, editorNoteReloadTimeRequest: 1 });
await waitFor(() => expect(onReloadInProgressChange).toHaveBeenCalledWith(true));
await waitFor(() => expect(formNote.result.current.formNote.title).toBe('Remote title'));
await waitFor(() => expect(onReloadInProgressChange).toHaveBeenLastCalledWith(false));
formNote.unmount();
});
it('should clear reloadInProgress after overlapping reload requests', async () => {
const note = await Note.save({ title: 'Original', body: '...' });
const onReloadInProgressChange = jest.fn();
const props = { ...defaultFormNoteProps, noteId: note.id, editorNoteReloadTimeRequest: 0, onReloadInProgressChange };
const formNote = renderHook(hookProps => useFormNote(hookProps), { initialProps: props });
await waitFor(() => expect(formNote.result.current.formNote.title).toBe('Original'));
const firstLoad = deferred<typeof note>();
const lastLoad = deferred<typeof note>();
const loadMock = jest.spyOn(Note, 'load')
.mockImplementationOnce(() => firstLoad.promise)
.mockImplementationOnce(() => lastLoad.promise);
formNote.rerender({ ...props, editorNoteReloadTimeRequest: 1 });
await waitFor(() => expect(loadMock).toHaveBeenCalledTimes(1));
formNote.rerender({ ...props, editorNoteReloadTimeRequest: 2 });
firstLoad.resolve({ ...note, title: 'First reload' });
await waitFor(() => expect(loadMock).toHaveBeenCalledTimes(2));
expect(onReloadInProgressChange).toHaveBeenLastCalledWith(true);
lastLoad.resolve({ ...note, title: 'Last reload' });
await waitFor(() => expect(formNote.result.current.formNote.title).toBe('Last reload'));
await waitFor(() => expect(onReloadInProgressChange).toHaveBeenLastCalledWith(false));
loadMock.mockRestore();
formNote.unmount();
});
test('should refresh resource infos when changed outside the editor', async () => {
let note = await Note.save({});
note = await shim.attachFileToNote(note, join(supportDir, 'sample.txt'));
@@ -9,12 +9,15 @@ import attachedResources, { clearResourceCache } from '@joplin/lib/utils/attache
import { MarkupToHtml } from '@joplin/renderer';
import Note from '@joplin/lib/models/Note';
import ResourceFetcher from '@joplin/lib/services/ResourceFetcher';
import { NoteEntity } from '@joplin/lib/services/database/types';
import { focus } from '@joplin/lib/utils/focusHandler';
import Logger from '@joplin/utils/Logger';
import eventManager, { EventName } from '@joplin/lib/eventManager';
import DecryptionWorker from '@joplin/lib/services/DecryptionWorker';
import useQueuedAsyncEffect from '@joplin/lib/hooks/useQueuedAsyncEffect';
import { NoteEntity } from '@joplin/lib/services/database/types';
import NoteLockNote from '@joplin/lib/services/noteLock/NoteLockNote';
import NoteLockSession from '@joplin/lib/services/noteLock/NoteLockSession';
import isNoteLockEnabled from '@joplin/lib/services/noteLock/isNoteLockEnabled';
const logger = Logger.create('useFormNote');
@@ -31,6 +34,10 @@ export interface HookDependencies {
onBeforeLoad(event: OnLoadEvent): void;
onAfterLoad(event: OnLoadEvent): void;
builtInEditorVisible: boolean;
noteLockSessionUnlocked: boolean;
onDecryptFailedChange(value: boolean): void;
onReloadInProgressChange?(value: boolean): void;
editorNoteReloadTimeRequest?: number;
}
type MapFormNoteCallback = (previousFormNote: FormNote)=> FormNote;
@@ -66,16 +73,41 @@ function resourceInfosChanged(a: ResourceInfos, b: ResourceInfos): boolean {
return false;
}
type InitNoteStateCallback = (note: NoteEntity, isNew: boolean)=> Promise<FormNote>;
const useRefreshFormNoteOnChange = (formNoteRef: RefObject<FormNote>, editorId: string, noteId: string, initNoteState: InitNoteStateCallback, builtInEditorVisible: boolean) => {
// While the session is locked, a locked note produces no form note at all - the unlock panel
// takes the editor's place. So no placeholder body exists that a save could write over the note.
const loadNoteForForm = async (noteId: string): Promise<{ note: NoteEntity|null; blocked: boolean; decryptFailed: boolean }> => {
if (isNoteLockEnabled()) {
const lockState = await Note.load(noteId, { fields: ['is_locked'] });
if (lockState && NoteLockNote.isLocked(lockState)) {
if (!NoteLockSession.instance().isUnlocked()) return { note: null, blocked: true, decryptFailed: false };
try {
return { note: await Note.load(noteId, { useNoteLock: true }), blocked: false, decryptFailed: false };
} catch (error) {
// Happens when the note was encrypted with a different key, e.g. before a password
// reset. Fail closed and let the editor show the cannot-decrypt panel.
logger.warn('Could not decrypt locked note:', noteId, error);
return { note: null, blocked: false, decryptFailed: true };
}
}
}
return { note: await Note.load(noteId), blocked: false, decryptFailed: false };
};
type InitNoteStateCallback = (note: NoteEntity, isNew: boolean, force?: boolean)=> Promise<FormNote>;
const useRefreshFormNoteOnChange = (formNoteRef: RefObject<FormNote>, editorId: string, noteId: string, initNoteState: InitNoteStateCallback, clearFormNote: ()=> void, builtInEditorVisible: boolean, noteLockSessionUnlocked: boolean, setDecryptFailed: (value: boolean)=> void, setLoadBlocked: (value: boolean)=> void, onReloadInProgressChange: (value: boolean)=> void, editorNoteReloadTimeRequest: number) => {
// Increasing the value of this counter cancels any ongoing note refreshes and starts
// a new refresh.
const [formNoteRefreshScheduled, setFormNoteRefreshScheduled] = useState<number>(0);
const [formNoteRefreshRequest, setFormNoteRefreshRequest] = useState({ counter: 0, force: false });
const previousEditorNoteReloadTimeRequestRef = useRef(editorNoteReloadTimeRequest);
const prevBuiltInEditorVisible = usePrevious<boolean>(builtInEditorVisible);
// Seeded because usePrevious defaults to null, which would read as a session change on mount.
const prevNoteLockSessionUnlocked = usePrevious<boolean>(noteLockSessionUnlocked, noteLockSessionUnlocked);
useQueuedAsyncEffect(async (event) => {
if (formNoteRefreshScheduled <= 0) return;
if (formNoteRef.current.hasChanged) {
if (event.cancelled) return;
if (formNoteRefreshRequest.counter <= 0) return;
const forceRefresh = formNoteRefreshRequest.force;
if (formNoteRef.current.hasChanged && !forceRefresh) {
logger.info('Form note changed between scheduling a refresh and the refresh itself. Cancelling the refresh.');
return;
}
@@ -83,38 +115,58 @@ const useRefreshFormNoteOnChange = (formNoteRef: RefObject<FormNote>, editorId:
logger.info('Sync has finished and note has never been changed - reloading it');
const loadNote = async () => {
const n = await Note.load(noteId);
if (event.cancelled || formNoteRef.current.hasChanged) return;
setDecryptFailed(false);
const { note: n, blocked, decryptFailed } = await loadNoteForForm(noteId);
if (event.cancelled || (formNoteRef.current.hasChanged && !forceRefresh)) return;
// Normally should not happened because if the note has been deleted via sync
// it would not have been loaded in the editor (due to note selection changing
// on delete)
if (!n) {
logger.warn('Trying to reload note that has been deleted:', noteId);
return;
setLoadBlocked(blocked);
setDecryptFailed(decryptFailed);
if (blocked || decryptFailed) {
// The session locked with this note open (or its content stopped decrypting) - drop
// the decrypted content and let the corresponding panel take over.
if (formNoteRef.current.id) clearFormNote();
} else {
// Normally should not happened because if the note has been deleted via sync
// it would not have been loaded in the editor (due to note selection changing
// on delete)
if (!n) {
logger.warn('Trying to reload note that has been deleted:', noteId);
return;
}
await initNoteState(n, false, forceRefresh);
if (event.cancelled) return;
}
await initNoteState(n, false);
if (event.cancelled) return;
setFormNoteRefreshScheduled(oldValue => {
setFormNoteRefreshRequest(oldValue => {
// If a new refresh was scheduled between initNoteState
// and now:
if (oldValue !== formNoteRefreshScheduled) {
if (oldValue.counter !== formNoteRefreshRequest.counter) {
return oldValue;
}
// A refresh is no longer scheduled
return 0;
return { counter: 0, force: false };
});
};
await loadNote();
}, [formNoteRefreshScheduled, noteId, editorId, initNoteState]);
try {
await loadNote();
} finally {
if (forceRefresh && !event.cancelled) onReloadInProgressChange(false);
}
}, [formNoteRefreshRequest, noteId, editorId, initNoteState, clearFormNote, setDecryptFailed, onReloadInProgressChange]);
const refreshFormNote = useCallback(() => {
const refreshFormNote = useCallback((force = false) => {
// Increase the counter to cancel any ongoing refresh attempts
// and start a new one.
setFormNoteRefreshScheduled(count => count + 1);
}, []);
if (force) onReloadInProgressChange(true);
setFormNoteRefreshRequest(previous => ({ counter: previous.counter + 1, force: previous.force || force }));
}, [onReloadInProgressChange]);
useEffect(() => {
if (editorNoteReloadTimeRequest === previousEditorNoteReloadTimeRequestRef.current) return;
previousEditorNoteReloadTimeRequestRef.current = editorNoteReloadTimeRequest;
refreshFormNote(true);
}, [editorNoteReloadTimeRequest, refreshFormNote]);
// When switching from the plugin editor to the built-in editor, we refresh the note since the
// plugin may have modified it via the data API.
@@ -124,6 +176,14 @@ const useRefreshFormNoteOnChange = (formNoteRef: RefObject<FormNote>, editorId:
}
}, [builtInEditorVisible, prevBuiltInEditorVisible, refreshFormNote]);
// Unlocking loads the note the panel was blocking (the form is then empty), locking must drop
// a locked note's plaintext - reload to recompute.
useEffect(() => {
if (isNoteLockEnabled() && prevNoteLockSessionUnlocked !== noteLockSessionUnlocked && (formNoteRef.current.is_locked || formNoteRef.current.id !== noteId)) {
refreshFormNote();
}
}, [noteLockSessionUnlocked, prevNoteLockSessionUnlocked, noteId, formNoteRef, refreshFormNote]);
useEffect(() => {
if (!noteId) return ()=>{};
@@ -153,18 +213,38 @@ const useRefreshFormNoteOnChange = (formNoteRef: RefObject<FormNote>, editorId:
export default function useFormNote(dependencies: HookDependencies) {
const {
noteId, isProvisional, titleInputRef, editorRef, onBeforeLoad, onAfterLoad, builtInEditorVisible, editorId,
noteId, isProvisional, titleInputRef, editorRef, onBeforeLoad, onAfterLoad, builtInEditorVisible, editorId, noteLockSessionUnlocked, onDecryptFailedChange,
} = dependencies;
const onReloadInProgressChange = dependencies.onReloadInProgressChange ?? (() => {});
const editorNoteReloadTimeRequest = dependencies.editorNoteReloadTimeRequest ?? 0;
const [formNote, setFormNote] = useState<FormNote>(defaultFormNote());
const [isNewNote, setIsNewNote] = useState(false);
// Keyed by note id so a failure can never carry over to another note the user switches to.
const [decryptFailedId, setDecryptFailedId] = useState<string|null>(null);
const setDecryptFailed = useCallback((value: boolean) => {
setDecryptFailedId(value ? noteId : null);
onDecryptFailedChange(value);
}, [noteId, onDecryptFailedChange]);
const [loadBlockedId, setLoadBlockedId] = useState<string|null>(null);
const setLoadBlocked = useCallback((value: boolean) => setLoadBlockedId(value ? noteId : null), [noteId]);
const previousNoteId = usePrevious(formNote.id);
const [resourceInfos, setResourceInfos] = useState<ResourceInfos>({});
const formNoteRef = useRef(formNote);
formNoteRef.current = formNote;
const initNoteState: InitNoteStateCallback = useCallback(async (n, isNewNote) => {
const initNoteState: InitNoteStateCallback = useCallback(async (n, isNewNote, force = false) => {
let noteLockKey = null;
if (isNoteLockEnabled() && NoteLockNote.isLocked(n)) {
// The session can lock between the gated load and here (e.g. lock-on-switch) - drop
// the load rather than show plaintext that pending saves could no longer encrypt.
if (!NoteLockSession.instance().isUnlocked()) return null;
// Capture the key with the plaintext so pending saves can re-encrypt even after
// the session locks.
noteLockKey = NoteLockSession.instance().decryptedKey();
}
let originalCss = '';
if (n.markup_language === MarkupToHtml.MARKUP_LANGUAGE_HTML) {
@@ -188,6 +268,9 @@ export default function useFormNote(dependencies: HookDependencies) {
hasChanged: false,
user_updated_time: n.user_updated_time,
encryption_applied: n.encryption_applied,
is_locked: n.is_locked,
noteLockKey,
isDecrypted: 'isDecrypted' in n && !!n.isDecrypted,
};
logger.debug('Initializing note state');
@@ -200,7 +283,7 @@ export default function useFormNote(dependencies: HookDependencies) {
// If the user changes the note while resources are loading, this can lead to
// a note being incorrectly marked as "unchanged".
if (!isNewNote && formNoteRef.current?.hasChanged) {
if (!isNewNote && formNoteRef.current?.hasChanged && !force) {
logger.info('Cancelled note refresh -- form note changed while loading attached resources.');
return null;
}
@@ -215,10 +298,16 @@ export default function useFormNote(dependencies: HookDependencies) {
return newFormNote;
}, []);
useRefreshFormNoteOnChange(formNoteRef, editorId, noteId, initNoteState, builtInEditorVisible);
const clearFormNote = useCallback(() => {
formNoteRef.current = defaultFormNote();
setFormNote(formNoteRef.current);
}, []);
useRefreshFormNoteOnChange(formNoteRef, editorId, noteId, initNoteState, clearFormNote, builtInEditorVisible, noteLockSessionUnlocked, setDecryptFailed, setLoadBlocked, onReloadInProgressChange, editorNoteReloadTimeRequest);
useEffect(() => {
if (!noteId) {
setDecryptFailed(false);
if (formNote.id) setFormNote(defaultFormNote());
return () => {};
}
@@ -244,8 +333,21 @@ export default function useFormNote(dependencies: HookDependencies) {
}
async function loadNote() {
const n = await Note.load(noteId);
setDecryptFailed(false);
const { note: n, blocked, decryptFailed } = await loadNoteForForm(noteId);
if (cancelled) return;
setLoadBlocked(blocked);
setDecryptFailed(decryptFailed);
if (blocked || decryptFailed) {
await onBeforeLoad({ formNote });
if (cancelled) return;
// Drop the previous form so no note content lingers behind the unlock or
// cannot-decrypt panel. The id guard stops the resulting effect re-run from looping.
if (formNoteRef.current.id) clearFormNote();
return;
}
if (!n) throw new Error(`Cannot find note with ID: ${noteId}`);
logger.debug('Loaded note:', n);
@@ -327,5 +429,7 @@ export default function useFormNote(dependencies: HookDependencies) {
formNote,
setFormNote: onSetFormNote,
resourceInfos,
decryptFailed: !!noteId && decryptFailedId === noteId,
loadBlocked: !!noteId && loadBlockedId === noteId,
};
}
@@ -1,5 +1,5 @@
import Logger from '@joplin/utils/Logger';
import { RefObject, useCallback } from 'react';
import { RefObject, useCallback, useRef } from 'react';
import { FormNote, NoteBodyEditorRef } from './types';
import { formNoteToNote } from '.';
import ExternalEditWatcher from '@joplin/lib/services/ExternalEditWatcher';
@@ -7,27 +7,44 @@ import Note from '@joplin/lib/models/Note';
import type { Dispatch } from 'redux';
import eventManager, { EventName } from '@joplin/lib/eventManager';
import type { OnSetFormNote } from './useFormNote';
import isNoteLockEnabled from '@joplin/lib/services/noteLock/isNoteLockEnabled';
const logger = Logger.create('useScheduleSaveCallbacks');
interface Props {
setFormNote: RefObject<OnSetFormNote>;
formNote: RefObject<FormNote>;
editorId: string;
dispatch: Dispatch;
editorRef: RefObject<NoteBodyEditorRef>;
editorNoteReloadTimeRequest: number;
}
const useScheduleSaveCallbacks = (props: Props) => {
const editorNoteReloadTimeRequestRef = useRef(props.editorNoteReloadTimeRequest);
editorNoteReloadTimeRequestRef.current = props.editorNoteReloadTimeRequest;
const scheduleSaveNote = useCallback((formNote: FormNote) => {
if (!formNote.saveActionQueue) throw new Error('saveActionQueue is not set!!'); // Sanity check
// reg.logger().debug('Scheduling...', formNote);
const editorNoteReloadTimeRequest = editorNoteReloadTimeRequestRef.current;
const makeAction = (formNote: FormNote) => {
return async function() {
const note = await formNoteToNote(formNote);
logger.debug('Saving note...', note);
const savedNote = await Note.save(note, { changeId: `editorChange-${props.editorId}` });
if (editorNoteReloadTimeRequestRef.current > editorNoteReloadTimeRequest) return;
// The lock state may change between scheduling and execution (e.g. encryption enabled
// from the note list menu), so the save uses the latest form state for this note.
const latestFormNote = props.formNote.current?.id === formNote.id ? props.formNote.current : formNote;
let note;
if (isNoteLockEnabled()) {
note = await formNoteToNote({ ...formNote, is_locked: latestFormNote.is_locked, isDecrypted: latestFormNote.isDecrypted });
} else {
note = await formNoteToNote(formNote);
}
logger.debug('Saving note...', isNoteLockEnabled() && note.is_locked ? note.id : note);
const savedNote = await Note.save(note, { changeId: `editorChange-${props.editorId}`, useNoteLock: true, noteLockKey: latestFormNote.noteLockKey });
props.setFormNote.current((prev: FormNote) => {
return { ...prev, user_updated_time: savedNote.user_updated_time, hasChanged: false };
@@ -46,7 +63,7 @@ const useScheduleSaveCallbacks = (props: Props) => {
formNote.saveActionQueue.push(makeAction(formNote));
return formNote.saveActionQueue.waitForAllDone();
}, [props.dispatch, props.editorId, props.setFormNote]);
}, [props.dispatch, props.editorId, props.setFormNote, props.formNote]);
const saveNoteIfWillChange = useCallback(async (formNote: FormNote) => {
if (!formNote.id || !formNote.bodyWillChangeId || !props.editorRef.current) return;
@@ -59,6 +76,7 @@ const useScheduleSaveCallbacks = (props: Props) => {
bodyWillChangeId: 0,
bodyChangeId: 0,
});
await formNote.saveActionQueue.processAllNow();
}, [scheduleSaveNote, props.editorRef]);
return { saveNoteIfWillChange, scheduleSaveNote };
@@ -0,0 +1,63 @@
import { runWithFakeTimers, setupDatabaseAndSynchronizer, switchClient } from '@joplin/lib/testing/test-utils';
import { act, renderHook, waitFor } from '@testing-library/react';
import useSearchMarkers from './useSearchMarkers';
import Note from '@joplin/lib/models/Note';
import SearchEngine from '@joplin/lib/services/search/SearchEngine';
import { Second } from '@joplin/utils/time';
import { setUpSemanticSearch, tearDownSemanticSearch, updateSearchIndex } from '@joplin/lib/testing/ai/semanticSearch';
describe('useSearchMarkers', () => {
beforeEach(async () => {
await setupDatabaseAndSynchronizer(1);
await switchClient(1);
await setUpSemanticSearch();
});
afterEach(async () => {
await tearDownSemanticSearch();
});
it.each([
{
label: 'should return markers for semantic search results',
noteBody: 'example example example example',
searchQuery: 'examples example example example',
expected: [{ type: 'text', value: 'example example example example' }],
},
{
label: 'should exclude Markdown from keywords',
noteBody: '*test-test-test-test*! test',
// cSpell:disable
searchQuery: 'testt-test-test-test',
// cSpell:enable
expected: [{ type: 'text', value: 'test-test-test-test' }],
},
])('should return keywords for semantic search matches: $label', async ({ noteBody, searchQuery, expected }) => {
const note = await Note.save({ title: 'test', body: noteBody });
await updateSearchIndex();
await runWithFakeTimers(async () => {
const searchResults = await SearchEngine.instance().search(searchQuery);
const test = renderHook(() => useSearchMarkers({
showLocalSearch: false,
localSearchMarkerOptions: () => null,
noteId: note.id,
searchResults,
searchId: '1',
searches: [{ id: '1', query_pattern: searchQuery, query_type: 1, type_: -1, title: searchQuery }],
highlightedWords: [],
noteTitle: note.title,
}));
await act(() => jest.advanceTimersByTimeAsync(5 * Second));
await waitFor(() => {
const result = test.result.current;
expect(result.keywords).toMatchObject(expected);
}, { timeout: 30 * Second });
test.unmount();
});
});
});
@@ -1,4 +1,9 @@
import { useMemo } from 'react';
import useQueuedAsyncEffect from '@joplin/lib/hooks/useQueuedAsyncEffect';
import { HighlightedWord, SearchEntry } from '@joplin/lib/reducer';
import SearchService, { SearchResult } from '@joplin/lib/services/ai/SearchService';
import { ProcessResultsRow, SearchType } from '@joplin/lib/services/search/SearchEngine';
import { Second } from '@joplin/utils/time';
import { useEffect, useMemo, useRef, useState } from 'react';
interface SearchMarkersOptions {
searchTimestamp: number;
@@ -7,8 +12,9 @@ interface SearchMarkersOptions {
withSelection?: boolean;
}
export interface SearchMarkers {
keywords: { value: string; type?: string; accuracy?: string }[];
keywords: HighlightedWord[];
options: SearchMarkersOptions;
}
@@ -23,16 +29,116 @@ function defaultSearchMarkers(): SearchMarkers {
};
}
// The indexer can prepend the note title to the first chunk:
// Remove it so that the results are exact substrings of the note body
const removeNoteTitle = (result: SearchResult, noteTitle: string) => {
if (result.chunkIndex !== 0) return result;
let chunkText = result.chunkText.trim();
while (chunkText.startsWith(`${noteTitle}\n`)) {
chunkText = chunkText.substring(noteTitle.length).trim();
}
return {
...result,
chunkText,
};
};
interface UseSemanticSearchMatchesProps {
query: string;
noteId: string;
noteTitle: string;
}
// Returns matching substrings of the note for the given search query
const useSemanticSearchMatches = ({ query, noteId, noteTitle }: UseSemanticSearchMatchesProps) => {
const [matchingChunks, setMatchingChunks] = useState<string[]>([]);
const matchingChunksRef = useRef(matchingChunks);
matchingChunksRef.current = matchingChunks;
useEffect(() => {
if (!query && matchingChunksRef.current.length !== 0) {
setMatchingChunks([]);
}
}, [query]);
const noteTitleRef = useRef(noteTitle);
noteTitleRef.current = noteTitle;
useQueuedAsyncEffect(async (event) => {
if (!query) {
return;
}
const results = await SearchService.instance().search({
query: { text: query }, scope: { type: 'note', noteId }, relevance: 'strict',
});
if (event.cancelled) return;
const matches = [];
for (const result of results) {
const bestMatch = await SearchService.instance().bestMatchInResult(
query, removeNoteTitle(result, noteTitleRef.current),
);
if (event.cancelled) return;
matches.push(bestMatch);
}
setMatchingChunks(matches);
}, [query, noteId], { interval: Second });
return matchingChunks;
};
interface UseSearchMarkersProps {
showLocalSearch: boolean;
localSearchMarkerOptions: ()=> SearchMarkers;
noteId: string;
searchResults: ProcessResultsRow[];
searchId: string;
searches: SearchEntry[];
highlightedWords: HighlightedWord[];
noteTitle: string;
}
export default function useSearchMarkers({
showLocalSearch,
localSearchMarkerOptions,
noteId,
searchResults,
searchId,
searches,
highlightedWords,
noteTitle,
}: UseSearchMarkersProps) {
const currentNoteSearchResult = useMemo(() => {
return searchResults.find(result => result.id === noteId);
}, [searchResults, noteId]);
const semanticSearchQuery = useMemo(() => {
if (!currentNoteSearchResult) return null;
if (!currentNoteSearchResult.searchType.includes(SearchType.Semantic)) return null;
const search = searches.find(search => search.id === searchId);
return search.query_pattern;
}, [searches, searchId, currentNoteSearchResult]);
const semanticSearchMatches = useSemanticSearchMatches({
query: semanticSearchQuery, noteId, noteTitle,
});
// eslint-disable-next-line @typescript-eslint/no-unsafe-function-type, @typescript-eslint/no-explicit-any -- Function type matches the original signature; any[] matches the lib reducer's searches shape and the heterogeneous highlightedWords shape (string[] at the call site, keyword shapes inside)
export default function useSearchMarkers(showLocalSearch: boolean, localSearchMarkerOptions: Function, searches: any[], selectedSearchId: string, highlightedWords: any[] = []) {
return useMemo((): SearchMarkers => {
if (showLocalSearch) return localSearchMarkerOptions();
const output = defaultSearchMarkers();
output.keywords = highlightedWords;
output.keywords = highlightedWords ?? [];
if (semanticSearchMatches.length) {
output.keywords = output.keywords.concat(semanticSearchMatches.map(match => (
{ type: 'text', accuracy: 'partial', value: match, source: 'semantic' }
)));
}
return output;
// eslint-disable-next-line @seiyab/react-hooks/exhaustive-deps -- Old code before rule was applied
}, [highlightedWords, showLocalSearch, localSearchMarkerOptions, searches, selectedSearchId]);
}, [highlightedWords, showLocalSearch, localSearchMarkerOptions, semanticSearchMatches]);
}
@@ -8,7 +8,7 @@ type Props = {
makeItemIndexVisible: (index: number)=> void;
};
describe('useAutoScroll', () => {
describe('UseAutoScroll', () => {
test('scrolls to the note when a new note is selected', () => {
const makeItemIndexVisible = jest.fn();
@@ -4,6 +4,7 @@ import { PluginStates } from '@joplin/lib/services/plugins/reducer';
import { Size } from '@joplin/utils/types';
import { Dispatch } from 'redux';
import { EventEmitter } from 'events';
import { HighlightedWord } from '@joplin/lib/reducer';
export interface Props {
themeId: number;
@@ -27,7 +28,7 @@ export interface Props {
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- searches: any[] is the shape in lib's reducer; tightening requires updating the reducer first
searches: any[];
selectedSearchId: string;
highlightedWords: string[];
highlightedWords: HighlightedWord[];
provisionalNoteIds: string[];
visible: boolean;
focusedField: string;
@@ -7,14 +7,20 @@ import useItemElement from './utils/useItemElement';
import { ItemEventHandlers, OnInputChange } from './utils/types';
import Note from '@joplin/lib/models/Note';
import { NoteEntity } from '@joplin/lib/services/database/types';
import isNoteLockEnabled from '@joplin/lib/services/noteLock/isNoteLockEnabled';
import NoteLockNote from '@joplin/lib/services/noteLock/NoteLockNote';
import NoteLockSession from '@joplin/lib/services/noteLock/NoteLockSession';
import bridge from '../../services/bridge';
import { _ } from '@joplin/lib/locale';
import useRenderedNote from './utils/useRenderedNote';
import { Dispatch } from 'redux';
import getNoteElementIdFromJoplinId from './utils/getNoteElementIdFromJoplinId';
import { HighlightedWord } from '@joplin/lib/reducer';
interface NoteItemProps {
dragIndex: number;
flow: ItemFlow;
highlightedWords: string[];
highlightedWords: HighlightedWord[];
index: number;
isProvisional: boolean;
itemSize: Size;
@@ -57,6 +63,15 @@ const NoteListItem = (props: NoteItemProps, ref: LegacyRef<HTMLDivElement>) => {
};
if (changeEvent.elementId === 'todo-checkbox') {
if (isNoteLockEnabled()) {
const lockState = await Note.load(changeEvent.noteId, { fields: ['is_locked'] });
if (NoteLockNote.isLocked(lockState) && !NoteLockSession.instance().isUnlocked()) {
// event.currentTarget is already cleared here, after the await - target is the same input
event.target.checked = !changeEvent.value;
bridge().showErrorMessageBox(_('Cannot change a locked note while the session is locked'));
return;
}
}
await Note.save({
id: changeEvent.noteId,
todo_completed: changeEvent.value ? Date.now() : 0,
@@ -2,8 +2,9 @@ import { htmlentities } from '@joplin/utils/html';
const Mark = require('mark.js/dist/mark.min.js');
const markJsUtils = require('@joplin/lib/markJsUtils');
import { replaceRegexDiacritics, pregQuote } from '@joplin/lib/string-utils';
import { HighlightedWord } from '@joplin/lib/reducer';
const getNoteTitleHtml = (highlightedWords: string[], displayTitle: string) => {
const getNoteTitleHtml = (highlightedWords: HighlightedWord[], displayTitle: string) => {
if (highlightedWords.length) {
const titleElement = document.createElement('span');
titleElement.textContent = displayTitle;
@@ -11,6 +11,7 @@ import prepareViewProps from './prepareViewProps';
import Tag from '@joplin/lib/models/Tag';
import { unique } from '@joplin/lib/array';
import Folder from '@joplin/lib/models/Folder';
import { HighlightedWord } from '@joplin/lib/reducer';
interface RenderedNote {
id: string;
@@ -22,7 +23,7 @@ const hashContent = (content: unknown) => {
return createHash('sha1').update(JSON.stringify(content)).digest('hex');
};
export default (note: NoteEntity, isSelected: boolean, isWatched: boolean, isPublished: boolean, listRenderer: ListRenderer, highlightedWords: string[], itemIndex: number, columns: NoteListColumns) => {
export default (note: NoteEntity, isSelected: boolean, isWatched: boolean, isPublished: boolean, listRenderer: ListRenderer, highlightedWords: HighlightedWord[], itemIndex: number, columns: NoteListColumns) => {
const [renderedNote, setRenderedNote] = useState<RenderedNote>(null);
let dependencies = columns && columns.length ? columns.map(c => c.name) as ListRendererDependency[] : [];
@@ -0,0 +1,86 @@
import * as React from 'react';
import { Dispatch } from 'redux';
import { useCallback, useState } from 'react';
import { _ } from '@joplin/lib/locale';
import DialogButtonRow, { ClickEvent } from '../DialogButtonRow';
import Dialog from '@joplin/lib/components/Dialog';
import DialogTitle from '../DialogTitle';
import CommandService from '@joplin/lib/services/CommandService';
import NoteLockSession from '@joplin/lib/services/noteLock/NoteLockSession';
import LabelledPasswordInput from '../PasswordInput/LabelledPasswordInput';
interface SuccessCommand {
name: string;
args: string[];
}
interface Props {
themeId: number;
dispatch: Dispatch;
successCommand?: SuccessCommand;
}
export default function(props: Props) {
const [password, setPassword] = useState('');
const [unlocking, setUnlocking] = useState(false);
const [errorMessage, setErrorMessage] = useState('');
const onClose = useCallback(() => {
props.dispatch({
type: 'DIALOG_CLOSE',
name: 'noteLockUnlock',
});
}, [props.dispatch]);
const onPasswordChange = useCallback((event: React.ChangeEvent<HTMLInputElement>) => {
setPassword(event.target.value);
}, []);
const onButtonRowClick = useCallback(async (event: ClickEvent) => {
if (event.buttonName === 'cancel') {
onClose();
return;
}
if (event.buttonName === 'ok') {
setUnlocking(true);
try {
await NoteLockSession.instance().unlock(password);
} catch (error) {
// WebCrypto reports a wrong password as a generic OperationError.
setErrorMessage(error.name === 'OperationError' ? _('Invalid password') : error.message);
setUnlocking(false);
return;
}
setUnlocking(false);
onClose();
if (props.successCommand) {
void CommandService.instance().execute(props.successCommand.name, ...props.successCommand.args);
}
}
}, [password, onClose, props.successCommand]);
return (
<Dialog onCancel={onClose} className="note-lock-unlock-dialog">
<div className="dialog-root">
<DialogTitle title={_('Unlock encrypted notes')}/>
<div className="dialog-content">
<p>{_('Enter the note lock password to unlock encrypted notes for this session.')}</p>
<LabelledPasswordInput
labelText={_('Note lock password')}
value={password}
onChange={onPasswordChange}
/>
{!!errorMessage && <p className="error-message" role="alert">{errorMessage}</p>}
</div>
<DialogButtonRow
themeId={props.themeId}
onClick={onButtonRowClick}
okButtonLabel={_('Unlock')}
okButtonDisabled={!password || unlocking}
cancelButtonDisabled={unlocking}
/>
</div>
</Dialog>
);
}
@@ -35,6 +35,7 @@ export interface NoteViewerControl {
focusLine(editorLine: number): void;
focus(): void;
hasFocus(): boolean;
stopMediaPlayback(): void;
}
const usePluginMessageResponder = (webviewRef: RefObject<HTMLIFrameElement>) => {
@@ -143,6 +144,10 @@ const NoteTextViewer = forwardRef((props: Props, ref: ForwardedRef<NoteViewerCon
if (channel === 'setMarkers') {
win.postMessage({ target: 'webview', name: 'setMarkers', data: { keywords: arg0, options: arg1 } }, '*');
}
if (channel === 'stopMediaPlayback') {
win.postMessage({ target: 'webview', name: 'stopMediaPlayback', data: {} }, '*');
}
},
focus: () => {
if (webviewRef.current) {
@@ -168,6 +173,9 @@ const NoteTextViewer = forwardRef((props: Props, ref: ForwardedRef<NoteViewerCon
}, 100);
}
},
stopMediaPlayback: () => {
result.send('stopMediaPlayback');
},
};
return result;
}, [parentDoc]);
@@ -35,7 +35,7 @@ const PasswordInput = (props: Props) => {
value={props.value}
onChange={props.onChange}
/>
<button onClick={onShowPassword} className="showpasswordbutton">
<button type="button" onClick={onShowPassword} className="showpasswordbutton">
<i className={icon} role='img' aria-label={title} title={title}></i>
</button>
</div>
@@ -7,6 +7,7 @@
display: flex;
flex: 1;
width: 100%;
padding-right: 28px;
}
> .showpasswordbutton {
@@ -1,6 +1,6 @@
import PromptDialog from './PromptDialog';
describe('PromptDialog Escape key handling', () => {
describe('PromptDialog', () => {
const setupKeyHandler = (inputType: string, menuIsOpened: boolean) => {
const onCloseMock = jest.fn();
@@ -1,7 +1,9 @@
import * as React from 'react';
import { useCallback, useEffect, useMemo, useState } from 'react';
import JoplinServerApi from '@joplin/lib/JoplinServerApi';
import { _, _n } from '@joplin/lib/locale';
import Folder from '@joplin/lib/models/Folder';
import shim from '@joplin/lib/shim';
import DialogButtonRow, { ClickEvent } from './DialogButtonRow';
import Dialog from '@joplin/lib/components/Dialog';
import DialogTitle from './DialogTitle';
@@ -15,6 +17,7 @@ import { clipboard } from 'electron';
import useOnPublishFolderLinkClick from '@joplin/lib/components/shared/PublishFolderDialog/useOnPublishFolderLinkClick';
import useShareStatusMessage from '@joplin/lib/components/shared/ShareNoteDialog/useShareStatusMessage';
import { SharingStatus } from '@joplin/lib/components/shared/ShareNoteDialog/types';
import Button from './Button/Button';
interface Props {
themeId: number;
@@ -28,6 +31,7 @@ export function PublishFolderDialog(props: Props) {
const [folder, setFolder] = useState<FolderEntity>(null);
const [noteCount, setNoteCount] = useState<number>(0);
const [publishFolderStatus, setPublishFolderStatus] = useState<SharingStatus>(SharingStatus.Unknown);
const [isUnpublishing, setIsUnpublishing] = useState(false);
const encryptionWarning = useEncryptionWarningMessage();
const publishedShare = useMemo(() => {
@@ -68,19 +72,37 @@ export function PublishFolderDialog(props: Props) {
props.onClose();
}, [props]);
const onUnpublishFolderClick = useCallback(async () => {
setIsUnpublishing(true);
try {
await ShareService.instance().unpublishFolder(props.folderId);
} catch (error) {
void shim.showErrorDialog(JoplinServerApi.connectionErrorMessage(error as Error));
} finally {
setIsUnpublishing(false);
}
}, [props.folderId]);
const statusMessage = useShareStatusMessage({ sharesState: publishFolderStatus, noteCount: 1 });
return (
<Dialog>
<div className="form share-note-dialog">
<DialogTitle title={_('Publish Notebook')}/>
<div className="folder-info">
<p>{_('Notebook: %s', folder ? folder.title : '...')}</p>
<p>{_('Status: %s', publishedShare ? _('Published') : _('Not published'))}</p>
<p>{_n('%d note in notebook', '%d notes in notebook', noteCount, noteCount)}</p>
<div className="shared-note-list-item">
<div className="title">
<div className="folder-info">
<p>{_('Notebook: %s', folder ? folder.title : '...')}</p>
<p>{_('Status: %s', publishedShare ? _('Published') : _('Not published'))}</p>
<p>{_n('%d note in notebook', '%d notes in notebook', noteCount, noteCount)}</p>
</div>
</div>
{publishedShare && (
<Button disabled={isUnpublishing} tooltip={_('Unpublish notebook')} iconName="fas fa-share-alt" onClick={onUnpublishFolderClick}/>
)}
</div>
<button
disabled={!folder || [SharingStatus.Creating, SharingStatus.Synchronizing].indexOf(publishFolderStatus) >= 0}
disabled={isUnpublishing || !folder || [SharingStatus.Creating, SharingStatus.Synchronizing].indexOf(publishFolderStatus) >= 0}
className="share"
onClick={publishFolderLinkButton_click}
>{publishedShare ? _('Copy Shareable Link') : _('Publish notebook')}</button>
@@ -34,43 +34,35 @@ const LayoutItemContainer: React.FC<Props> = ({
const canResizeBottom = parentDir === LayoutItemDirection.Column && !isLastChild;
const className = `resizableLayoutItem rli-${item.key}`;
if (canResizeRight || canResizeBottom) {
const enable = {
top: false,
right: canResizeRight,
bottom: canResizeBottom,
left: false,
topRight: false,
bottomRight: false,
bottomLeft: false,
topLeft: false,
};
const enable = {
top: false,
right: canResizeRight,
bottom: canResizeBottom,
left: false,
topRight: false,
bottomRight: false,
bottomLeft: false,
topLeft: false,
};
return (
<Resizable
key={item.key}
className={className}
style={style}
size={size}
onResizeStart={onResizeStart}
onResize={onResize}
onResizeStop={onResizeStop}
enable={enable}
minWidth={'minWidth' in item ? item.minWidth : itemMinWidth}
minHeight={'minHeight' in item ? item.minHeight : itemMinHeight}
maxWidth={resizedItemMaxSize?.width}
maxHeight={resizedItemMaxSize?.height}
>
{children}
</Resizable>
);
} else {
return (
<div key={item.key} className={className} style={{ ...style, ...size }}>
{children}
</div>
);
}
return (
<Resizable
key={item.key}
className={className}
style={style}
size={size}
onResizeStart={onResizeStart}
onResize={onResize}
onResizeStop={onResizeStop}
enable={enable}
minWidth={'minWidth' in item ? item.minWidth : itemMinWidth}
minHeight={'minHeight' in item ? item.minHeight : itemMinHeight}
maxWidth={resizedItemMaxSize?.width}
maxHeight={resizedItemMaxSize?.height}
>
{children}
</Resizable>
);
};
export default LayoutItemContainer;
+2
View File
@@ -33,6 +33,7 @@ import SsoLoginScreen from './SsoLoginScreen/SsoLoginScreen';
import SamlShared from '@joplin/lib/components/shared/SamlShared';
import PopupNotificationProvider from './PopupNotification/PopupNotificationProvider';
import { ThemeProvider, StyleSheetManager, createGlobalStyle } from 'styled-components';
import QuitSyncDialog from './QuitSyncDialog';
interface Props {
themeId: number;
@@ -178,6 +179,7 @@ class RootComponent extends React.Component<Props, any> {
<MenuBar/>
<GlobalStyle/>
<WindowCommandsAndDialogs windowId={defaultWindowId} />
<QuitSyncDialog themeId={this.props.themeId} />
<Navigator style={navigatorStyle} screens={screens} className={`profile-${this.props.profileConfigCurrentProfileId}`} />
{this.renderSecondaryWindows()}
{this.renderModalMessage(this.modalDialogProps())}
@@ -170,7 +170,6 @@ function ShareFolderDialog(props: Props) {
useEffect(() => {
setRecipientSuggestions([]);
setLatestError(null);
let cancelled = false;
@@ -245,7 +244,9 @@ function ShareFolderDialog(props: Props) {
ShareService.instance().refreshShares(),
ShareService.instance().refreshShareUsers(share.id),
]);
setRecipientEmail('');
// Only clear the input on success, otherwise the user loses the email
// they typed and the error message would be wiped by the suggestions effect.
if (!errorSet) setRecipientEmail('');
await synchronize();
} catch (error) {
@@ -257,6 +258,7 @@ function ShareFolderDialog(props: Props) {
const recipientEmail_change = useCallback((value: string) => {
setRecipientEmail(value);
setLatestError(null);
}, []);
const renderRecipientSuggestion = useCallback((suggestedValue: string) => {
+21 -13
View File
@@ -17,6 +17,7 @@ import onUnshareNoteClick from '@joplin/lib/components/shared/ShareNoteDialog/on
import useShareStatusMessage from '@joplin/lib/components/shared/ShareNoteDialog/useShareStatusMessage';
import useEncryptionWarningMessage from '@joplin/lib/components/shared/ShareNoteDialog/useEncryptionWarningMessage';
import { SharingStatus } from '@joplin/lib/components/shared/ShareNoteDialog/types';
import useIsPublished from '@joplin/lib/components/shared/ShareNoteDialog/useIsPublished';
import { clipboard } from 'electron';
interface Props {
@@ -66,22 +67,10 @@ export function ShareNoteDialog(props: Props) {
recursiveShare,
});
const renderNote = (note: NoteEntity) => {
const unshareButton = !props.shares.find(s => s.note_id === note.id) ? null : (
<Button tooltip={_('Unpublish note')} iconName="fas fa-share-alt" onClick={() => onUnshareNoteClick({ noteId: note.id })}/>
);
return (
<div key={note.id} className='shared-note-list-item'>
<span className='title'>{note.title}</span>{unshareButton}
</div>
);
};
const renderNoteList = (notes: NoteEntity[]) => {
const noteComps = [];
for (const note of notes) {
noteComps.push(renderNote(note));
noteComps.push(<NoteItem key={note.id} note={note} shares={props.shares}/>);
}
return <div className="notes">{noteComps}</div>;
};
@@ -136,6 +125,25 @@ export function ShareNoteDialog(props: Props) {
);
}
interface NoteItemProps {
note: NoteEntity;
shares: StateShare[];
}
const NoteItem: React.FC<NoteItemProps> = ({ note, shares }) => {
const published = useIsPublished(note, shares);
const unshareButton = published && (
<Button tooltip={_('Unpublish note')} iconName='fas fa-share-alt' onClick={() => onUnshareNoteClick({ noteId: note.id })}/>
);
return (
<div className='shared-note-list-item'>
<span className='title'>{note.title}</span>{unshareButton}
</div>
);
};
const mapStateToProps = (state: AppState) => {
return {
shares: state.shareService.shares.filter(s => !!s.note_id),
+4 -2
View File
@@ -32,13 +32,15 @@ const SidebarComponent = (props: Props) => {
const renderSynchronizeButton = (type: string) => {
const label = type === 'sync' ? _('Synchronise') : _('Cancel');
const nothingToSync = type === 'sync' && !props.syncPending && syncCompletedWithoutError(props.syncReport);
const iconName = nothingToSync ? 'fas fa-check' : 'icon-sync';
const syncHasErrors = type === 'sync' && !props.syncPending && !!props.syncReport.errors?.length;
const iconName = syncHasErrors ? 'fas fa-exclamation-triangle' : nothingToSync ? 'fas fa-check' : 'icon-sync';
return (
<StyledSynchronizeButton
level={ButtonLevel.SidebarSecondary}
className={`sidebar-sync-button ${type === 'sync' ? '' : '-syncing'} ${nothingToSync ? '-synced' : ''}`}
className={`sidebar-sync-button ${type === 'sync' ? '' : '-syncing'} ${nothingToSync ? '-synced' : ''} ${syncHasErrors ? '-error' : ''}`}
iconName={iconName}
iconLabel={syncHasErrors ? _('Synchronisation error') : undefined}
key="sync_button"
title={label}
onClick={() => {
@@ -38,6 +38,10 @@
font-size: 0.85em;
}
&.-error > .icon {
font-size: 1.25em;
}
&:not(.-syncing):not(.-synced) > .icon {
animation: icon-fade-in-b 300ms ease-in-out;
}
@@ -1,22 +0,0 @@
import { CommandRuntime, CommandDeclaration, CommandContext } from '@joplin/lib/services/CommandService';
import { _ } from '@joplin/lib/locale';
import { stateUtils } from '@joplin/lib/reducer';
import { getNoteCallbackUrl } from '@joplin/lib/callbackUrlUtils';
import { clipboard } from 'electron';
export const declaration: CommandDeclaration = {
name: 'copyNoteExternalLink',
label: () => _('Copy external link'),
iconName: 'fas fa-link',
};
export const runtime = (): CommandRuntime => {
return {
execute: async (context: CommandContext, noteId: string = null) => {
noteId = noteId || stateUtils.selectedNoteId(context.state);
if (!noteId) return;
clipboard.writeText(getNoteCallbackUrl(noteId));
},
enabledCondition: 'oneNoteSelected',
};
};
@@ -0,0 +1,35 @@
import { CommandRuntime, CommandDeclaration, CommandContext } from '@joplin/lib/services/CommandService';
import { _ } from '@joplin/lib/locale';
import isNoteLockEnabled from '@joplin/lib/services/noteLock/isNoteLockEnabled';
import { disableNoteLock } from '@joplin/lib/services/noteLock/setNoteLockState';
import bridge from '../../../services/bridge';
export const declaration: CommandDeclaration = {
name: 'disableNoteEncryption',
label: () => _('Disable encryption'),
};
export const runtime = (): CommandRuntime => {
return {
execute: async (context: CommandContext, noteId: string = null) => {
if (!isNoteLockEnabled()) return;
if (noteId === null) {
if (context.state.selectedNoteIds.length !== 1) return;
noteId = context.state.selectedNoteIds[0];
}
// Same pending-save guard as enableNoteEncryption.
if (context.state.editorNoteStatuses[noteId] === 'saving') {
bridge().showErrorMessageBox(_('This note is currently being saved. Please try again in a moment.'));
return;
}
try {
await disableNoteLock(noteId);
} catch (error) {
bridge().showErrorMessageBox(error.message);
}
},
enabledCondition: 'oneNoteSelected && noteIsLocked && noteLockSessionUnlocked && !noteIsReadOnly && !noteIsDeleted && !inTrash && !inConflictFolder',
};
};
@@ -19,6 +19,6 @@ export const runtime = (): CommandRuntime => {
});
}
},
enabledCondition: '!noteIsReadOnly',
enabledCondition: '!noteIsReadOnlyShare',
};
};
@@ -0,0 +1,70 @@
import { CommandRuntime, CommandDeclaration } from '@joplin/lib/services/CommandService';
import { DesktopCommandContext } from '../../../services/commands/types';
import { _ } from '@joplin/lib/locale';
import isNoteLockEnabled from '@joplin/lib/services/noteLock/isNoteLockEnabled';
import NoteLockKey from '@joplin/lib/services/noteLock/NoteLockKey';
import NoteLockSession from '@joplin/lib/services/noteLock/NoteLockSession';
import { enableNoteLock } from '@joplin/lib/services/noteLock/setNoteLockState';
import ExternalEditWatcher from '@joplin/lib/services/ExternalEditWatcher';
import bridge from '../../../services/bridge';
export const declaration: CommandDeclaration = {
name: 'enableNoteEncryption',
label: () => _('Enable encryption'),
};
export const runtime = (): CommandRuntime => {
return {
execute: async (context: DesktopCommandContext, noteId: string = null) => {
if (!isNoteLockEnabled()) return;
if (noteId === null) {
if (context.state.selectedNoteIds.length !== 1) return;
noteId = context.state.selectedNoteIds[0];
}
// An open editor's pending save still has the old lock state and would fail against
// the note once its lock state changes underneath it.
if (context.state.editorNoteStatuses[noteId] === 'saving') {
bridge().showErrorMessageBox(_('This note is currently being saved. Please try again in a moment.'));
return;
}
if (!NoteLockKey.instance().load()) {
const setUpNow = bridge().showConfirmMessageBox(_('Encrypting a note requires a note lock password, which has not yet been set. Set it up now?'), {
buttons: [_('Yes'), _('No')],
});
if (setUpNow) {
context.dispatch({
type: 'NAV_GO',
routeName: 'Config',
props: { defaultSection: 'noteLock' },
});
}
return;
}
// A note that is not yet encrypted has no unlock overlay, so the session unlock has to
// happen through the dialog here.
if (!NoteLockSession.instance().isUnlocked()) {
context.dispatch({
type: 'DIALOG_OPEN',
name: 'noteLockUnlock',
props: { successCommand: { name: 'enableNoteEncryption', args: [noteId] } },
});
return;
}
// An externally edited note has a plaintext file on disk; flush and delete it before locking.
if (context.state.watchedNoteFiles.includes(noteId)) {
await ExternalEditWatcher.instance().stopWatching(noteId);
}
try {
await enableNoteLock(noteId);
} catch (error) {
bridge().showErrorMessageBox(error.message);
}
},
enabledCondition: 'oneNoteSelected && !noteIsLocked && !noteIsReadOnly && !noteIsDeleted && !inTrash && !inConflictFolder',
};
};
@@ -2,10 +2,11 @@
import * as addNoteToWhiteboard from './addNoteToWhiteboard';
import * as addProfile from './addProfile';
import * as commandPalette from './commandPalette';
import * as copyNoteExternalLink from './copyNoteExternalLink';
import * as deleteFolder from './deleteFolder';
import * as disableNoteEncryption from './disableNoteEncryption';
import * as duplicateNote from './duplicateNote';
import * as editAlarm from './editAlarm';
import * as enableNoteEncryption from './enableNoteEncryption';
import * as exportPdf from './exportPdf';
import * as globalRedo from './globalRedo';
import * as globalUndo from './globalUndo';
@@ -13,6 +14,7 @@ import * as gotoAnything from './gotoAnything';
import * as hideModalMessage from './hideModalMessage';
import * as importFrom from './importFrom';
import * as linkToNote from './linkToNote';
import * as lockEncryptedNotes from './lockEncryptedNotes';
import * as moveToFolder from './moveToFolder';
import * as newFolder from './newFolder';
import * as newNote from './newNote';
@@ -59,10 +61,11 @@ const index: any[] = [
addNoteToWhiteboard,
addProfile,
commandPalette,
copyNoteExternalLink,
deleteFolder,
disableNoteEncryption,
duplicateNote,
editAlarm,
enableNoteEncryption,
exportPdf,
globalRedo,
globalUndo,
@@ -70,6 +73,7 @@ const index: any[] = [
hideModalMessage,
importFrom,
linkToNote,
lockEncryptedNotes,
moveToFolder,
newFolder,
newNote,
@@ -34,6 +34,6 @@ export const runtime = (): CommandRuntime => {
return result;
},
enabledCondition: 'markdownEditorPaneVisible || richTextEditorVisible',
enabledCondition: 'oneNoteSelected && (markdownEditorPaneVisible || richTextEditorVisible) && !noteIsReadOnly && !noteIsDeleted',
};
};
@@ -0,0 +1,19 @@
import { CommandRuntime, CommandDeclaration } from '@joplin/lib/services/CommandService';
import { _ } from '@joplin/lib/locale';
import isNoteLockEnabled from '@joplin/lib/services/noteLock/isNoteLockEnabled';
import NoteLockSession from '@joplin/lib/services/noteLock/NoteLockSession';
export const declaration: CommandDeclaration = {
name: 'lockEncryptedNotes',
label: () => _('Lock encrypted notes'),
};
export const runtime = (): CommandRuntime => {
return {
execute: async () => {
if (!isNoteLockEnabled()) return;
NoteLockSession.instance().lock();
},
enabledCondition: 'noteLockSessionUnlocked',
};
};
@@ -64,6 +64,6 @@ export const runtime = (comp: WindowControl): CommandRuntime => {
}
}
},
enabledCondition: 'someNotesSelected && !noteIsReadOnly',
enabledCondition: 'someNotesSelected && !noteIsReadOnlyShare',
};
};
@@ -21,6 +21,6 @@ export const runtime = (comp: WindowControl): CommandRuntime => {
bridge().showErrorMessageBox(error.message);
}
},
enabledCondition: 'someNotesSelected',
enabledCondition: 'someNotesSelected && !noteLockContentUnavailable',
};
};
@@ -2,6 +2,8 @@ import { CommandRuntime, CommandDeclaration, CommandContext } from '@joplin/lib/
import { _ } from '@joplin/lib/locale';
import Tag from '@joplin/lib/models/Tag';
import { TagEntity } from '@joplin/lib/services/database/types';
import hasLockedNoteWhileSessionLocked from '@joplin/lib/services/noteLock/hasLockedNoteWhileSessionLocked';
import bridge from '../../../services/bridge';
import { WindowControl } from '../utils/useWindowControl';
export const declaration: CommandDeclaration = {
@@ -20,6 +22,11 @@ export const runtime = (comp: WindowControl): CommandRuntime => {
execute: async (context: CommandContext, noteIds: string[] = null) => {
noteIds = noteIds || context.state.selectedNoteIds;
if (await hasLockedNoteWhileSessionLocked(noteIds)) {
bridge().showErrorMessageBox(_('Tags cannot be changed for locked notes while the session is locked'));
return;
}
const tags = await Tag.commonTagsByNoteIds(noteIds);
const startTags: TagOption[] = tags
.map((a: TagEntity) => {
@@ -74,6 +81,6 @@ export const runtime = (comp: WindowControl): CommandRuntime => {
},
});
},
enabledCondition: 'someNotesSelected && !inTrash',
enabledCondition: 'someNotesSelected && !allSelectedNotesAreDeleted && !inTrash',
};
};
@@ -12,7 +12,7 @@ jest.mock('../../../services/bridge', () => ({
}),
}));
describe('mapStateToTitle', () => {
describe('showSpellCheckerMenu', () => {
test('should return null if spellchecker.enabled is false', () => {
@@ -1,6 +1,8 @@
import { CommandRuntime, CommandDeclaration, CommandContext } from '@joplin/lib/services/CommandService';
import { _ } from '@joplin/lib/locale';
import Note from '@joplin/lib/models/Note';
import hasLockedNoteWhileSessionLocked from '@joplin/lib/services/noteLock/hasLockedNoteWhileSessionLocked';
import bridge from '../../../services/bridge';
export const declaration: CommandDeclaration = {
name: 'toggleNoteType',
@@ -12,6 +14,11 @@ export const runtime = (): CommandRuntime => {
execute: async (context: CommandContext, noteIds: string[] = null) => {
if (noteIds === null) noteIds = context.state.selectedNoteIds;
if (await hasLockedNoteWhileSessionLocked(noteIds)) {
bridge().showErrorMessageBox(_('Cannot change a locked note while the session is locked'));
return;
}
for (let i = 0; i < noteIds.length; i++) {
const note = await Note.load(noteIds[i]);
await Note.save(Note.toggleIsTodo(note), { userSideValidation: true });
@@ -3,6 +3,7 @@ import { Dispatch } from 'redux';
import SyncWizardDialog from '../../SyncWizard/Dialog';
import MasterPasswordDialog from '../../MasterPasswordDialog/Dialog';
import NoteLockUnlockDialog from '../../NoteLockUnlockDialog/Dialog';
import EditFolderDialog from '../../EditFolderDialog/Dialog';
import PdfViewer from '../../PdfViewer';
@@ -32,6 +33,13 @@ const appDialogs: Record<string, RegisteredDialog> = {
},
},
noteLockUnlock: {
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- See RegisteredDialog.render
render: (props: RegisteredDialogProps, customProps: any) => {
return <NoteLockUnlockDialog key={props.key} dispatch={props.dispatch} themeId={props.themeId} {...customProps}/>;
},
},
editFolder: {
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- See RegisteredDialog.render
render: (props: RegisteredDialogProps, customProps: any) => {
+3 -1
View File
@@ -2,7 +2,6 @@ export default function() {
return [
'attachFile',
'copyDevCommand',
'copyNoteExternalLink',
'exportPdf',
'focusElementNoteBody',
'focusElementNoteViewer',
@@ -38,6 +37,9 @@ export default function() {
'textSelectAll',
'textBulletedList',
'toggleExternalEditing',
'enableNoteEncryption',
'disableNoteEncryption',
'lockEncryptedNotes',
'openNoteInNewWindow',
'toggleLayoutMoveMode',
'resetLayout',
@@ -430,6 +430,26 @@
}
};
// https://github.com/laurent22/joplin/issues/15278
// Embedded players (e.g. YouTube) are cross-origin iframes that can't be
// paused from here, so reassigning the src reloads them, which stops playback.
function stopMediaPlayback() {
for (const element of contentElement.querySelectorAll('video, audio')) {
if (!element.paused) element.pause();
}
// Exclude .media-pdf: it's the interactive PDF viewer, not a media
// player, and reloading it would reset the current page and scroll.
for (const iframe of contentElement.querySelectorAll('iframe:not(.media-pdf)')) {
const src = iframe.getAttribute('src');
if (src) iframe.setAttribute('src', src);
}
}
ipc.stopMediaPlayback = () => {
stopMediaPlayback();
};
ipc.setHtml = (event) => {
const html = event.html;
@@ -439,6 +459,8 @@
alreadyAllImagesLoaded = false;
stopMediaPlayback();
contentElement.innerHTML = html;
if (html.includes('file://')) {
@@ -5,8 +5,8 @@
left: 0;
width: 100%;
height: 100%;
padding: 20px;
align-items: flex-start;
justify-content: center;
border: none;
margin: 0;
background-color: transparent;
@@ -20,11 +20,12 @@
color: var(--joplin-color);
padding: 16px;
box-shadow: 6px 6px 20px rgba(0,0,0,0.5);
margin: 20px;
min-height: fit-content;
display: flex;
flex-direction: column;
border-radius: 10px;
margin-left: auto;
margin-right: auto;
}
&::backdrop {
@@ -34,6 +35,7 @@
&.-fullscreen {
max-width: 100vw;
max-height: 100vh;
padding: 0;
&::backdrop {
background-color: var(--joplin-background-color);
Loaded 100 of 535 files, more files were not shown because too many files have changed in this diff. Show more