Compare commits

...
Author SHA1 Message Date
Laurent Cozic 95cab5a86d Desktop release v3.7.22 2026-10-28 18:09:51 +01:00
Henry Heino f5823d1bd0 Desktop: Fixes #16939: Security: Upgrade to Electron 42.11.18 (#16964) 2026-10-28 18:07:48 +01:00
Laurent Cozic 94a152d985 Desktop: Resolves #16782: Add read_notes MCP tool (#16845) 2026-10-16 10:04:31 +01:00
Laurent Cozic f0e9ed2176 Desktop: Resolves #16778: Return the enclosing section with each search_notes result (#16842) 2026-10-14 20:44:51 +01:00
Laurent Cozic 6f8b850683 Desktop: Resolves #16777: Reduce read_note token consumption with outline and section navigation (#16838) 2026-10-14 17:30:21 +01:00
Laurent Cozic cabfcc1731 Desktop: Treat private network AI providers as local (#16837) 2026-10-14 12:03:26 +01:00
Aissa Benfodda da899da451 Mobile, Desktop: Resolves #16721: Upgrade app banner shows the wrong minimum app version (#16819) 2026-10-11 16:00:07 +01:00
Laurent Cozic 01b12f603d Mobile: Fix encoding of generated event handler attributes in note viewer (#16794) 2026-10-07 23:53:18 +01:00
Laurent Cozic e41516e669 Desktop release v3.7.21 2026-09-25 19:32:38 +01:00
Laurent Cozic 97b655510a Update lock files 2026-09-25 19:32:30 +01:00
Laurent Cozic e84f3614b4 Desktop release v3.7.20 2026-09-25 17:13:32 +01:00
Laurent Cozic 003f4bf9b7 Releasing sub-packages 2026-09-25 17:04:34 +01:00
Laurent Cozic 6061d506ea Releasing sub-packages 2026-09-25 17:04:34 +01:00
Aissa Benfodda 8b76139278 Desktop: Resolves #16498: AI Chat: Persist conversation history and add a chat switcher (#16657) 2026-09-25 17:02:35 +01:00
Laurent Cozic fdd4005528 iOS 13.7.6 2026-09-25 12:32:32 +01:00
Laurent Cozic 70eac1b2ec Android 3.7.11 2026-09-25 12:31:22 +01:00
Laurent Cozic 545a448ca9 Desktop release v3.7.19 2026-09-25 12:13:21 +01:00
Laurent Cozic 0a8bfffb96 Desktop, Mobile: Fix table editor losing and mis-rendering inline markdown (#16632) 2026-09-25 12:01:53 +01:00
Henry Heino ef29c43cae Desktop, Mobile, Cli: Fixes #16647: Fix notes and sub-folders are still marked as published after being moved out of a published folder (#16648) 2026-09-25 11:55:30 +01:00
Henry Heino 310b5bbbb6 Desktop: Resolves #16644: Improve how semantic search interacts with item IDs (#16652) 2026-09-25 11:55:00 +01:00
Aissa Benfodda 51560e8084 Desktop: Resolves #16498: AI Chat: Persist conversation history and add a chat switcher (#16583) 2026-09-25 11:53:50 +01:00
Laurent Cozic 12cdce0d43 Desktop, Mobile: Fix AI configuration test failing on reasoning models (#16594) 2026-09-19 17:16:36 +01:00
Laurent Cozic 18ba88e90d Desktop: Add setting to hide the AI Chat toolbar button (#16607) 2026-09-19 17:13:49 +01:00
Henry Heino cbd3445de9 Linux: Fixes #16457: Fix dropping notes into the whiteboard editor (#16565) 2026-09-19 17:03:56 +01:00
Laurent Cozic 4d36a87296 Desktop: Fix reasoning blocks leaking into AI chat replies (#16595) 2026-09-18 19:05:23 +01:00
Laurent Cozic 36bed147fd Desktop, Cli: Fix ENEX import failing on Evernote's unescaped ampersands (#16580) 2026-09-17 17:33:42 +01:00
Laurent Cozic de42c3fba6 Cli: Fixes #16541: Downgrade nanoid to fix ERR_REQUIRE_ESM crash on startup (#16542) 2026-09-13 11:38:00 +01:00
Laurent Cozic 330b86e634 Desktop, Mobile, Cli: Fixes #16504: Fix sync deadlock when Joplin Server rejects an item with HTTP 422 (#16544) 2026-09-13 11:37:43 +01:00
Mirko Kerezovic becfd248f9 Linux: Fixes #16434: Fix AppImage StartupWMClass (#16547) 2026-09-13 11:37:15 +01:00
Henry Heino 70ebecb781 Desktop: Fixes #16539: Fix LMStudio compatibility (#16552) 2026-09-13 11:36:20 +01:00
Laurent Cozic dbde4e84b3 Desktop: Fix x-callback-url responses never reaching the calling app (#16497) 2026-09-11 16:00:09 +01:00
Laurent Cozic 7ff01914f9 iOS 13.7.5 2026-09-10 20:16:21 +01:00
Laurent Cozic 57b2f3082f Android 3.7.10 2026-09-10 20:10:22 +01:00
Laurent Cozic ce25482097 Desktop release v3.7.18 2026-09-10 18:40:51 +01:00
Laurent Cozic 29fbc59a85 Desktop, Mobile, Cli: Fixes #16486: Fix WebDAV sync error "Protocol https: not supported" on servers that redirect (#16488) 2026-09-10 18:38:59 +01:00
Laurent Cozic 2d27dc4992 Android 3.7.9 2026-09-10 16:57:35 +01:00
Laurent Cozic d242a43b23 Desktop release v3.7.17 2026-09-10 16:03:54 +01:00
Aissa Benfodda 79875c5c8f Desktop: Resolves #16424: Fix notes imported from OneNote are displayed with a very small font size (#16440) 2026-09-10 14:43:28 +01:00
Aissa Benfodda 77ea73d669 Desktop: Resolves #16427: Tag area broken with Japanese language (#16483) 2026-09-10 14:27:48 +01:00
Henry Heino df261c2465 Desktop: Fixes #16454: In-editor Markdown rendering: Fix markup syntax is visible while clicking and dragging the scrollbar (#16461) 2026-09-09 23:55:14 +01:00
Laurent Cozic c3c0d56e35 Desktop: Fixes #16414: Return 202 for MCP notification requests (#16473) 2026-09-09 23:54:07 +01:00
Laurent Cozic f82b0c7850 Desktop: Fixes #16431: Treat private network AI providers as local and make their API key optional (#16472) 2026-09-09 23:54:00 +01:00
Laurent Cozic 9b11d3b595 Desktop, Mobile, Cli: Fixes #16422: Fix WebDAV sync failing with 409 errors after 3.7 upgrade (#16471) 2026-09-09 23:53:47 +01:00
Laurent Cozic 7fb480dc1a Desktop: Fixes #16457: Whiteboard: Add missing "Add note to whiteboard" menu item (#16468) 2026-09-09 23:53:24 +01:00
Laurent Cozic 7ef7b5ee83 Desktop: Whiteboard: Double-click empty canvas to add a note (#16470) 2026-09-09 18:44:50 +01:00
Henry Heino 62f07b4f64 Mobile: Resolves #16442: Remove sync wizard button from config screen (#16458) 2026-09-09 12:07:46 +01:00
summoner a190a75737 All: Translation: Update hu_HU.po (#16418) 2026-09-09 12:06:07 +01:00
Laurent Cozic e2ff538613 Desktop: Fixes #16444: Allow OpenAI reasoning models to be used with tools (#16451) 2026-09-08 16:24:14 +01:00
Laurent Cozic 536790b37d Desktop: Fixes #16433: Toggle AI Chat keystroke shortcut does nothing 2026-09-08 12:54:38 +01:00
Laurent Cozic 425a05ac4b CLI v3.7.1 2026-09-07 10:20:33 +01:00
Laurent Cozic de139fc9c1 Lock file 2026-09-07 10:19:42 +01:00
Laurent Cozic c17c205049 Releasing sub-packages 2026-09-07 10:19:13 +01:00
Henry Heino 63e1f216e1 Desktop, Mobile: Fixes #16407: Fix keyboard navigation issues related to level-1 headings (#16409) 2026-09-06 20:13:05 +01:00
Henry Heino a263733971 Web: Fix incorrect "Only one copy of Joplin can be open at a time" warning in Safari and Firefox (#16405) 2026-09-06 20:12:36 +01:00
Henry Heino e3e4aebc67 Web: Fixes #16379: Fix menu animation in Safari (#16404) 2026-09-06 20:12:25 +01:00
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
599 changed files with 200924 additions and 84957 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
+94 -20
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
@@ -191,7 +196,9 @@ packages/app-desktop/commands/toggleSafeMode.js
packages/app-desktop/commands/toggleTabMovesFocus.js
packages/app-desktop/gui/AiDegradedNotice.js
packages/app-desktop/gui/Button/Button.js
packages/app-desktop/gui/ChatPanel/ChatHistory.js
packages/app-desktop/gui/ChatPanel/ChatMessageItem.js
packages/app-desktop/gui/ChatPanel/ChatPanel.test.js
packages/app-desktop/gui/ChatPanel/ChatPanel.js
packages/app-desktop/gui/ClipperConfigScreen.js
packages/app-desktop/gui/ConfigScreen/ButtonBar.js
@@ -247,6 +254,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 +308,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 +325,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 +374,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 +422,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
@@ -503,8 +518,10 @@ packages/app-desktop/gui/WindowCommandsAndDialogs/commands/addNoteToWhiteboard.j
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/addProfile.js
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/commandPalette.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
@@ -513,6 +530,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
@@ -520,6 +538,7 @@ packages/app-desktop/gui/WindowCommandsAndDialogs/commands/newNote.js
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/newSubFolder.js
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/newTodo.js
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/newWhiteboard.js
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/openAiChatConversation.js
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/openFolder.js
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/openFolderDialog.js
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/openItem.js
@@ -586,15 +605,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
@@ -694,6 +718,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
@@ -855,6 +880,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
@@ -1110,8 +1137,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
@@ -1142,6 +1167,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
@@ -1153,6 +1180,7 @@ packages/editor/CodeMirror/pluginApi/PluginLoader.js
packages/editor/CodeMirror/pluginApi/codeMirrorRequire.js
packages/editor/CodeMirror/pluginApi/customEditorCompletion.test.js
packages/editor/CodeMirror/pluginApi/customEditorCompletion.js
packages/editor/CodeMirror/testing/backspaceOnce.js
packages/editor/CodeMirror/testing/createEditorControl.js
packages/editor/CodeMirror/testing/createTestEditor.js
packages/editor/CodeMirror/testing/findNodesWithName.js
@@ -1160,7 +1188,12 @@ 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/clampSelectionToDocument.test.js
packages/editor/CodeMirror/utils/clampSelectionToDocument.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
@@ -1289,9 +1322,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
@@ -1309,6 +1339,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
@@ -1340,6 +1371,7 @@ packages/lib/SyncTargetWebDAV.js
packages/lib/Synchronizer.js
packages/lib/TaskQueue.test.js
packages/lib/TaskQueue.js
packages/lib/WebDavApi.test.js
packages/lib/WebDavApi.js
packages/lib/WelcomeUtils.test.js
packages/lib/WelcomeUtils.js
@@ -1379,6 +1411,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
@@ -1393,6 +1426,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
@@ -1411,6 +1445,7 @@ packages/lib/errorUtils.js
packages/lib/errors.js
packages/lib/eventManager.test.js
packages/lib/eventManager.js
packages/lib/file-api-driver-joplinServer.test.js
packages/lib/file-api-driver-joplinServer.js
packages/lib/file-api-driver-local.js
packages/lib/file-api-driver-memory.js
@@ -1467,7 +1502,11 @@ packages/lib/mimeUtils.test.js
packages/lib/models/Alarm.js
packages/lib/models/BaseItem.test.js
packages/lib/models/BaseItem.js
packages/lib/models/ChatConversation.test.js
packages/lib/models/ChatConversation.js
packages/lib/models/ChatMessage.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
@@ -1585,24 +1624,51 @@ 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
packages/lib/services/ai/providers/ChatProviderBase.js
packages/lib/services/ai/providers/JoplinCloud.test.js
packages/lib/services/ai/providers/JoplinCloud.js
packages/lib/services/ai/providers/OpenAiCompatible.test.js
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/readNotes.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/loadNoteForReading.js
packages/lib/services/ai/tools/utils/noteOutline.test.js
packages/lib/services/ai/tools/utils/noteOutline.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/extractReasoning.test.js
packages/lib/services/ai/utils/extractReasoning.js
packages/lib/services/ai/utils/findFencedBlock.js
packages/lib/services/commands/MenuUtils.js
packages/lib/services/commands/ToolbarButtonUtils.test.js
@@ -1613,6 +1679,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
@@ -1628,6 +1701,7 @@ packages/lib/services/database/migrations/50.js
packages/lib/services/database/migrations/51.js
packages/lib/services/database/migrations/52.js
packages/lib/services/database/migrations/53.js
packages/lib/services/database/migrations/54.js
packages/lib/services/database/migrations/index.js
packages/lib/services/database/sqlStringToLines.js
packages/lib/services/database/types.js
@@ -1665,12 +1739,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
@@ -1692,17 +1769,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
@@ -1720,7 +1786,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
@@ -1818,11 +1887,13 @@ packages/lib/services/rest/routes/events.js
packages/lib/services/rest/routes/folders.test.js
packages/lib/services/rest/routes/folders.js
packages/lib/services/rest/routes/master_keys.js
packages/lib/services/rest/routes/mcp.test.js
packages/lib/services/rest/routes/mcp.js
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
@@ -1836,6 +1907,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
@@ -1916,6 +1988,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
@@ -2071,6 +2144,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
@@ -2148,8 +2222,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
+94 -20
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
@@ -217,7 +222,9 @@ packages/app-desktop/commands/toggleSafeMode.js
packages/app-desktop/commands/toggleTabMovesFocus.js
packages/app-desktop/gui/AiDegradedNotice.js
packages/app-desktop/gui/Button/Button.js
packages/app-desktop/gui/ChatPanel/ChatHistory.js
packages/app-desktop/gui/ChatPanel/ChatMessageItem.js
packages/app-desktop/gui/ChatPanel/ChatPanel.test.js
packages/app-desktop/gui/ChatPanel/ChatPanel.js
packages/app-desktop/gui/ClipperConfigScreen.js
packages/app-desktop/gui/ConfigScreen/ButtonBar.js
@@ -273,6 +280,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 +334,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 +351,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 +400,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 +448,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
@@ -529,8 +544,10 @@ packages/app-desktop/gui/WindowCommandsAndDialogs/commands/addNoteToWhiteboard.j
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/addProfile.js
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/commandPalette.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
@@ -539,6 +556,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
@@ -546,6 +564,7 @@ packages/app-desktop/gui/WindowCommandsAndDialogs/commands/newNote.js
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/newSubFolder.js
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/newTodo.js
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/newWhiteboard.js
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/openAiChatConversation.js
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/openFolder.js
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/openFolderDialog.js
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/openItem.js
@@ -612,15 +631,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
@@ -720,6 +744,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
@@ -881,6 +906,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
@@ -1136,8 +1163,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
@@ -1168,6 +1193,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
@@ -1179,6 +1206,7 @@ packages/editor/CodeMirror/pluginApi/PluginLoader.js
packages/editor/CodeMirror/pluginApi/codeMirrorRequire.js
packages/editor/CodeMirror/pluginApi/customEditorCompletion.test.js
packages/editor/CodeMirror/pluginApi/customEditorCompletion.js
packages/editor/CodeMirror/testing/backspaceOnce.js
packages/editor/CodeMirror/testing/createEditorControl.js
packages/editor/CodeMirror/testing/createTestEditor.js
packages/editor/CodeMirror/testing/findNodesWithName.js
@@ -1186,7 +1214,12 @@ 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/clampSelectionToDocument.test.js
packages/editor/CodeMirror/utils/clampSelectionToDocument.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
@@ -1315,9 +1348,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
@@ -1335,6 +1365,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
@@ -1366,6 +1397,7 @@ packages/lib/SyncTargetWebDAV.js
packages/lib/Synchronizer.js
packages/lib/TaskQueue.test.js
packages/lib/TaskQueue.js
packages/lib/WebDavApi.test.js
packages/lib/WebDavApi.js
packages/lib/WelcomeUtils.test.js
packages/lib/WelcomeUtils.js
@@ -1405,6 +1437,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
@@ -1419,6 +1452,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
@@ -1437,6 +1471,7 @@ packages/lib/errorUtils.js
packages/lib/errors.js
packages/lib/eventManager.test.js
packages/lib/eventManager.js
packages/lib/file-api-driver-joplinServer.test.js
packages/lib/file-api-driver-joplinServer.js
packages/lib/file-api-driver-local.js
packages/lib/file-api-driver-memory.js
@@ -1493,7 +1528,11 @@ packages/lib/mimeUtils.test.js
packages/lib/models/Alarm.js
packages/lib/models/BaseItem.test.js
packages/lib/models/BaseItem.js
packages/lib/models/ChatConversation.test.js
packages/lib/models/ChatConversation.js
packages/lib/models/ChatMessage.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
@@ -1611,24 +1650,51 @@ 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
packages/lib/services/ai/providers/ChatProviderBase.js
packages/lib/services/ai/providers/JoplinCloud.test.js
packages/lib/services/ai/providers/JoplinCloud.js
packages/lib/services/ai/providers/OpenAiCompatible.test.js
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/readNotes.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/loadNoteForReading.js
packages/lib/services/ai/tools/utils/noteOutline.test.js
packages/lib/services/ai/tools/utils/noteOutline.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/extractReasoning.test.js
packages/lib/services/ai/utils/extractReasoning.js
packages/lib/services/ai/utils/findFencedBlock.js
packages/lib/services/commands/MenuUtils.js
packages/lib/services/commands/ToolbarButtonUtils.test.js
@@ -1639,6 +1705,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
@@ -1654,6 +1727,7 @@ packages/lib/services/database/migrations/50.js
packages/lib/services/database/migrations/51.js
packages/lib/services/database/migrations/52.js
packages/lib/services/database/migrations/53.js
packages/lib/services/database/migrations/54.js
packages/lib/services/database/migrations/index.js
packages/lib/services/database/sqlStringToLines.js
packages/lib/services/database/types.js
@@ -1691,12 +1765,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
@@ -1718,17 +1795,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
@@ -1746,7 +1812,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
@@ -1844,11 +1913,13 @@ packages/lib/services/rest/routes/events.js
packages/lib/services/rest/routes/folders.test.js
packages/lib/services/rest/routes/folders.js
packages/lib/services/rest/routes/master_keys.js
packages/lib/services/rest/routes/mcp.test.js
packages/lib/services/rest/routes/mcp.js
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
@@ -1862,6 +1933,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
@@ -1942,6 +2014,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
@@ -2097,6 +2170,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
@@ -2174,8 +2248,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> = {
+2 -2
View File
@@ -35,7 +35,7 @@
],
"owner": "Laurent Cozic"
},
"version": "3.7.0",
"version": "3.7.1",
"bin": "./main.js",
"engines": {
"node": ">=10.0.0"
@@ -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>
@@ -0,0 +1,29 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE en-export SYSTEM "http://xml.evernote.com/pub/evernote-export4.dtd">
<en-export export-date="20260914T100000Z" application="Evernote" version="10.0">
<note>
<title>Unescaped ampersand</title>
<content>
<![CDATA[<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE en-note SYSTEM "http://xml.evernote.com/pub/enml2.dtd">
<en-note><div>Mobilize &amp; Measure</div></en-note>
]]>
</content>
<created>20260914T100000Z</created>
<updated>20260914T100000Z</updated>
<note-attributes>
<application-data key="evernote.caption">Phase 1: Mobilize & Measure, which involves forming an advocacy group.</application-data>
</note-attributes>
</note>
<note>
<title>Note after the bad one</title>
<content>
<![CDATA[<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE en-note SYSTEM "http://xml.evernote.com/pub/enml2.dtd">
<en-note><div>This note must still be imported</div></en-note>
]]>
</content>
<created>20260914T100000Z</created>
<updated>20260914T100000Z</updated>
</note>
</en-export>
@@ -1 +1 @@
<a data-from-md href='#'>test</a>
<a>test</a>
+26 -13
View File
@@ -1,9 +1,25 @@
import { ChatRole, ChatToolCall, ChatToolMessage } from '@joplin/lib/services/ai/types';
import { AiChatMessage, AppState, createAppDefaultWindowState } from './app.reducer';
import appReducer, { createAppDefaultState } from './app.reducer';
import { defaultWindowId } from '@joplin/lib/reducer';
describe('app.reducer', () => {
it('should clear a deleted conversation from matching windows and keep other conversations', () => {
const messages: AiChatMessage[] = [{ id: 'question', createdTime: 1, role: 'user', text: 'Question', raw: [], noteId: '', noteTitle: '' }];
const state = createAppDefaultState({});
state.aiChatConversationId = 'deleted';
state.aiChatMessages = messages;
state.backgroundWindows = {
second: { ...createAppDefaultWindowState(), windowId: 'second', aiChatConversationId: 'deleted', aiChatMessages: messages },
third: { ...createAppDefaultWindowState(), windowId: 'third', aiChatConversationId: 'other', aiChatMessages: messages },
};
const result = appReducer(state, { type: 'AI_CHAT_DELETE', conversationId: 'deleted' });
expect(result.aiChatConversationId).toBeNull();
expect(result.aiChatMessages).toEqual([]);
expect(result.backgroundWindows.second.aiChatConversationId).toBeNull();
expect(result.backgroundWindows.second.aiChatMessages).toEqual([]);
expect(result.backgroundWindows.third.aiChatConversationId).toBe('other');
expect(result.backgroundWindows.third.aiChatMessages).toEqual(messages);
});
it('should handle DIALOG_OPEN', async () => {
const state: AppState = createAppDefaultState({});
@@ -107,11 +123,15 @@ describe('app.reducer', () => {
it('should build AI chat history', () => {
let state: AppState = {
...createAppDefaultState({}),
aiChatConversationId: 'chat-1',
};
let idCounter = 0;
const buildMessage = (role: ChatRole.User | ChatRole.Assistant, content: string, toolCalls: ChatToolCall[]) => ({
id: `id-${idCounter++}`,
createdTime: 1,
noteId: 'note-a',
noteTitle: 'A',
role,
text: content,
raw: [
@@ -125,12 +145,12 @@ describe('app.reducer', () => {
state = appReducer(state, {
type: 'AI_CHAT_APPEND',
windowId: defaultWindowId,
conversationId: 'chat-1',
message: buildMessage(ChatRole.User, 'Test', []),
});
state = appReducer(state, {
type: 'AI_CHAT_APPEND',
windowId: defaultWindowId,
conversationId: 'chat-1',
message: buildMessage(
ChatRole.Assistant,
'Testing',
@@ -139,28 +159,21 @@ describe('app.reducer', () => {
});
state = appReducer(state, {
type: 'AI_CHAT_ADD_TOOL_RESULT',
windowId: defaultWindowId,
conversationId: 'chat-1',
toolCall: ({
role: ChatRole.Tool,
toolName: 'testTool',
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' },
]);
state = appReducer(state, {
type: 'AI_CHAT_RESET',
windowId: defaultWindowId,
});
expect(state.aiChatMessages).toEqual([]);
});
});
+45 -48
View File
@@ -5,16 +5,18 @@ import iterateItems from './gui/ResizableLayout/utils/iterateItems';
import { LayoutItem } from './gui/ResizableLayout/utils/types';
import validateLayout from './gui/ResizableLayout/utils/validateLayout';
import Logger from '@joplin/utils/Logger';
import { ChatMessage } from '@joplin/lib/services/ai/types';
import { ChatMessage, ChatRole } from '@joplin/lib/services/ai/types';
const logger = Logger.create('app.reducer');
export interface AiChatMessage {
id: string;
createdTime: number;
noteId: string;
noteTitle: string;
role: 'user' | 'assistant' | 'error' | 'separator';
text: string;
editsApplied?: number;
editsMissed?: number;
hide?: boolean;
// The raw message(s) corresponding to this event
raw: ChatMessage[];
@@ -79,6 +81,7 @@ export interface AppWindowState extends WindowState {
// In window state so the conversation survives panel hide/show (the
// layout container can swap component types and unmount the panel).
aiChatMessages: AiChatMessage[];
aiChatConversationId: string|null;
// Layout for secondary windows
secondaryWindowLayout: LayoutItem|null;
}
@@ -116,6 +119,7 @@ export const createAppDefaultWindowState = (): AppWindowState => {
whiteboardForceMarkdown: {},
activeNoteIsWhiteboard: false,
aiChatMessages: [],
aiChatConversationId: null,
secondaryWindowLayout: null,
};
};
@@ -300,56 +304,49 @@ export default function(state: AppState, action: any) {
}
case 'WHITEBOARD_ACTIVE_NOTE_SET':
newState = {
...state,
activeNoteIsWhiteboard: !!action.value,
};
newState = withWindowStateUpdated(
state, action.windowId, 'activeNoteIsWhiteboard', () => !!action.value,
);
break;
case 'AI_CHAT_DELETE':
newState = produce(state, draft => {
for (const windowState of stateUtils.allWindowStates(draft)) {
if (windowState.aiChatConversationId !== action.conversationId) continue;
windowState.aiChatConversationId = null;
windowState.aiChatMessages = [];
}
});
break;
case 'AI_CHAT_OPEN':
newState = produce(state, draft => {
const openConversation = action.conversationId && stateUtils.allWindowStates(state)
.find(windowState => windowState.aiChatConversationId === action.conversationId);
const windowState = stateUtils.windowStateById(draft, action.windowId);
windowState.aiChatConversationId = action.conversationId;
windowState.aiChatMessages = openConversation ? openConversation.aiChatMessages : action.messages;
});
break;
case 'AI_CHAT_APPEND':
newState = withWindowStateUpdated(
state, action.windowId, 'aiChatMessages', messages => [...messages, action.message as AiChatMessage],
);
break;
case 'AI_CHAT_ADD_TOOL_RESULT':
newState = withWindowStateUpdated(
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,
],
};
return [...messages.slice(0, messages.length - 1), lastMessage];
}
return messages;
},
);
break;
case 'AI_CHAT_REMOVE':
newState = withWindowStateUpdated(
state, action.windowId, 'aiChatMessages', messages => messages.filter(m => m.id !== action.id),
);
break;
case 'AI_CHAT_RESET':
newState = withWindowStateUpdated(
state, action.windowId, 'aiChatMessages', (): AiChatMessage[] => [],
);
newState = produce(state, draft => {
for (const windowState of stateUtils.allWindowStates(draft)) {
if (windowState.aiChatConversationId !== action.conversationId) continue;
if (action.type === 'AI_CHAT_APPEND') {
windowState.aiChatMessages.push(action.message);
} else if (action.type === 'AI_CHAT_REMOVE') {
windowState.aiChatMessages = windowState.aiChatMessages.filter(message => message.id !== action.id);
} else {
const message = windowState.aiChatMessages.find(message => message.raw.some(entry =>
entry.role === ChatRole.Assistant && entry.toolCalls?.some(call => call.callId === action.toolCall.toolCallId),
));
message?.raw.push(action.toolCall);
}
}
});
break;
case 'AI_STATUS_UPDATE':
+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') : '';
@@ -0,0 +1,141 @@
@mixin chat-history-input {
box-sizing: border-box;
font: inherit;
color: var(--joplin-color);
background-color: var(--joplin-background-color);
border: 1px solid var(--joplin-divider-color);
border-radius: 4px;
}
.chat-history {
position: absolute;
top: 100%;
left: 0;
right: 30px;
z-index: 10;
display: flex;
flex-direction: column;
max-width: 400px;
margin-left: auto;
padding: 6px 10px 10px;
font-weight: normal;
background-color: var(--joplin-background-color);
border-bottom: 1px solid var(--joplin-divider-color);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
> .search {
flex-shrink: 0;
}
}
.conversation-search {
position: relative;
display: flex;
align-items: center;
> .icon {
position: absolute;
left: 8px;
color: var(--joplin-color-faded);
pointer-events: none;
}
> .field {
@include chat-history-input;
width: 100%;
padding: 6px 6px 6px 26px;
}
}
.conversation-list {
list-style: none;
margin: 6px 0 0;
padding: 0;
max-height: 200px;
overflow-y: auto;
}
.conversation-row {
display: flex;
align-items: center;
line-height: 16px;
cursor: pointer;
&.-current {
font-weight: bold;
background-color: var(--joplin-selected-color);
}
&:hover:not(.-current) {
background-color: var(--joplin-background-color-hover3);
}
> .open {
flex: 1;
min-width: 0;
}
> .title-input {
@include chat-history-input;
flex: 1;
min-width: 0;
margin: 6px 20px;
padding: 3px 4px;
}
> .actions {
flex-shrink: 0;
margin-right: 20px;
}
&:hover,
&:focus-within {
--chat-history-timestamp-display: none;
}
&:hover > .actions,
&:focus-within > .actions {
display: flex;
}
}
.conversation-link {
display: flex;
padding: 10px 20px;
gap: 18px;
text-align: left;
font: inherit;
color: inherit;
background: none;
border: none;
cursor: pointer;
> .title {
flex: 1;
min-width: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
> .timestamp {
display: var(--chat-history-timestamp-display, initial);
flex-shrink: 0;
text-align: right;
color: var(--joplin-color-faded);
}
}
.conversation-actions {
display: none;
gap: 10px;
align-items: center;
> button {
display: flex;
color: var(--joplin-color3);
cursor: pointer;
font-size: 16px;
line-height: 1;
}
}
@@ -0,0 +1,112 @@
import * as React from 'react';
import { useState } from 'react';
import { _ } from '@joplin/lib/locale';
import { formatMsToRelativeTime } from '@joplin/utils/time';
export interface Conversation {
id: string;
title: string;
updated_time: number;
}
interface Props {
conversations: Conversation[];
currentConversationId?: string|null;
id: string;
popupRef: React.Ref<HTMLDivElement>;
onSearchChange: (search: string)=> void;
onOpen: (conversationId: string)=> void;
onRename: (conversation: Conversation, title: string)=> void;
onDelete: (conversationId: string)=> void;
onClose: ()=> void;
}
const ChatHistory: React.FC<Props> = props => {
const [search, setSearch] = useState('');
const [editingId, setEditingId] = useState<string|null>(null);
const [draftTitle, setDraftTitle] = useState('');
const startEditing = (conversation: Conversation) => {
setEditingId(conversation.id);
setDraftTitle(conversation.title);
};
const stopEditing = () => setEditingId(null);
const onTitleKeyDown = (event: React.KeyboardEvent<HTMLInputElement>, conversation: Conversation) => {
if (event.nativeEvent.isComposing) return;
if (event.key === 'Escape') {
event.stopPropagation();
stopEditing();
return;
}
if (event.key !== 'Enter') return;
event.preventDefault();
props.onRename(conversation, draftTitle);
stopEditing();
};
const onPopupKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
if (event.nativeEvent.isComposing || event.key !== 'Escape') return;
event.stopPropagation();
props.onClose();
};
return <div className='chat-history' id={props.id} ref={props.popupRef} onKeyDown={onPopupKeyDown}>
<div className='conversation-search search'>
<i className='icon icon-search' aria-hidden='true'/>
<input
className='field'
type='search'
autoFocus
aria-label={_('Search conversations')}
placeholder={_('Search conversations')}
value={search}
onChange={event => {
setSearch(event.target.value);
props.onSearchChange(event.target.value);
}}
/>
</div>
<ul className='conversation-list' aria-label={_('Conversations')}>
{props.conversations.map(conversation => {
const current = conversation.id === props.currentConversationId;
const title = conversation.title || _('(untitled)');
const editing = conversation.id === editingId;
return <li key={conversation.id} className={`conversation-row${current ? ' -current' : ''}`} aria-current={current ? 'true' : undefined}
onClick={event => {
if (event.target === event.currentTarget) props.onOpen(conversation.id);
}}
>
{editing ? (
<input
className='title-input'
type='text'
autoFocus
aria-label={_('Conversation title')}
value={draftTitle}
onChange={event => setDraftTitle(event.target.value)}
onKeyDown={event => onTitleKeyDown(event, conversation)}
onBlur={stopEditing}
/>
) : (
<button className='conversation-link open' type='button' onClick={() => props.onOpen(conversation.id)}>
<span className='title'>{title}</span>
<span className='timestamp'>{formatMsToRelativeTime(conversation.updated_time)}</span>
</button>
)}
<div className='conversation-actions actions'>
<button className='flat-button' type='button' title={_('Rename')} aria-label={_('Rename')} onClick={() => startEditing(conversation)}>
<i className='fa fa-pen' aria-hidden='true'/>
</button>
<button className='flat-button' type='button' title={_('Delete')} aria-label={_('Delete')} onClick={() => props.onDelete(conversation.id)}>
<i className='fas fa-trash' aria-hidden='true'/>
</button>
</div>
</li>;
})}
</ul>
</div>;
};
export default ChatHistory;
@@ -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>
@@ -0,0 +1,200 @@
import * as React from 'react';
import { act, fireEvent, render, waitFor, within } from '@testing-library/react';
import BaseModel from '@joplin/lib/BaseModel';
import ChatConversation from '@joplin/lib/models/ChatConversation';
import Note from '@joplin/lib/models/Note';
import CommandService from '@joplin/lib/services/CommandService';
import { runNoteChat } from '@joplin/lib/services/ai/noteChat';
import { ChatRole } from '@joplin/lib/services/ai/types';
import { setupDatabaseAndSynchronizer, switchClient } from '@joplin/lib/testing/test-utils';
import appReducer, { AiChatMessage, createAppDefaultState, createAppDefaultWindowState } from '../../app.reducer';
import ChatPanel from './ChatPanel';
import { WindowIdContext } from '../NewWindowOrIFrame';
import uuid from '@joplin/lib/uuid';
import '../../utils/window/eventHandlerOverrides';
jest.mock('@joplin/lib/services/ai/noteChat', () => ({ runNoteChat: jest.fn() }));
jest.mock('./ChatMessageItem', () => ({ message }: { message: { text: string } }) => <div>{message.text}</div>);
const Panel = ChatPanel.WrappedComponent;
const message: AiChatMessage = { id: 'question', createdTime: 1, role: 'user', text: 'Question', raw: [], noteId: 'note-a', noteTitle: 'A' };
const renderPanel = (props: Partial<React.ComponentProps<typeof Panel>> = {}) => render(
<WindowIdContext.Provider value='second'><Panel
themeId={1} available={true} unavailableHint='' providerType='joplin-cloud'
noteId={null} noteTitle='' noteIsEncrypted={false} messages={[]} aiDegraded={false} dispatch={jest.fn()}
showToolbarButton={false}
{...props}
/></WindowIdContext.Provider>,
);
const openHistory = async (view: ReturnType<typeof render>) => {
await act(async () => { fireEvent.click(view.getByTitle('Chat history')); });
};
const saveConversation = async (id: string, title: string, updatedTime = 1) => {
await ChatConversation.save({ id, title, created_time: 1, updated_time: updatedTime }, { isNew: true, autoTimestamp: false });
};
const saveMessage = async (conversationId: string, text: string) => {
await ChatConversation.addMessage(conversationId, { id: uuid.create(), createdTime: 1, role: 'user', text, raw: [], noteId: '', noteTitle: '' });
};
describe('ChatPanel', () => {
let modelDispatch: jest.Mock;
beforeEach(async () => {
await setupDatabaseAndSynchronizer(1);
await switchClient(1);
Element.prototype.scrollIntoView = jest.fn();
jest.mocked(runNoteChat).mockReset();
modelDispatch = jest.fn();
BaseModel.dispatch = modelDispatch;
});
afterEach(() => {
jest.restoreAllMocks();
BaseModel.dispatch = () => {};
});
it('should search conversation titles and messages', async () => {
await saveConversation('shopping', 'Shopping list');
await saveConversation('travel', 'Travel plans');
await saveMessage('travel', 'Visit Paris');
const view = renderPanel();
await openHistory(view);
await view.findByText('Shopping list');
const search = view.getByRole('searchbox', { name: 'Search conversations' });
fireEvent.change(search, { target: { value: 'Shopping' } });
await waitFor(() => expect(view.getAllByRole('listitem')).toHaveLength(1));
expect(view.getByText('Shopping list')).toBeTruthy();
fireEvent.change(search, { target: { value: 'Paris' } });
expect(await view.findByText('Travel plans')).toBeTruthy();
expect(view.getAllByRole('listitem')).toHaveLength(1);
fireEvent.change(search, { target: { value: '' } });
expect(await view.findByText('Shopping list')).toBeTruthy();
expect(view.getAllByRole('listitem')).toHaveLength(2);
});
it('should rename a conversation inline when Enter is pressed', async () => {
await saveConversation('chat-1', 'Old title');
const view = renderPanel();
await openHistory(view);
const row = (await view.findByText('Old title')).closest('li');
fireEvent.click(within(row).getByRole('button', { name: 'Rename' }));
const input = within(row).getByRole('textbox', { name: 'Conversation title' });
fireEvent.change(input, { target: { value: 'New title' } });
await act(async () => { fireEvent.keyDown(input, { key: 'Enter' }); });
expect(await view.findByText('New title')).toBeTruthy();
expect(view.queryByRole('textbox', { name: 'Conversation title' })).toBeNull();
expect(await ChatConversation.load('chat-1')).toMatchObject({ title: 'New title' });
});
it('should close the history popup when clicking outside of it', async () => {
await saveConversation('chat-1', 'Saved chat');
const view = renderPanel();
await openHistory(view);
await view.findByText('Saved chat');
fireEvent.mouseDown(view.getByRole('searchbox', { name: 'Search conversations' }));
expect(view.getByText('Saved chat')).toBeTruthy();
fireEvent.mouseDown(view.getByRole('textbox', { name: 'Chat message' }));
await waitFor(() => expect(view.queryByText('Saved chat')).toBeNull());
});
it('should delete the selected conversation and its messages', async () => {
await saveConversation('delete-chat', 'Delete this chat');
await saveConversation('keep-chat', 'Keep this chat');
await saveMessage('delete-chat', 'Delete this message');
await saveMessage('keep-chat', 'Keep this message');
const dispatch = jest.fn();
const view = renderPanel({ conversationId: 'delete-chat', dispatch });
await openHistory(view);
const row = (await view.findByText('Delete this chat')).closest('li');
await act(async () => { fireEvent.click(within(row).getByRole('button', { name: 'Delete' })); });
await waitFor(() => expect(view.queryByText('Delete this chat')).toBeNull());
expect(view.getByText('Keep this chat')).toBeTruthy();
expect((await ChatConversation.history()).map(conversation => conversation.id)).toEqual(['keep-chat']);
expect(await ChatConversation.messages('keep-chat')).toMatchObject([{ text: 'Keep this message' }]);
await saveConversation('delete-chat', 'Recreated chat');
expect(await ChatConversation.messages('delete-chat')).toEqual([]);
expect(dispatch).toHaveBeenCalledWith({ type: 'AI_CHAT_DELETE', conversationId: 'delete-chat' });
});
it('should keep history when New chat opens an empty conversation in the current window', async () => {
await saveConversation('chat-1', 'Current chat');
const dispatch = jest.fn();
const view = renderPanel({ conversationId: 'chat-1', messages: [message], dispatch });
await act(async () => { fireEvent.click(view.getByRole('button', { name: 'New chat' })); });
await waitFor(() => expect(dispatch).toHaveBeenCalled());
expect(await ChatConversation.load('chat-1')).toMatchObject({ title: 'Current chat' });
const action = dispatch.mock.calls[0][0];
expect(action).toEqual({ type: 'AI_CHAT_OPEN', windowId: 'second', conversationId: expect.any(String), messages: [] });
expect(action.conversationId).not.toBe('chat-1');
const state = createAppDefaultState({});
state.aiChatConversationId = 'main-chat';
state.aiChatMessages = [message];
state.backgroundWindows = { second: { ...createAppDefaultWindowState(), windowId: 'second' } };
const opened = appReducer(state, action);
expect(opened.aiChatConversationId).toBe('main-chat');
expect(opened.aiChatMessages).toEqual([message]);
expect(opened.backgroundWindows.second.aiChatConversationId).toBe(action.conversationId);
expect(opened.backgroundWindows.second.aiChatMessages).toEqual([]);
});
it('should not add an empty conversation to history', async () => {
await saveConversation('chat-1', 'Current chat');
const before = await ChatConversation.history();
const dispatch = jest.fn();
const view = renderPanel({ conversationId: 'chat-1', messages: [message], dispatch });
await act(async () => { fireEvent.click(view.getByRole('button', { name: 'New chat' })); });
await waitFor(() => expect(dispatch).toHaveBeenCalled());
const after = await ChatConversation.history();
expect(after).toHaveLength(before.length);
});
it('should open the selected history row in the current window', async () => {
await saveConversation('chat-1', 'Current chat');
await saveConversation('chat-2', 'Saved chat');
const execute = jest.spyOn(CommandService.instance(), 'executeInWindow').mockResolvedValue(undefined);
const view = renderPanel({ conversationId: 'chat-1', messages: [message] });
await openHistory(view);
fireEvent.click(await view.findByRole('button', { name: /Saved chat/ }));
await waitFor(() => expect(execute).toHaveBeenCalledWith('openAiChatConversation', { windowId: 'second', args: ['chat-2'] }));
});
it('should save the question and reply with the active note ID and title as they are created', async () => {
jest.spyOn(Note, 'load').mockResolvedValue({ id: 'note-a', title: 'A', body: '' });
jest.mocked(runNoteChat).mockImplementation(async (_context, _history, _text, _tools, onHistoryChanged) => {
onHistoryChanged([{ role: ChatRole.System, content: '' }, { role: ChatRole.Assistant, content: 'Reply' }]);
return [];
});
const view = renderPanel({ conversationId: 'chat-1', noteId: 'note-a', noteTitle: 'A' });
fireEvent.change(view.getByRole('textbox'), { target: { value: 'Question' } });
await act(async () => { fireEvent.click(view.getByRole('button', { name: 'Send' })); });
const expected = [
{ role: 'user', text: 'Question', noteId: 'note-a', noteTitle: 'A' },
{ role: 'assistant', text: 'Reply', noteId: 'note-a', noteTitle: 'A' },
];
await waitFor(async () => expect(await ChatConversation.messages('chat-1')).toMatchObject(expected));
expect(await ChatConversation.load('chat-1')).toMatchObject({ title: 'Question' });
const appended = modelDispatch.mock.calls.map(([action]) => action).filter(action => action.type === 'AI_CHAT_APPEND');
expect(appended).toMatchObject(expected.map(message => ({ conversationId: 'chat-1', message })));
});
it('should remove the saved question when the request fails before any reply', async () => {
jest.spyOn(Note, 'load').mockResolvedValue({ id: 'note-a', title: 'A', body: '' });
jest.mocked(runNoteChat).mockRejectedValue(new Error('Network down'));
const view = renderPanel({ conversationId: 'chat-1', noteId: 'note-a', noteTitle: 'A', dispatch: jest.fn() });
fireEvent.change(view.getByRole('textbox'), { target: { value: 'Question' } });
await act(async () => { fireEvent.click(view.getByRole('button', { name: 'Send' })); });
await waitFor(async () => expect(await ChatConversation.messages('chat-1')).toMatchObject([{ role: 'error', text: 'Network down' }]));
});
it('should assign an ID before messages are added to a new conversation', async () => {
const dispatch = jest.fn();
renderPanel({ dispatch });
await waitFor(() => expect(dispatch).toHaveBeenCalledWith({
type: 'AI_CHAT_OPEN', windowId: 'second', conversationId: expect.any(String), messages: [],
}));
});
});
+217 -36
View File
@@ -10,7 +10,7 @@ import Logger from '@joplin/utils/Logger';
import { stateUtils } from '@joplin/lib/reducer';
import { AiChatMessage, AppState } from '../../app.reducer';
import { runNoteChat } from '@joplin/lib/services/ai/noteChat';
import { chatAvailability } from '@joplin/lib/services/ai/availability';
import { AvailabilityReason, chatAvailability } from '@joplin/lib/services/ai/availability';
import { WindowIdContext } from '../NewWindowOrIFrame';
import AiDegradedNotice from '../AiDegradedNotice';
import { ChatMessage, ChatRole, ChatToolMessage } from '@joplin/lib/services/ai/types';
@@ -18,26 +18,35 @@ 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';
import ChatConversation from '@joplin/lib/models/ChatConversation';
import uuid from '@joplin/lib/uuid';
import { focus } from '@joplin/lib/utils/focusHandler';
import dialogs from '../dialogs';
import ChatHistory, { Conversation } from './ChatHistory';
import Button, { ButtonLevel } from '../Button/Button';
const logger = Logger.create('ChatPanel');
interface Props {
themeId: number;
available: boolean;
availabilityReason?: AvailabilityReason;
unavailableHint: string;
providerType: string;
noteId: string | null;
noteTitle: string;
noteIsEncrypted: boolean;
messages: AiChatMessage[];
conversationId?: string|null;
aiDegraded: boolean;
showToolbarButton: boolean;
dispatch: Dispatch;
}
const disclosureSetting = 'ai.chat.disclosureAcknowledged';
let nextMessageId = 0;
const makeId = () => `m-${Date.now()}-${++nextMessageId}`;
const makeId = () => uuid.create();
const waitForNextNoteChangeOrTimeout = (noteId: string, timeout: number) => {
return new Promise<void>((resolve) => {
@@ -104,7 +113,40 @@ const useHasFocus = () => {
const ChatPanel: React.FC<Props> = (props) => {
const { dispatch, messages } = props;
const [input, setInput] = useState('');
const [historyOpen, setHistoryOpen] = useState(false);
const historyId = useId();
const [conversations, setConversations] = useState<Conversation[]>([]);
const loadConversationHistory = useCallback(async (search = '') => {
try {
setConversations(await ChatConversation.history(search));
} catch (error) {
logger.error('Could not load chat conversations', error);
}
}, []);
const handleHistoryToggle = useCallback(async () => {
setHistoryOpen(!historyOpen);
if (historyOpen) return;
await loadConversationHistory();
}, [historyOpen, loadConversationHistory]);
const historyPopupRef = useRef<HTMLDivElement>(null);
const historyButtonRef = useRef<HTMLButtonElement>(null);
const closeHistory = useCallback(() => {
setHistoryOpen(false);
focus('ChatPanel::closeHistory', historyButtonRef.current);
}, []);
useEffect(() => {
if (!historyOpen) return () => {};
const onMouseDown = (event: MouseEvent) => {
const target = event.target as Node;
if (historyPopupRef.current?.contains(target) || historyButtonRef.current?.contains(target)) return;
setHistoryOpen(false);
};
const doc = historyButtonRef.current.ownerDocument;
doc.addEventListener('mousedown', onMouseDown);
return () => doc.removeEventListener('mousedown', onMouseDown);
}, [historyOpen]);
const [sending, setSending] = useState(false);
const archivingRef = useRef(false);
const [disclosureShown, setDisclosureShown] = useState<boolean>(() => {
try {
return !!Setting.value(disclosureSetting);
@@ -127,13 +169,35 @@ const ChatPanel: React.FC<Props> = (props) => {
const windowId = useContext(WindowIdContext);
const appendMessage = useCallback((message: AiChatMessage) => {
dispatch({ type: 'AI_CHAT_APPEND', windowId, message });
}, [dispatch, windowId]);
const appendMessage = useCallback(async (message: Omit<AiChatMessage, 'noteId' | 'noteTitle' | 'createdTime'>) => {
const fullMessage: AiChatMessage = { ...message, createdTime: Date.now(), noteId: props.noteId ?? '', noteTitle: props.noteId ? props.noteTitle : '' };
try {
await ChatConversation.addMessage(props.conversationId, fullMessage);
} catch (error) {
logger.error('Could not save chat message:', error);
}
}, [props.noteId, props.noteTitle, props.conversationId]);
const addToolResult = useCallback((result: ChatToolMessage) => {
dispatch({ type: 'AI_CHAT_ADD_TOOL_RESULT', windowId, toolCall: result });
}, [dispatch, windowId]);
const removeMessage = useCallback(async (id: string) => {
try {
await ChatConversation.removeMessage(props.conversationId, id);
} catch (error) {
logger.error('Could not remove chat message:', error);
}
}, [props.conversationId]);
const addToolResult = useCallback(async (result: ChatToolMessage) => {
try {
await ChatConversation.addToolResult(props.conversationId, result);
} catch (error) {
logger.error('Could not save chat tool result:', error);
}
}, [props.conversationId]);
useEffect(() => {
if (props.conversationId) return;
dispatch({ type: 'AI_CHAT_OPEN', windowId, conversationId: uuid.create(), messages });
}, [props.conversationId, dispatch, windowId, messages]);
// Drop a separator when the active note changes mid-conversation. Skip
// the first ever opened note (no prior context to separate from).
@@ -142,11 +206,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;
appendMessage({
const text = _('— now viewing: %s —', props.noteTitle || _('(untitled)'));
void 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]);
@@ -167,14 +233,14 @@ const ChatPanel: React.FC<Props> = (props) => {
const handleCancel = useCallback(() => {
cancelRequest();
appendMessage({ id: makeId(), role: 'assistant', text: _('(Cancelled)'), raw: [] });
void appendMessage({ id: makeId(), role: 'assistant', text: _('(Cancelled)'), raw: [] });
}, [cancelRequest, appendMessage]);
const handleSend = useCallback(async () => {
const text = input.trim();
if (!text || sending) return;
if (!props.noteId) {
appendMessage({
void appendMessage({
id: makeId(), role: 'error', text: _('Open a note to start chatting.'), raw: [],
});
return;
@@ -188,8 +254,8 @@ 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;
appendMessage({
let hadVisibleResponse = false;
void appendMessage({
id: userTurnId,
role: 'user',
text,
@@ -198,6 +264,7 @@ const ChatPanel: React.FC<Props> = (props) => {
try {
const note = await Note.load(props.noteId);
if (abortController.signal.aborted) return;
if (!note) throw new Error(`Note not found: ${props.noteId}`);
const getContext = async () => {
@@ -217,6 +284,8 @@ const ChatPanel: React.FC<Props> = (props) => {
body: note.body,
title: note.title,
selection,
noteId: note.id,
folderId: note.parent_id,
};
};
@@ -236,15 +305,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);
void addToolResult(entry);
} else {
appendMessage({
hadVisibleResponse ||= !entry.hide;
void appendMessage({
id: makeId(),
role: entry.role,
text: entry.content,
hide: entry.hide,
raw: [entry],
});
}
@@ -287,7 +357,7 @@ const ChatPanel: React.FC<Props> = (props) => {
await changeListener;
},
displayError: (message) => {
appendMessage({ id: makeId(), role: 'error', text: message, raw: [] });
void appendMessage({ id: makeId(), role: 'error', text: message, raw: [] });
},
}, onHistoryChanged, abortController.signal,
);
@@ -295,27 +365,78 @@ const ChatPanel: React.FC<Props> = (props) => {
logger.warn('Chat failed:', error);
if (abortController.signal.aborted) return;
if (!hadSuccessfulResponse) {
dispatch({ type: 'AI_CHAT_REMOVE', windowId, id: userTurnId });
if (!hadVisibleResponse) {
void removeMessage(userTurnId);
setInput(text);
}
appendMessage({ id: makeId(), role: 'error', text: error.message || _('Something went wrong.'), raw: [] });
void appendMessage({ id: makeId(), role: 'error', text: error.message || _('Something went wrong.'), raw: [] });
} finally {
setSending(false);
}
}, [input, sending, props.noteId, conversationTurns, windowId, addToolResult, appendMessage, dispatch, cancelRequest, abortControllerRef]);
}, [input, sending, props.noteId, conversationTurns, windowId, addToolResult, appendMessage, removeMessage, cancelRequest, abortControllerRef]);
const handleAcknowledgeDisclosure = useCallback(() => {
Setting.setValue(disclosureSetting, true);
setDisclosureShown(true);
}, []);
const handleReset = useCallback(() => {
const handleNewChat = useCallback(() => {
if (archivingRef.current) return;
cancelRequest();
dispatch({ type: 'AI_CHAT_RESET', windowId: windowId });
// Only saved once a message is sent so empty chats don't show up in history
dispatch({ type: 'AI_CHAT_OPEN', windowId, conversationId: uuid.create(), messages: [] });
}, [dispatch, windowId, cancelRequest]);
const handleOpenConversation = useCallback(async (conversationId: string) => {
if (conversationId === props.conversationId || archivingRef.current) return;
archivingRef.current = true;
cancelRequest();
try {
await CommandService.instance().executeInWindow('openAiChatConversation', { windowId, args: [conversationId] });
} catch (error) {
logger.error('Could not open chat conversation:', error);
} finally {
archivingRef.current = false;
}
}, [windowId, cancelRequest, props.conversationId]);
const handleClose = useCallback(() => {
void CommandService.instance().executeInWindow('toggleAiChat', { windowId: windowId, args: [] });
}, [windowId]);
const handleDeleteConversation = useCallback(async (conversationId: string) => {
if (archivingRef.current) return;
archivingRef.current = true;
if (conversationId === props.conversationId) cancelRequest();
try {
await ChatConversation.deleteConversation(conversationId);
setConversations(current => current.filter(item => item.id !== conversationId));
dispatch({ type: 'AI_CHAT_DELETE', conversationId });
} catch (error) {
logger.error('Could not delete chat conversation:', error);
await dialogs.alert(_('Could not delete conversation.'));
} finally {
archivingRef.current = false;
}
}, [dispatch, cancelRequest, props.conversationId]);
const handleRenameConversation = useCallback(async (conversation: Conversation, newTitle: string) => {
const title = newTitle.trim();
if (!title || title === conversation.title) return;
try {
await ChatConversation.renameConversation(conversation.id, title);
setConversations(current => current.map(item => item.id === conversation.id ? { ...item, title } : item));
} catch (error) {
logger.error('Could not rename chat conversation:', error);
await dialogs.alert(_('Could not rename conversation.'));
}
}, []);
const handleHideToolbarButton = useCallback(() => {
Setting.setValue('ai.chat.showToolbarButton', false);
handleClose();
}, [handleClose]);
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.
@@ -329,7 +450,16 @@ const ChatPanel: React.FC<Props> = (props) => {
if (!props.available) {
const content = (
<div className='disabled-message'>
{props.unavailableHint}
<div className='hint'>{props.unavailableHint}</div>
{props.showToolbarButton && <>
<div className='hint'>{_('If you do not want to use the AI features, you may hide the toolbar button:')}</div>
<Button
level={ButtonLevel.Secondary}
title={_('Hide the AI Chat button')}
onClick={handleHideToolbarButton}
/>
<div className='hint'>{_('(You can show it again from Settings → AI)')}</div>
</>}
</div>
);
return { content, showingMessages: false };
@@ -343,15 +473,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,10 +524,12 @@ 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 closeLabel = _('Close');
const newChatLabel = _('New chat');
const headerId = useId();
const { content, showingMessages } = renderContent();
@@ -400,11 +541,46 @@ 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>
{props.availabilityReason !== AvailabilityReason.Disabled && (
<button
type='button'
className='history toolbar-button'
ref={historyButtonRef}
onClick={handleHistoryToggle}
title={_('Chat history')}
aria-label={_('Chat history')}
aria-expanded={historyOpen}
aria-controls={historyId}
>
<i className='toolbar-icon far fa-clock' aria-hidden='true'/>
</button>
)}
{historyOpen && (
// Placed right after its button so that Tab moves from the button into the popup
<ChatHistory
conversations={conversations}
currentConversationId={props.conversationId}
id={historyId}
popupRef={historyPopupRef}
onSearchChange={loadConversationHistory}
onOpen={handleOpenConversation}
onRename={handleRenameConversation}
onDelete={handleDeleteConversation}
onClose={closeHistory}
/>
)}
<button type='button' className='reset toolbar-button' onClick={handleNewChat} disabled={!showingMessages} title={newChatLabel} aria-label={newChatLabel}>
<i className='toolbar-icon fas fa-comment-medical' aria-hidden='true'/>
</button>
<button
type='button'
className='close toolbar-button'
onClick={handleClose}
title={closeLabel}
aria-label={closeLabel}
><i className='toolbar-icon fas fa-times' role='img' aria-hidden={true}/></button>
</div>
{content}
</div>
@@ -423,14 +599,19 @@ const mapStateToProps = (state: AppState, ownProps: OwnProps) => {
return {
themeId: state.settings.theme,
available: availability.available,
availabilityReason: availability.reason,
unavailableHint: availability.hint ?? '',
providerType: state.settings['ai.chat.providerType'] || 'openai-compatible',
noteId,
noteTitle: note?.title || '',
noteIsEncrypted: !!note?.encryption_applied,
messages: windowState.aiChatMessages || [],
conversationId: windowState.aiChatConversationId,
aiDegraded: !!state.aiStatus?.degraded,
showToolbarButton: !!state.settings['ai.chat.showToolbarButton'],
};
};
export default connect(mapStateToProps)(ChatPanel);
const ConversationPanel: React.FC<Props> = props => <ChatPanel key={props.conversationId} {...props} />;
export default connect(mapStateToProps)(ConversationPanel);
+24 -19
View File
@@ -1,8 +1,11 @@
@use './ChatHistory.scss';
.chat-panel {
display: flex;
flex-direction: column;
width: 100%;
height: 100%;
overflow-y: auto;
box-sizing: border-box;
background-color: var(--joplin-background-color);
color: var(--joplin-color);
@@ -11,6 +14,7 @@
}
.chat-panel > .header {
position: relative;
display: flex;
flex-direction: row;
align-items: center;
@@ -19,23 +23,24 @@
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, > .history {
background: none;
border: none;
color: var(--joplin-color-faded);
cursor: pointer;
padding: 2px 6px;
.chat-panel > .header > .reset:hover {
color: var(--joplin-color);
&:hover:not(:disabled) {
color: var(--joplin-color);
}
}
}
.chat-panel > .degraded-status {
@@ -213,10 +218,10 @@
.chat-panel > .disabled-message {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
text-align: center;
flex-direction: column;
align-items: flex-start;
gap: 12px;
padding: 12px;
color: var(--joplin-color-faded);
line-height: 1.5;
}
@@ -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,
+13 -10
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>
@@ -833,7 +836,7 @@ const mapStateToProps = (state: AppState) => {
lastDeletion: state.lastDeletion,
lastDeletionNotificationTime: state.lastDeletionNotificationTime,
mustUpgradeAppMessage: state.mustUpgradeAppMessage,
syncTargetAppMinVersion: syncInfo.appMinVersion,
syncTargetAppMinVersion: state.mustUpgradeAppMinVersion,
showInvalidJoplinCloudCredential: state.settings['sync.target'] === 10 && state.mustAuthenticate,
toast: state.toast,
shouldSwitchToAppleSiliconVersion: shim.isAppleSilicon() && shim.isMac() && process.arch !== 'arm64',
@@ -0,0 +1,78 @@
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) => {
// A null id makes handleGetCurrentNote take its x-error branch, which reaches
// respond() without touching the database.
commandUtils.store = {
getState: () => ({ selectedNoteIds: noteId ? [noteId] : [] }),
};
};
describe('handleCallbackUrl', () => {
beforeEach(() => {
mockOpenExternal.mockClear();
setSelectedNoteId(null);
});
test.each([
'ms-msdt:/id PCWDiagnostic',
'search-ms:query=secret',
'shell:startup',
'file:///etc/passwd',
'javascript:alert(1)',
'data:text/html,<script>alert(1)</script>',
'mailto:someone@example.com',
'http://example.com/cb',
'https://example.com/cb',
'unknown-app://attacker.example.com/cb',
'unknown-app:///hostless',
'not a url',
])('should not dispatch an unrecognised callback target (%s)', async (target) => {
await withWarningSilenced(/Rejected malformed callback|Rejected callback target with host/, async () => {
await executeCallbackUrl(`joplin://x-callback-url/getCurrentNote?x-error=${encodeURIComponent(target)}`);
}, { requireWarning: true });
expect(mockOpenExternal).not.toHaveBeenCalled();
});
test.each([
'hook://x-callback-url/setCurrentNode',
'drafts://x-callback-url/create',
'bear://x-callback-url/open-note',
'ulysses://x-callback-url/new-sheet',
'new-app://x-callback-url/cb',
'editorial://workflow-callback',
'things:///add',
])('should dispatch a recognised callback target (%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('hook://x-callback-url/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,132 @@
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');
// Schemes of known apps, which are allowed whatever their callback format. Add
// any legitimate app here - it's preferred over relying on the host check below.
// From https://x-callback-url.com/apps/ and https://app-talk.com/
const knownCallbackProtocols = [
'agenda:', 'airmail:', 'bear:', 'beorg:', 'byword:', 'calca:', 'copied:',
'dayone:', 'devonthink:', 'drafts:', 'drafts5:', 'due:', 'editorial:',
'fantastical2:', 'gladys:', 'hook:', 'instapaper:', 'launcher:', 'omnifocus:',
'omnifocus3:', 'onewriter:', 'opener:', 'outlinely:', 'overcast:', 'prizmo:',
'pyto:', 'scriptable:', 'shortcutsiosopen:', 'terminology:', 'textastic:',
'things:', 'timepage:', 'todoist:', 'trello:', 'twodo:', 'ulysses:',
'working-copy:', 'x-devonthink:',
];
const respond = (target: string, params: Record<string, string> = {}) => {
if (!target) return;
// A joplin:// URL can be triggered from a web page and the target is passed to
// the OS URI dispatcher, so it must not reach an arbitrary handler.
let url;
try {
url = new URL(target);
} catch (error) {
// The target is untrusted and may contain secrets, so don't log it.
logger.warn('Rejected malformed callback target');
return;
}
if (url.host !== 'x-callback-url' && !knownCallbackProtocols.includes(url.protocol)) {
logger.warn(`Rejected callback target with host "${url.host}" and scheme "${url.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;
+13
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';
@@ -31,6 +32,7 @@ import NavService from '@joplin/lib/services/NavService';
import Logger from '@joplin/utils/Logger';
import { ImportCommandOptions } from './WindowCommandsAndDialogs/commands/importFrom';
import { FileSystemItem } from '@joplin/lib/services/interop/types';
import { chatAvailability } from '@joplin/lib/services/ai/availability';
const logger = Logger.create('MenuBar');
@@ -184,6 +186,7 @@ interface Props {
secondaryWindowFocused: boolean;
showMenuBar: boolean;
syncPending: boolean;
showAiChatMenuItem: boolean;
}
const commandNames: string[] = menuCommandNames();
@@ -531,6 +534,7 @@ function useMenu(props: Props) {
// the following menu items will be available for all OS under Tools
const toolsItemsAll = [
menuItemDic.newWhiteboard,
menuItemDic.addNoteToWhiteboard,
separator(),
{
label: _('Note attachments...'),
@@ -753,6 +757,7 @@ function useMenu(props: Props) {
menuItemDic.toggleSideBar,
shim.isMac() ? noItem : menuItemDic.toggleMenuBar,
menuItemDic.toggleNoteList,
{ ...menuItemDic.toggleAiChat, visible: props.showAiChatMenuItem },
menuItemDic.toggleVisiblePanes,
menuItemDic.toggleEditorPlugin,
menuItemDic.toggleEditors,
@@ -867,6 +872,12 @@ function useMenu(props: Props) {
menuItemDic.setTags,
menuItemDic.showShareNoteDialog,
menuItemDic.convertNoteToMarkdown,
...(isNoteLockEnabled() ? [
separator(),
menuItemDic.enableNoteEncryption,
menuItemDic.disableNoteEncryption,
menuItemDic.lockEncryptedNotes,
] : []),
separator(),
menuItemDic.showNoteProperties,
menuItemDic.showNoteContentProperties,
@@ -1061,6 +1072,7 @@ function useMenu(props: Props) {
switchProfileMenuItems,
menuItemDic,
props.syncPending,
props.showAiChatMenuItem,
]);
useMenuStates(menu, props);
@@ -1148,6 +1160,7 @@ const mapStateToProps = (state: AppState): Partial<Props> => {
noteListRendererId: state.settings['notes.listRendererId'],
showMenuBar: state.settings.showMenuBar,
syncPending: state.syncPending,
showAiChatMenuItem: chatAvailability().available || !!state.settings['ai.chat.showToolbarButton'],
};
};
@@ -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);
},
@@ -1126,7 +1130,7 @@ const TinyMCE = (props: NoteBodyEditorProps, ref: Ref<NoteBodyEditorRef>) => {
if (!hasOneNoteInkContentStyle && new DOMParser().parseFromString(result.html, 'text/html').querySelector('.ink-text, .ink-space, .container-outline')) {
const styleElement = editor.getDoc().createElement('style');
styleElement.id = oneNoteInkContentStyleId;
styleElement.textContent = '.ink-text, .ink-space { display: inline-block; position: relative; vertical-align: bottom; } .container-outline { font-family: Calibri, sans-serif; font-size: 6pt; font-weight: normal; }';
styleElement.textContent = '.ink-text, .ink-space { display: inline-block; position: relative; vertical-align: bottom; } .container-outline p:has(> .ink-text, > .ink-space) { font-family: Calibri, sans-serif; font-size: 6pt; font-weight: normal; }';
editor.getDoc().head.appendChild(styleElement);
}
const htmlAndCss = [
@@ -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');
}
@@ -28,6 +28,10 @@ import { Canvas, CanvasColor, CanvasEdge, CanvasNode } from '@joplin/lib/service
import { presetColors, resolveCanvasColor } from '@joplin/lib/services/whiteboard/presetColors';
import { useWhiteboardContext } from './WhiteboardContext';
import shim from '@joplin/lib/shim';
import CommandService from '@joplin/lib/services/CommandService';
import { ModelType } from '@joplin/lib/BaseModel';
import { Mode } from '../../../../plugins/GotoAnything';
import { GotoAnythingOptions, UiType } from '../../../WindowCommandsAndDialogs/commands/gotoAnything';
import Logger from '@joplin/utils/Logger';
import { webUtils } from 'electron';
import { canvasNodeToFlowNode, canvasToFlow, flowToCanvas, WhiteboardFlowEdge, WhiteboardFlowNode } from './canvasFlow';
@@ -229,6 +233,39 @@ const InnerSurface = ({ canvas, onChange }: Props) => {
}));
}, [flowNodes]);
const addFileCard = useCallback((itemId: string, at: { x: number; y: number }, index = 0) => {
const offset = index * 24;
addCanvasNode({
id: generateId(),
type: 'file',
x: at.x - 120 + offset,
y: at.y - 60 + offset,
width: 240,
height: 160,
file: `:/${itemId}`,
});
}, [addCanvasNode]);
const onPaneDoubleClick = useCallback(async (event: React.MouseEvent) => {
// The <Background> svg covers the pane, so it's the event target on an
// empty-space click.
const target = event.target as Element;
if (!target?.closest) return;
const onEmptySpace = target.classList.contains('react-flow__pane')
|| !!target.closest('.react-flow__background');
if (!onEmptySpace) return;
const at = rf.screenToFlowPosition({ x: event.clientX, y: event.clientY });
const options: GotoAnythingOptions = { mode: Mode.TitleOnly };
const result = await CommandService.instance().execute('gotoAnything', UiType.ControlledApi, options);
if (!result) return;
if (result.type !== ModelType.Note) {
logger.warn('Selected item is not a note:', result);
return;
}
addFileCard(result.item.id, at);
}, [rf, addFileCard]);
const onAddText = useCallback(() => {
const { x: cx, y: cy } = viewportCentre();
addCanvasNode({
@@ -368,10 +405,15 @@ const InnerSurface = ({ canvas, onChange }: Props) => {
);
const onDragOver = useCallback((e: ReactDragEvent<HTMLDivElement>) => {
const effectAllowed = e.dataTransfer.effectAllowed;
const canLink = effectAllowed.toLowerCase().includes('link') || ['all', 'uninitialized'].includes(effectAllowed);
const types = Array.from(e.dataTransfer.types);
if (types.includes('text/x-jop-note-ids') || types.includes('text/x-jop-resource-ids')) {
e.preventDefault();
e.dataTransfer.dropEffect = 'link';
// Workaround: On Linux, the 'link' drag effect prevents dropping items into the editor.
// See https://github.com/laurent22/joplin/issues/16457.
e.dataTransfer.dropEffect = canLink ? 'link' : 'copy';
} else if (types.includes('Files')) {
e.preventDefault();
e.dataTransfer.dropEffect = 'copy';
@@ -396,16 +438,7 @@ const InnerSurface = ({ canvas, onChange }: Props) => {
};
const placeCardForResource = (resourceId: string, index: number) => {
const offset = index * 24;
addCanvasNode({
id: generateId(),
type: 'file',
x: drop.x - 120 + offset,
y: drop.y - 60 + offset,
width: 240,
height: 160,
file: `:/${resourceId}`,
});
addFileCard(resourceId, drop, index);
};
const internalIds = [
@@ -428,7 +461,7 @@ const InnerSurface = ({ canvas, onChange }: Props) => {
}
}));
}
}, [rf, addCanvasNode]);
}, [rf, addFileCard]);
return (
<div
@@ -436,6 +469,7 @@ const InnerSurface = ({ canvas, onChange }: Props) => {
className="whiteboard-surface"
onDragOver={onDragOver}
onDrop={onDrop}
onDoubleClick={onPaneDoubleClick}
>
<ReactFlow
nodes={flowNodes as unknown as Node[]}
@@ -460,6 +494,7 @@ const InnerSurface = ({ canvas, onChange }: Props) => {
panOnDrag
zoomOnPinch
zoomOnScroll={false}
zoomOnDoubleClick={false}
fitView={flowNodes.length > 0}
elevateNodesOnSelect={false}
proOptions={{ hideAttribution: true }}
@@ -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,
@@ -488,11 +579,11 @@ function NoteEditorContent(props: NoteEditorProps) {
// show the editor toggle. We can't compute this from the redux note list
// because note bodies aren't in the preview fields.
useEffect(() => {
props.dispatch({ type: 'WHITEBOARD_ACTIVE_NOTE_SET', value: noteHasWhiteboardFence });
}, [noteHasWhiteboardFence, props.dispatch]);
props.dispatch({ type: 'WHITEBOARD_ACTIVE_NOTE_SET', value: noteHasWhiteboardFence, windowId });
}, [noteHasWhiteboardFence, windowId, 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,6 +2,7 @@
.editor-status-indicator {
width: 0;
overflow: hidden;
white-space: nowrap;
&:has(> :focus-visible), &.-show {
width: unset;
@@ -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]);
@@ -10,6 +10,7 @@ import { _ } from '@joplin/lib/locale';
import getActivePluginEditorView from '@joplin/lib/services/plugins/utils/getActivePluginEditorView';
import { stateUtils } from '@joplin/lib/reducer';
import { AppState, AppWindowState } from '../../app.reducer';
import { chatAvailability } from '@joplin/lib/services/ai/availability';
interface NoteToolbarProps {
themeId: number;
@@ -59,10 +60,13 @@ const mapStateToProps = (state: AppState, ownProps: ConnectProps) => {
'editAlarm',
'toggleVisiblePanes',
'showNoteProperties',
// Always shown — the panel itself surfaces any configuration issue.
'toggleAiChat',
];
// The hide setting only applies while chat is unavailable.
if (chatAvailability().available || state.settings['ai.chat.showToolbarButton']) {
commands.push('toggleAiChat');
}
// `toggleEditorPlugin` shows for plugin editors; we extend it to also
// toggle the core whiteboard editor on whiteboard notes (see the command's
// runtime). The button is the same eye icon either way.
@@ -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;
}
@@ -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',
};
};
@@ -10,6 +10,7 @@ import Setting from '@joplin/lib/models/Setting';
import { PackageInfo } from '@joplin/lib/versionInfo';
import shim from '@joplin/lib/shim';
import { ImportModule } from '@joplin/lib/services/interop/Module';
import { isRecoverableError } from '@joplin/lib/import-enex';
import Logger from '@joplin/utils/Logger';
const packageInfo: PackageInfo = require('../../../packageInfo.js');
@@ -136,7 +137,9 @@ export const runtime = (control: WindowControl): CommandRuntime => {
void CommandService.instance().execute('showModalMessage', `${modalMessage}\n\n${statusStrings.join('\n')}`);
},
onError: (error: string|Error) => {
errors.push(error);
// Recovered errors are logged but not reported, since the
// notes were imported successfully anyway.
if (!isRecoverableError(error)) errors.push(error);
console.warn(error);
},
destinationFolderId: options.destinationFolderId,
@@ -3,8 +3,10 @@ import * as addNoteToWhiteboard from './addNoteToWhiteboard';
import * as addProfile from './addProfile';
import * as commandPalette from './commandPalette';
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';
@@ -12,12 +14,14 @@ 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';
import * as newSubFolder from './newSubFolder';
import * as newTodo from './newTodo';
import * as newWhiteboard from './newWhiteboard';
import * as openAiChatConversation from './openAiChatConversation';
import * as openFolder from './openFolder';
import * as openFolderDialog from './openFolderDialog';
import * as openItem from './openItem';
@@ -59,8 +63,10 @@ const index: any[] = [
addProfile,
commandPalette,
deleteFolder,
disableNoteEncryption,
duplicateNote,
editAlarm,
enableNoteEncryption,
exportPdf,
globalRedo,
globalUndo,
@@ -68,12 +74,14 @@ const index: any[] = [
hideModalMessage,
importFrom,
linkToNote,
lockEncryptedNotes,
moveToFolder,
newFolder,
newNote,
newSubFolder,
newTodo,
newWhiteboard,
openAiChatConversation,
openFolder,
openFolderDialog,
openItem,
@@ -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',
};
};
@@ -0,0 +1,17 @@
import { CommandContext, CommandDeclaration, CommandRuntime } from '@joplin/lib/services/CommandService';
import ChatConversation from '@joplin/lib/models/ChatConversation';
export const declaration: CommandDeclaration = {
name: 'openAiChatConversation',
};
export const runtime = (): CommandRuntime => ({
execute: async (context: CommandContext, conversationId: string) => {
const messages = await ChatConversation.messages(conversationId);
context.dispatch({ type: 'AI_CHAT_OPEN', windowId: context.state.windowId, conversationId, messages });
context.dispatch({
type: 'WINDOW_LAYOUT_SET_ITEM_PROP', windowId: context.state.windowId,
itemKey: 'chatPanel', propName: 'visible', propValue: true,
});
},
});
Loaded 100 of 599 files, more files were not shown because too many files have changed in this diff. Show more