Compare commits

..
255 Commits
Author SHA1 Message Date
Keshav 1d7c091d55 Desktop, Mobile, Cli: Resolves #16140: Add locked note handling to export and import (#16785) 2026-10-25 10:47:07 +00:00
mrjo118 bad38b7fff Android: Fixes #8099: Fix data share not working after leaving by back button (#16906) 2026-10-24 18:25:18 +02:00
KeshavandLaurent Cozic 75f888ff0d Desktop, Mobile: Resolves #16382: Migrate locked notes when the sync target uses a different note lock key (#16723)
Co-authored-by: Laurent Cozic <laurent22@users.noreply.github.com>
2026-10-24 18:23:40 +02:00
Ben BalterandClaude Opus 5.5 94168d33d6 Cli: Fixes #16894: Fix quadratic conversion time for large tables (#16901)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 12:42:49 +02:00
Laurent Cozic 451200c76c Server v3.7.3 2026-10-23 12:33:59 +02:00
Laurent Cozic d865d1434e Chore: Build assets 2026-10-23 12:33:37 +02:00
Laurent Cozic 23c85cdecf Server: Pin Node version to 24.22.1 2026-10-23 12:28:52 +02:00
Joplin Bot 280954e58f Doc: Auto-update documentation
Auto-updated using release-website.sh
2026-10-22 21:22:22 +00:00
Milo Ivir 1dde6e8fb8 Doc: Resolves #16868: Add Croatian website support (#16869) 2026-10-22 22:30:10 +02:00
Ben BalterandClaude Opus 5.5 94ddfda2d6 Desktop, Mobile, Cli: Fixes #16895: Fix quadratic conversion time for elements with many children (#16902)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 22:24:44 +02:00
renovate[bot] 30a017b800 fix(deps): update dependency tar to v7.5.22 (#16922)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-10-22 22:24:24 +02:00
renovate[bot] 0bce1ae02b chore(deps): update dependency @axe-core/playwright to v4.12.1 (#16913)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-10-22 20:09:42 +02:00
renovate[bot] 2e45f22d85 chore(deps): update dependency sass to v1.101.7 (#16915)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-10-22 20:09:21 +02:00
Joplin Bot ac56a7c729 Doc: Auto-update documentation
Auto-updated using release-website.sh
2026-10-22 16:51:59 +00:00
Joplin Bot 585ec251b0 Doc: Auto-update documentation
Auto-updated using release-website.sh
2026-10-21 11:31:36 +00:00
Laurent Cozic 95375b1f11 Doc: Add information about Joplin Cloud AI and privacy 2026-10-21 12:20:24 +01:00
Aissa BenfoddaandLaurent Cozic 410b5abab9 Desktop: Resolves #16383: Keypresses are sometimes ignored when searching (#16686)
Co-authored-by: Laurent Cozic <laurent22@users.noreply.github.com>
2026-10-21 10:19:40 +01:00
renovate[bot] 4a74b34e83 chore(deps): update dependency sass to v1.101.6 (#16910)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-10-21 07:44:58 +00:00
renovate[bot] 5c2601a283 fix(deps): update dependency node-cron to v4.5.0 (#16904)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-10-20 23:28:26 +01:00
renovate[bot] 7e23fc6446 chore(deps): update dependency globals to v17.7.0 (#16903)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-10-20 23:27:40 +01:00
Aissa Benfodda e09c8e8527 Desktop: Resolves #16698: OneNote import (*.one / *.onepkg) doesn’t import notes with title longer than about 240 chars (#16836) 2026-10-20 23:27:07 +01:00
mrjo118 4d33866822 Mobile: Support highlighting text in custom colours in the ProseMirror editor, to match TinyMCE behaviour (#16847) 2026-10-20 22:31:54 +01:00
mrjo118 b63bd50b9d Desktop: Show truncated note and notebook title warnings for lossy export formats (#16796) 2026-10-20 22:29:14 +01:00
mrjo118andLaurent Cozic f5b2d654d2 Desktop, Mobile, Cli: Fix sync eligible conflicts not being synced, if the conflict is for a note in a share (#16726)
Co-authored-by: Laurent Cozic <laurent22@users.noreply.github.com>
2026-10-20 21:00:54 +01:00
Aissa BenfoddaandLaurent Cozic abb4f7e73f Desktop: Resolves #16436: Line return after a bulleted list is stripped when switching away from a note in the Rich Text Editor (#16717)
Co-authored-by: Laurent Cozic <laurent22@users.noreply.github.com>
2026-10-20 20:54:37 +01:00
KeshavandLaurent Cozic 4571f69389 Desktop, Mobile, Server: Resolves #16336: Handle locked notes inside shared notebooks (#16579)
Co-authored-by: Laurent Cozic <laurent22@users.noreply.github.com>
2026-10-20 20:40:52 +01:00
renovate[bot] cf977d3839 fix(deps): update dependency tar to v7.5.21 (#16899)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-10-20 20:35:01 +01:00
Laurent Cozic cf57e59325 Doc: Update canary file 2026-10-19 12:07:36 +01:00
renovate[bot] 7dc1efb1ec chore(deps): update dependency sass to v1.101.3 (#16888)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-10-19 07:27:05 +00:00
renovate[bot] 1935d47755 fix(deps): update dependency react-native-webview to v13.17.0 (#16884)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-10-18 22:03:34 +01:00
Laurent Cozic 4724e3c0d8 Server: Only record changes for resource blobs under the .resource/ directory 2026-10-18 22:00:23 +01:00
Laurent Cozic c4c49b6d28 Desktop, Cli: Bind OneDrive OAuth and resource servers to loopback and validate OAuth state 2026-10-18 21:55:10 +01:00
renovate[bot] eba62ede92 fix(deps): update dependency pg to v8.22.0 (#16865)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-10-18 15:05:06 +01:00
renovate[bot] 95f21039bf fix(deps): update dependency onnxruntime-node to v1.27.0 (#16881)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-10-18 15:04:44 +01:00
renovate[bot] 1515e8b81c fix(deps): update dependency terminal-kit to v3.1.4 (#16879)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-10-18 00:24:12 +00:00
renovate[bot] 3dc0e2c0f6 chore(deps): update dependency concurrent-ruby to '< 1.3.9' (#16878)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-10-18 00:22:50 +00:00
renovate[bot] d0d35c173a chore(deps): update dependency @types/serviceworker to v0.0.200 (#16867)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-10-17 11:15:58 +00:00
Miguel Lima 71f403d0db All: Translation: Update pt_BR.po (#16810) 2026-10-16 17:32:16 -04:00
Milo Ivir 7c0e5654c6 All: Translation: Update hr_HR.po (#16861) 2026-10-16 17:31:03 -04:00
renovate[bot] c4254c3774 chore(deps): update dependency npm-package-json-lint to v10.4.1 (#16857)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-10-16 18:25:32 +01:00
renovate[bot] e255ac99be fix(deps): update dependency @react-native-vector-icons/material-design-icons to v13.1.2 (#16859)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-10-16 18:25:19 +01:00
renovate[bot] cd9f2728bd fix(deps): update dependency @react-native-vector-icons/material-design-icons to v13 (#16856)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-10-16 14:36:42 +01:00
renovate[bot]andLaurent Cozic 4c0fbaae67 chore(deps): update dependency npm-package-json-lint to v10 (#16855)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Laurent Cozic <laurent22@users.noreply.github.com>
2026-10-16 14:36:31 +01:00
renovate[bot]andLaurent Cozic 44031f1cbb fix(deps): update dependency node-cron to v4.4.1 (#16851)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Laurent Cozic <laurent22@users.noreply.github.com>
2026-10-16 14:36:12 +01:00
renovate[bot] ef61ac2043 fix(deps): update dependency @react-native-vector-icons/get-image to v13 (#16830)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-10-16 11:20:19 +01:00
Laurent Cozic 3fb835a9bf Chore: Fix flaky Note screen reload test leaking an open act() scope 2026-10-16 10:01:31 +01:00
renovate[bot] a28eb47c1a fix(deps): update dependency @react-native-vector-icons/ionicons to v13.1.2 (#16850)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-10-16 08:46:11 +01:00
Wei-Lun LiandClaude Opus 5.5 e43be19dbb All: Translation: Update zh_TW.po (#16853)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 14:56:58 -04:00
renovate[bot] 6753a76b99 fix(deps): update dependency @react-native-vector-icons/ionicons to v13 (#16843)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-10-15 14:29:31 +01:00
renovate[bot] 79eccc2d67 chore(deps): update dependency @playwright/test to v1.61.1 (#16844)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-10-15 14:29:08 +01:00
mrjo118 057596c23c Desktop, Mobile: Fixes #16734: Upgrade CodeMirror View to v6.43.14 (#16835) 2026-10-14 21:43:24 +01:00
renovate[bot] f3d8e33847 chore(deps): update dependency @playwright/test to v1.61.0 (#16840)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-10-14 21:04:20 +01:00
renovate[bot] 0d4947bc8e fix(deps): update dependency @react-native-vector-icons/fontawesome5 to v13.1.2 (#16841)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-10-14 21:04:01 +01:00
renovate[bot] 321dcdbbd5 fix(deps): update dependency @react-native-vector-icons/fontawesome5 to v13 (#16829)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-10-14 16:30:09 +01:00
renovate[bot]andLaurent Cozic 6b5e3db472 chore(deps): update dependency axios to v1.18.0 (#16828)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Laurent Cozic <laurent22@users.noreply.github.com>
2026-10-14 16:24:08 +01:00
Laurent Cozic 17d3e378d2 Chore: Trying to fix flaky test failure on CI 2026-10-14 12:02:55 +01:00
Laurent Cozic c17f98efb4 Chore: Set published Joplin Server image as latest 2026-10-13 09:59:37 +01:00
Henry Heino 6de5ec074a Desktop: Fix error overlay text has low contast in dark mode (#16827) 2026-10-12 21:26:41 +01:00
mrjo118andLaurent Cozic c87f694dc7 Mobile: Fix issues with modals created via plugins (#16674)
Co-authored-by: Laurent Cozic <laurent22@users.noreply.github.com>
2026-10-11 16:18:55 +01:00
Laurent Cozic 21d0a8ad71 Revert "Desktop: Resolves #16698: OneNote import (*.one / *.onepkg) doesn’t import notes with title longer than about 240 chars" (#16824) 2026-10-11 16:17:26 +01:00
mrjo118andLaurent Cozic 553ddf6ec1 Mobile: Refresh note when updated by the sync for editor plugins (#16688)
Co-authored-by: Laurent Cozic <laurent22@users.noreply.github.com>
2026-10-11 16:15:50 +01:00
Aissa Benfodda 7c619e4744 Desktop: Resolves #16698: OneNote import (*.one / *.onepkg) doesn’t import notes with title longer than about 240 chars (#16752) 2026-10-11 16:15:24 +01:00
Keshav 3f7dde0045 Desktop: Fixes #16758: Stop external editing when a note gets locked on another device (#16762) 2026-10-11 16:07:47 +01:00
Henry Heino 052583a8fb Server: Allow syncing with a web app instance hosted on a custom domain (#16760) 2026-10-11 16:02:44 +01:00
Ahmet SezenandClaude Opus 5.5 c51a2cb7e1 Desktop: Fixes #16783: Fix AI chat counting tool results twice towards the size limit (#16784)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 16:02:13 +01:00
Aissa Benfodda 8905795ed1 Server: Resolves #16757: Restore footer (#16763) 2026-10-11 16:01:21 +01:00
Aissa Benfodda 9c820ef8e2 Server: Resolves #16795: Published notebook sidebar has no overlay on small screens (#16798) 2026-10-11 15:58:13 +01:00
renovate[bot] 6bca95dbdc chore(deps): update dependency sass to v1.101.0 (#16813)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-10-11 11:02:25 +01:00
renovate[bot] 7da9753f71 fix(deps): update dependency tar to v7.5.20 (#16814)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-10-10 12:22:34 +00:00
renovate[bot] 03c2fa6b07 fix(deps): update dependency dompurify to v3.4.12 (#16811)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-10-09 22:19:28 +01:00
Laurent Cozic 02f5db5bc8 Doc: More neutral tone in SECURITY.md 2026-10-09 17:46:17 +01:00
renovate[bot] cb2f658b6c fix(deps): update dependency abcjs to v6.6.4 (#16805)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-10-09 00:23:11 +00:00
renovate[bot] 26ce406e49 chore(deps): update dependency @docusaurus/plugin-sitemap to v3.10.2 (#16801)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-10-08 22:19:53 +01:00
renovate[bot] 33449150e5 fix(deps): update dependency sharp to v0.35.0 (#16802)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-10-08 22:19:37 +01:00
Henry HeinoandLaurent Cozic b0affc0a30 Mobile: Implement redesigned screen header (#16345)
Co-authored-by: Laurent Cozic <laurent22@users.noreply.github.com>
2026-10-08 12:03:38 +01:00
Laurent Cozic 00d62e8700 Desktop: Fix external editor saves being lost immediately after opening a note (#16771) 2026-10-07 08:48:41 +01:00
renovate[bot] 5c7eab0808 chore(deps): update dependency yauzl to v3.4.0 (#16767)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-10-06 22:57:54 +01:00
renovate[bot] d5a6aeaa75 chore(deps): update dependency @crowdin/cli to v4.14.3 (#16765)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-10-06 20:24:55 +01:00
mrjo118 bc8bbb85dd Desktop, Mobile, Cli: Fixes #15601: Fix S3 sync failing in the web app with getReader TypeError on some browsers (#16746) 2026-10-06 15:51:39 +01:00
mrjo118 e972fd2397 Desktop, Mobile: Fixes #16759: Fix revision service does not start if the sync on startup fails (#16761) 2026-10-06 15:49:40 +01:00
Henry HeinoandLaurent Cozic ab241bd8d6 Desktop, Mobile: Resolves #16710: Improve E2EE UI when a key is stored in an unknown format (#16711)
Co-authored-by: Laurent Cozic <laurent22@users.noreply.github.com>
2026-10-06 15:42:30 +01:00
Henry HeinoandLaurent Cozic 9e4ca948be Mobile: Fixes #16589: Upgrade React Native to v0.86, Expo to v57 (#16636)
Co-authored-by: Laurent Cozic <laurent22@users.noreply.github.com>
2026-10-06 15:37:33 +01:00
9f84bda5db Mobile, Desktop, Cli: Resolves #16130: Encrypt plaintext bodies on ungated saves of locked notes (#16131)
Co-authored-by: Laurent Cozic <laurent22@users.noreply.github.com>
Co-authored-by: mrjo118 <jo.118@hotmail.com>
2026-10-06 15:31:11 +01:00
renovate[bot] 5dd5842401 fix(deps): update dependency js-yaml to v4.2.0 (#16755)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-10-06 15:26:27 +01:00
Sriram Varun KumarandLaurent Cozic aaa6f8e3ae Doc: Resolves #16603: Add documentation for conflict auto-merge and desktop resolution UI (#16604)
Co-authored-by: Laurent Cozic <laurent22@users.noreply.github.com>
2026-10-05 14:09:44 +01:00
renovate[bot] 618f7e4ff7 fix(deps): update dependency prosemirror-dropcursor to v1.8.3 (#16749)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-10-05 04:24:23 +00:00
renovate[bot] a47a208aa4 chore(deps): update dependency concurrent-ruby to '< 1.3.8' (#16748)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-10-05 04:22:45 +00:00
renovate[bot] 850030faf6 chore(deps): update dependency @types/nodemailer to v7.0.12 (#16747)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-10-05 00:22:28 +00:00
Laurent Cozic 5bffa4b728 Chore: Fix flaky test checker 2026-10-04 23:39:06 +01:00
renovate[bot]andLaurent Cozic 6bfd8bb5d9 Update actions/upload-artifact action to v7 (#16735)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Laurent Cozic <laurent22@users.noreply.github.com>
2026-10-04 22:42:51 +01:00
renovate[bot]andLaurent Cozic 7042c2233c Update dependency @react-native-community/datetimepicker to v9 (#16739)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Laurent Cozic <laurent22@users.noreply.github.com>
2026-10-04 22:42:44 +01:00
renovate[bot] 0642345314 chore(deps): update dependency @types/nodemailer to v7 (#16742)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-10-04 22:42:22 +01:00
James Carroll e8d15e7f92 Chore: Resolves #16119: Remove --no-sandbox checks in installation script (#16738) 2026-10-04 13:04:35 +01:00
Laurent Cozic d32307364c CI: Repeat test files changed by a pull request to detect flaky tests (#16741) 2026-10-04 12:59:25 +01:00
qx100 ca681c57c6 All: Translation: Update zh_CN.po (#16737) 2026-10-03 15:55:58 -04:00
mrjo118 97ab341275 Desktop: Show export warnings on export completion to match mobile behaviour (#16682) 2026-10-03 20:11:32 +01:00
Laurent Cozic cd32d862c4 Chore: Fixed flaky test 2026-10-03 19:21:53 +01:00
renovate[bot]andLaurent Cozic b94994b6e7 Update nvidia/cuda Docker tag to v13.3.0 (#16730)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Laurent Cozic <laurent22@users.noreply.github.com>
2026-10-03 14:12:54 +01:00
renovate[bot] 90d810c0c6 Update actions/create-github-app-token action to v3 (#16732)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-10-03 14:12:35 +01:00
Laurent Cozic 8db668e22f Chore: Trying to fix flaky test on CI 2026-10-03 14:10:50 +01:00
renovate[bot] b04a5f0489 Update dependency ts-loader to v9.6.2 (#16731)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-10-03 00:23:02 +00:00
Laurent Cozic 0164f34d8f Chore: Trying to fix flaky iOS UI test by waiting for elements before tapping 2026-10-02 23:51:01 +01:00
renovate[bot] 42cd68983b Update dependency ts-loader to v9.6.0 (#16729)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-10-02 21:16:25 +01:00
Aissa Benfodda f15506b873 Server: Resolves #16669: Published notebook notes cannot be scrolled down on Android browsers (#16702) 2026-10-02 17:13:30 +01:00
mrjo118 2603333ae5 Desktop: Fixes #15865: Fix toolbar state becoming disabled on a secondary window when the note has been moved (#16620) 2026-10-02 17:09:07 +01:00
Aissa Benfodda 1008705034 iOS: Resolves #16551: "Share" in the note menu does nothing on iOS (promise never settles) (#16664) 2026-10-02 17:08:21 +01:00
Henry Heino c64cfb1943 Desktop: Fix infinite loop in some cases when the notebook ordering is set to "updated date" (#16692) 2026-10-02 16:53:05 +01:00
Sriram Varun Kumar 2a901dea1d Mobile, Desktop, Cli: Resolves #16690: Automerge adds a blank line when one side deleted lines the other edited (#16706) 2026-10-02 16:52:08 +01:00
mrjo118 c199af7ca8 Desktop, Mobile: Fix displayed note updated_time is reverted to the remote updated_time upon auto merging a conflict (#16727) 2026-10-02 16:51:04 +01:00
Henry Heino 5a48b72d43 Chore: Desktop: Refactor warning banner (#16722) 2026-10-02 16:50:03 +01:00
Keshav 7fb04b0a11 Desktop, Mobile: Resolves #16724: Label the note list padlock on desktop and match its colour to the title on mobile (#16725) 2026-10-02 16:49:21 +01:00
renovate[bot] b1c43ccce9 Update dependency webpack-dev-server to v5.2.6 (#16714)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-10-01 10:27:16 +01:00
renovate[bot] def3e7928f Update eslint (#16715)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-10-01 10:26:13 +01:00
Joplin Bot 44bd68dbe3 Doc: Auto-update documentation
Auto-updated using release-website.sh
2026-10-01 04:36:37 +00:00
Pål Martin 9cbf49b260 All: Translation: Update nb_NO.po (#16705) 2026-09-30 13:38:34 -04:00
Pål Martin cd996c2eff All: Translation: Update eo.po (#16704) 2026-09-30 13:36:40 -04:00
renovate[bot] 06ebfbacde Update dependency terminal-kit to v3.1.3 (#16700)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-09-29 19:52:24 +02:00
renovate[bot] a653fe877d Update dependency fs-extra to v11.3.6 (#16696)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-09-29 12:29:23 +00:00
renovate[bot] 1499d4f11f Update dependency adm-zip to v0.5.18 (#16695)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-09-29 05:33:14 +00:00
renovate[bot] a560f14917 Update dependency @types/serviceworker to v0.0.199 (#16694)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-09-29 02:52:57 +00:00
renovate[bot] 70665897b5 Update dependency node-forge to v1.4.0 (#16673)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-09-29 00:27:21 +02:00
renovate[bot] 4c0e2bc0e2 Update dependency @playwright/test to v1.60.0 (#16665)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-09-27 01:19:02 +02:00
renovate[bot] 3cb9ad062d Update dependency chokidar to v3.6.0 (#16667)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-09-27 01:18:54 +02:00
renovate[bot] c5f185aacd Update dependency tar to v7.5.19 (#16666)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-09-26 04:22:27 +00:00
Joplin Bot 80af3696f8 Doc: Auto-update documentation
Auto-updated using release-website.sh
2026-09-26 03:57:03 +00:00
renovate[bot] 625f474b6f Update dependency ts-loader to v9.5.7 (#16663)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-09-25 22:38:00 +00:00
Laurent Cozic d8d9218b76 Merge branch 'release-3.7' into dev 2026-09-25 19:34:37 +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
Joplin Bot c58052c48b Doc: Auto-update documentation
Auto-updated using release-website.sh
2026-09-25 16:48:48 +00:00
Laurent Cozic a15bead867 Merge remote-tracking branch 'origin/dev' into dev 2026-09-25 17:11:48 +01:00
Laurent Cozic 6ae9353849 Merge branch 'release-3.7' into dev 2026-09-25 17:09:18 +01:00
renovate[bot] a03416829c chore(deps): update dependency python to v3.14.4 (#16656)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-09-25 16:05:42 +00:00
Laurent Cozic 83059a860e Doc: Update company registered address 2026-09-25 16:28:24 +01:00
mrjo118 d9f0b4206a Desktop: Fixes #16215: Increase defaultAutoSelectFamilyAttemptTimeout to 500 ms (#16576) 2026-09-25 12:04:03 +01:00
mrjo118 c32c50fbe9 Desktop: Fixes #16628: Prevent note being selected in the primary window when a secondary window is closed when it's not in focus (#16629) 2026-09-25 12:02:35 +01:00
Henry Heino 4978154dc1 Web: Fixes #16633: Fix sync (#16634) 2026-09-25 12:01:44 +01:00
renovate[bot] 6e28b0a40d chore(deps): update dependency @babel/plugin-transform-export-namespace-from to v7.29.7 (#16639)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-09-25 11:55:49 +01:00
Henry Heino 61eecacf49 Chore: Coding style: Prefer single quotes for SQLite string literals (#16653) 2026-09-25 11:54:44 +01:00
renovate[bot] dbdc233bbb fix(deps): update dependency tar to v7.5.17 (#16646)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-09-25 03:34:38 +00:00
renovate[bot] 32fde22f54 fix(deps): update dependency nanoid to v5.1.16 (#16643)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-09-23 04:59:45 +00:00
Jonatan Nyberg 1d6beb0443 All: Translation: Update sv.po (#16637)
Signed-off-by: Jonatan Nyberg <84130654+NickWick13@users.noreply.github.com>
2026-09-22 13:42:58 -04:00
renovate[bot] c795a55895 chore(deps): update docker/login-action action to v4 (#16627)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-09-20 14:35:38 +01:00
renovate[bot] 49b677ea33 chore(deps): update dependency @types/node to v22.19.21 (#16614)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-09-20 12:16:42 +01:00
mrjo118 7fdcf1eabd Desktop, Mobile: Fix sync status incorrectly calculated for conflict notes (#16621) 2026-09-20 11:04:48 +01:00
mrjo118 227dbdedfc Mobile: Fixes #15892: Prevent the open side menu gesture when in multi selection mode (#16619) 2026-09-20 10:56:38 +01:00
mrjo118 b15d55765a Mobile: Fixes #16597: Avoid overriding the text selection colour (#16618) 2026-09-20 10:56:13 +01:00
Laurent Cozic 56ce951f37 Desktop, Mobile: Handle xlink:href in the note link sanitizer (#16616) 2026-09-20 10:55:42 +01:00
Eric Duarte f2a3569d72 Update Catalan translation of the ca.po file. Correct translation err… (#16571) 2026-09-19 17:17:17 +01:00
Jozef Gaal a3d94d4d8e All: Translation: Update sk_SK.po (#16609) 2026-09-19 17:15:45 +01:00
RobertGarciaa d1ac85c0d4 All: Translation: Update ko.po (#16568)
Signed-off-by: Robertgarciaa <chae0218@naver.com>
2026-09-19 17:15:18 +01:00
mrjo118 a7eaddeeee Mobile: Prevent scenario which can cause the note list to become blocked and make the app appear unresponsive (#16612) 2026-09-19 17:14:13 +01:00
mrjo118 56ea34fbe4 Desktop: Fixes #16229: Deselect conflicts or trashed notes when switching to the all notes notebook (#16590) 2026-09-19 17:13:26 +01:00
renovate[bot] c81243852d fix(deps): update dependency react-native-safe-area-context to v5.8.0 (#16577)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-09-19 17:10:34 +01:00
renovate[bot] 6473be223a fix(deps): update dependency pg to v8.21.0 (#16573)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-09-19 17:10:10 +01:00
mrjo118 9e4b920cf1 Desktop, Mobile, Cli: Extend the logged basic delta report to include the count of items ignored by the sync (#16567) 2026-09-19 17:10:02 +01:00
mrjo118 552671dde8 Desktop, Mobile, Cli: Support syncing conflict notes (#16439) 2026-09-19 17:06:41 +01:00
Henry Heino 24e5778256 Desktop, Cli: Migrate from node-fetch to Undici (#16566) 2026-09-19 17:04:12 +01:00
Henry Heino 8cc854c9be Chore: Refactor settings screen logic (#16564) 2026-09-19 17:00:17 +01:00
Keshav 2bddab0d04 Desktop, Mobile: Resolves #16038: Add note history support for locked notes (#16476) 2026-09-19 16:59:53 +01:00
Aissa Benfodda 76ea0093ca Desktop: Fixes #16252: Converting HTML note to Markdown results in new noteID, which breaks any internal Joplin links to that original note (#15588) 2026-09-19 16:43:40 +01:00
renovate[bot] e76fed31df fix(deps): update dependency @adobe/css-tools to v4.5.0 (#16613)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-09-19 13:59:09 +01:00
renovate[bot] 2b217ce3f9 chore(deps): update dependency sass to v1.100.0 (#16611)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-09-19 10:55:36 +01:00
renovate[bot] f4ecbed4e6 fix(deps): update dependency nanoid to v5.1.15 (#16606)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-09-19 01:11:28 +00:00
Henry Heino a859a62506 Server: Accessibility: Associate labels with controls on the "Your Profile" page (#16588) 2026-09-18 19:10:16 +01:00
Henry Heino 32578c761e Web: Fixes #16593: Fix crash when clicking the "upgrade app banner" (#16591) 2026-09-18 19:09:56 +01:00
Henry Heino 27da730f04 Chore: Improve return type of reg.syncTarget() (#16592) 2026-09-18 19:09:36 +01:00
thesunshade 0369f2fd84 Doc: Fix grammatical error in front.mustache (#16599) 2026-09-18 18:48:27 +01:00
renovate[bot] b90691d562 fix(deps): update dependency react-native-webview to v13.16.2 (#16600)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-09-18 13:53:01 +00:00
Joplin Bot 04f53e0c69 Doc: Auto-update documentation
Auto-updated using release-website.sh
2026-09-18 10:58:28 +00:00
Laurent Cozic 2c33e1db44 Doc: Update sponsors 2026-09-18 11:47:36 +01:00
Laurent Cozic ef70b72d38 Server: Fix Stripe subscription item being located by position instead of price 2026-09-18 00:07:00 +01:00
renovate[bot] 981a03c5c9 fix(deps): update dependency nanoid to v5.1.14 (#16581)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-09-16 23:13:49 +01:00
renovate[bot] 4f4494fe1b fix(deps): update dependency dompurify to v3.4.11 (#16572)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-09-15 23:15:06 +01:00
Jozef Gaal de42dc1e48 All: Translation: Update sk_SK.po (#16562) 2026-09-14 16:33:22 -04:00
Joplin Bot f611833b08 Doc: Auto-update documentation
Auto-updated using release-website.sh
2026-09-14 18:36:53 +00:00
Laurent Cozic 6459e6fffd Tools: Fix website build when no Windows arm64 asset exists
downloadUrl() throws when it finds no matching asset, so looking up the
arm64 build unconditionally broke the website build for every release
that predates it. Gate it behind the same flag as the download page,
which is now exported rather than duplicated.
2026-09-14 19:24:53 +01:00
Laurent Cozic fd7f557552 Doc: Add article about AI and privacy in Joplin 2026-09-14 19:22:08 +01:00
Laurent Cozic a2fcac2e08 Chore: CI: Trying to fix randomly failing tests 2026-09-13 22:40:17 +01:00
Laurent Cozic e5ebf70ced Desktop: Resolves #13146: Add Windows ARM64 build (#16555) 2026-09-13 20:52:11 +01:00
Laurent Cozic 7fc4abe2ce Server: Fix account not being created when the Stripe customer has no name (#16557) 2026-09-13 18:42:24 +01:00
mrjo118 468c40d09b Mobile: Add an optional sync button to the editor toolbar (#16268) 2026-09-13 12:01:13 +01:00
Eric DuarteandLaurent Cozic 0342c649bd updating and correcting old strings in the Spanish translation es_ES.po (#16417)
Co-authored-by: Laurent Cozic <laurent22@users.noreply.github.com>
2026-09-13 11:50:50 +01:00
Arda Kılıçdağı e2036e4d60 Turkish translations updated (#16548) 2026-09-13 11:39:35 +01:00
Henry Heino a661b8897a Chore: Fixes #16503: Fix mobile startup tests not running (#16505) 2026-09-13 11:38:07 +01:00
Liffindra Angga Zaaldian 7c0c50eead All: Translation: Update id_ID.po (#16477) 2026-09-13 11:36:43 +01:00
renovate[bot] 41b5934cb0 chore(deps): update dependency git to v2.53.0 (#16550)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-09-13 01:20:08 +01:00
Henry Heino 141c451ff9 Chore: Desktop: Add additional networking tests (#16501) 2026-09-12 00:24:41 +01:00
Joplin Bot 1e9f72738a Doc: Auto-update documentation
Auto-updated using release-website.sh
2026-09-11 20:40:59 +00:00
Keshav 362d613c3e Chore: Fixes #16494: Fix flaky note menu test and decryption warning (#16499) 2026-09-11 15:59:58 +01:00
renovate[bot] 94084f3677 chore(deps): update dependency nan to v2.27.0 (#16475)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-09-11 09:31:32 +01:00
renovate[bot]andLaurent Cozic ede6a35169 fix(deps): update dependency react-native-share to v12.3.1 (#16374)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Laurent Cozic <laurent22@users.noreply.github.com>
2026-09-11 09:30:50 +01:00
Henry Heino b1979eda7c Mobile: Joplin Cloud login: Hide "invalid credentials" banner on login screen (#16490) 2026-09-11 09:30:22 +01:00
Laurent Cozic 59ecb92cd2 Server: Do not log invalid requests as errors (#16492) 2026-09-11 09:29:23 +01:00
Laurent Cozic 77a8249388 Server: Fixes sync errors when an upload is retried (#16491) 2026-09-11 09:29:05 +01:00
renovate[bot] 2fd7ac6573 fix(deps): update dependency form-data to v4.0.6 (#16496)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-09-11 08:23:16 +00:00
Laurent Cozic f97765f332 Server: Do not log failed image downloads as warnings (#16493) 2026-09-11 09:20:37 +01:00
renovate[bot] 1348db9624 chore(deps): update dependency webpack-dev-server to v5.2.5 (#16495)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-09-11 05:56:21 +00:00
renovate[bot] e356e2b508 fix(deps): update dependency dompurify to v3.4.10 (#16489)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-09-11 03:42:45 +00:00
Joplin Bot 26bff74d08 Doc: Auto-update documentation
Auto-updated using release-website.sh
2026-09-10 20:38:50 +00:00
Laurent Cozic f6b19f80e4 Merge branch 'release-3.7' into dev 2026-09-10 20:16:44 +01:00
Henry Heino e55856d74c Mobile: Joplin Cloud login screen: Fix loading indicator is difficult to see in dark mode (#16480) 2026-09-10 18:00:28 +01:00
Henry Heino e0f51d8f43 Server, Web: Applications tab: Fix web app is marked as "unknown" (#16478) 2026-09-10 18:00:06 +01:00
Laurent Cozic 23504fcf7e Server: Add rate limiting to the signup and password reset endpoints (#16467) 2026-09-09 18:44:42 +01:00
Laurent Cozic d9d14ce692 Server: Fix notification read-state being updatable by non-owner (#16466) 2026-09-09 18:44:32 +01:00
Laurent Cozic 7eae6cf3fa Chore: Pin MacOs version on CI to prevent random failures 2026-09-09 17:06:59 +01:00
Laurent Cozic 7ff6bf48b5 Chore: lock file 2026-09-09 15:38:05 +01:00
Laurent Cozic 8a1669c76d Doc: Updated SECURITY.md with new rules for LLM-generated reports 2026-09-09 15:15:05 +01:00
Laurent Cozic 6b09f9d378 Chore: Trying to fix flaky Goto Anything and editor plugin saving integration tests (#16455) 2026-09-09 12:07:32 +01:00
Henry Heino 5d733e3268 Linux: Fixes #16434: Fix missing window icon with certain system setups (#16437) 2026-09-09 12:06:16 +01:00
003233a225 Desktop: Fixes #16410: Prevent secondary instance from releasing profile lock (#16411)
Co-authored-by: Laurent Cozic <laurent22@users.noreply.github.com>
Co-authored-by: mrjo118 <jo.118@hotmail.com>
2026-09-09 12:05:01 +01:00
Henry Heino 6470029203 Mobile: Adjust sidebar appearance (#16389) 2026-09-09 12:04:42 +01:00
Aissa Benfodda 71b0c2027a Mobile, Desktop: Resolves #16066: Add button to toggle blockquotes in Markdown editor (#16385) 2026-09-09 12:03:15 +01:00
renovate[bot] 3a1cd0edc8 chore(deps): update dependency @types/nodemailer to v6.4.24 (#16463)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-09-09 09:39:26 +00:00
renovate[bot] 076ffc42e8 fix(deps): update dependency multiparty to v4.3.0 (#16460)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-09-09 00:17:53 +01:00
renovate[bot] 055f92b9f7 fix(deps): update dependency dompurify to v3.4.9 (#16445)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-09-08 10:43:01 +00:00
Laurent Cozic e2f30308f8 Server v3.7.2 2026-09-07 17:29:53 +01:00
Laurent Cozic 71d4b09d48 Chore: Fixed CI 2026-09-07 13:00:31 +01:00
Laurent Cozic 81f8bfed39 Merge branch 'release-3.7' into dev 2026-09-07 10:20:56 +01:00
renovate[bot]andLaurent Cozic 7e73a2a271 fix(deps): update dependency onnxruntime-node to v1.26.0 (#16416)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Laurent Cozic <laurent22@users.noreply.github.com>
2026-09-06 20:31:02 +01:00
renovate[bot]andLaurent Cozic c2d4d37403 fix(deps): update dependency tcp-port-used to v1.0.3 (#16415)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Laurent Cozic <laurent22@users.noreply.github.com>
2026-09-06 20:30:30 +01:00
Henry Heino e6a99b978a Chore: Allow the "Web" prefix in pull request titles (#16406) 2026-09-06 20:12:45 +01:00
Aissa Benfodda ac2e04204b Mobile: Fixes #15982: Mobile: Tag list design should be consistent with the note list (#16386) 2026-09-06 12:53:44 +01:00
mrjo118 e611eb4f0c Desktop: Fixes #16363: Fix the editor bar being disabled when opening a conflict or trashed note in a new window (#16369) 2026-09-06 12:50:27 +01:00
mrjo118 5ae6b7b6c6 Desktop, Mobile, Cli: Allow cancelling sync during potentially lengthy start and finish steps (#16362) 2026-09-06 12:50:14 +01:00
Aissa Benfodda 82652953a8 Desktop: Resolves #16209: Unpublish button uses a misleading share icon (#16358) 2026-09-06 12:49:10 +01:00
mrjo118 f4069b5f02 Desktop: Add reveal in notebook option (#16354) 2026-09-06 12:49:03 +01:00
Henry Heino c27de4f34e iOS, Android: Database: Migrate to expo-sqlite (#16347) 2026-09-06 12:47:42 +01:00
mrjo118 b6c2efc567 Android: Improve performance of file system sync (#16321) 2026-09-06 12:41:13 +01:00
Aissa BenfoddaandLaurent Cozic c07d94cc95 Desktop: Fixes #15789: Joplin Desktop external edit feature sometimes causes data loss (#16318)
Co-authored-by: Laurent Cozic <laurent22@users.noreply.github.com>
2026-09-06 12:40:15 +01:00
Keshav ac5b30aed7 Mobile: Resolves #16002: Add note lock UI (#16069) 2026-09-06 12:38:59 +01:00
Keshav 39a0ce387c Desktop: Resolves #16160: Use lock wording for note lock actions and messages (#16292) 2026-09-06 12:24:27 +01:00
Sriram Varun Kumarandmrjo118 70c2d357b9 Mobile, Desktop, Cli: Resolves #16020: Automatically merge non-overlapping note changes on sync conflicts (#16023)
Co-authored-by: mrjo118 <jo.118@hotmail.com>
2026-09-06 12:23:33 +01:00
Laurent Cozic a92b37e3e7 Merge branch 'release-3.7' into dev 2026-09-06 11:55:55 +01:00
Joplin Bot e67e4d7685 Doc: Auto-update documentation
Auto-updated using release-website.sh
2026-09-06 10:30:46 +00:00
Joplin Bot 7969c8fce9 Doc: Auto-update documentation
Auto-updated using release-website.sh
2026-09-05 14:50:22 +00:00
Laurent Cozic fa25937bc3 Merge branch 'release-3.7' into dev 2026-09-05 11:10:35 +01:00
renovate[bot] 21a7dd4307 fix(deps): update dependency react-native-zip-archive to v7.1.1 (#16387)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-09-03 17:25:37 +01:00
renovate[bot] c7dea58402 fix(deps): update dependency @react-native-community/netinfo to v12 (#16388)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-09-03 17:25:15 +01:00
Laurent Cozic 5df15f3737 Server: Improve logging for shared notebook maintenance (#16400) 2026-09-03 13:45:31 +01:00
ERYpTION 1090c509e4 All: Translation: Update da_DK.po (#16380) 2026-09-02 05:23:52 -04:00
renovate[bot] 518fbb40f8 fix(deps): update dependency node-diff3 to v3.2.1 (#16376)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-09-02 00:06:42 +01:00
renovate[bot] 517839d99a fix(deps): update dependency dompurify to v3.4.8 (#16373)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-09-01 22:42:11 +01:00
Joplin Bot c7a4f4c031 Doc: Auto-update documentation
Auto-updated using release-website.sh
2026-09-01 11:20:54 +00:00
renovate[bot] 46303619c3 chore(deps): update eslint (#16365)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-09-01 09:36:07 +01:00
Laurent Cozic c048bd9464 Merge branch 'release-3.7' into dev 2026-09-01 01:52:50 +01:00
Joplin Bot a0d83c5115 Doc: Auto-update documentation
Auto-updated using release-website.sh
2026-08-31 23:09:52 +00:00
Laurent Cozic 5755024a31 Doc: Add release notes 3.7 (#16360) 2026-08-31 23:59:35 +01:00
renovate[bot] 65d0a81a37 fix(deps): update dependency tar to v7.5.16 (#16356)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-08-30 20:32:08 +01:00
Joplin Bot 77b2ecf567 Doc: Auto-update documentation
Auto-updated using release-website.sh
2026-08-30 16:18:26 +00:00
Laurent Cozic 3f23202ec1 Merge branch 'release-3.7' into dev 2026-08-30 12:48:46 +01:00
mrjo118 6fb816d014 Desktop: Fixes #16059: Stop external editing on note deletion (#16355) 2026-08-30 10:24:03 +01:00
renovate[bot] f49eb1a597 fix(deps): update dependency onnxruntime-node to v1.25.1 (#16338)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-08-29 22:44:10 +01:00
renovate[bot] d4d9df18d1 fix(deps): update dependency tar-stream to v3.2.0 (#16332)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-08-29 22:43:49 +01:00
renovate[bot] a903027344 chore(deps): update dependency yauzl to v3.3.2 (#16351)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-08-29 21:17:08 +01:00
449 changed files with 29144 additions and 19408 deletions

No files matched your search

+1
View File
@@ -26,6 +26,7 @@ Valid prefixes:
- `Mobile` — the mobile app (both Android and iOS)
- `Android` — only the Android app
- `iOS` — only the iOS app
- `Web` — only the web app
- `Windows` — Windows-specific changes
- `Linux` — Linux-specific changes
- `macOS` — macOS-specific changes
+37 -1
View File
@@ -8,6 +8,22 @@ function Install-WindowsDeps {
$env:BUILD_SEQUENCIAL = '1'
$env:IS_CONTINUOUS_INTEGRATION = '1'
if ($env:PROCESSOR_ARCHITECTURE -eq 'ARM64') {
# Deliberately not setting npm_config_build_from_source to force the
# sqlite3 source build: sharp treats that variable as a boolean and
# would then also build libvips from source, which fails. sqlite3
# installs with --fallback-to-build, so it compiles by itself when the
# win32-arm64 prebuilt is missing.
# Deliberately not installing the VSSetup PowerShell module here: with it
# present, node-gyp probes Visual Studio via `Get-VSSetupInstance |
# ConvertTo-Json`, whose output exceeds the default 1MB child process
# buffer on the VS 2026 image. That fails with
# ERR_CHILD_PROCESS_STDIO_MAXBUFFER and node-gyp then reports the
# install as version "undefined". Without the module it falls through to
# its own Find-VisualStudio.cs helper, which prints far less.
}
$attempts = 3
for ($i = 1; $i -le $attempts; $i++) {
yarn install
@@ -20,12 +36,32 @@ function Install-WindowsDeps {
# Install dependencies and run `yarn dist` for the Windows desktop app.
# Extra args are forwarded to `yarn dist` (e.g. --publish=never).
#
# Each runner builds only its own arch: cross compiling arm64 from x64 silently
# ships x64 .node files, as @electron/rebuild doesn't resolve native modules
# hoisted to the workspace root.
# https://github.com/electron-userland/electron-builder/issues/10187
function Build-WindowsApp {
param([string[]]$DistArgs = @())
Install-WindowsDeps
if ($env:PROCESSOR_ARCHITECTURE -eq 'ARM64') {
# NSIS gets an `-arm64` suffix on its own, but the portable name is fixed
# and the update metadata is `latest.yml` for every Windows arch. Both
# would clobber the x64 runner's artifacts.
# https://github.com/electron-userland/electron-builder/issues/6372
$archArgs = @(
'--arm64'
'-c.portable.artifactName=${productName}Portable-arm64.${ext}'
'-c.publish.provider=github'
'-c.publish.channel=latest-win-arm64'
)
} else {
$archArgs = @('--x64', '--ia32')
}
cd packages/app-desktop
yarn dist @DistArgs
yarn dist @archArgs @DistArgs
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
}
+1 -1
View File
@@ -25,7 +25,7 @@ module.exports = async ({ github, context, core }) => {
// the changelog generator. Non-product prefixes (Tools, Chore, CI, Doc,
// Plugin Repo) are administrative categories for changes that do not
// ship to users and so do not appear in the changelog.
const prefix = '(Desktop|Mobile|Android|iOS|Windows|Linux|macOS|Cli|Clipper|Server|Transcribe|Plugins|Api|Cloud|Tools|Chore|CI|Doc)';
const prefix = '(Desktop|Mobile|Android|iOS|Web|Windows|Linux|macOS|Cli|Clipper|Server|Transcribe|Plugins|Api|Cloud|Tools|Chore|CI|Doc)';
const prefixList = `${prefix}(,\\s*${prefix})*`;
const strictRegex = new RegExp(`^${prefixList}: (Fixes|Resolves) #[0-9]+: .+`);
const softRegex = new RegExp(`^${prefixList}: ((Fixes|Resolves) #[0-9]+: )?.+`);
@@ -32,3 +32,12 @@ docker manifest annotate $SERVER_REPOSITORY:$VERSION $SERVER_REPOSITORY:arm64-$V
docker manifest annotate $SERVER_REPOSITORY:$VERSION $SERVER_REPOSITORY:amd64-$VERSION --arch amd64
docker manifest push $SERVER_REPOSITORY:$VERSION
docker manifest create $SERVER_REPOSITORY:latest \
$SERVER_REPOSITORY:arm64-$VERSION \
$SERVER_REPOSITORY:amd64-$VERSION
docker manifest annotate $SERVER_REPOSITORY:latest $SERVER_REPOSITORY:arm64-$VERSION --arch arm64
docker manifest annotate $SERVER_REPOSITORY:latest $SERVER_REPOSITORY:amd64-$VERSION --arch amd64
docker manifest push $SERVER_REPOSITORY:latest
+73
View File
@@ -0,0 +1,73 @@
#!/bin/bash
# Run the test files changed by a pull request several times, to catch flaky
# tests before they are merged.
#
# Usage: repeat_changed_tests.sh <base_ref> [repeat_count]
set -u
SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )"
ROOT_DIR="$SCRIPT_DIR/../.."
BASE_REF="${1:-}"
REPEAT_COUNT="${2:-5}"
cd "$ROOT_DIR"
# The CI checkout is shallow, so the base branch is not present locally. Always
# fetch it rather than using a local branch of the same name, which may be
# outdated.
if git fetch --no-tags --depth=1 origin "$BASE_REF" 2> /dev/null; then
BASE_REF=FETCH_HEAD
elif ! git rev-parse --verify --quiet "$BASE_REF" > /dev/null; then
echo "Could not resolve base ref \"$BASE_REF\""
exit 1
fi
# `integration-tests` holds the Playwright specs (see app-desktop/playwright.config.ts),
# which Jest cannot run. Other `.spec.*` files are Jest tests, so they are kept.
testFiles=$(git diff --name-only --diff-filter=d "$BASE_REF" HEAD | grep -E '^packages/[^/]+/.*\.(test|spec)\.(ts|tsx|js|jsx)$' | grep -v -E '^packages/[^/]+/integration-tests/' || true)
if [ -z "$testFiles" ]; then
echo "No test files were changed - skipping flaky test detection"
exit 0
fi
echo "Changed test files:"
echo "$testFiles" | sed 's/^/ /'
# Each package has its own Jest config and must be run from its own directory.
packages=$(echo "$testFiles" | sed -E 's|^packages/([^/]+)/.*|\1|' | sort -u)
for package in $packages; do
packageDir="$ROOT_DIR/packages/$package"
# Some packages have test files but no test runner configured.
if ! node -e "const s = require('$packageDir/package.json').scripts; process.exit(s && s['test-ci'] ? 0 : 1)" 2> /dev/null; then
echo "Package $package has no test-ci script - skipping"
continue
fi
# See packages/app-mobile/tools/runTestsConditionally.js
if [ "$package" == "app-mobile" ] && [ "$RUNNER_OS" == "macOS" ]; then
echo "Skipping app-mobile tests on macOS"
continue
fi
# Pass the file names without their extension: some packages run the
# TypeScript sources while others run the compiled files, and Jest matches
# these patterns against the full path either way.
packageTestNames=$(echo "$testFiles" | grep -E "^packages/$package/" | xargs -n 1 basename | sed -E 's#\.[a-z]+$##')
cd "$packageDir"
for i in $(seq 1 "$REPEAT_COUNT"); do
echo "Running $package tests - attempt $i/$REPEAT_COUNT..."
# shellcheck disable=SC2086
if ! yarn jest $packageTestNames --forceExit; then
echo "Tests failed on attempt $i/$REPEAT_COUNT in $package - they are flaky if earlier attempts passed"
exit 1
fi
done
cd "$ROOT_DIR"
done
+12
View File
@@ -165,6 +165,18 @@ if [ "$RUN_TESTS" == "1" ]; then
if [ $testResult -ne 0 ]; then
exit $testResult
fi
# Run the test files changed by the pull request several times, to catch
# flaky tests before they are merged.
if [ "$IS_PULL_REQUEST" == "1" ]; then
echo "Step: Checking changed tests for flakiness..."
"$SCRIPT_DIR/repeat_changed_tests.sh" "$GITHUB_BASE_REF"
testResult=$?
if [ $testResult -ne 0 ]; then
exit $testResult
fi
fi
fi
# =============================================================================
@@ -35,7 +35,7 @@ jobs:
- name: Generate GitHub App token
if: steps.check-secrets.outputs.available == 'true'
id: app-token
uses: actions/create-github-app-token@v2
uses: actions/create-github-app-token@v3
with:
app-id: ${{ secrets.CODERABBIT_CLEANUP_APP_ID }}
private-key: ${{ secrets.CODERABBIT_CLEANUP_APP_PRIVATE_KEY }}
+7 -2
View File
@@ -10,7 +10,12 @@ jobs:
matrix:
# Do not use unbuntu-latest because it causes `The operation was canceled` failures:
# https://github.com/actions/runner-images/issues/6709
os: [macos-15-intel, ubuntu-22.04, windows-2025, ubuntu-22.04-arm]
#
# windows-11-vs2026-arm is pinned because windows-11-arm migrates to
# Visual Studio 2026 in late September 2026, and that job compiles
# sqlite3 from source.
# https://github.com/actions/runner-images/issues/14602
os: [macos-15-intel, ubuntu-22.04, windows-2025, ubuntu-22.04-arm, windows-11-vs2026-arm]
steps:
- uses: actions/checkout@v6
@@ -45,7 +50,7 @@ jobs:
# Login to Docker only if we're on a server release tag. If we run this on
# a pull request it will fail because the PR doesn't have access to
# secrets
- uses: docker/login-action@v3
- uses: docker/login-action@v4
if: runner.os == 'Linux' && (startsWith(github.ref, 'refs/tags/server-v') || startsWith(github.ref, 'refs/tags/transcribe-v'))
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
+2 -2
View File
@@ -39,7 +39,7 @@ jobs:
cd ${GITHUB_WORKSPACE}/packages/app-desktop/
bash ./integration-tests/run-ci.sh
# See https://playwright.dev/docs/ci-intro#setting-up-github-actions
- uses: actions/upload-artifact@v6
- uses: actions/upload-artifact@v7
if: ${{ !cancelled() }}
with:
name: playwright-report-${{ matrix.os }}
@@ -47,7 +47,7 @@ jobs:
retention-days: 7
Mobile:
if: github.repository == 'laurent22/joplin'
runs-on: macos-latest
runs-on: macos-26
steps:
- uses: actions/checkout@v6
- name: Setup build environment
+44 -2
View File
@@ -164,6 +164,7 @@ packages/app-desktop/ElectronAppWrapper.js
packages/app-desktop/InteropServiceHelper.js
packages/app-desktop/app.reducer.test.js
packages/app-desktop/app.reducer.js
packages/app-desktop/app.test.js
packages/app-desktop/app.js
packages/app-desktop/bridge.js
packages/app-desktop/checkForUpdates.js
@@ -182,6 +183,7 @@ packages/app-desktop/commands/openNoteInNewWindow.js
packages/app-desktop/commands/openPrimaryAppInstance.js
packages/app-desktop/commands/openProfileDirectory.js
packages/app-desktop/commands/openSecondaryAppInstance.js
packages/app-desktop/commands/openSyncWizard.js
packages/app-desktop/commands/replaceMisspelling.js
packages/app-desktop/commands/restoreNoteRevision.js
packages/app-desktop/commands/showProfileEditor.js
@@ -212,6 +214,7 @@ packages/app-desktop/gui/ConfigScreen/controls/GlobalHotkeyInput.test.js
packages/app-desktop/gui/ConfigScreen/controls/GlobalHotkeyInput.js
packages/app-desktop/gui/ConfigScreen/controls/MissingPasswordHelpLink.js
packages/app-desktop/gui/ConfigScreen/controls/NoteLockSettings.js
packages/app-desktop/gui/ConfigScreen/controls/SettingButton.js
packages/app-desktop/gui/ConfigScreen/controls/SettingComponent.js
packages/app-desktop/gui/ConfigScreen/controls/SettingDescription.js
packages/app-desktop/gui/ConfigScreen/controls/SettingHeader.js
@@ -223,6 +226,7 @@ packages/app-desktop/gui/ConfigScreen/controls/plugins/SearchPlugins.js
packages/app-desktop/gui/ConfigScreen/controls/useNoteLockMode.js
packages/app-desktop/gui/ConfigScreen/searchHighlight.test.js
packages/app-desktop/gui/ConfigScreen/searchHighlight.js
packages/app-desktop/gui/ConfigScreen/types.js
packages/app-desktop/gui/DialogButtonRow.js
packages/app-desktop/gui/DialogButtonRow/useKeyboardHandler.js
packages/app-desktop/gui/DialogTitle.js
@@ -422,6 +426,8 @@ 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/NoteLockMigrationDialog/Dialog.test.js
packages/app-desktop/gui/NoteLockMigrationDialog/Dialog.js
packages/app-desktop/gui/NoteLockUnlockDialog/Dialog.js
packages/app-desktop/gui/NotePropertiesDialog.js
packages/app-desktop/gui/NoteRevisionViewer.js
@@ -510,6 +516,7 @@ packages/app-desktop/gui/ToolbarSpace.js
packages/app-desktop/gui/TrashNotification/TrashNotification.js
packages/app-desktop/gui/TrashNotification/TrashNotificationMessage.js
packages/app-desktop/gui/UpdateNotification/UpdateNotification.js
packages/app-desktop/gui/WarningBanner.js
packages/app-desktop/gui/WindowCommandsAndDialogs/AppDialogs.js
packages/app-desktop/gui/WindowCommandsAndDialogs/ModalMessageOverlay.js
packages/app-desktop/gui/WindowCommandsAndDialogs/PluginDialogs.js
@@ -551,6 +558,7 @@ packages/app-desktop/gui/WindowCommandsAndDialogs/commands/renameTag.js
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/resetLayout.js
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/restoreFolder.js
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/restoreNote.js
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/revealInNotebook.js
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/revealResourceFile.js
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/search.js
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/setTags.js
@@ -603,6 +611,7 @@ packages/app-desktop/gui/utils/NoteListUtils.js
packages/app-desktop/gui/utils/announceForAccessibility.js
packages/app-desktop/gui/utils/convertToScreenCoordinates.js
packages/app-desktop/gui/utils/dragAndDrop.js
packages/app-desktop/gui/utils/hasNoteLockKey.js
packages/app-desktop/gui/utils/loadScript.js
packages/app-desktop/gulpfile.js
packages/app-desktop/integration-tests/chatPanel.spec.js
@@ -612,6 +621,7 @@ 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/GoToAnything.js
packages/app-desktop/integration-tests/models/JoplinCloudLoginScreen.js
packages/app-desktop/integration-tests/models/MainScreen.js
packages/app-desktop/integration-tests/models/NoteEditor/BaseEditor.js
packages/app-desktop/integration-tests/models/NoteEditor/EditorCodeDialog.js
@@ -620,9 +630,11 @@ 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/SettingsScreen/SettingsScreen.js
packages/app-desktop/integration-tests/models/SettingsScreen/SyncTab.js
packages/app-desktop/integration-tests/models/Sidebar.js
packages/app-desktop/integration-tests/multiWindow.spec.js
packages/app-desktop/integration-tests/networking.spec.js
packages/app-desktop/integration-tests/noteList.spec.js
packages/app-desktop/integration-tests/pluginApi.spec.js
packages/app-desktop/integration-tests/resizableLayout.spec.js
@@ -698,9 +710,11 @@ packages/app-desktop/utils/customProtocols/registerCustomProtocols.js
packages/app-desktop/utils/getAssetPath.js
packages/app-desktop/utils/initReact.js
packages/app-desktop/utils/initializeCommandService.js
packages/app-desktop/utils/initializeNetworkConnectionAttemptTimeout.js
packages/app-desktop/utils/isSafeToOpen.test.js
packages/app-desktop/utils/isSafeToOpen.js
packages/app-desktop/utils/layout/layoutKeyToLabel.js
packages/app-desktop/utils/noteLockPrompts.js
packages/app-desktop/utils/restartInSafeModeFromMain.test.js
packages/app-desktop/utils/restartInSafeModeFromMain.js
packages/app-desktop/utils/sourceMapSetup.js
@@ -816,6 +830,11 @@ packages/app-mobile/components/ScreenHeader/index.js
packages/app-mobile/components/SearchInput.js
packages/app-mobile/components/SelectDateTimeDialog.js
packages/app-mobile/components/SideMenu.js
packages/app-mobile/components/SideMenuContent/BottomPanelActions.js
packages/app-mobile/components/SideMenuContent/FolderItem.js
packages/app-mobile/components/SideMenuContent/SideMenuContent.js
packages/app-mobile/components/SideMenuContent/SideMenuItem.js
packages/app-mobile/components/SideMenuContent/SidebarIcon.js
packages/app-mobile/components/SideMenuContentNote.js
packages/app-mobile/components/SyncWizard/JoplinCloudIcon.js
packages/app-mobile/components/SyncWizard/SyncWizard.js
@@ -848,6 +867,7 @@ packages/app-mobile/components/plugins/PluginNotification.js
packages/app-mobile/components/plugins/PluginRunner.js
packages/app-mobile/components/plugins/PluginRunnerWebView.test.js
packages/app-mobile/components/plugins/PluginRunnerWebView.js
packages/app-mobile/components/plugins/backgroundPage/initializeDialogWebView.test.js
packages/app-mobile/components/plugins/backgroundPage/initializeDialogWebView.js
packages/app-mobile/components/plugins/backgroundPage/initializePluginBackgroundIframe.js
packages/app-mobile/components/plugins/backgroundPage/pluginRunnerBackgroundPage.js
@@ -925,6 +945,7 @@ packages/app-mobile/components/screens/JoplinCloudLoginScreen.js
packages/app-mobile/components/screens/LogScreen.js
packages/app-mobile/components/screens/Note/Note.test.js
packages/app-mobile/components/screens/Note/Note.js
packages/app-mobile/components/screens/Note/NoteLockPanel.js
packages/app-mobile/components/screens/Note/commands/attachFile.js
packages/app-mobile/components/screens/Note/commands/hideKeyboard.js
packages/app-mobile/components/screens/Note/commands/index.js
@@ -932,6 +953,8 @@ packages/app-mobile/components/screens/Note/commands/insertDateTime.js
packages/app-mobile/components/screens/Note/commands/setTags.js
packages/app-mobile/components/screens/Note/commands/toggleVisiblePanes.js
packages/app-mobile/components/screens/Note/types.js
packages/app-mobile/components/screens/NoteLockMigrationScreen.test.js
packages/app-mobile/components/screens/NoteLockMigrationScreen.js
packages/app-mobile/components/screens/NoteRevisionViewer.test.js
packages/app-mobile/components/screens/NoteRevisionViewer.js
packages/app-mobile/components/screens/NoteTagsDialog.js
@@ -960,7 +983,6 @@ packages/app-mobile/components/screens/resourceScreenUtils.test.js
packages/app-mobile/components/screens/resourceScreenUtils.js
packages/app-mobile/components/screens/status.js
packages/app-mobile/components/screens/tags.js
packages/app-mobile/components/side-menu-content.js
packages/app-mobile/components/testing/TestProviderStack.js
packages/app-mobile/components/voiceTyping/AudioRecordingBanner.js
packages/app-mobile/components/voiceTyping/RecordingControls.js
@@ -1042,6 +1064,7 @@ packages/app-mobile/utils/database-driver-react-native.web.js
packages/app-mobile/utils/debounce.js
packages/app-mobile/utils/focusView.js
packages/app-mobile/utils/fs-driver/constants.js
packages/app-mobile/utils/fs-driver/fs-driver-rn.test.js
packages/app-mobile/utils/fs-driver/fs-driver-rn.js
packages/app-mobile/utils/fs-driver/fs-driver-rn.web.js
packages/app-mobile/utils/fs-driver/fs-driver-rn.web.worker.js
@@ -1604,6 +1627,7 @@ packages/lib/services/ResourceEditWatcher/reducer.js
packages/lib/services/ResourceFetcher.js
packages/lib/services/ResourceService.test.js
packages/lib/services/ResourceService.js
packages/lib/services/RevisionService.noteLock.test.js
packages/lib/services/RevisionService.test.js
packages/lib/services/RevisionService.js
packages/lib/services/SettingUtils.js
@@ -1675,8 +1699,14 @@ 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/autoMergeNote.test.js
packages/lib/services/conflict/autoMergeNote.js
packages/lib/services/conflict/boundedDiff3.test.js
packages/lib/services/conflict/boundedDiff3.js
packages/lib/services/conflict/decryptNoteInMemory.js
packages/lib/services/conflict/diffNotes.test.js
packages/lib/services/conflict/diffNotes.js
packages/lib/services/conflict/isAutoMergeEnabled.js
packages/lib/services/conflict/isConflictResolutionEnabled.js
packages/lib/services/conflict/loadConflictData.test.js
packages/lib/services/conflict/loadConflictData.js
@@ -1720,6 +1750,7 @@ packages/lib/services/e2ee/ppk/webCrypto/buildRsaCryptoProvider.js
packages/lib/services/e2ee/types.js
packages/lib/services/e2ee/utils.test.js
packages/lib/services/e2ee/utils.js
packages/lib/services/interop/InteropService.noteLock.test.js
packages/lib/services/interop/InteropService.test.js
packages/lib/services/interop/InteropService.js
packages/lib/services/interop/InteropService_Exporter_Base.js
@@ -1771,12 +1802,16 @@ packages/lib/services/noteList/defaultLeftToRightListRenderer.js
packages/lib/services/noteList/defaultListRenderer.js
packages/lib/services/noteList/defaultMultiColumnsRenderer.js
packages/lib/services/noteList/depNameToNoteProp.js
packages/lib/services/noteList/isSyncDisabledConflict.test.js
packages/lib/services/noteList/isSyncDisabledConflict.js
packages/lib/services/noteList/renderTemplate.test.js
packages/lib/services/noteList/renderTemplate.js
packages/lib/services/noteList/renderViewProps.test.js
packages/lib/services/noteList/renderViewProps.js
packages/lib/services/noteList/renderers.js
packages/lib/services/noteLock/NoteLockKey.js
packages/lib/services/noteLock/NoteLockKeyMigration.test.js
packages/lib/services/noteLock/NoteLockKeyMigration.js
packages/lib/services/noteLock/NoteLockNote.js
packages/lib/services/noteLock/NoteLockService.test.js
packages/lib/services/noteLock/NoteLockService.js
@@ -1784,6 +1819,8 @@ 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/noteLockPrompts.test.js
packages/lib/services/noteLock/noteLockPrompts.js
packages/lib/services/noteLock/setNoteLockState.test.js
packages/lib/services/noteLock/setNoteLockState.js
packages/lib/services/ocr/OcrDriverBase.js
@@ -1933,10 +1970,12 @@ packages/lib/services/synchronizer/ItemUploader.test.js
packages/lib/services/synchronizer/ItemUploader.js
packages/lib/services/synchronizer/LockHandler.js
packages/lib/services/synchronizer/MigrationHandler.js
packages/lib/services/synchronizer/Synchronizer.autoMerge.test.js
packages/lib/services/synchronizer/Synchronizer.baseFields.test.js
packages/lib/services/synchronizer/Synchronizer.basics.test.js
packages/lib/services/synchronizer/Synchronizer.conflicts.test.js
packages/lib/services/synchronizer/Synchronizer.e2ee.test.js
packages/lib/services/synchronizer/Synchronizer.noteLock.test.js
packages/lib/services/synchronizer/Synchronizer.ppk.test.js
packages/lib/services/synchronizer/Synchronizer.resources.test.js
packages/lib/services/synchronizer/Synchronizer.revisions.test.js
@@ -1985,6 +2024,7 @@ packages/lib/shim.js
packages/lib/string-utils.test.js
packages/lib/string-utils.js
packages/lib/testing/ai/semanticSearch.js
packages/lib/testing/createLocalhostServer.js
packages/lib/testing/dom-test-environment.js
packages/lib/testing/plugins/createTestPlugin.js
packages/lib/testing/share/makeMockShareInvitation.js
@@ -2037,6 +2077,8 @@ packages/lib/utils/replaceUnsupportedCharacters.test.js
packages/lib/utils/replaceUnsupportedCharacters.js
packages/lib/utils/resolvePathWithinDir.test.js
packages/lib/utils/resolvePathWithinDir.js
packages/lib/utils/tls/setExtraRootCertificates.test.js
packages/lib/utils/tls/setExtraRootCertificates.js
packages/lib/utils/types/pdfJs.js
packages/lib/utils/userFetcher.js
packages/lib/utils/webDAVUtils.test.js
+44 -2
View File
@@ -190,6 +190,7 @@ packages/app-desktop/ElectronAppWrapper.js
packages/app-desktop/InteropServiceHelper.js
packages/app-desktop/app.reducer.test.js
packages/app-desktop/app.reducer.js
packages/app-desktop/app.test.js
packages/app-desktop/app.js
packages/app-desktop/bridge.js
packages/app-desktop/checkForUpdates.js
@@ -208,6 +209,7 @@ packages/app-desktop/commands/openNoteInNewWindow.js
packages/app-desktop/commands/openPrimaryAppInstance.js
packages/app-desktop/commands/openProfileDirectory.js
packages/app-desktop/commands/openSecondaryAppInstance.js
packages/app-desktop/commands/openSyncWizard.js
packages/app-desktop/commands/replaceMisspelling.js
packages/app-desktop/commands/restoreNoteRevision.js
packages/app-desktop/commands/showProfileEditor.js
@@ -238,6 +240,7 @@ packages/app-desktop/gui/ConfigScreen/controls/GlobalHotkeyInput.test.js
packages/app-desktop/gui/ConfigScreen/controls/GlobalHotkeyInput.js
packages/app-desktop/gui/ConfigScreen/controls/MissingPasswordHelpLink.js
packages/app-desktop/gui/ConfigScreen/controls/NoteLockSettings.js
packages/app-desktop/gui/ConfigScreen/controls/SettingButton.js
packages/app-desktop/gui/ConfigScreen/controls/SettingComponent.js
packages/app-desktop/gui/ConfigScreen/controls/SettingDescription.js
packages/app-desktop/gui/ConfigScreen/controls/SettingHeader.js
@@ -249,6 +252,7 @@ packages/app-desktop/gui/ConfigScreen/controls/plugins/SearchPlugins.js
packages/app-desktop/gui/ConfigScreen/controls/useNoteLockMode.js
packages/app-desktop/gui/ConfigScreen/searchHighlight.test.js
packages/app-desktop/gui/ConfigScreen/searchHighlight.js
packages/app-desktop/gui/ConfigScreen/types.js
packages/app-desktop/gui/DialogButtonRow.js
packages/app-desktop/gui/DialogButtonRow/useKeyboardHandler.js
packages/app-desktop/gui/DialogTitle.js
@@ -448,6 +452,8 @@ 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/NoteLockMigrationDialog/Dialog.test.js
packages/app-desktop/gui/NoteLockMigrationDialog/Dialog.js
packages/app-desktop/gui/NoteLockUnlockDialog/Dialog.js
packages/app-desktop/gui/NotePropertiesDialog.js
packages/app-desktop/gui/NoteRevisionViewer.js
@@ -536,6 +542,7 @@ packages/app-desktop/gui/ToolbarSpace.js
packages/app-desktop/gui/TrashNotification/TrashNotification.js
packages/app-desktop/gui/TrashNotification/TrashNotificationMessage.js
packages/app-desktop/gui/UpdateNotification/UpdateNotification.js
packages/app-desktop/gui/WarningBanner.js
packages/app-desktop/gui/WindowCommandsAndDialogs/AppDialogs.js
packages/app-desktop/gui/WindowCommandsAndDialogs/ModalMessageOverlay.js
packages/app-desktop/gui/WindowCommandsAndDialogs/PluginDialogs.js
@@ -577,6 +584,7 @@ packages/app-desktop/gui/WindowCommandsAndDialogs/commands/renameTag.js
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/resetLayout.js
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/restoreFolder.js
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/restoreNote.js
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/revealInNotebook.js
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/revealResourceFile.js
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/search.js
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/setTags.js
@@ -629,6 +637,7 @@ packages/app-desktop/gui/utils/NoteListUtils.js
packages/app-desktop/gui/utils/announceForAccessibility.js
packages/app-desktop/gui/utils/convertToScreenCoordinates.js
packages/app-desktop/gui/utils/dragAndDrop.js
packages/app-desktop/gui/utils/hasNoteLockKey.js
packages/app-desktop/gui/utils/loadScript.js
packages/app-desktop/gulpfile.js
packages/app-desktop/integration-tests/chatPanel.spec.js
@@ -638,6 +647,7 @@ 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/GoToAnything.js
packages/app-desktop/integration-tests/models/JoplinCloudLoginScreen.js
packages/app-desktop/integration-tests/models/MainScreen.js
packages/app-desktop/integration-tests/models/NoteEditor/BaseEditor.js
packages/app-desktop/integration-tests/models/NoteEditor/EditorCodeDialog.js
@@ -646,9 +656,11 @@ 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/SettingsScreen/SettingsScreen.js
packages/app-desktop/integration-tests/models/SettingsScreen/SyncTab.js
packages/app-desktop/integration-tests/models/Sidebar.js
packages/app-desktop/integration-tests/multiWindow.spec.js
packages/app-desktop/integration-tests/networking.spec.js
packages/app-desktop/integration-tests/noteList.spec.js
packages/app-desktop/integration-tests/pluginApi.spec.js
packages/app-desktop/integration-tests/resizableLayout.spec.js
@@ -724,9 +736,11 @@ packages/app-desktop/utils/customProtocols/registerCustomProtocols.js
packages/app-desktop/utils/getAssetPath.js
packages/app-desktop/utils/initReact.js
packages/app-desktop/utils/initializeCommandService.js
packages/app-desktop/utils/initializeNetworkConnectionAttemptTimeout.js
packages/app-desktop/utils/isSafeToOpen.test.js
packages/app-desktop/utils/isSafeToOpen.js
packages/app-desktop/utils/layout/layoutKeyToLabel.js
packages/app-desktop/utils/noteLockPrompts.js
packages/app-desktop/utils/restartInSafeModeFromMain.test.js
packages/app-desktop/utils/restartInSafeModeFromMain.js
packages/app-desktop/utils/sourceMapSetup.js
@@ -842,6 +856,11 @@ packages/app-mobile/components/ScreenHeader/index.js
packages/app-mobile/components/SearchInput.js
packages/app-mobile/components/SelectDateTimeDialog.js
packages/app-mobile/components/SideMenu.js
packages/app-mobile/components/SideMenuContent/BottomPanelActions.js
packages/app-mobile/components/SideMenuContent/FolderItem.js
packages/app-mobile/components/SideMenuContent/SideMenuContent.js
packages/app-mobile/components/SideMenuContent/SideMenuItem.js
packages/app-mobile/components/SideMenuContent/SidebarIcon.js
packages/app-mobile/components/SideMenuContentNote.js
packages/app-mobile/components/SyncWizard/JoplinCloudIcon.js
packages/app-mobile/components/SyncWizard/SyncWizard.js
@@ -874,6 +893,7 @@ packages/app-mobile/components/plugins/PluginNotification.js
packages/app-mobile/components/plugins/PluginRunner.js
packages/app-mobile/components/plugins/PluginRunnerWebView.test.js
packages/app-mobile/components/plugins/PluginRunnerWebView.js
packages/app-mobile/components/plugins/backgroundPage/initializeDialogWebView.test.js
packages/app-mobile/components/plugins/backgroundPage/initializeDialogWebView.js
packages/app-mobile/components/plugins/backgroundPage/initializePluginBackgroundIframe.js
packages/app-mobile/components/plugins/backgroundPage/pluginRunnerBackgroundPage.js
@@ -951,6 +971,7 @@ packages/app-mobile/components/screens/JoplinCloudLoginScreen.js
packages/app-mobile/components/screens/LogScreen.js
packages/app-mobile/components/screens/Note/Note.test.js
packages/app-mobile/components/screens/Note/Note.js
packages/app-mobile/components/screens/Note/NoteLockPanel.js
packages/app-mobile/components/screens/Note/commands/attachFile.js
packages/app-mobile/components/screens/Note/commands/hideKeyboard.js
packages/app-mobile/components/screens/Note/commands/index.js
@@ -958,6 +979,8 @@ packages/app-mobile/components/screens/Note/commands/insertDateTime.js
packages/app-mobile/components/screens/Note/commands/setTags.js
packages/app-mobile/components/screens/Note/commands/toggleVisiblePanes.js
packages/app-mobile/components/screens/Note/types.js
packages/app-mobile/components/screens/NoteLockMigrationScreen.test.js
packages/app-mobile/components/screens/NoteLockMigrationScreen.js
packages/app-mobile/components/screens/NoteRevisionViewer.test.js
packages/app-mobile/components/screens/NoteRevisionViewer.js
packages/app-mobile/components/screens/NoteTagsDialog.js
@@ -986,7 +1009,6 @@ packages/app-mobile/components/screens/resourceScreenUtils.test.js
packages/app-mobile/components/screens/resourceScreenUtils.js
packages/app-mobile/components/screens/status.js
packages/app-mobile/components/screens/tags.js
packages/app-mobile/components/side-menu-content.js
packages/app-mobile/components/testing/TestProviderStack.js
packages/app-mobile/components/voiceTyping/AudioRecordingBanner.js
packages/app-mobile/components/voiceTyping/RecordingControls.js
@@ -1068,6 +1090,7 @@ packages/app-mobile/utils/database-driver-react-native.web.js
packages/app-mobile/utils/debounce.js
packages/app-mobile/utils/focusView.js
packages/app-mobile/utils/fs-driver/constants.js
packages/app-mobile/utils/fs-driver/fs-driver-rn.test.js
packages/app-mobile/utils/fs-driver/fs-driver-rn.js
packages/app-mobile/utils/fs-driver/fs-driver-rn.web.js
packages/app-mobile/utils/fs-driver/fs-driver-rn.web.worker.js
@@ -1630,6 +1653,7 @@ packages/lib/services/ResourceEditWatcher/reducer.js
packages/lib/services/ResourceFetcher.js
packages/lib/services/ResourceService.test.js
packages/lib/services/ResourceService.js
packages/lib/services/RevisionService.noteLock.test.js
packages/lib/services/RevisionService.test.js
packages/lib/services/RevisionService.js
packages/lib/services/SettingUtils.js
@@ -1701,8 +1725,14 @@ 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/autoMergeNote.test.js
packages/lib/services/conflict/autoMergeNote.js
packages/lib/services/conflict/boundedDiff3.test.js
packages/lib/services/conflict/boundedDiff3.js
packages/lib/services/conflict/decryptNoteInMemory.js
packages/lib/services/conflict/diffNotes.test.js
packages/lib/services/conflict/diffNotes.js
packages/lib/services/conflict/isAutoMergeEnabled.js
packages/lib/services/conflict/isConflictResolutionEnabled.js
packages/lib/services/conflict/loadConflictData.test.js
packages/lib/services/conflict/loadConflictData.js
@@ -1746,6 +1776,7 @@ packages/lib/services/e2ee/ppk/webCrypto/buildRsaCryptoProvider.js
packages/lib/services/e2ee/types.js
packages/lib/services/e2ee/utils.test.js
packages/lib/services/e2ee/utils.js
packages/lib/services/interop/InteropService.noteLock.test.js
packages/lib/services/interop/InteropService.test.js
packages/lib/services/interop/InteropService.js
packages/lib/services/interop/InteropService_Exporter_Base.js
@@ -1797,12 +1828,16 @@ packages/lib/services/noteList/defaultLeftToRightListRenderer.js
packages/lib/services/noteList/defaultListRenderer.js
packages/lib/services/noteList/defaultMultiColumnsRenderer.js
packages/lib/services/noteList/depNameToNoteProp.js
packages/lib/services/noteList/isSyncDisabledConflict.test.js
packages/lib/services/noteList/isSyncDisabledConflict.js
packages/lib/services/noteList/renderTemplate.test.js
packages/lib/services/noteList/renderTemplate.js
packages/lib/services/noteList/renderViewProps.test.js
packages/lib/services/noteList/renderViewProps.js
packages/lib/services/noteList/renderers.js
packages/lib/services/noteLock/NoteLockKey.js
packages/lib/services/noteLock/NoteLockKeyMigration.test.js
packages/lib/services/noteLock/NoteLockKeyMigration.js
packages/lib/services/noteLock/NoteLockNote.js
packages/lib/services/noteLock/NoteLockService.test.js
packages/lib/services/noteLock/NoteLockService.js
@@ -1810,6 +1845,8 @@ 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/noteLockPrompts.test.js
packages/lib/services/noteLock/noteLockPrompts.js
packages/lib/services/noteLock/setNoteLockState.test.js
packages/lib/services/noteLock/setNoteLockState.js
packages/lib/services/ocr/OcrDriverBase.js
@@ -1959,10 +1996,12 @@ packages/lib/services/synchronizer/ItemUploader.test.js
packages/lib/services/synchronizer/ItemUploader.js
packages/lib/services/synchronizer/LockHandler.js
packages/lib/services/synchronizer/MigrationHandler.js
packages/lib/services/synchronizer/Synchronizer.autoMerge.test.js
packages/lib/services/synchronizer/Synchronizer.baseFields.test.js
packages/lib/services/synchronizer/Synchronizer.basics.test.js
packages/lib/services/synchronizer/Synchronizer.conflicts.test.js
packages/lib/services/synchronizer/Synchronizer.e2ee.test.js
packages/lib/services/synchronizer/Synchronizer.noteLock.test.js
packages/lib/services/synchronizer/Synchronizer.ppk.test.js
packages/lib/services/synchronizer/Synchronizer.resources.test.js
packages/lib/services/synchronizer/Synchronizer.revisions.test.js
@@ -2011,6 +2050,7 @@ packages/lib/shim.js
packages/lib/string-utils.test.js
packages/lib/string-utils.js
packages/lib/testing/ai/semanticSearch.js
packages/lib/testing/createLocalhostServer.js
packages/lib/testing/dom-test-environment.js
packages/lib/testing/plugins/createTestPlugin.js
packages/lib/testing/share/makeMockShareInvitation.js
@@ -2063,6 +2103,8 @@ packages/lib/utils/replaceUnsupportedCharacters.test.js
packages/lib/utils/replaceUnsupportedCharacters.js
packages/lib/utils/resolvePathWithinDir.test.js
packages/lib/utils/resolvePathWithinDir.js
packages/lib/utils/tls/setExtraRootCertificates.test.js
packages/lib/utils/tls/setExtraRootCertificates.js
packages/lib/utils/types/pdfJs.js
packages/lib/utils/userFetcher.js
packages/lib/utils/webDAVUtils.test.js
@@ -0,0 +1,17 @@
diff --git a/types/react-native-web.d.ts b/types/react-native-web.d.ts
index d6159045aa4b1f6864037cfc1c001a68605b416d..5950b82015f82f7840cf3bd2fc88e81dd0ff789c 100644
--- a/types/react-native-web.d.ts
+++ b/types/react-native-web.d.ts
@@ -3,7 +3,11 @@
import { StyleProp } from 'react-native';
import * as RN from 'react-native';
-declare module 'react-native' {
+// Expo's extended react-native types are out-of-sync with upstream.
+// This causes TypeScript errors when compiling. These errors only occur
+// when strict null checks is disabled, and so do not occur with the default React Native
+// and Expo TypeScript config.
+declare module 'react-native-disabled' {
/**
* View
*/
@@ -0,0 +1,56 @@
# This patch makes react-native-quick-actions compatible with the iOS UIKit
# scene-based lifecycle. Requires calling `setInitialAction` on app load when
# started from a quick action.
diff --git a/RNQuickAction/RNQuickAction/RNQuickActionManager.h b/RNQuickAction/RNQuickAction/RNQuickActionManager.h
index ad4baad075513115892bd120a278f1ba664480b7..7a366391a48e77d7b08eb2741d2a499a267304b8 100644
--- a/RNQuickAction/RNQuickAction/RNQuickActionManager.h
+++ b/RNQuickAction/RNQuickAction/RNQuickActionManager.h
@@ -10,4 +10,5 @@
@interface RNQuickActionManager : NSObject <RCTBridgeModule>
+(void) onQuickActionPress:(UIApplicationShortcutItem *) shortcutItem completionHandler:(void (^)(BOOL succeeded)) completionHandler;
++(void) setInitialAction:(UIApplicationShortcutItem *) shortcutItem;
@end
diff --git a/RNQuickAction/RNQuickAction/RNQuickActionManager.m b/RNQuickAction/RNQuickAction/RNQuickActionManager.m
index 8db8cab947349763a9c3d9d6004868b675b9c7ef..3217a35634ad457e33756b895b32d80f3fa11178 100644
--- a/RNQuickAction/RNQuickAction/RNQuickActionManager.m
+++ b/RNQuickAction/RNQuickAction/RNQuickActionManager.m
@@ -31,6 +31,7 @@ @implementation RNQuickActionManager
RCT_EXPORT_MODULE();
@synthesize bridge = _bridge;
+UIApplicationShortcutItem *lastInitialAction_ = NULL;
- (instancetype)init
{
@@ -39,6 +40,7 @@ - (instancetype)init
selector:@selector(handleQuickActionPress:)
name:RCTShortcutItemClicked
object:nil];
+ _initialAction = lastInitialAction_;
}
return self;
}
@@ -59,7 +61,9 @@ - (void)dealloc
- (void)setBridge:(RCTBridge *)bridge
{
_bridge = bridge;
- _initialAction = [bridge.launchOptions[UIApplicationLaunchOptionsShortcutItemKey] copy];
+ // Patch: The initial action must now be set through `setInitialAction`:
+ // _initialAction = NULL;
+ // _initialAction = [bridge.launchOptions[UIApplicationLaunchOptionsShortcutItemKey] copy];
}
// Map user passed array of UIApplicationShortcutItem
@@ -155,6 +159,11 @@ + (void)onQuickActionPress:(UIApplicationShortcutItem *) shortcutItem completion
completionHandler(YES);
}
++ (void)setInitialAction:(UIApplicationShortcutItem *) shortcutItem
+{
+ lastInitialAction_ = [shortcutItem copy];
+}
+
- (void)handleQuickActionPress:(NSNotification *) notification
{
[_bridge.eventDispatcher sendDeviceEventWithName:@"quickActionShortcut"
@@ -0,0 +1,47 @@
# Fixes the following error:
# A problem occurred evaluating project ':react-native-rsa-native'.
# > Could not find method jcenter() for arguments [] on repository container of type org.gradle.api.internal.artifacts.dsl.DefaultRepositoryHandler.
#
# See https://github.com/amitaymolko/react-native-rsa-native/issues/175
diff --git a/android/bin/build.gradle b/android/bin/build.gradle
index b5f54a631f4bed61973c77c2a6b27c5df0b26d17..28f38ca5966097f798f3f3607ded55dd4fe9b6d1 100644
--- a/android/bin/build.gradle
+++ b/android/bin/build.gradle
@@ -1,7 +1,6 @@
buildscript {
repositories {
maven { url "https://maven.google.com" }
- jcenter()
google()
maven {
url 'https://maven.google.com/'
@@ -41,7 +40,7 @@ allprojects {
repositories {
mavenLocal()
maven { url "https://maven.google.com" }
- jcenter()
+ mavenCentral()
google()
maven {
// All of React Native (JS, Obj-C sources, Android binaries) is installed from npm
diff --git a/android/build.gradle b/android/build.gradle
index 82f60d3d862258e50023076b0db6e792bd2a2503..9e4606903f2dcf4ee7bb49dc878a080023ed314c 100644
--- a/android/build.gradle
+++ b/android/build.gradle
@@ -9,7 +9,6 @@ buildscript {
if (project == rootProject) {
repositories {
google()
- jcenter()
maven {
url "https://maven.fabric.io/public"
}
@@ -45,7 +44,7 @@ allprojects {
repositories {
mavenLocal()
google()
- jcenter()
+ mavenCentral()
maven {
// All of React Native (JS, Obj-C sources, Android binaries) is installed from npm
url "$rootDir/../node_modules/react-native/android"
@@ -1,37 +0,0 @@
diff --git a/platforms/android/src/main/java/org/pgsqlite/SQLitePlugin.java b/platforms/android/src/main/java/org/pgsqlite/SQLitePlugin.java
index 4f2391b..f7cc433 100644
--- a/platforms/android/src/main/java/org/pgsqlite/SQLitePlugin.java
+++ b/platforms/android/src/main/java/org/pgsqlite/SQLitePlugin.java
@@ -8,11 +8,14 @@
package org.pgsqlite;
import android.annotation.SuppressLint;
+import android.database.AbstractWindowedCursor;
import android.database.Cursor;
+import android.database.CursorWindow;
import android.database.sqlite.SQLiteDatabase;
import android.database.sqlite.SQLiteException;
import android.database.sqlite.SQLiteStatement;
import android.content.Context;
+import android.os.Build;
import android.util.Base64;
import java.io.Closeable;
@@ -808,6 +811,17 @@ public class SQLitePlugin extends ReactContextBaseJavaModule {
throw ex;
}
+ // To try to fix the error "Row too big to fit into CursorWindow" when using sqlite binary bundled with the device
+ // https://github.com/andpor/react-native-sqlite-storage/issues/364#issuecomment-526423153
+ // https://github.com/laurent22/joplin/issues/1767#issuecomment-515617991
+
+ if (cur != null && Build.VERSION.SDK_INT >= 28) {
+ CursorWindow cw = new CursorWindow(null, 50 * 1024 * 1024);
+ AbstractWindowedCursor ac = (AbstractWindowedCursor) cur;
+ ac.setWindow(cw);
+ cur = ac;
+ }
+
// If query result has rows
if (cur != null && cur.moveToFirst()) {
WritableArray rowsArrayResult = Arguments.createArray();
@@ -1,6 +1,25 @@
# Fixes sync issues caused by locale-sensitive lowercasing
# of HTTP headers.
# See https://github.com/laurent22/joplin/issues/10681
# Fixes the following errors:
# - Could not find method jcenter() for arguments [] on repository container of type org.gradle.api.internal.artifacts.dsl.DefaultRepositoryHandler.
# - Sync issues caused by locale-sensitive lowercasing of HTTP headers (see https://github.com/laurent22/joplin/issues/10681).
diff --git a/android/build.gradle b/android/build.gradle
index a4ca7a421bff66bcc378c1092474bac6b7a82738..d2527c7e695d09dac3069fecd14931eae33a03e7 100644
--- a/android/build.gradle
+++ b/android/build.gradle
@@ -6,13 +6,12 @@ def safeExtGet(prop, fallback) {
repositories {
mavenCentral()
- jcenter()
google()
}
buildscript {
repositories {
- jcenter()
+ mavenCentral()
google()
}
dependencies {
diff --git a/android/src/main/java/com/RNFetchBlob/RNFetchBlobConfig.java b/android/src/main/java/com/RNFetchBlob/RNFetchBlobConfig.java
index 8ac9e7a855162cefbf99024eb013c8a3b11de1ec..1c639cf9d84821b6ffc132960e2d1c044bedbd48 100644
--- a/android/src/main/java/com/RNFetchBlob/RNFetchBlobConfig.java
@@ -0,0 +1,15 @@
diff --git a/binary.js b/binary.js
index f91bdbd2e4b9bfb3a6376849598a9352f8678584..f42f96aef91c483c88fdd47ff82473069ce3456d 100644
--- a/binary.js
+++ b/binary.js
@@ -9,7 +9,9 @@ const getPlatform = () => {
const arch = os.arch();
// https://github.com/nodejs/node/blob/c3664227a83cf009e9a2e1ddeadbd09c14ae466f/deps/uv/src/win/util.c#L1566-L1573
- if ((type === "Windows_NT" || type.startsWith("MINGW32_NT-")) && arch === "x64") {
+ // arm64 uses the x64 binary, which runs under emulation - as for Darwin
+ // below. Upstream publishes no aarch64-pc-windows-msvc build.
+ if ((type === "Windows_NT" || type.startsWith("MINGW32_NT-")) && (arch === "x64" || arch === "arm64")) {
return windows;
}
if (type === "Linux" && arch === "x64") {
Binary file not shown.

After

Width:  |  Height:  |  Size: 288 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 82 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 137 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 193 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 208 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 700 KiB

+25 -3
View File
@@ -1,3 +1,6 @@
// Set to true once a Windows ARM64 build is published in a release.
const windowsArm64Enabled = false;
async function getOs() {
// The macOS release is available for Intel and Apple silicon processors,
@@ -25,7 +28,21 @@ async function getOs() {
}
}
if (navigator.appVersion.indexOf("Win")!=-1) return "windows";
// Windows on ARM has the same appVersion as x64, so the architecture has to
// come from getHighEntropyValues, as for Apple Silicon above. Falling back
// to the x64 build is fine - it runs on ARM under emulation.
if (navigator.appVersion.indexOf("Win")!=-1) {
if (windowsArm64Enabled) {
try {
const platformInfo = await navigator.userAgentData.getHighEntropyValues(['architecture']);
console.info('Got platform info:', platformInfo);
if (platformInfo.architecture === 'arm') return "windowsArm64";
} catch (error) {
console.warn('Failed getting Windows architecture:', error);
}
}
return "windows";
}
if (navigator.appVersion.indexOf("X11")!=-1) return "linux";
if (navigator.appVersion.indexOf("Linux")!=-1) return "linux";
return null;
@@ -78,7 +95,12 @@ async function setupDownloadPage() {
$('.page-download .get-it-desktop a').each(function() {
const href = $(this).attr('href');
if (href.indexOf('-Setup') > 0) downloadLinks['windows'] = href;
// The arm64 installer is also named "-Setup", so it must be excluded
// from the x64 link even when disabled, or it would overwrite it.
if (href.indexOf('-arm64.exe') > 0) {
if (windowsArm64Enabled) downloadLinks['windowsArm64'] = href;
}
else if (href.indexOf('-Setup') > 0) downloadLinks['windows'] = href;
if (href.indexOf('.dmg') > 0) downloadLinks['macOs'] = href;
if (href.indexOf('arm64.DMG') > 0) downloadLinks['macOsM1'] = href;
if (href.indexOf('.AppImage') > 0) downloadLinks['linux'] = href;
@@ -131,7 +153,7 @@ const localePathOverrides = {
};
// List of supported language codes
const supportedLanguages = ['fr', 'de', 'zh'];
const supportedLanguages = ['fr', 'de', 'hr', 'zh'];
function getLocalePath(langCode) {
const pathPrefix = localePathOverrides[langCode] || langCode;
+81 -100
View File
@@ -1,4 +1,83 @@
<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[Joplin]]></title><description><![CDATA[Joplin, the open source note-taking application]]></description><link>https://joplinapp.org</link><generator>RSS for Node</generator><lastBuildDate>Tue, 05 May 2026 00:00:00 GMT</lastBuildDate><atom:link href="https://joplinapp.org/rss.xml" rel="self" type="application/rss+xml"/><pubDate>Tue, 05 May 2026 00:00:00 GMT</pubDate><item><title><![CDATA[What's new in Joplin 3.6]]></title><description><![CDATA[<p>Joplin 3.6 brings a more polished editing experience, a much improved OneNote importer, and a clearer view of what's happening when the app syncs. Mobile also gains a number of features that previously were only available on desktop.</p>
<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[Joplin]]></title><description><![CDATA[Joplin, the open source note-taking application]]></description><link>https://joplinapp.org</link><generator>RSS for Node</generator><lastBuildDate>Wed, 21 Oct 2026 00:00:00 GMT</lastBuildDate><atom:link href="https://joplinapp.org/rss.xml" rel="self" type="application/rss+xml"/><pubDate>Wed, 21 Oct 2026 00:00:00 GMT</pubDate><item><title><![CDATA[How we protect your privacy with Joplin Cloud AI]]></title><description><![CDATA[<p>Joplin Cloud AI features work on your notes. Notes are often personal, so we've set up the AI service so that your content goes to as few places as possible and stays there for as short a time as possible.</p>
<p><strong>A European AI provider, processing in Europe.</strong> Joplin Cloud AI uses models from Mistral AI, a French company. We send requests to Mistral's EU regional endpoint, which processes data only in data centres in the European Union and EFTA countries. Your notes are not sent to the United States for processing.</p>
<p><strong>Zero Data Retention.</strong> Mistral has enabled Zero Data Retention on our account. Once a response has been generated, Mistral doesn't store or log the content of the request or the response. Only basic billing information, such as how many tokens were used, is kept.</p>
<p><strong>No training on your notes.</strong> Your content isn't used to train or improve AI models. Mistral's commercial terms rule this out for our account, and training is switched off in our account settings.</p>
<p><strong>GDPR.</strong> Our use of Mistral is covered by their Data Processing Addendum, which sets out how they may handle personal data on our behalf under the GDPR.</p>
<p><strong>What Joplin Cloud stores.</strong> Our own servers don't keep the text you send to the AI or the replies you get back. For each request we only record who made it, which model was used, how many tokens it used and whether it succeeded. We need this to apply usage limits and keep track of costs.</p>
]]></description><link>https://joplinapp.org/news/20261021-joplin-cloud-ai-privacy</link><guid isPermaLink="false">20261021-joplin-cloud-ai-privacy</guid><pubDate>Wed, 21 Oct 2026 00:00:00 GMT</pubDate><twitter-text></twitter-text></item><item><title><![CDATA[Your notes and AI: privacy first]]></title><description><![CDATA[<p>AI can be useful for summarising, rewriting and asking questions about your notes. But your notes are private, so you should always know where your data is going.</p>
<p><strong>Joplin does not send your notes to an AI service by default.</strong> AI features are disabled by default, and you have to explicitly enable them and choose an AI provider. <a href="https://joplinapp.org/help/apps/ai_chat">Learn more about AI Chat</a>.</p>
<h2>Keep your notes on your own device<a name="keep-your-notes-on-your-own-device" href="#keep-your-notes-on-your-own-device" class="heading-anchor">🔗</a></h2>
<p>You can connect Joplin to a local AI model, such as Ollama or LM Studio. In this case, your notes stay within your own device or private network and are not sent to an external service.</p>
<p>Joplin also blocks remote AI providers by default, as an additional safeguard.</p>
<h2>You control what AI can access<a name="you-control-what-ai-can-access" href="#you-control-what-ai-can-access" class="heading-anchor">🔗</a></h2>
<p>Joplin lets you choose <strong>which tools are available to AI</strong>. This applies both to AI Chat and to the Model Context Protocol (MCP) integration.</p>
<p>For example, you can allow an AI assistant to search your notes without giving it permission to modify them. You can enable or disable individual tools depending on what you are comfortable with.</p>
<p>This means that connecting an AI service to Joplin does not automatically give it unrestricted access to your data or to all of Joplin's capabilities. <a href="https://joplinapp.org/help/apps/ai_mcp">Learn more about MCP</a>.</p>
<h2>If you use a cloud AI provider<a name="if-you-use-a-cloud-ai-provider" href="#if-you-use-a-cloud-ai-provider" class="heading-anchor">🔗</a></h2>
<p>You can connect Joplin to cloud AI services such as Joplin Cloud AI, OpenAI or Anthropic. When you do, the relevant data is sent to the provider you have selected.</p>
<p>For AI Chat, Joplin only sends the note content required for your request — such as the currently open note or selected text — rather than automatically sending your entire notebook. <a href="https://joplinapp.org/help/apps/ai_chat_panel">Learn more about the AI Chat panel</a>.</p>
<h2>You are in control<a name="you-are-in-control" href="#you-are-in-control" class="heading-anchor">🔗</a></h2>
<p>Joplin's approach is simple: <strong>AI should be opt-in, transparent and configurable.</strong></p>
<p>You choose whether to use AI, which provider to use, whether to use a local or remote model, and which tools that AI is allowed to access.</p>
<p>If you want maximum privacy, use a local AI model and only enable the tools you actually need.</p>
]]></description><link>https://joplinapp.org/news/20260914-ai-privacy</link><guid isPermaLink="false">20260914-ai-privacy</guid><pubDate>Mon, 14 Sep 2026 00:00:00 GMT</pubDate><twitter-text></twitter-text></item><item><title><![CDATA[What's new in Joplin 3.7]]></title><description><![CDATA[<p>Joplin 3.7 is the release where AI arrives in the app. A new chat panel lets you ask questions about the note you're working on, semantic search finds notes by meaning rather than by keyword, and a built-in MCP server allows external tools to work with your notes.</p>
<p>Alongside this, the desktop app gains whiteboards - a new way to lay out notes and ideas visually - while mobile continues its interface redesign. Both platforms benefit from a more capable Markdown table editor and a number of sync reliability improvements.</p>
<h2>Improvements across desktop and mobile<a name="improvements-across-desktop-and-mobile" href="#improvements-across-desktop-and-mobile" class="heading-anchor">🔗</a></h2>
<h3>A real table editor in the Markdown editor<a name="a-real-table-editor-in-the-markdown-editor" href="#a-real-table-editor-in-the-markdown-editor" class="heading-anchor">🔗</a></h3>
<p>Editing Markdown tables has always been the least pleasant part of writing in plain text. Joplin 3.7 adds an interactive table editor to the Markdown editor, with a context menu to add and delete rows and columns.</p>
<p><img src="https://raw.githubusercontent.com/laurent22/joplin/dev/Assets/WebsiteAssets/images/news/20260831-table-editor.png" alt="The Joplin Markdown editor showing a table with the table context menu open, listing the commands to add and delete rows and columns."></p>
<p>Because the editor is shared between platforms, this is available on mobile as well.</p>
<h3>More reliable syncing<a name="more-reliable-syncing" href="#more-reliable-syncing" class="heading-anchor">🔗</a></h3>
<p>Syncing is faster and safer this cycle. Deleting a lot of notes at once on Joplin Server and Joplin Cloud no longer takes as long, and if you happen to be typing in a note just as it's being updated from another device, your changes are no longer at risk of being overwritten.</p>
<h3>Faster startup<a name="faster-startup" href="#faster-startup" class="heading-anchor">🔗</a></h3>
<p>Joplin no longer processes plugin files that don't need processing at startup, which noticeably reduces launch time for users with several plugins installed.</p>
<h2>Desktop-specific improvements<a name="desktop-specific-improvements" href="#desktop-specific-improvements" class="heading-anchor">🔗</a></h2>
<h3>AI features (Beta)<a name="ai-features-beta" href="#ai-features-beta" class="heading-anchor">🔗</a></h3>
<p>The headline addition of this release is a set of AI features built into the app.</p>
<p>The <strong>chat panel</strong> sits alongside the current note and can answer questions about it, summarise it, or make edits to it on your behalf, as well as read images in your notes.</p>
<p>To use the AI features you need a provider, and this release introduces <strong>Joplin Cloud AI</strong> to make that as simple as possible. If you already sync with Joplin Cloud, you can simply turn it on and start using the chat panel right away - there's no API key to obtain, and nothing to configure.</p>
<p>If you would rather use your own provider, that remains possible: the chat panel also works with your own OpenAI or Anthropic key. It can equally be pointed at a local model running on your own machine, in which case your notes never leave your computer at all.</p>
<p><img src="https://raw.githubusercontent.com/laurent22/joplin/dev/Assets/WebsiteAssets/images/news/20260831-ai-chat.png" alt="The Joplin desktop app with the AI chat panel open beside a note, showing a question about the note and the reply from the model."></p>
<p><strong>Semantic search</strong> is now available in the sidebar and in Go To Anything. Unlike the regular search, it matches on meaning rather than exact words, so a search for &quot;holiday plans&quot; can surface a note titled &quot;Trip to Portugal&quot;.</p>
<p>This is powered by a new note embeddings indexer, which runs in the background and indexes both note titles and content.</p>
<p>Finally, all of this is available to plugin authors too. Plugins can now hold conversations with a model, run semantic searches, and work with the note index directly - which opens the door to a whole new class of plugins, from automatic tagging and summarising to tools that surface related notes as you write.</p>
<p>All the AI features are marked as <strong>Beta</strong>: they work, but they are new, and we expect to refine them based on how people actually use them.</p>
<h3>MCP server<a name="mcp-server" href="#mcp-server" class="heading-anchor">🔗</a></h3>
<p>Joplin now includes an <a href="https://modelcontextprotocol.io/">MCP server</a>, which means external AI tools and assistants can read from and write to your notes through a standard protocol. If you already use an MCP-capable app such as Claude, it can now work directly with your Joplin data.</p>
<h3>Whiteboards<a name="whiteboards" href="#whiteboards" class="heading-anchor">🔗</a></h3>
<p>A brand new feature on desktop: whiteboards let you arrange notes, files and free-form cards on an infinite canvas, and connect them with labelled arrows.</p>
<p>Notebooks and tags are good at filing things away, but they say little about how your notes relate to one another. A whiteboard gives you that missing view: you can map out a project, plan a piece of writing, or lay out the pieces of a problem and see the connections between them at a glance.</p>
<p>Cards and groups can be coloured, and files can be dragged and dropped straight onto the canvas.</p>
<p><img src="https://raw.githubusercontent.com/laurent22/joplin/dev/Assets/WebsiteAssets/images/news/20260831-whiteboard.png" alt="A Joplin whiteboard with several coloured cards linked to notes, arranged on the canvas and connected by labelled arrows."></p>
<h3>Importing from other apps<a name="importing-from-other-apps" href="#importing-from-other-apps" class="heading-anchor">🔗</a></h3>
<p>Joplin 3.7 adds support for importing an Obsidian vault, on both desktop and the CLI, making it straightforward to move over from Obsidian while keeping your notes and their structure.</p>
<p>The Evernote importer now resolves cross-notebook links in many cases where they were previously lost.</p>
<p>The OneNote importer also received another round of fixes, in particular around note order, printouts and handwriting.</p>
<h3>Publishing and sharing<a name="publishing-and-sharing" href="#publishing-and-sharing" class="heading-anchor">🔗</a></h3>
<p>Joplin Cloud users could already publish a note to the internet as a public page. It is now possible to publish a <strong>whole notebook</strong> at once.</p>
<p>The notes are published together, with their links between each other preserved, which effectively turns a notebook into a small website: a piece of documentation, a recipe collection, a set of course notes, or anything else you want to share as a whole rather than one page at a time. Anyone with the link can read it, and it stays up to date as you edit the notes in Joplin.</p>
<p>And when sharing a notebook, email addresses are now auto-completed, which makes sharing with the same people repeatedly much quicker.</p>
<h3>Settings search<a name="settings-search" href="#settings-search" class="heading-anchor">🔗</a></h3>
<p>Joplin has accumulated a lot of settings over the years, and it is not always obvious which section holds the one you need. The configuration screen now has a search box: type what you are after, and the matching settings appear, wherever they happen to live.</p>
<p><img src="https://raw.githubusercontent.com/laurent22/joplin/dev/Assets/WebsiteAssets/images/news/20260831-setting-search.png" alt="The Joplin configuration screen with a term typed into the new search box and the matching settings listed below it."></p>
<h3>Post-quantum encryption for connections<a name="post-quantum-encryption-for-connections" href="#post-quantum-encryption-for-connections" class="heading-anchor">🔗</a></h3>
<p>Joplin desktop now supports post-quantum cryptography (PQC) for TLS connections. This protects your synchronisation traffic against &quot;harvest now, decrypt later&quot; attacks, where encrypted data captured today could be decrypted by a quantum computer in the future.</p>
<h3>Easier installation on Linux<a name="easier-installation-on-linux" href="#easier-installation-on-linux" class="heading-anchor">🔗</a></h3>
<p>The Linux AppImage no longer depends on the deprecated <code>libfuse2</code> library. On many distributions, installing Joplin used to mean first tracking down and installing that dependency by hand - it should now simply run out of the box.</p>
<h2>Mobile-specific improvements<a name="mobile-specific-improvements" href="#mobile-specific-improvements" class="heading-anchor">🔗</a></h2>
<h3>The interface redesign continues<a name="the-interface-redesign-continues" href="#the-interface-redesign-continues" class="heading-anchor">🔗</a></h3>
<p>The mobile redesign that started in previous releases carries on through 3.7, this time focusing on the menus you reach for constantly - creating a note, acting on one, or long-pressing a notebook. They are now easier to read at a glance, and their options are laid out more sensibly, so common actions take less hunting. The search screen has had the same treatment.</p>
<p>Dialogs have been tidied up too, with buttons always appearing in the same order throughout the app - a small thing, but it means you stop having to check which side &quot;Cancel&quot; is on before tapping.</p>
<p><img src="https://raw.githubusercontent.com/laurent22/joplin/dev/Assets/WebsiteAssets/images/news/20260831-mobile-new-note.png" alt="The redesigned new note menu on Joplin mobile."></p>
<p><img src="https://raw.githubusercontent.com/laurent22/joplin/dev/Assets/WebsiteAssets/images/news/20260831-mobile-note-menu.png" alt="The redesigned note actions menu on Joplin mobile."></p>
<h3>Faster encryption<a name="faster-encryption" href="#faster-encryption" class="heading-anchor">🔗</a></h3>
<p>The native crypto module has been refactored, which makes encryption and decryption substantially faster on mobile. This is most noticeable when first setting up end-to-end encryption or syncing a large collection for the first time.</p>
<h3>Notebook sort options<a name="notebook-sort-options" href="#notebook-sort-options" class="heading-anchor">🔗</a></h3>
<p>You can now choose how the notebook list itself is ordered, from the configuration screen: alphabetically, or by which notebook was most recently updated - and in either direction. Sorting by recent activity is particularly handy on mobile, as it keeps the notebooks you are actually working in at the top of the sidebar.</p>
<h2>Bug fixes and security fixes<a name="bug-fixes-and-security-fixes" href="#bug-fixes-and-security-fixes" class="heading-anchor">🔗</a></h2>
<p>Beyond the new features, a lot of this release went into making the apps more reliable and more secure: Joplin 3.7 includes <strong>over 100 bug fixes</strong> and <strong>eight security fixes</strong> across desktop and mobile.</p>
<p>For the full list of changes, see <a href="https://joplinapp.org/help/about/changelog/desktop/">the desktop changelog</a> and <a href="https://joplinapp.org/help/about/changelog/android/">the mobile changelog</a>.</p>
]]></description><link>https://joplinapp.org/news/20260831-release-3-7</link><guid isPermaLink="false">20260831-release-3-7</guid><pubDate>Mon, 31 Aug 2026 00:00:00 GMT</pubDate><twitter-text></twitter-text></item><item><title><![CDATA[What's new in Joplin 3.6]]></title><description><![CDATA[<p>Joplin 3.6 brings a more polished editing experience, a much improved OneNote importer, and a clearer view of what's happening when the app syncs. Mobile also gains a number of features that previously were only available on desktop.</p>
<h2>Improvements across desktop and mobile<a name="improvements-across-desktop-and-mobile" href="#improvements-across-desktop-and-mobile" class="heading-anchor">🔗</a></h2>
<h3>A more visual Markdown editor<a name="a-more-visual-markdown-editor" href="#a-more-visual-markdown-editor" class="heading-anchor">🔗</a></h3>
<p>The Markdown editor now renders formatting directly in the editor by default, which means headings, bold and italic text, links, images and other elements now appear styled as you type, instead of as raw Markdown. This brings the editing experience much closer to the final rendered note, while keeping all the speed and predictability of plain Markdown.</p>
@@ -485,102 +564,4 @@ sys 0m38.013s</p>
<li><a href="https://ryotak.net/">RyotaK</a></li>
<li><a href="https://twitter.com/YNizry">Yaniv Nizry</a></li>
</ul>
]]></description><link>https://joplinapp.org/news/20231023-white-hat-hackers</link><guid isPermaLink="false">20231023-white-hat-hackers</guid><pubDate>Mon, 23 Oct 2023 00:00:00 GMT</pubDate><twitter-text>Working in the shadows with white-hat hackers</twitter-text></item><item><title><![CDATA[What's new in Joplin 2.12]]></title><description><![CDATA[<h2>Desktop<a name="desktop" href="#desktop" class="heading-anchor">🔗</a></h2>
<h3>Support for Apple Silicon<a name="support-for-apple-silicon" href="#support-for-apple-silicon" class="heading-anchor">🔗</a></h3>
<p>A new release is now available for Apple Silicon, which provides improve performances on this architecture.</p>
<h3>Rich Text editor<a name="rich-text-editor" href="#rich-text-editor" class="heading-anchor">🔗</a></h3>
<p>In this release, we've undertaken numerous enhancements and addressed various bugs in the Rich Text editor. Notably, we've introduced support for plugin toolbar icons. Additionally, we've refined the editor's ability to manage text that's copied from applications like Word, Office, and LibreOffice, thereby enhancing cross-application compatibility. Among the minor yet impactful improvements, we've fine-tuned the handling of newlines and paragraphs—a highly requested feature. Another notable update is the improved automatic switching between light and dark modes.</p>
<p>Altogether, this release encompasses around 12 significant changes for the Rich Text editor, and rest assured, there's more to come in the future!</p>
<h3>Share permissions<a name="share-permissions" href="#share-permissions" class="heading-anchor">🔗</a></h3>
<p>Using Joplin Cloud Teams, you now have the ability to customize the read and write permissions for the notebooks you share. You have the option to grant other users permission to edit the notes or share them as read-only. This ensures that you can confidently share a notebook without worrying about unintentional modifications by your friends or colleagues.</p>
<p><img src="https://raw.githubusercontent.com/laurent22/joplin/dev/Assets/WebsiteAssets/images/news/20230825-share-permissions.png" alt=""></p>
<h3>Email to Note<a name="email-to-note" href="#email-to-note" class="heading-anchor">🔗</a></h3>
<p>Joplin Cloud Pro and Teams also now include the Email to Note feature, allowing you to conveniently store your emails within Joplin Cloud. By simply forwarding your emails to your Joplin Cloud address, you can transform them into notes. The email's subject will serve as the note title, while the body of the email will be the note's content. These notes will be organized within a notebook named &quot;Inbox.&quot;</p>
<p>More information in the <a href="https://joplinapp.org/help/apps/email_to_note">Email to Note documentation</a>.</p>
<h3>Choose to resize an image or not<a name="choose-to-resize-an-image-or-not" href="#choose-to-resize-an-image-or-not" class="heading-anchor">🔗</a></h3>
<p>By default, when you add a large image, Joplin will ask you if you would like to shrink it down or not. With this new release, you now have the option to always ask, to always resize, or to never resize the image, giving you more flexibility and reducing the number of prompts in the app.</p>
<p><img src="https://raw.githubusercontent.com/laurent22/joplin/dev/Assets/WebsiteAssets/images/news/20230825-resize-note.png" alt=""></p>
<h3>Rotating log<a name="rotating-log" href="#rotating-log" class="heading-anchor">🔗</a></h3>
<p>Up until now, the logs could grow to become very large, and if you wanted to shrink them down you would have to manually delete them. With this new release, we now automatically handle logs by rotating them - once the log becomes large enough it is moved to an archive. And once the archive is older than a number of days, it is deleted.</p>
<h3>Mobile<a name="mobile" href="#mobile" class="heading-anchor">🔗</a></h3>
<p>Like the desktop app, the mobile apps benefits from the Share Permissions update, as well as the Email to Note feature for Joplin Cloud. There are also plenty of other improvements - all in all there's 23 bug fixes, enhancements and security fixes in this release.</p>
<h2>Full changelog<a name="full-changelog" href="#full-changelog" class="heading-anchor">🔗</a></h2>
<p>This is just an overview of the main features. The full changelog is available there:</p>
<ul>
<li>Desktop: <a href="https://joplinapp.org/help/about/changelog/desktop">https://joplinapp.org/help/about/changelog/desktop</a></li>
<li>Android: <a href="https://joplinapp.org/help/about/changelog/android">https://joplinapp.org/help/about/changelog/android</a></li>
<li>iOS: <a href="https://joplinapp.org/help/about/changelog/ios">https://joplinapp.org/help/about/changelog/ios</a></li>
</ul>
]]></description><link>https://joplinapp.org/news/20230508-release-2-12</link><guid isPermaLink="false">20230508-release-2-12</guid><pubDate>Wed, 06 Sep 2023 12:00:00 GMT</pubDate><twitter-text>What&apos;s new in Joplin 2.12</twitter-text></item><item><title><![CDATA[What's new in Joplin 2.11]]></title><description><![CDATA[<h2>Desktop<a name="desktop" href="#desktop" class="heading-anchor">🔗</a></h2>
<h3>Add support for plugin user data<a name="add-support-for-plugin-user-data" href="#add-support-for-plugin-user-data" class="heading-anchor">🔗</a></h3>
<p>Developers of plugins now have the ability to associate additional information with notes, notebooks, and tags. This data is then synchronized across multiple devices. For instance, it is possible to envision attaching OCR data to an image or incorporating plugin-specific parameters to a note, which can subsequently be synchronized across all devices.</p>
<h3>Improved end-to-end encryption<a name="improved-end-to-end-encryption" href="#improved-end-to-end-encryption" class="heading-anchor">🔗</a></h3>
<p>End-to-end encryption (E2EE) plays a vital role in safeguarding your data privacy. Our aim is to make it effortlessly user-friendly, seamlessly integrated, and possibly enabled by default.</p>
<p>To achieve this objective, we have implemented several enhancements in this version. These improvements encompass more streamlined management of encryption keys and simplified setup processes for both new and existing devices utilising end-to-end encryption.</p>
<p>Moreover, we have upgraded the encryption algorithm from AES-128 to AES-256, ensuring heightened security for your data in the long run.</p>
<h3>Export a Mermaid graph<a name="export-a-mermaid-graph" href="#export-a-mermaid-graph" class="heading-anchor">🔗</a></h3>
<p>There is now a context menu on Mermaid graphs which you can use to export your graph as PNG, SVG, or by copying the vector data to the clipboard.</p>
<p><img src="https://raw.githubusercontent.com/laurent22/joplin/dev/Assets/WebsiteAssets/images/news/20230711-mermaid-export.png" alt=""></p>
<h3>Auto-detect language on startup<a name="auto-detect-language-on-startup" href="#auto-detect-language-on-startup" class="heading-anchor">🔗</a></h3>
<p>A small but useful change - the application language is now automatically detected startup. Of course, if needed you can later change it in the configuration screen.</p>
<h2>Android<a name="android" href="#android" class="heading-anchor">🔗</a></h2>
<p>As always most of these changes also apply to the mobile applications, such as the improved end-to-end encryption support, language auto-detection, and various bug fixes and improvements. But besides those, we also have several mobile-specific updates:</p>
<h3>Support for offline voice typing<a name="support-for-offline-voice-typing" href="#support-for-offline-voice-typing" class="heading-anchor">🔗</a></h3>
<p>The Android mobile app now support offline voice typing - it means your voice recording does not need to be uploaded to Google or Samsung server - it all takes place on your device for added security. To make use of it, open any note, tap on the three dots menu, then &quot;Voice typing&quot;.</p>
<p><img src="https://raw.githubusercontent.com/laurent22/joplin/dev/Assets/WebsiteAssets/images/news/20230711-voice-typing-1.png" alt=""></p>
<p>The first time, the app will need to download the language files to your device:</p>
<p><img src="https://raw.githubusercontent.com/laurent22/joplin/dev/Assets/WebsiteAssets/images/news/20230711-voice-typing-2.png" alt=""></p>
<p>Once they are there, simply record your message and it will appear in the note:</p>
<p><img src="https://raw.githubusercontent.com/laurent22/joplin/dev/Assets/WebsiteAssets/images/news/20230711-voice-typing-3.png" alt=""></p>
<h3>Create and move sub-notebooks<a name="create-and-move-sub-notebooks" href="#create-and-move-sub-notebooks" class="heading-anchor">🔗</a></h3>
<p>@jcgurango implemented support for creating sub-notebooks, and moving notebooks around in the mobile app. To do so, long press on any notebook and press &quot;Edit&quot;. You can now choose to move it under any other notebook using the provided dropdown list.</p>
<h2>Full changelog<a name="full-changelog" href="#full-changelog" class="heading-anchor">🔗</a></h2>
<p>This is just an overview of the main features. The full changelog is available there:</p>
<ul>
<li>Desktop: <a href="https://joplinapp.org/help/about/changelog/desktop">https://joplinapp.org/help/about/changelog/desktop</a></li>
<li>Android: <a href="https://joplinapp.org/help/about/changelog/android">https://joplinapp.org/help/about/changelog/android</a></li>
<li>iOS: <a href="https://joplinapp.org/help/about/changelog/ios">https://joplinapp.org/help/about/changelog/ios</a></li>
</ul>
]]></description><link>https://joplinapp.org/news/20230508-release-2-11</link><guid isPermaLink="false">20230508-release-2-11</guid><pubDate>Fri, 14 Jul 2023 12:00:00 GMT</pubDate><twitter-text>What&apos;s new in Joplin 2.11</twitter-text></item><item><title><![CDATA[What's new in Joplin 2.10]]></title><description><![CDATA[<p>Great news! Joplin 2.10 is here and we've made some amazing improvements and bug fixes, with a focus on the mobile app.</p>
<h2>New design for &quot;New note&quot; and &quot;New to-do&quot; buttons<a name="new-design-for-new-note-and-new-to-do-buttons" href="#new-design-for-new-note-and-new-to-do-buttons" class="heading-anchor">🔗</a></h2>
<p>We're excited to announce that we've made it even easier to create new notes and to-do lists with new designs for the &quot;New note&quot; and &quot;New to-do&quot; buttons.</p>
<p>If there is enough space, the button labels will be shown in full:</p>
<p><img src="https://raw.githubusercontent.com/laurent22/joplin/dev/Assets/WebsiteAssets/images/news/20230508-new-note-1.png" alt=""></p>
<p>While for those who prefer a more narrow note list, only the button icons will be shown:</p>
<p><img src="https://raw.githubusercontent.com/laurent22/joplin/dev/Assets/WebsiteAssets/images/news/20230508-new-note-2.png" alt=""></p>
<p>It's a small improvement, but we're confident it will make the app even more intuitive for new users.</p>
<h2>Fixes and improvements<a name="fixes-and-improvements" href="#fixes-and-improvements" class="heading-anchor">🔗</a></h2>
<p>This version includes 30 bug fixes and 16 general improvements. Let's dive into some of the highlights:</p>
<ul>
<li>
<p>Self Not Found and Pedro have been working tirelessly on improving the pasting of plain text in the application, and we're happy to say that there is now a brand new &quot;Paste as text&quot; option in the Edit menu.</p>
</li>
<li>
<p>For our amazing plugin developers out there, we have added a few new APIs and fixed a bug that was preventing certain plugins from starting. You can now get even more creative with your plugins!</p>
</li>
<li>
<p>Tao Klerks, has been hard at work fixing and improving the custom sort order of the note list. No more notes in the wrong position when you drop them! Plus, custom sort order is now synchronised too.</p>
</li>
<li>
<p>We've also fixed a few bugs with our Web Clipper, including an issue where certain pages wouldn't import their images. A new Web Clipper has also been released, so you can clip to your heart's content!</p>
</li>
</ul>
<p>Last but not least, we have modernised both the desktop and mobile application modules, just <a href="https://joplinapp.org/news/20221115-renovate/">as we previously announced</a>. Although these changes may not be visible to you, they required a lot of work! But the result is that our applications are now more stable and secure, and it will be easier to maintain them in the long run. We're using a tool called <a href="https://www.mend.io/free-developer-tools/renovate/">Renovate</a>, which will automatically propose package updates that we will review. In total, we've updated a whopping 633 packages so far!</p>
<h2>Android app is available in the Play Store<a name="android-app-is-available-in-the-play-store" href="#android-app-is-available-in-the-play-store" class="heading-anchor">🔗</a></h2>
<p>Our latest version, 2.10, is now back in the Play Store and ready for download! Although we had to skip 2.9 due to some of Google's requirements, we worked hard to ensure that our app complies with their standards, and we are excited to announce that we are back and better than ever! Our iOS version is also available, so you can continue to enjoy the app regardless of the platform you use.</p>
<h3>Biometrics support<a name="biometrics-support" href="#biometrics-support" class="heading-anchor">🔗</a></h3>
<p>To make your experience even more secure, our Android and iOS apps now support biometric unlock! With just a quick scan of your fingerprint or Face ID, you can unlock your app in no time. To enable this beta feature, just head over to the settings and click on &quot;Use biometrics to secure access to the app&quot;.</p>
<p>We've tested this feature thoroughly during prerelease, and have already fixed all known issues. However we still consider it as a beta feature for now, so if you run into any issues please let us know.</p>
<h3>Support for multiple profile<a name="support-for-multiple-profile" href="#support-for-multiple-profile" class="heading-anchor">🔗</a></h3>
<p>We're thrilled to announce that multiple profiles are now supported in our mobile app! To create a new profile, simply go to the Configuration screen and click on &quot;Manage profiles&quot; under the Tools section.</p>
<p><img src="https://raw.githubusercontent.com/laurent22/joplin/dev/Assets/WebsiteAssets/images/news/20230508-biometrics-1.png" alt=""></p>
<p>From there, you can easily add or remove profiles as needed.</p>
<p><img src="https://raw.githubusercontent.com/laurent22/joplin/dev/Assets/WebsiteAssets/images/news/20230508-biometrics-2.png" alt=""></p>
<p>Once multiple profiles are setup, you will see a new option in the sidebar to quickly toggle from one profile to another:</p>
<p><img src="https://raw.githubusercontent.com/laurent22/joplin/dev/Assets/WebsiteAssets/images/news/20230508-biometrics-3.png" alt=""></p>
<p>Once you've set up multiple profiles, you can easily toggle between them using the new option in the sidebar. This feature is perfect for separating your personal and work notes into independent collections.</p>
<h3>Support for realtime search<a name="support-for-realtime-search" href="#support-for-realtime-search" class="heading-anchor">🔗</a></h3>
<p>Our mobile app now has an improved search function that performs text searches in real time! No more waiting for the search results to load, they'll appear instantly as you type.</p>
<h3>Improved filesystem sync performance<a name="improved-filesystem-sync-performance" href="#improved-filesystem-sync-performance" class="heading-anchor">🔗</a></h3>
<p>Thanks to the hard work of jd1378, the sync no longer freezes during filesystem synchronisation. We know how frustrating that can be, and we're thrilled to have solved this issue. Getting filesystem sync to work on Android is never easy due to the restrictions put in place by Google, especially since they frequently change, but we're committed to delivering the best possible experience for our Android users.</p>
]]></description><link>https://joplinapp.org/news/20230508-release-2-10</link><guid isPermaLink="false">20230508-release-2-10</guid><pubDate>Wed, 10 May 2023 12:00:00 GMT</pubDate><twitter-text>What&apos;s new in Joplin 2.10</twitter-text></item></channel></rss>
]]></description><link>https://joplinapp.org/news/20231023-white-hat-hackers</link><guid isPermaLink="false">20231023-white-hat-hackers</guid><pubDate>Mon, 23 Oct 2023 00:00:00 GMT</pubDate><twitter-text>Working in the shadows with white-hat hackers</twitter-text></item></channel></rss>
@@ -300,7 +300,7 @@
A <span class="frame-bg frame-bg-yellow-lg">French</span> Alternative
</h2>
<p translate>
Joplin Cloud is based in France. This means your data is protected by strict European Union privacy laws. In addition, Joplin Cloud implements strong end-to-end encryption so that not even us can have access to your data.
Joplin Cloud is based in France. This means your data is protected by strict European Union privacy laws. In addition, Joplin Cloud implements strong end-to-end encryption so that not even we can access your data.
</p>
<br />
<br />
+2 -2
View File
@@ -2,7 +2,7 @@
# Build stage
# =============================================================================
FROM node:24 AS builder
FROM node:24.22.1 AS builder
RUN apt-get update \
&& apt-get install -y \
@@ -66,7 +66,7 @@ RUN --mount=type=cache,target=/build/.yarn/cache --mount=type=cache,target=/buil
# from a smaller base image.
# =============================================================================
FROM node:24-slim
FROM node:24.22.1-slim
ARG user=joplin
RUN useradd --create-home --shell /bin/bash $user
+1 -1
View File
@@ -1,4 +1,4 @@
FROM node:24-bookworm
FROM node:24.22.1-bookworm
RUN apt-get update \
&& apt-get install -y \
+1 -1
View File
@@ -23,7 +23,7 @@
FROM ghcr.io/ggml-org/llama.cpp:full-cuda-b5449 AS llama-cuda
# Stage 2: our runtime image.
FROM nvidia/cuda:13.2.1-cudnn-runtime-ubuntu22.04
FROM nvidia/cuda:13.3.0-cudnn-runtime-ubuntu22.04
RUN apt-get update \
&& apt-get install -y \
+2 -46
View File
@@ -262,42 +262,6 @@ DESKTOP=${DESKTOP,,} # convert to lower case
echo 'Create Desktop icon...'
# Detect distribution environment, and apply --no-sandbox fix
SANDBOXPARAM=""
# lsb_release isn't available on some platforms (e.g. opensuse)
# The equivalent of lsb_release in OpenSuse is the file /usr/lib/os-release
if command -v lsb_release &> /dev/null; then
DISTVER=$(lsb_release -is) && DISTVER=$DISTVER$(lsb_release -rs)
DISTCODENAME=$(lsb_release -cs)
DISTMAJOR=$(lsb_release -rs|cut -d. -f1)
#-----------------------------------------------------
# Check for "The SUID sandbox helper binary was found, but is not configured correctly" problem.
# It is present in Debian 1X. A (temporary) patch will be applied at .desktop file
# Linux Mint 4 Debbie is based on Debian 10 and requires the same param handling.
#
# TODO: Remove: This is likely no longer an issue. See https://issues.chromium.org/issues/40462640.
BAD_HELPER_BINARY=false
if [[ $DISTVER =~ Debian1. || ( "$DISTVER" = "Linuxmint4" && "$DISTCODENAME" = "debbie" ) || ( "$DISTVER" = "CentOS" && "$DISTMAJOR" =~ 6|7 ) ]]; then
BAD_HELPER_BINARY=true
fi
# Work around Ubuntu 23.10+'s restrictions on unprivileged user namespaces. Electron
# uses these to sandbox processes. Unfortunately, it doesn't look like we can get around this
# without writing the AppImage to a non-user-writable location (without invalidating other security
# controls). See https://discourse.joplinapp.org/t/possible-future-requirement-for-no-sandbox-flag-for-ubuntu-23-10/.
HAS_USERNS_RESTRICTIONS=false
if [[ "$DISTVER" =~ ^(Ubuntu|Tuxedo) && $DISTMAJOR -ge 23 ]]; then
HAS_USERNS_RESTRICTIONS=true
fi
if [[ $HAS_USERNS_RESTRICTIONS = true || $BAD_HELPER_BINARY = true ]]; then
SANDBOXPARAM="--no-sandbox"
print "${COLOR_YELLOW}WARNING${COLOR_RESET} Electron sandboxing disabled."
print " See https://discourse.joplinapp.org/t/32160/5 for details."
fi
fi
# Initially only desktop environments that were confirmed to use desktop files stored in
# `.local/share/desktop` had a desktop file created.
# However some environments don't return a desktop BUT still support these desktop files
@@ -310,13 +274,7 @@ if [[ $DESKTOP =~ .*gnome.*|.*kde.*|.*xfce.*|.*mate.*|.*lxqt.*|.*unity.*|.*x-cin
DATA_HOME=${XDG_DATA_HOME:-~/.local/share}
DESKTOP_FILE_LOCATION="$DATA_HOME/applications"
# Only later versions of Joplin default to Wayland
IS_WAYLAND_BY_DEFAULT=$(compareVersions "$RELEASE_VERSION" "3.5.6")
# Joplin has a different startup WM class on Wayland and X11:
STARTUP_WM_CLASS=Joplin
if [[ $XDG_SESSION_TYPE != "x11" && $IS_WAYLAND_BY_DEFAULT == "1" ]]; then
STARTUP_WM_CLASS=@joplin/app-desktop
fi
STARTUP_WM_CLASS=appimagekit-joplin
# Only delete the desktop file if it will be replaced
rm -f "$DESKTOP_FILE_LOCATION/appimagekit-joplin.desktop"
@@ -330,10 +288,8 @@ if [[ $DESKTOP =~ .*gnome.*|.*kde.*|.*xfce.*|.*mate.*|.*lxqt.*|.*unity.*|.*x-cin
Encoding=UTF-8
Name=Joplin
Comment=Joplin for Desktop
Exec=env APPIMAGELAUNCHER_DISABLE=TRUE "${INSTALL_DIR}/Joplin.AppImage" ${SANDBOXPARAM} %u
Exec=env APPIMAGELAUNCHER_DISABLE=TRUE "${INSTALL_DIR}/Joplin.AppImage" %u
Icon=joplin
# This will be different between Wayland and X11. On Wayland, the startup
# WM class is "@joplin/app-desktop". On X11, it's "Joplin".
StartupWMClass=${STARTUP_WM_CLASS}
Type=Application
Categories=Office;
+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://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>
<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> <a href="https://essayservice.com/nursing-essay-writing-service"><img title="write my nursing paper today" width="256" src="https://joplinapp.org/images/sponsors/EssayService.png" alt="write my nursing paper today"/></a>
<!-- SPONSORS-ORG -->
* * *
+11
View File
@@ -1,5 +1,8 @@
# Security Policy
> [!IMPORTANT]
> Before submitting a report, please read the [Rules for reporting](#rules-for-reporting).
## Supported Versions
Only the latest version is supported with security updates.
@@ -10,6 +13,14 @@ Please report vulnerabilities [through private vulnerability reporting](https://
For general opinions on what makes an app more or less secure, please use the forum.
## Rules for reporting
Joplin is maintained by a small team, and we receive a high volume of reports that have not been verified by the person submitting them. Reviewing and reproducing an unverified finding takes us as long as a real one, so we need a few rules to keep the process workable for everyone.
- **Two active reports per person.** If you have more than two open at once, we may close them all, and we may block the account.
- **Verify your report before submitting it.** Confirm that the issue is still present in the latest version, and that you have actually carried out the steps your conclusion relies on. Repeatedly submitting findings that do not hold up may result in a permanent block.
## Areas outside Joplin's Threat Model
Note: we're mostly linking to Chrome's documentation since our reasoning for these exclusions is the same.
+2 -2
View File
@@ -11,13 +11,13 @@
},
"nodejs": "24.12.0",
"pkg-config": "latest",
"python": "3.14.3",
"python": "3.14.4",
"bat": "latest",
"electron": {
"version": "latest",
"excluded_platforms": ["aarch64-darwin", "x86_64-darwin"],
},
"git": "2.52.0",
"git": "2.53.0",
},
"shell": {
"init_hook": [
+30
View File
@@ -33,3 +33,33 @@ if (typeof document !== 'undefined') {
return range;
};
}
if (typeof window !== 'undefined') {
const { Buffer, Blob, File } = require('node:buffer');
const { TextDecoder, TextEncoder } = require('node:util');
const { URLSearchParams, URL } = require('node:url');
const { ReadableStream } = require('node:stream/web');
const { MessageChannel, MessagePort } = require('node:worker_threads');
const { setTimeout, setInterval, clearTimeout, clearInterval, setImmediate, clearImmediate } = require('node:timers');
const { performance } = require('node:perf_hooks');
window.performance.markResourceTiming = performance.markResourceTiming;
// Override some of the JSDom globals. Some libraries (e.g. Undici) require the
// original Node.js versions of these globals:
globalThis.Buffer = Buffer;
globalThis.Blob = Blob;
globalThis.File = File;
globalThis.URLSearchParams = URLSearchParams;
globalThis.URL = URL;
globalThis.TextDecoder = TextDecoder;
globalThis.TextEncoder = TextEncoder;
globalThis.ReadableStream = ReadableStream;
globalThis.MessageChannel = MessageChannel;
globalThis.MessagePort = MessagePort;
globalThis.setTimeout = setTimeout;
globalThis.setInterval = setInterval;
globalThis.clearTimeout = clearTimeout;
globalThis.clearInterval = clearInterval;
globalThis.setImmediate = setImmediate;
globalThis.clearImmediate = clearImmediate;
}
+22 -18
View File
@@ -9,8 +9,8 @@
"url": "git+https://github.com/laurent22/joplin.git"
},
"engines": {
"node": ">=22.12",
"yarn": "4.12.0"
"node": ">=22.19",
"yarn": "4.16.0"
},
"scripts": {
"buildApiDoc": "yarn workspace joplin start apidoc ../../readme/api/references/rest_api.md",
@@ -77,51 +77,50 @@
"@seiyab/eslint-plugin-react-hooks": "4.5.1-beta.0",
"@stylistic/eslint-plugin": "5.10.0",
"@types/jest": "29.5.14",
"@typescript-eslint/eslint-plugin": "8.60.0",
"@typescript-eslint/parser": "8.60.0",
"@typescript-eslint/eslint-plugin": "8.60.1",
"@typescript-eslint/parser": "8.60.1",
"cspell": "5.21.2",
"eslint": "9.39.4",
"eslint-interactive": "14.0.0",
"eslint-plugin-import": "2.32.0",
"eslint-plugin-jest": "29.15.2",
"eslint-plugin-jest": "29.15.4",
"eslint-plugin-promise": "7.3.0",
"eslint-plugin-react": "7.37.5",
"execa": "5.1.1",
"fs-extra": "11.3.5",
"fs-extra": "11.3.6",
"glob": "11.1.0",
"globals": "17.6.0",
"globals": "17.7.0",
"gulp": "4.0.2",
"husky": "9.1.7",
"lerna": "3.22.1",
"lint-staged": "16.4.0",
"madge": "8.0.0",
"npm-package-json-lint": "9.1.0",
"npm-package-json-lint": "10.4.1",
"typescript": "5.9.3"
},
"dependencies": {
"@types/fs-extra": "11.0.4",
"eslint-plugin-github": "4.10.2",
"http-server": "14.1.1",
"node-gyp": "11.5.0",
"node-gyp": "12.4.0",
"nodemon": "3.1.14"
},
"packageManager": "yarn@4.16.0",
"resolutions": {
"@codemirror/view": "6.43.6",
"@codemirror/view": "6.43.14",
"@codemirror/state": "6.7.1",
"@codemirror/language": "6.12.3",
"@codemirror/lint": "6.9.5",
"@codemirror/autocomplete": "6.20.1",
"onnxruntime-node": "1.24.3",
"onnxruntime-node": "1.27.0",
"eslint": "patch:eslint@9.39.4#./.yarn/patches/eslint-npm-9.39.4-81a84865c0.patch",
"yauzl": "3.3.1",
"yauzl": "3.4.0",
"react-native-camera@4.2.1": "patch:react-native-camera@npm%3A4.2.1#./.yarn/patches/react-native-camera-npm-4.2.1-24b2600a7e.patch",
"app-builder-lib@24.4.0": "patch:app-builder-lib@npm%3A24.4.0#./.yarn/patches/app-builder-lib-npm-24.4.0-05322ff057.patch",
"nanoid": "patch:nanoid@npm%3A3.3.7#./.yarn/patches/nanoid-npm-3.3.7-98824ba130.patch",
"pdfjs-dist": "patch:pdfjs-dist@npm%3A3.11.174#./.yarn/patches/pdfjs-dist-npm-3.11.174-67f2fee6d6.patch",
"chokidar@^2.0.0": "3.5.3",
"chokidar@^2.0.0": "3.6.0",
"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",
"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",
"react-native@0.79.2": "patch:react-native@npm%3A0.79.2#./.yarn/patches/react-native-npm-0.79.2-9db13eddfe.patch",
@@ -130,9 +129,13 @@
"pdfjs-dist@3.11.174": "patch:pdfjs-dist@npm%3A3.11.174#./.yarn/patches/pdfjs-dist-npm-3.11.174-67f2fee6d6.patch",
"canvas@npm:^2.11.2": "link:./.yarn/joplin-empty-package/",
"@huggingface/transformers/sharp": "link:./.yarn/joplin-empty-package/",
"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",
"node-gyp@npm:^9.0.0": "12.4.0",
"node-gyp@npm:11.2.0": "12.4.0",
"node-gyp@npm:11.5.0": "12.4.0",
"node-gyp@npm:latest": "12.4.0",
"node-gyp@npm:8.x": "12.4.0",
"node-forge@npm:^1": "1.4.0",
"node-forge@npm:^1.3.0": "1.4.0",
"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",
@@ -146,6 +149,7 @@
"@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",
"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"
"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",
"wasm-pack@npm:0.13.1": "patch:wasm-pack@npm%3A0.13.1#~/.yarn/patches/wasm-pack-npm-0.13.1-2a81da84b4.patch"
}
}
+1 -1
View File
@@ -84,7 +84,7 @@ class ResourceServer {
this.logger().error('Resource server:', error);
});
this.server_.listen(this.port_);
this.server_.listen(this.port_, '127.0.0.1');
enableServerDestroy(this.server_);
+2 -2
View File
@@ -2,7 +2,7 @@ import { _ } from '@joplin/lib/locale';
import time from '@joplin/lib/time';
import Logger, { TargetType } from '@joplin/utils/Logger';
import * as readline from 'readline';
import { Writable } from 'stream';
import { Stream, Writable } from 'stream';
import BaseCommand from './base-command';
import yargParser = require('yargs-parser');
const stringPadding = require('string-padding');
@@ -236,7 +236,7 @@ cliUtils.prompt = function(_initialText = '', promptString = ':', options: Promp
if (!this.muted) process.stdout.write(chunk, encoding);
callback();
},
}) as MutableStdout;
} as Stream.WritableOptions<Writable>) as MutableStdout;
const rl = readline.createInterface({
input: process.stdin,
+2 -2
View File
@@ -1,6 +1,6 @@
import Note from '@joplin/lib/models/Note';
import Setting from '@joplin/lib/models/Setting';
import { setupDatabaseAndSynchronizer, switchClient } from '@joplin/lib/testing/test-utils';
import { setupDatabaseAndSynchronizer, switchClient, noteLockCipherTextStandIn } from '@joplin/lib/testing/test-utils';
import { setupCommandForTesting, setupApplication } from './utils/testUtils';
const Command = require('./command-cat');
@@ -14,7 +14,7 @@ describe('command-cat', () => {
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: '' });
const note = await Note.save({ title: 'hello', body: noteLockCipherTextStandIn(), is_locked: 1, parent_id: '' });
let output = '';
const command = setupCommandForTesting(Command, (text: string) => { output += text; });
+1 -1
View File
@@ -58,7 +58,7 @@ class Command extends BaseCommand {
}
});
inputStream.on('error', (error) => {
inputStream.on('error', (error: Error) => {
if (!isSettled) {
isSettled = true;
reject(error);
+2 -2
View File
@@ -1,7 +1,7 @@
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 { setupDatabaseAndSynchronizer, switchClient, noteLockCipherTextStandIn } from '@joplin/lib/testing/test-utils';
import { setupCommandForTesting, setupApplication } from './utils/testUtils';
const Command = require('./command-edit');
@@ -15,7 +15,7 @@ describe('command-edit', () => {
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 note = await Note.save({ title: 'hello', body: noteLockCipherTextStandIn(), is_locked: 1, parent_id: '' });
const tempDir = Setting.value('tempDir');
await fs.ensureDir(tempDir);
const filesBefore = await fs.readdir(tempDir);
+2 -2
View File
@@ -1,6 +1,6 @@
import Note from '@joplin/lib/models/Note';
import Setting from '@joplin/lib/models/Setting';
import { setupDatabaseAndSynchronizer, switchClient } from '@joplin/lib/testing/test-utils';
import { setupDatabaseAndSynchronizer, switchClient, noteLockCipherTextStandIn } from '@joplin/lib/testing/test-utils';
import { setupCommandForTesting, setupApplication } from './utils/testUtils';
const Command = require('./command-set');
@@ -18,7 +18,7 @@ describe('command-set', () => {
{ 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 note = await Note.save({ title: 'hello', body: noteLockCipherTextStandIn(), is_locked: 1, parent_id: '' });
const command = setupCommandForTesting(Command);
let error: string|null = null;
+4 -2
View File
@@ -18,6 +18,8 @@ import { checkIfLoginWasSuccessful, generateApplicationConfirmUrl } from '@jopli
import Logger from '@joplin/utils/Logger';
import { uuidgen } from '@joplin/lib/uuid';
import ShareService from '@joplin/lib/services/share/ShareService';
import SyncTargetOneDrive from '@joplin/lib/SyncTargetOneDrive';
import SyncTargetDropbox from '@joplin/lib/SyncTargetDropbox';
const logger = Logger.create('command-sync');
@@ -57,7 +59,7 @@ class Command extends BaseCommand {
if (this.syncTargetId_ === 3 || this.syncTargetId_ === 4) {
// OneDrive
this.oneDriveApiUtils_ = new OneDriveApiNodeUtils(syncTarget.api());
this.oneDriveApiUtils_ = new OneDriveApiNodeUtils((syncTarget as SyncTargetOneDrive).api());
const auth = await this.oneDriveApiUtils_.oauthDance({
log: (s: string) => {
return this.stdout(s);
@@ -74,7 +76,7 @@ class Command extends BaseCommand {
return true;
} else if (syncTargetMd.name === 'dropbox') {
// Dropbox
const api = await syncTarget.api();
const api = await (syncTarget as SyncTargetDropbox).api();
const loginUrl = api.loginUrl();
this.stdout(_('To allow Joplin to synchronise with Dropbox, please follow the steps below:'));
this.stdout(_('Step 1: Open this URL in your browser to authorise the application:'));
+6 -6
View File
@@ -38,7 +38,7 @@
"version": "3.7.1",
"bin": "./main.js",
"engines": {
"node": ">=10.0.0"
"node": ">=22.19.0"
},
"dependencies": {
"@joplin/lib": "~3.7",
@@ -48,7 +48,7 @@
"chalk": "4.1.2",
"compare-version": "0.1.2",
"file-type": "16.5.4",
"fs-extra": "11.3.5",
"fs-extra": "11.3.6",
"html-entities": "1.4.0",
"keytar": "7.9.0",
"md5": "2.3.0",
@@ -57,13 +57,13 @@
"proper-lockfile": "4.1.2",
"redux": "4.2.1",
"server-destroy": "1.0.1",
"sharp": "0.34.5",
"sharp": "0.35.0",
"sprintf-js": "1.1.3",
"sqlite3": "5.1.6",
"string-padding": "1.0.2",
"strip-ansi": "6.0.1",
"tcp-port-used": "1.0.2",
"terminal-kit": "3.1.2",
"tcp-port-used": "1.0.3",
"terminal-kit": "3.1.4",
"tkwidgets": "0.5.27",
"url-parse": "1.5.10",
"word-wrap": "1.2.5",
@@ -73,7 +73,7 @@
"@joplin/tools": "~3.7",
"@types/fs-extra": "11.0.4",
"@types/jest": "29.5.14",
"@types/node": "18.19.130",
"@types/node": "22.19.21",
"@types/proper-lockfile": "^4.1.2",
"gulp": "4.0.2",
"jest": "29.7.0",
+52
View File
@@ -2,7 +2,9 @@ import shim from '@joplin/lib/shim';
import * as os from 'os';
import { readFile } from 'fs/promises';
import { filename } from '@joplin/lib/path-utils';
import { dirname } from 'path';
import HtmlToMd, { ParseOptions } from '@joplin/lib/HtmlToMd';
import MdToHtml from '@joplin/renderer/MdToHtml';
describe('HtmlToMd', () => {
@@ -129,4 +131,54 @@ describe('HtmlToMd', () => {
expect(collapsedResult).toContain('\n\n');
});
it('should not re-read the rows of a table for every row', async () => {
// Under domino, each access to `table.rows` walks the whole table, so reading it once per row made
// converting large tables quadratic: https://github.com/laurent22/joplin/issues/16894
const resolveFrom = (moduleName: string, fromPath: string) => require.resolve(moduleName, { paths: [dirname(fromPath)] });
const domino: { createDocument: (html: string)=> Document } = require(resolveFrom('@mixmark-io/domino', resolveFrom('@joplin/turndown', require.resolve('@joplin/lib/HtmlToMd'))));
const rowCount = 100;
const rows = Array.from({ length: rowCount }, (_, i) => `<tr><td>${i}</td><td>b</td><td>c</td></tr>`).join('');
const root = domino.createDocument(`<div><table><thead><tr><th>A</th><th>B</th><th>C</th></tr></thead><tbody>${rows}</tbody></table></div>`).querySelector('div');
// Turndown converts a clone of the element it's given, which wouldn't have the counter below
Object.defineProperty(root, 'cloneNode', { value: () => root });
const table = root.querySelector('table');
const getRows = Object.getOwnPropertyDescriptor(Object.getPrototypeOf(table), 'rows').get;
let rowsReadCount = 0;
Object.defineProperty(table, 'rows', { get: () => {
rowsReadCount++;
return getRows.call(table);
} });
const markdown = new HtmlToMd().parse(root);
expect(markdown.split('\n')).toHaveLength(rowCount + 2);
expect(rowsReadCount).toBeLessThan(rowCount);
});
// Covers how newlines are carried between children when joining them, which was rewritten to fix
// https://github.com/laurent22/joplin/issues/16895
it.each([
['<ul><li>a</li><li></li><li>b</li></ul>', '- a\n\n- b'],
['<span><p>a</p></span>b', 'a\n\nb'],
['<pre><code><span>line 1</span>\n\n\n<span>line 2</span></code></pre>', '```\nline 1\n\n\nline 2\n```'],
])('should join the Markdown of child elements: %j', (html, expected) => {
expect(new HtmlToMd().parse(html)).toBe(expected);
});
it.each([
['inline code ending with a space at the end of a heading', '<h2><code>npm</code> install&nbsp;<code>a&nbsp;</code></h2>', 'a\u00A0'],
['a heading that only contains inline code with a space', '<h2><code>&nbsp;</code></h2>', '\u00A0'],
])('should not change %s when the note is saved again', async (_title, html, codeText) => {
const htmlToMd = new HtmlToMd();
const mdToHtml = new MdToHtml();
const firstSave = htmlToMd.parse(html);
const reopened = await mdToHtml.render(firstSave, null, { bodyOnly: true });
const secondSave = htmlToMd.parse(reopened.html);
expect(reopened.html).toContain(`>${codeText}</code>`);
expect(secondSave).toBe(firstSave);
});
});
@@ -0,0 +1,5 @@
<p>Headings with nonbreaking spaces should be preserved:</p>
<h2>&nbsp;</h2>
<h2>&nbsp; &nbsp;</h2>
<h3>&nbsp;Leading space</h3>
<h2><code>a&nbsp; b</code></h2>
@@ -0,0 +1,9 @@
Headings with nonbreaking spaces should be preserved:
## &nbsp;
## &nbsp; &nbsp;
### &nbsp;Leading space
## `a  b`
+58 -3
View File
@@ -8,8 +8,12 @@ import { ExportModule } from '@joplin/lib/services/interop/Module';
import { _ } from '@joplin/lib/locale';
import { PluginStates } from '@joplin/lib/services/plugins/reducer';
import bridge from './services/bridge';
import isNoteLockEnabled from '@joplin/lib/services/noteLock/isNoteLockEnabled';
import { unlockNoteLockSession } from '@joplin/lib/services/noteLock/noteLockPrompts';
import noteLockPrompts from './utils/noteLockPrompts';
import Setting from '@joplin/lib/models/Setting';
import Note from '@joplin/lib/models/Note';
import Folder from '@joplin/lib/models/Folder';
import { friendlySafeFilename } from '@joplin/lib/path-utils';
import time from '@joplin/lib/time';
import { BrowserWindow, BrowserWindowConstructorOptions } from 'electron';
@@ -36,6 +40,8 @@ export default class InteropServiceHelper {
format: ExportModuleOutputFormat.Html,
target: FileSystemItem.File,
sourceNoteIds: [noteId],
// The PDF export and printing ask first.
decryptLockedNotes: true,
customCss: '', ...exportOptions };
const service = InteropService.instance();
@@ -193,7 +199,30 @@ export default class InteropServiceHelper {
return `${filename}.${fileExtension}`;
}
public static async export(_dispatch: Dispatch, module: ExportModule, options: ExportNoteOptions = null) {
// Decrypting exports write locked notes out as plain text, so the user is asked even with the session unlocked,
// which only skips the password.
public static async confirmLockedNoteExport(dispatch: Dispatch, noteIds: string[], folderIds: string[] = null) {
if (!isNoteLockEnabled()) return 'export';
let hasLockedNotes = false;
if (noteIds?.length) {
hasLockedNotes = (await Note.byIds(noteIds, { fields: ['is_locked'] })).some(note => !!note.is_locked);
} else if (folderIds?.length) {
hasLockedNotes = await Note.hasLockedNoteInFolderTrees(folderIds);
} else {
// Same scope as the export of everything: notebooks not in the trash.
hasLockedNotes = await Note.hasLockedNoteInFolderTrees(await Folder.childrenIds(''));
}
if (!hasLockedNotes) return 'export';
const answer = bridge().showMessageBox(_('Some notes are locked and this export format requires them to be unlocked. Would you like to export these notes unprotected, or export without these notes?'), {
buttons: [_('Export unprotected'), _('Export without locked notes'), _('Cancel')],
});
if (answer === 1) return 'skipLocked';
if (answer === 0 && await unlockNoteLockSession(noteLockPrompts(dispatch))) return 'export';
return 'cancel';
}
public static async export(dispatch: Dispatch, module: ExportModule, options: ExportNoteOptions = null) {
if (!options) options = {};
let path = null;
@@ -214,6 +243,18 @@ export default class InteropServiceHelper {
if (Array.isArray(path)) path = path[0];
// Backups keep locked notes encrypted, so only the decrypting formats ask.
const isBackup = module.format === ExportModuleOutputFormat.Raw || module.format === ExportModuleOutputFormat.Jex;
const lockedNoteChoice = isBackup ? 'export' : await this.confirmLockedNoteExport(dispatch, options.sourceNoteIds, options.sourceFolderIds);
if (lockedNoteChoice === 'cancel') return;
let sourceNoteIds = options.sourceNoteIds;
// A selection left with no note would make a single-file export fail on the file it never wrote.
if (sourceNoteIds?.length && lockedNoteChoice === 'skipLocked') {
sourceNoteIds = (await Note.byIds(sourceNoteIds, { fields: ['id', 'is_locked'] })).filter(note => !note.is_locked).map(note => note.id);
if (!sourceNoteIds.length) return;
}
void CommandService.instance().execute('showModalMessage', _('Exporting to "%s" as "%s" format. Please wait...', path, module.format));
const exportOptions: ExportOptions = {};
@@ -225,20 +266,34 @@ export default class InteropServiceHelper {
exportOptions.target = module.target;
exportOptions.includeConflicts = !!options.includeConflicts;
if (options.sourceFolderIds) exportOptions.sourceFolderIds = options.sourceFolderIds;
if (options.sourceNoteIds) exportOptions.sourceNoteIds = options.sourceNoteIds;
if (sourceNoteIds) exportOptions.sourceNoteIds = sourceNoteIds;
exportOptions.skipLockedNotes = lockedNoteChoice === 'skipLocked';
exportOptions.decryptLockedNotes = lockedNoteChoice === 'export';
const service = InteropService.instance();
let keepModalOpen = false;
try {
const result = await service.export(exportOptions);
// eslint-disable-next-line no-console
console.info('Export result: ', result);
if (result.warnings.length) {
keepModalOpen = true;
const message = [
_('Completed with warnings:'),
'',
_('If any files are reported missing, download all relevant attachments to this device and try again.'),
'',
...result.warnings,
].join('\n');
void CommandService.instance().execute('showModalMessage', message, true);
}
} catch (error) {
console.error(error);
bridge().showErrorMessageBox(_('Could not export notes: %s', error.message));
}
void CommandService.instance().execute('hideModalMessage');
if (!keepModalOpen) void CommandService.instance().execute('hideModalMessage');
}
}
+8 -2
View File
@@ -100,6 +100,7 @@ export interface AppState extends State, AppWindowState {
layoutMoveMode: boolean;
startupPluginsLoaded: boolean;
modalOverlayMessage: string|null;
modalOverlayHasCloseButton: boolean;
// Extra reducer keys go here
mainLayout: LayoutItem;
@@ -143,6 +144,7 @@ export function createAppDefaultState(resourceEditWatcherDefaultState: Partial<A
startupPluginsLoaded: false,
isResettingLayout: false,
modalOverlayMessage: null,
modalOverlayHasCloseButton: false,
aiStatus: defaultAiStatus(),
...resourceEditWatcherDefaultState,
};
@@ -396,11 +398,15 @@ export default function(state: AppState, action: any) {
}
case 'SHOW_MODAL_MESSAGE':
newState = { ...newState, modalOverlayMessage: action.message };
newState = {
...newState,
modalOverlayMessage: action.message,
modalOverlayHasCloseButton: !!action.hasCloseButton,
};
break;
case 'HIDE_MODAL_MESSAGE':
newState = { ...newState, modalOverlayMessage: null };
newState = { ...newState, modalOverlayMessage: null, modalOverlayHasCloseButton: false };
break;
case 'NOTE_FILE_WATCHER_ADD':
+306
View File
@@ -0,0 +1,306 @@
import BaseApplication, { shouldPreserveSelectedNoteOnSmartFilterSelect } from '@joplin/lib/BaseApplication';
import ItemChange from '@joplin/lib/models/ItemChange';
import Note from '@joplin/lib/models/Note';
import reducer, { defaultState, defaultWindowId, State } from '@joplin/lib/reducer';
import { ALL_NOTES_FILTER_ID } from '@joplin/lib/reserved-ids';
import { NoteEntity } from '@joplin/lib/services/database/types';
import ExternalEditWatcher from '@joplin/lib/services/ExternalEditWatcher';
import { setEncryptionEnabled } from '@joplin/lib/services/synchronizer/syncInfoUtils';
import { loadEncryptionMasterKey, setupDatabaseAndSynchronizer, switchClient } from '@joplin/lib/testing/test-utils';
import waitFor from '@joplin/lib/testing/waitFor';
import AsyncActionQueue from '@joplin/lib/AsyncActionQueue';
import { appendFile, readFile } from 'fs/promises';
import { TextEncoder as NodeTextEncoder } from 'util';
import app from './app';
type NoteUpdateAction = {
changeSource: number;
changedFields: string[];
note: NoteEntity;
[key: string]: unknown;
};
const mockBaseMiddleware = () => {
return jest.spyOn(BaseApplication.prototype as unknown as {
generalMiddleware: (store: unknown, next: (action: unknown)=> unknown, action: unknown)=> Promise<unknown>;
}, 'generalMiddleware').mockImplementation(async (_store, next, action) => next(action));
};
const createDesktopActionHandler = (watchedNoteFiles: string[] = []) => {
const middleware = app().generalMiddlewareFn()({ getState: () => ({ watchedNoteFiles }) });
return middleware(jest.fn());
};
describe('app', () => {
const watcher = ExternalEditWatcher.instance();
const originalTextEncoder = globalThis.TextEncoder;
const originalNoteDispatch = Note.dispatch;
let baseMiddlewareMock: jest.SpyInstance;
test.each([
['regular', { deleted_time: 0, is_conflict: 0 }, true],
['trashed', { deleted_time: 1, is_conflict: 0 }, false],
['conflict', { deleted_time: 0, is_conflict: 1 }, false],
])('should determine whether to preserve a %s note when selecting all notes', (_noteType, noteProperties, expected) => {
watcher.initialize(jest.fn(), jest.fn());
const note = { id: 'note-id', ...noteProperties } as NoteEntity;
const state = { ...defaultState, notes: [note], selectedNoteIds: [note.id] };
expect(shouldPreserveSelectedNoteOnSmartFilterSelect(state, ALL_NOTES_FILTER_ID)).toBe(expected);
});
test('should not apply a note refresh after the active window changes', async () => {
const secondaryState = {
...defaultState,
windowId: 'secondary-window',
notesParentType: 'Folder',
selectedFolderId: 'secondary-folder',
selectedNoteIds: ['secondary-note'],
} as State;
let activeState = secondaryState;
const dispatch = jest.fn();
const storeMock = jest.spyOn(app(), 'store').mockReturnValue({
dispatch,
getState: () => activeState,
} as unknown as ReturnType<ReturnType<typeof app>['store']>);
const previewsMock = jest.spyOn(Note, 'previews').mockImplementation(async () => {
activeState = { ...defaultState, windowId: defaultWindowId } as State;
return [{ id: 'secondary-note' }] as NoteEntity[];
});
try {
await app().refreshNotes(secondaryState, true);
expect(dispatch).not.toHaveBeenCalled();
} finally {
previewsMock.mockRestore();
storeMock.mockRestore();
}
});
test('should not apply a note refresh after a newer refresh has started', async () => {
const state = { ...defaultState, notesParentType: 'Folder', selectedFolderId: 'folder' } as State;
const dispatch = jest.fn();
const storeMock = jest.spyOn(app(), 'store').mockReturnValue({
dispatch,
getState: () => state,
} as unknown as ReturnType<ReturnType<typeof app>['store']>);
let resolveSlowPreviews: (notes: NoteEntity[])=> void = null;
const previewsMock = jest.spyOn(Note, 'previews')
.mockImplementationOnce(() => new Promise(resolve => { resolveSlowPreviews = resolve; }))
.mockResolvedValueOnce([{ id: 'fast-note' }]);
try {
const slowRefresh = app().refreshNotes(state);
const fastRefresh = (async () => {
await app().refreshNotes(state);
resolveSlowPreviews([{ id: 'slow-note' }]);
})();
await Promise.all([slowRefresh, fastRefresh]);
const noteUpdates = dispatch.mock.calls.map(call => call[0]).filter(action => action.type === 'NOTE_UPDATE_ALL');
expect(noteUpdates.map(action => action.notes)).toEqual([[{ id: 'fast-note' }]]);
} finally {
previewsMock.mockRestore();
storeMock.mockRestore();
}
});
test('should not let a refresh for an inactive window cancel the active window refresh', async () => {
const state = { ...defaultState, notesParentType: 'Folder', selectedFolderId: 'folder' } as State;
const dispatch = jest.fn();
const storeMock = jest.spyOn(app(), 'store').mockReturnValue({
dispatch,
getState: () => state,
} as unknown as ReturnType<ReturnType<typeof app>['store']>);
const previewsMock = jest.spyOn(Note, 'previews').mockResolvedValue([]);
try {
await Promise.all([app().refreshNotes(state), app().refreshNotes({ ...state, windowId: 'inactive-window' })]);
expect(dispatch).toHaveBeenCalledWith(expect.objectContaining({ type: 'NOTE_UPDATE_ALL' }));
} finally {
previewsMock.mockRestore();
storeMock.mockRestore();
}
});
test.each([
['the active window', true, [[expect.objectContaining({ windowId: defaultWindowId }), false, '']]],
['a background window', false, []],
])('should only refresh notes when the active window is closed (closing %s)', async (_name, focusSecondaryWindow, expectedRefreshCalls) => {
let state = reducer(defaultState, { type: 'WINDOW_OPEN', windowId: 'secondary-window', folderId: 'folder', noteId: 'note' });
if (focusSecondaryWindow) state = reducer(state, { type: 'WINDOW_FOCUS', windowId: 'secondary-window' });
const store = { getState: () => state, dispatch: jest.fn() };
const next = (action: unknown) => { state = reducer(state, action); };
const refreshNotesMock = jest.spyOn(app(), 'refreshNotes').mockResolvedValue(undefined);
baseMiddlewareMock.mockRestore();
try {
await (BaseApplication.prototype as unknown as {
generalMiddleware: (store: unknown, next: (action: unknown)=> unknown, action: unknown)=> Promise<unknown>;
}).generalMiddleware.call(app(), store, next, { type: 'WINDOW_CLOSE', windowId: 'secondary-window' });
expect(refreshNotesMock.mock.calls).toEqual(expectedRefreshCalls);
} finally {
refreshNotesMock.mockRestore();
}
});
beforeEach(async () => {
await setupDatabaseAndSynchronizer(0);
await switchClient(0);
baseMiddlewareMock = mockBaseMiddleware();
});
afterEach(async () => {
Note.dispatch = originalNoteDispatch;
baseMiddlewareMock.mockRestore();
setEncryptionEnabled(false);
Object.defineProperty(globalThis, 'TextEncoder', { configurable: true, value: originalTextEncoder });
await watcher.stopWatchingAll();
});
const startExternalEditing = async () => {
const openItem = jest.fn();
watcher.initialize(jest.fn(() => ({
openItem,
})), jest.fn());
const originalNote = await Note.save({
title: 'Test note',
body: 'Original body',
});
await watcher.openAndWatch(originalNote);
return {
originalNote,
externalFilePath: openItem.mock.calls[0][0] as string,
};
};
test('should update the external edit file after a synced note changes', async () => {
const { originalNote, externalFilePath } = await startExternalEditing();
const syncedNote = await Note.save({
...originalNote,
body: 'Changed on another device',
}, { changeSource: ItemChange.SOURCE_SYNC });
const syncedUpdateAction = {
type: 'NOTE_UPDATE_ONE',
changeSource: ItemChange.SOURCE_SYNC,
changedFields: ['body'],
note: syncedNote,
};
const handleDesktopAction = createDesktopActionHandler();
await handleDesktopAction(syncedUpdateAction);
expect(await readFile(externalFilePath, 'utf8')).toBe(await Note.serializeForEdit(syncedNote));
});
test('should not update the external edit file when the note is not watched', async () => {
const note = await Note.save({
title: 'Test note',
body: 'Changed on another device',
}, { changeSource: ItemChange.SOURCE_SYNC });
const updateNoteFile = jest.spyOn(watcher, 'updateNoteFile');
const handleDesktopAction = createDesktopActionHandler();
await handleDesktopAction({
type: 'NOTE_UPDATE_ONE',
changeSource: ItemChange.SOURCE_SYNC,
changedFields: ['body'],
note,
});
expect(watcher.noteIsWatched(note)).toBe(false);
expect(updateNoteFile).not.toHaveBeenCalled();
updateNoteFile.mockRestore();
});
test('should not update the external edit file when the synced note is locked', async () => {
const { originalNote } = await startExternalEditing();
const updateNoteFile = jest.spyOn(watcher, 'updateNoteFile');
const handleDesktopAction = createDesktopActionHandler();
await handleDesktopAction({
type: 'NOTE_UPDATE_ONE',
changeSource: ItemChange.SOURCE_SYNC,
changedFields: ['body', 'is_locked'],
note: { ...originalNote, body: 'Locked note ciphertext', is_locked: 1 },
});
expect(updateNoteFile).not.toHaveBeenCalled();
updateNoteFile.mockRestore();
});
test('should drop pending external edits when a synced note arrives locked', async () => {
const { originalNote, externalFilePath } = await startExternalEditing();
const lockedNote = await Note.save({
...originalNote,
body: 'Locked note ciphertext',
is_locked: 1,
}, { changeSource: ItemChange.SOURCE_SYNC });
await appendFile(externalFilePath, ' (plaintext edit)');
const changeEventQueue = (watcher as unknown as { changeEventQueue_: AsyncActionQueue<unknown> }).changeEventQueue_;
await waitFor(() => {
expect(changeEventQueue.isEmpty).toBe(false);
});
const handleDesktopAction = createDesktopActionHandler([originalNote.id]);
await handleDesktopAction({
type: 'NOTE_UPDATE_ONE',
changeSource: ItemChange.SOURCE_SYNC,
changedFields: ['body', 'is_locked'],
note: lockedNote,
});
await changeEventQueue.waitForAllDone();
expect(watcher.noteIsWatched(originalNote)).toBe(false);
expect((await Note.load(originalNote.id)).body).toBe('Locked note ciphertext');
});
test('should wait for a synced encrypted note to be decrypted before updating the external edit file', async () => {
const { originalNote, externalFilePath } = await startExternalEditing();
const noteActions = jest.fn();
Note.dispatch = noteActions;
Object.defineProperty(globalThis, 'TextEncoder', { configurable: true, value: NodeTextEncoder });
setEncryptionEnabled(true);
await loadEncryptionMasterKey();
const encryptedSyncPayload = await Note.serializeForSync({
...originalNote,
body: 'Changed on another device',
} as NoteEntity);
const encryptedNote = Note.filter(await Note.unserialize(encryptedSyncPayload));
const savedEncryptedNote = await Note.save(encryptedNote, {
autoTimestamp: false,
changeSource: ItemChange.SOURCE_SYNC,
oldItem: originalNote as unknown as Record<string, unknown>,
});
const encryptedSyncAction = noteActions.mock.calls[0][0] as NoteUpdateAction;
expect(encryptedSyncAction.note.encryption_applied).toBe(1);
const handleDesktopAction = createDesktopActionHandler();
await handleDesktopAction(encryptedSyncAction);
expect(await readFile(externalFilePath, 'utf8')).toBe(await Note.serializeForEdit(originalNote));
noteActions.mockClear();
const decryptedNote = await Note.decrypt(savedEncryptedNote);
const decryptedUpdateActions = noteActions.mock.calls
.map(call => call[0] as NoteUpdateAction)
.filter(action => action.type === 'NOTE_UPDATE_ONE');
expect(decryptedUpdateActions).toHaveLength(1);
const decryptedAction = decryptedUpdateActions[0];
expect(decryptedAction.note).toEqual(decryptedNote);
expect(decryptedAction.note.body).toBe('Changed on another device');
expect(decryptedAction.note.encryption_applied).toBe(0);
expect(decryptedAction.changedFields).toEqual(expect.arrayContaining(['body', 'encryption_applied']));
await handleDesktopAction(decryptedAction);
expect(await readFile(externalFilePath, 'utf8')).toBe(await Note.serializeForEdit(decryptedNote));
});
});
+26 -1
View File
@@ -69,6 +69,7 @@ import Resource from '@joplin/lib/models/Resource';
import AiService from '@joplin/lib/services/ai/AiService';
import LocalEmbeddingProvider from '@joplin/lib/services/ai/LocalEmbeddingProvider';
import { installAiStatusBridge, AiStatusStore } from './services/aiStatusBridge';
import ItemChange from '@joplin/lib/models/ItemChange';
const perfLogger = PerformanceLogger.create();
@@ -162,6 +163,17 @@ class Application extends BaseApplication {
await AlarmService.updateNoteNotification(action.id, action.type === 'NOTE_DELETE');
}
if (action.type === 'NOTE_DELETE' && store.getState().watchedNoteFiles.includes(action.id)) {
await ExternalEditWatcher.instance().stopWatching(action.id);
}
// Like a lock made on this device (see enableNoteEncryption), a lock that arrives with a sync ends the note's
// external editing, which the UI cannot stop for a locked note. The lock is already saved here, so pending
// plaintext edits are dropped rather than saved over the ciphertext.
if (action.type === 'NOTE_UPDATE_ONE' && action.note.is_locked && store.getState().watchedNoteFiles.includes(action.note.id)) {
await ExternalEditWatcher.instance().stopWatching(action.note.id, false);
}
if (action.type === 'SETTING_UPDATE_ONE' && action.key === 'featureFlag.autoUpdaterServiceEnabled' || action.type === 'SETTING_UPDATE_ALL') {
if (Setting.value('featureFlag.autoUpdaterServiceEnabled')) this.setupAutoUpdaterService();
}
@@ -169,6 +181,19 @@ class Application extends BaseApplication {
const result = await super.generalMiddleware(store, next, action);
const newState = store.getState();
if (
action.type === 'NOTE_UPDATE_ONE' &&
[ItemChange.SOURCE_SYNC, ItemChange.SOURCE_DECRYPTION].includes(action.changeSource) &&
!action.note.encryption_applied &&
!action.note.is_locked &&
action.changedFields.some((field: string) => ['title', 'body'].includes(field))
) {
const externalEditWatcher = ExternalEditWatcher.instance();
if (externalEditWatcher.noteIsWatched(action.note)) {
await externalEditWatcher.updateNoteFile(action.note);
}
}
if (['NOTE_VISIBLE_PANES_TOGGLE', 'NOTE_VISIBLE_PANES_SET'].indexOf(action.type) >= 0) {
Setting.setValue('noteVisiblePanes', newState.noteVisiblePanes);
}
@@ -658,7 +683,7 @@ class Application extends BaseApplication {
setTimeout(() => {
// Schedule sync with a delay of 0 and wrap with the desired timeout, as shim.setTimeout may not fire on first run or after an upgrade
// eslint-disable-next-line promise/prefer-await-to-then -- Old code before rule was applied
void reg.scheduleSync(0).then(() => {
void reg.scheduleSync(0).finally(() => {
// Wait for the first sync before updating the notifications, since synchronisation
// might change the notifications.
void AlarmService.updateAllNotifications();
+8
View File
@@ -16,6 +16,8 @@ import { KB } from '@joplin/utils/bytes';
import { defaultWindowId } from '@joplin/lib/reducer';
import { execCommand } from '@joplin/utils';
const allowedExternalProtocols = ['http:', 'https:', 'mailto:', 'joplin:'];
interface LastSelectedPath {
file: string;
directory: string;
@@ -495,6 +497,12 @@ export class Bridge {
if (protocol === 'file:') {
await this.openItem(url);
} else {
// Some callers pass untrusted note content, and this reaches the OS
// URI dispatcher, where a dangerous scheme handler can mean RCE.
if (!allowedExternalProtocols.includes(protocol)) {
console.warn(`Bridge: openExternal: rejected URL with disallowed scheme "${protocol}"`);
return;
}
return shell.openExternal(url);
}
}
@@ -0,0 +1,2 @@
<!-- Cloud Offline Outline from Ionicons. See readme/licenses.md under "ionicon icons" for attribution and MIT license terms. -->
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><path d="M93.72 183.25C49.49 198.05 16 233.1 16 288c0 66 54 112 120 112h184.37M467.82 377.74C485.24 363.3 496 341.61 496 312c0-59.82-53-85.76-96-88-8.89-89.54-71-144-144-144-26.16 0-48.79 6.93-67.6 18.14" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="32"/><path d="M448 448 64 64" fill="none" stroke="currentColor" stroke-linecap="round" stroke-miterlimit="10" stroke-width="32"/></svg>

After

Width:  |  Height:  |  Size: 628 B

+2
View File
@@ -12,6 +12,7 @@ import * as openNoteInNewWindow from './openNoteInNewWindow';
import * as openPrimaryAppInstance from './openPrimaryAppInstance';
import * as openProfileDirectory from './openProfileDirectory';
import * as openSecondaryAppInstance from './openSecondaryAppInstance';
import * as openSyncWizard from './openSyncWizard';
import * as replaceMisspelling from './replaceMisspelling';
import * as restoreNoteRevision from './restoreNoteRevision';
import * as showProfileEditor from './showProfileEditor';
@@ -39,6 +40,7 @@ const index: any[] = [
openPrimaryAppInstance,
openProfileDirectory,
openSecondaryAppInstance,
openSyncWizard,
replaceMisspelling,
restoreNoteRevision,
showProfileEditor,
@@ -2,8 +2,11 @@ import { CommandRuntime, CommandDeclaration, CommandContext } from '@joplin/lib/
import { _ } from '@joplin/lib/locale';
import { stateUtils } from '@joplin/lib/reducer';
import Note from '@joplin/lib/models/Note';
import Folder from '@joplin/lib/models/Folder';
import { createAppDefaultWindowState } from '../app.reducer';
import Setting from '@joplin/lib/models/Setting';
import { getDisplayParentId } from '@joplin/lib/services/trash';
import { ALL_NOTES_FILTER_ID } from '@joplin/lib/reserved-ids';
export const declaration: CommandDeclaration = {
name: 'openNoteInNewWindow',
@@ -18,14 +21,18 @@ export const runtime = (): CommandRuntime => {
execute: async (context: CommandContext, noteId: string = null) => {
noteId = noteId || stateUtils.selectedNoteId(context.state);
const note = await Note.load(noteId, { fields: ['parent_id'] });
const note = await Note.load(noteId, { fields: Note.previewFields() });
const parentFolder = await Folder.load(note.parent_id);
let folderId = note.is_conflict && !note.deleted_time ? Folder.conflictFolderId() : getDisplayParentId(note, parentFolder);
if (!note.is_conflict && !note.deleted_time && !parentFolder) folderId = ALL_NOTES_FILTER_ID;
context.dispatch({
type: 'WINDOW_OPEN',
noteId,
folderId: note.parent_id,
folderId,
windowId: `window-${noteId}-${idCounter++}`,
defaultAppWindowState: {
...createAppDefaultWindowState(),
notes: [note],
noteVisiblePanes: Setting.value('noteVisiblePanes'),
editorCodeView: Setting.value('editor.codeView'),
},
@@ -0,0 +1,20 @@
import { CommandRuntime, CommandDeclaration, CommandContext } from '@joplin/lib/services/CommandService';
import { _ } from '@joplin/lib/locale';
export const declaration: CommandDeclaration = {
name: 'openSyncWizard',
label: () => _('Open sync wizard'),
};
export const runtime = (): CommandRuntime => {
return {
execute: async (context: CommandContext) => {
context.dispatch({
type: 'DIALOG_OPEN',
name: 'syncWizard',
});
},
// Prevents the user from attempting to open multiple copies of the sync wizard, which would throw
enabledCondition: '!modalDialogVisible',
};
};
@@ -4,7 +4,7 @@ import ButtonBar from './ButtonBar';
import Button, { ButtonLevel } from '../Button/Button';
import { _ } from '@joplin/lib/locale';
import bridge from '../../services/bridge';
import Setting, { AppType, SettingMetadataSection, SettingValueType, SyncStartupOperation } from '@joplin/lib/models/Setting';
import Setting, { AppType, SettingMetadataSection, SettingsRecord, SettingValueType } from '@joplin/lib/models/Setting';
import { AppState } from '../../app.reducer';
import EncryptionConfigScreen from '../EncryptionConfigScreen/EncryptionConfigScreen';
import NoteLockSettings from './controls/NoteLockSettings';
@@ -25,10 +25,12 @@ import MacOSMissingPasswordHelpLink from './controls/MissingPasswordHelpLink';
import AiIndexStatus from './controls/AiIndexStatus';
import AiStatus from './controls/AiStatus';
const { KeymapConfigScreen } = require('../KeymapConfig/KeymapConfigScreen');
import SettingComponent, { UpdateSettingValueEvent } from './controls/SettingComponent';
import SettingComponent from './controls/SettingComponent';
import shim, { MessageBoxType } from '@joplin/lib/shim';
import { OnChangeEvent } from '../lib/SearchInput/SearchInput';
import highlightSearchText from './searchHighlight';
import { UpdateSettingValueEvent } from './types';
import { Dispatch } from 'redux';
interface Font {
@@ -42,8 +44,21 @@ declare global {
}
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old class component without props/state interfaces; tightening requires structural refactor
class ConfigScreenComponent extends React.Component<any, any> {
interface Props {
dispatch: Dispatch;
defaultSection?: string;
settings: SettingsRecord;
themeId: number;
style: React.CSSProperties;
}
interface State extends shared.ConfigScreenState {
selectedSectionName: string;
screenName: string;
needRestart: boolean;
fonts: string[];
}
class ConfigScreenComponent extends React.Component<Props, State> {
private rowStyle_: React.CSSProperties = null;
@@ -102,7 +117,7 @@ class ConfigScreenComponent extends React.Component<any, any> {
});
}
}
await shared.checkSyncConfig(this, this.state.settings);
return await shared.checkSyncConfig(this, this.state.settings);
}
public UNSAFE_componentWillMount() {
@@ -122,35 +137,7 @@ class ConfigScreenComponent extends React.Component<any, any> {
}
private async handleSettingButton(key: string) {
if (key === 'sync.clearLocalSyncStateButton') {
if (!await shim.showConfirmationDialog('This cannot be undone. Do you want to continue?')) return;
Setting.setValue('sync.startupOperation', SyncStartupOperation.ClearLocalSyncState);
await Setting.saveAll();
await restart();
} else if (key === 'sync.clearLocalDataButton') {
if (!await shim.showConfirmationDialog('This cannot be undone. Do you want to continue?')) return;
Setting.setValue('sync.startupOperation', SyncStartupOperation.ClearLocalData);
await Setting.saveAll();
await restart();
} else if (key === 'ocr.clearLanguageDataCacheButton') {
if (!await shim.showConfirmationDialog(this.restartMessage())) return;
Setting.setValue('ocr.clearLanguageDataCache', true);
await restart();
} else if (key === 'ai.usage.resetButton') {
if (!await shim.showConfirmationDialog(_('Reset AI token usage counters?'))) return;
Setting.setValue('ai.usage.inputTokens', 0);
Setting.setValue('ai.usage.outputTokens', 0);
await Setting.saveAll();
} else if (key === 'ai.chat.testButton') {
await shared.checkAiConfig(this);
} else if (key === 'sync.openSyncWizard') {
this.props.dispatch({
type: 'DIALOG_OPEN',
name: 'syncWizard',
});
} else {
throw new Error(`Unhandled key: ${key}`);
}
await shared.onSettingButtonPress(this, Setting.settingMetadata(key));
}
public sectionByName(name: string) {
@@ -222,7 +209,7 @@ class ConfigScreenComponent extends React.Component<any, any> {
);
}
public sectionToComponent(key: string, section: SettingMetadataSection, settings: Record<string, unknown>, selected: boolean) {
public sectionToComponent(key: string, section: SettingMetadataSection, settings: shared.SettingsMap, selected: boolean) {
const theme = themeStyle(this.props.themeId);
const searchMode = !!normalizeQuery(this.state.searchQuery);
@@ -308,24 +295,6 @@ class ConfigScreenComponent extends React.Component<any, any> {
</div>
);
if (settings['sync.target'] === SyncTargetRegistry.nameToId('joplinCloud')) {
const goToJoplinCloudLogin = () => {
this.props.dispatch({
type: 'NAV_GO',
routeName: 'JoplinCloudLogin',
});
};
settingComps.push(
<div key="connect_to_joplin_cloud_button" style={this.rowStyle_}>
<Button
title={_('Connect to Joplin Cloud')}
level={ButtonLevel.Primary}
onClick={goToJoplinCloudLogin}
/>
</div>,
);
}
if (settings['sync.target'] === SyncTargetRegistry.nameToId('joplinServerSaml')) {
const server = settings['sync.11.path'] as string;
@@ -397,7 +366,7 @@ class ConfigScreenComponent extends React.Component<any, any> {
);
}
private onUpdateSettingValue = ({ key, value }: UpdateSettingValueEvent) => {
private onUpdateSettingValue = ({ key, value }: UpdateSettingValueEvent<string>) => {
const md = Setting.settingMetadata(key);
if (md.needRestart) {
this.setState({ needRestart: true });
@@ -405,6 +374,10 @@ class ConfigScreenComponent extends React.Component<any, any> {
shared.updateSettingValue(this, key, value);
};
public setSettingValue<Key extends string>(key: Key, value: SettingValueType<Key>) {
this.onUpdateSettingValue({ key: key as string, value });
}
private renderSearchHighlightedText = (text: string): React.ReactNode => {
return highlightSearchText(text, this.state.searchQuery);
};
@@ -425,7 +398,7 @@ class ConfigScreenComponent extends React.Component<any, any> {
}
private restartMessage() {
return _('The application must be restarted for these changes to take effect.');
return shared.restartMessage();
}
private async restartApp() {
@@ -471,7 +444,7 @@ class ConfigScreenComponent extends React.Component<any, any> {
const searchMode = !!searchQuery;
const sectionFilter = this.state.searchSectionFilter;
const style = {
const style: React.CSSProperties = {
...this.props.style,
overflow: 'hidden',
display: 'flex',
@@ -124,6 +124,10 @@ const NoteLockSettings: React.FC<Props> = props => {
const resetPasswordTitle = `⚠️ ${_('Reset password')} ⚠️`;
const actionButtonTitle = mode === ActionMode.Reset ? resetPasswordTitle : _('Save');
// The sync advice only applies to a first setup, where there may be nothing to lose yet.
const passwordReminder = mode === ActionMode.Create ?
_('Please make sure you remember your password. It cannot be recovered if lost, and any locked notes will become inaccessible. If you have already set up the note lock password on another device, there is no need to set it again, as the information required to access your locked notes is updated when you set up sync.') :
_('Please make sure you remember your password. It cannot be recovered if lost, and any locked notes will become inaccessible.');
const getSectionTitle = () => {
if (mode === ActionMode.Reset) return resetPasswordTitle;
@@ -197,7 +201,7 @@ const NoteLockSettings: React.FC<Props> = props => {
/>
)}
</div>
<p className='reminder'><strong>{_('Please make sure you remember your password. It cannot be recovered if lost, and any data encrypted with it will become inaccessible.')}</strong></p>
<p className='reminder'><strong>{passwordReminder}</strong></p>
</div>
);
};
@@ -208,7 +212,7 @@ const NoteLockSettings: React.FC<Props> = props => {
return (
<div className='section'>
<h2>{_('Key upgrade')}</h2>
<p>{_('The note lock key uses an out-dated encryption algorithm and it is recommended to upgrade it. The upgraded key will still be able to decrypt and encrypt your data as usual.')}</p>
<p>{_('For better security, your note lock protection can be upgraded to a newer method. Enter your password to upgrade it. Your notes will keep locking and unlocking as usual.')}</p>
<div className='form'>
<LabelledPasswordInput
labelText={_('Password')}
@@ -0,0 +1,48 @@
import * as React from 'react';
import Setting, { SettingButtonStyle } from '@joplin/lib/models/Setting';
import Button, { ButtonLevel } from '../../Button/Button';
import { useCallback, useState } from 'react';
import Logger from '@joplin/utils/Logger';
const logger = Logger.create('SettingButton');
interface Props {
settingKey: string;
onSettingButtonClick: (key: string)=> Promise<void>;
}
const SettingButton: React.FC<Props> = ({
settingKey, onSettingButtonClick,
}) => {
const key = settingKey;
const md = Setting.settingMetadata(key);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string|null>(null);
const onClick = useCallback(async () => {
setError(null);
setLoading(true);
try {
await onSettingButtonClick(key);
} catch (error) {
logger.warn('Failed to run command for button', key, error);
setError(String(error));
} finally {
setLoading(false);
}
}, [key, onSettingButtonClick]);
return <>
<Button
level={md.buttonStyle === SettingButtonStyle.Highlighted ? ButtonLevel.Primary : ButtonLevel.Secondary}
title={md.label()}
onClick={onClick}
disabled={loading}
/>
{error && <span className='error'>{error}</span>}
</>;
};
export default SettingButton;
@@ -11,6 +11,8 @@ import FontSearch from './FontSearch';
import * as pathUtils from '@joplin/lib/path-utils';
import SettingLabel from './SettingLabel';
import SettingDescription from './SettingDescription';
import { OnUpdateSettingValue } from '../types';
import SettingButton from './SettingButton';
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Each control component has different prop types
const settingKeyToControl: Record<string, React.FC<any>> = {
@@ -18,18 +20,13 @@ const settingKeyToControl: Record<string, React.FC<any>> = {
'globalHotkey': control_GlobalHotkeyInput,
};
export interface UpdateSettingValueEvent {
key: string;
value: unknown;
}
interface Props {
themeId: number;
settingKey: string;
value: unknown;
fonts: string[];
onUpdateSettingValue: (event: UpdateSettingValueEvent)=> void;
onSettingButtonClick: (key: string)=> void;
onUpdateSettingValue: OnUpdateSettingValue;
onSettingButtonClick: (key: string)=> Promise<void>;
renderSearchText?: (text: string)=> React.ReactNode;
}
@@ -363,10 +360,9 @@ const SettingComponent: React.FC<Props> = props => {
return (
<div style={rowStyle}>
{labelComp}
<Button
level={ButtonLevel.Secondary}
title={md.label()}
onClick={md.onClick ? md.onClick : () => props.onSettingButtonClick(key)}
<SettingButton
settingKey={key}
onSettingButtonClick={props.onSettingButtonClick}
/>
{descriptionComp}
</div>
@@ -0,0 +1,7 @@
import { SettingsRecord } from '@joplin/lib/models/Setting';
export interface UpdateSettingValueEvent<Key extends string> {
key: Key;
value: SettingsRecord[Key];
}
export type OnUpdateSettingValue = <Key extends string> (event: UpdateSettingValueEvent<Key>)=> void;
@@ -5,10 +5,10 @@ import { _ } from '@joplin/lib/locale';
import time from '@joplin/lib/time';
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 { decryptedStatText, determineKeyPassword, dontReencryptData, enableEncryptionConfirmationMessages, onSavePasswordClick, onToggleEnabledClick, reencryptData, upgradeMasterKey, useHasUnknownEncryptionMethodKeysMessage, useInputPasswords, useNeedMasterPassword, usePasswordChecker, useStats, useToggleShowDisabledMasterKeys } from '@joplin/lib/components/EncryptionConfigScreen/utils';
import { MasterKeyEntity } from '@joplin/lib/services/e2ee/types';
import { getEncryptionEnabled, localSyncInfoSelector, masterKeyEnabled } from '@joplin/lib/services/synchronizer/syncInfoUtils';
import { getDefaultMasterKey, getMasterPasswordStatusMessage, masterPasswordIsValid, toggleAndSetupEncryption } from '@joplin/lib/services/e2ee/utils';
import { getDefaultMasterKey, getMasterPasswordStatusMessage, isKnownEncryptionMethod, masterPasswordIsValid, toggleAndSetupEncryption } from '@joplin/lib/services/e2ee/utils';
import Button, { ButtonLevel } from '../Button/Button';
import { useCallback, useEffect, useId, useMemo, useRef, useState } from 'react';
import { connect } from 'react-redux';
@@ -76,6 +76,7 @@ export const EncryptionConfigScreen = (props: Props) => {
const { passwordChecks, masterPasswordKeys, masterPasswordStatus } = usePasswordChecker(props.masterKeys, props.activeMasterKeyId, props.masterPassword, props.passwords);
const { showDisabledMasterKeys, toggleShowDisabledMasterKeys } = useToggleShowDisabledMasterKeys();
const needMasterPassword = useNeedMasterPassword(passwordChecks, props.masterKeys);
const unknownEncryptionMethodMessage = useHasUnknownEncryptionMethodKeysMessage(props.masterKeys);
useEffect(() => {
const wasOpen = wasMasterPasswordDialogOpen.current;
@@ -173,6 +174,7 @@ export const EncryptionConfigScreen = (props: Props) => {
const password = inputPasswords[mk.id] ? inputPasswords[mk.id] : '';
const isActive = props.activeMasterKeyId === mk.id;
const activeIcon = isActive ? '✔' : '';
const knownKeyType = isKnownEncryptionMethod(mk.encryption_method);
const passwordOk = passwordChecks[mk.id] === true ? '✔' : '❌';
const renderPasswordInput = (masterKeyId: string) => {
@@ -207,7 +209,9 @@ export const EncryptionConfigScreen = (props: Props) => {
<td style={theme.textStyle}>{mk.id}<br/>{_('Source: ')}{mk.source_application}</td>
<td style={theme.textStyle}>{_('Created: ')}{time.formatMsToLocal(mk.created_time)}<br/>{_('Updated: ')}{time.formatMsToLocal(mk.updated_time)}</td>
{renderPasswordInput(mk.id)}
<td style={theme.textStyle}>{passwordOk}</td>
<td
style={theme.textStyle}
>{knownKeyType ? passwordOk : <span title={_('Unknown key format')}>❓</span>}</td>
<td style={theme.textStyle}>
<button style={theme.buttonStyle} onClick={() => onToggleEnabledClick(mk)}>{masterKeyEnabled(mk) ? _('Disable') : _('Enable')}</button>
</td>
@@ -456,7 +460,7 @@ export const EncryptionConfigScreen = (props: Props) => {
const buttonTitle = CommandService.instance().label('openMasterPasswordDialog');
const needPasswordMessage = !needMasterPassword ? null : (
<p className="needpassword">
<p className="warning">
{_('Your password is needed to decrypt some of your data.')}
<br/>
{_('Please click on "%s" to proceed, or set the passwords in the "%s" list below.', buttonTitle, _('Encryption keys'))}
@@ -468,12 +472,19 @@ export const EncryptionConfigScreen = (props: Props) => {
</p>
);
const needUpgradeAppMessage = !!unknownEncryptionMethodMessage && (
<p className='warning'>
{unknownEncryptionMethodMessage}
</p>
);
return (
<div className="section">
<div className="manage-password-section">
<h2>{_('Master password')}</h2>
<p className="status"><span>{_('Master password:')}</span>&nbsp;<span className="bold">{getMasterPasswordStatusMessage(masterPasswordStatus)}</span></p>
{needPasswordMessage}
{needUpgradeAppMessage}
<Button className="managebutton" level={needMasterPassword ? ButtonLevel.Primary : ButtonLevel.Secondary} onClick={onManageMasterPassword} title={buttonTitle} />
</div>
</div>
@@ -8,6 +8,6 @@
align-items: center;
}
.manage-password-section > .needpassword {
.manage-password-section > .warning {
color: var(--joplin-color-warn);
}
+1 -1
View File
@@ -161,7 +161,7 @@ export default class ErrorBoundary extends React.Component<Props, State> {
const isLegacyEditorError = !!this.state.error.stack.includes('CodeMirror/v5');
return (
<div style={{ overflow: 'auto', fontFamily: 'sans-serif', padding: '5px 20px' }}>
<div className='error-boundary'>
<SwitchToNewEditorBanner isVisible={isLegacyEditorError} />
<h1>Error</h1>
{this.renderMessage()}
+15 -342
View File
@@ -14,39 +14,31 @@ import { _ } from '@joplin/lib/locale';
import { AppState } from '../app.reducer';
import { saveLayout, loadLayout } from './ResizableLayout/utils/persist';
import Setting from '@joplin/lib/models/Setting';
import shouldShowMissingPasswordWarning from '@joplin/lib/components/shared/config/shouldShowMissingPasswordWarning';
import { produce } from 'immer';
import shim from '@joplin/lib/shim';
import bridge from '../services/bridge';
import styled from 'styled-components';
import { themeStyle, ThemeStyle } from '@joplin/lib/theme';
import { themeStyle } from '@joplin/lib/theme';
import validateLayout from './ResizableLayout/utils/validateLayout';
import iterateItems from './ResizableLayout/utils/iterateItems';
import removeItem from './ResizableLayout/utils/removeItem';
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 } 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';
import invitationRespond from '@joplin/lib/services/share/invitationRespond';
import restart from '../services/restart';
import { connect } from 'react-redux';
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 Logger from '@joplin/utils/Logger';
import checkForUpdates, { isReleaseVersion } from '../checkForUpdates';
const logger = Logger.create('MainScreen');
import { ipcRenderer } from 'electron';
import layoutKeyToLabel from '../utils/layout/layoutKeyToLabel';
import MainLayoutPane from './MainLayoutPane';
import WarningBanner from './WarningBanner';
interface Props {
plugins: PluginStates;
@@ -57,26 +49,13 @@ interface Props {
style: React.CSSProperties & { width?: number; height?: number };
layoutMoveMode: boolean;
shouldUpgradeSyncTarget: boolean;
hasDisabledSyncItems: boolean;
hasDisabledEncryptionItems: boolean;
hasMissingSyncCredentials: boolean;
showMissingMasterKeyMessage: boolean;
showNeedUpgradingMasterKeyMessage: boolean;
showShouldReencryptMessage: boolean;
themeId: number;
shareInvitations: ShareInvitation[];
isSafeMode: boolean;
enableLegacyMarkdownEditor: boolean;
needApiAuth: boolean;
processingShareInvitationResponse: boolean;
isResettingLayout: boolean;
lastDeletion: StateLastDeletion;
lastDeletionNotificationTime: number;
mustUpgradeAppMessage: string;
syncTargetAppMinVersion: string;
showInvalidJoplinCloudCredential: boolean;
toast: Toast;
shouldSwitchToAppleSiliconVersion: boolean;
}
interface ShareFolderDialogOptions {
@@ -90,8 +69,7 @@ interface State {
noteContentPropertiesDialogOptions: Record<string, unknown>;
shareNoteDialogOptions: Record<string, unknown>;
shareFolderDialogOptions: ShareFolderDialogOptions;
syncTargetAppMinVersionIsRelease: boolean | null;
didSyncTargetAppMinVersionReleaseLoadFail: boolean;
messageBoxVisible: boolean;
}
const StyledUserWebviewDialogContainer = styled.div`
@@ -134,8 +112,7 @@ class MainScreenComponent extends React.Component<Props, State> {
visible: false,
folderId: '',
},
syncTargetAppMinVersionIsRelease: null,
didSyncTargetAppMinVersionReleaseLoadFail: false,
messageBoxVisible: false,
};
this.updateMainLayout(this.buildLayout(props.plugins));
@@ -212,11 +189,6 @@ class MainScreenComponent extends React.Component<Props, State> {
return newLayout !== layout ? validateLayout(newLayout) : layout;
}
private showShareInvitationNotification(props: Props): boolean {
if (props.processingShareInvitationResponse) return false;
return !!props.shareInvitations.find(i => i.status === 0);
}
private buildLayout(plugins: PluginStates): LayoutItem {
const rootLayoutSize = this.rootLayoutSize();
@@ -327,7 +299,7 @@ class MainScreenComponent extends React.Component<Props, State> {
public componentDidUpdate(prevProps: Props, prevState: State) {
if (prevProps.style.width !== this.props.style.width ||
prevProps.style.height !== this.props.style.height ||
this.messageBoxVisible(prevProps) !== this.messageBoxVisible(this.props)
prevState.messageBoxVisible !== this.state.messageBoxVisible
) {
this.updateRootLayoutSize();
}
@@ -357,13 +329,6 @@ class MainScreenComponent extends React.Component<Props, State> {
value: false,
});
}
if (
this.props.mustUpgradeAppMessage !== prevProps.mustUpgradeAppMessage ||
this.props.syncTargetAppMinVersion !== prevProps.syncTargetAppMinVersion
) {
void this.loadSyncTargetAppMinVersionIsRelease();
}
}
public layoutModeListenerKeyDown(event: KeyboardEvent) {
@@ -374,7 +339,6 @@ class MainScreenComponent extends React.Component<Props, State> {
public componentDidMount() {
window.addEventListener('keydown', this.layoutModeListenerKeyDown);
void this.loadSyncTargetAppMinVersionIsRelease();
}
public componentWillUnmount() {
@@ -382,28 +346,6 @@ class MainScreenComponent extends React.Component<Props, State> {
window.removeEventListener('keydown', this.layoutModeListenerKeyDown);
}
private async loadSyncTargetAppMinVersionIsRelease() {
const version = this.props.syncTargetAppMinVersion;
this.setState({
syncTargetAppMinVersionIsRelease: null,
didSyncTargetAppMinVersionReleaseLoadFail: false,
});
if (!this.props.mustUpgradeAppMessage || !version) return;
try {
const syncTargetAppMinVersionIsRelease = await isReleaseVersion(version);
if (!this.props.mustUpgradeAppMessage || this.props.syncTargetAppMinVersion !== version) return;
this.setState({
syncTargetAppMinVersionIsRelease,
didSyncTargetAppMinVersionReleaseLoadFail: syncTargetAppMinVersionIsRelease === null,
});
} catch (error) {
logger.error(error);
if (!this.props.mustUpgradeAppMessage || this.props.syncTargetAppMinVersion !== version) return;
this.setState({ didSyncTargetAppMinVersionReleaseLoadFail: true });
}
}
public rootLayoutSize() {
return {
@@ -414,7 +356,7 @@ class MainScreenComponent extends React.Component<Props, State> {
public rowHeight() {
if (!this.props) return 0;
return this.props.style.height - (this.messageBoxVisible() ? this.messageBoxHeight() : 0);
return this.props.style.height - (this.state.messageBoxVisible ? this.messageBoxHeight() : 0);
}
public messageBoxHeight() {
@@ -425,8 +367,6 @@ class MainScreenComponent extends React.Component<Props, State> {
const styleKey = [themeId, width, height, messageBoxVisible].join('_');
if (styleKey === this.styleKey_) return this.styles_;
const theme = themeStyle(themeId);
this.styleKey_ = styleKey;
this.styles_ = {};
@@ -435,16 +375,7 @@ class MainScreenComponent extends React.Component<Props, State> {
width: width,
};
this.styles_.messageBox = {
width: width,
height: this.messageBoxHeight(),
display: 'flex',
alignItems: 'center',
paddingLeft: 10,
backgroundColor: theme.warningBackgroundColor,
};
const rowHeight = height - (messageBoxVisible ? this.styles_.messageBox.height : 0);
const rowHeight = height - (messageBoxVisible ? this.messageBoxHeight() : 0);
this.styles_.rowHeight = rowHeight;
@@ -460,254 +391,12 @@ class MainScreenComponent extends React.Component<Props, State> {
return this.styles_;
}
private renderNotificationMessage(message: string, callForAction: string = null, callForActionHandler: ()=> void = null, callForAction2: string = null, callForActionHandler2: ()=> void = null) {
const theme = themeStyle(this.props.themeId);
const urlStyle: React.CSSProperties = { color: theme.colorWarnUrl, textDecoration: 'underline' };
if (!callForAction) return <span>{message}</span>;
const cfa = (
<a href="#" style={urlStyle} onClick={() => callForActionHandler()}>
{callForAction}
</a>
);
const cfa2 = !callForAction2 ? null : (
<a href="#" style={urlStyle} onClick={() => callForActionHandler2()}>
{callForAction2}
</a>
);
if (!callForAction2 && message.includes(callForAction)) {
const actionIndex = message.indexOf(callForAction);
return (
<span>
{message.substring(0, actionIndex)}
{cfa}
{message.substring(actionIndex + callForAction.length)}
</span>
);
}
return (
<span>
{message}{callForAction ? ' ' : ''}
{cfa}{callForAction2 ? ' / ' : ''}{cfa2}
</span>
);
}
public renderNotification(theme: ThemeStyle, styles: Record<string, React.CSSProperties>) {
if (!this.messageBoxVisible()) return null;
const onViewStatusScreen = () => {
this.props.dispatch({
type: 'NAV_GO',
routeName: 'Status',
});
};
const onViewEncryptionConfigScreen = () => {
this.props.dispatch({
type: 'NAV_GO',
routeName: 'Config',
props: {
defaultSection: 'encryption',
},
});
};
const onViewJoplinCloudLoginScreen = () => {
this.props.dispatch({
type: 'NAV_GO',
routeName: 'JoplinCloudLogin',
});
};
const onDisableSync = () => {
Setting.setValue('sync.target', null);
};
const onViewSyncSettingsScreen = () => {
this.props.dispatch({
type: 'NAV_GO',
routeName: 'Config',
props: {
defaultSection: 'sync',
},
});
};
const onDownloadAppleSiliconVersion = () => {
// The website should redirect to the correct version
shim.openUrl('https://joplinapp.org/download/');
};
const onCheckForUpdates = () => {
void checkForUpdates(false, bridge().mainWindow(), { includePreReleases: false });
};
const onRestartAndUpgrade = async () => {
Setting.setValue('sync.upgradeState', Setting.SYNC_UPGRADE_STATE_MUST_DO);
await Setting.saveAll();
await restart();
};
const onDisableSafeModeAndRestart = async () => {
Setting.setValue('isSafeMode', false);
await Setting.saveAll();
await restart();
};
const onInvitationRespond = async (shareUserId: string, folderId: string, masterKey: MasterKeyEntity, accept: boolean) => {
await invitationRespond(shareUserId, folderId, masterKey, accept);
};
let msg = null;
// When adding something here, don't forget to update the condition in
// this.messageBoxVisible()
if (this.props.isSafeMode) {
msg = this.renderNotificationMessage(
_('Safe mode is currently active. Note rendering and all plugins are temporarily disabled.'),
_('Disable safe mode and restart'),
onDisableSafeModeAndRestart,
);
} else if (this.props.hasMissingSyncCredentials) {
msg = this.renderNotificationMessage(
_('The synchronisation password is missing.'),
_('Set the password'),
onViewSyncSettingsScreen,
);
} else if (this.props.shouldUpgradeSyncTarget) {
msg = this.renderNotificationMessage(
_('The sync target needs to be upgraded before Joplin can sync. The operation may take a few minutes to complete and the app needs to be restarted. To proceed please click on the link.'),
_('Restart and upgrade'),
onRestartAndUpgrade,
);
} else if (this.props.hasDisabledEncryptionItems) {
msg = this.renderNotificationMessage(
_('Some items cannot be decrypted.'),
_('View them now'),
onViewStatusScreen,
);
} else if (this.props.showNeedUpgradingMasterKeyMessage) {
msg = this.renderNotificationMessage(
_('One of your master keys use an obsolete encryption method.'),
_('View them now'),
onViewEncryptionConfigScreen,
);
} else if (this.props.showShouldReencryptMessage) {
msg = this.renderNotificationMessage(
_('The default encryption method has been changed, you should re-encrypt your data.'),
_('More info'),
onViewEncryptionConfigScreen,
);
} else if (this.showShareInvitationNotification(this.props)) {
const invitation = this.props.shareInvitations.find(inv => inv.status === 0);
const sharer = invitation.share.user;
msg = this.renderNotificationMessage(
_('%s (%s) would like to share a notebook with you.', sharer.full_name, sharer.email),
_('Accept'),
() => onInvitationRespond(invitation.id, invitation.share.folder_id, invitation.master_key, true),
_('Reject'),
() => onInvitationRespond(invitation.id, invitation.share.folder_id, invitation.master_key, false),
);
} else if (this.props.hasDisabledSyncItems) {
msg = this.renderNotificationMessage(
_('Some items cannot be synchronised.'),
_('View them now'),
onViewStatusScreen,
);
} else if (this.props.showMissingMasterKeyMessage) {
msg = this.renderNotificationMessage(
_('One or more master keys need a password.'),
_('Set the password'),
onViewEncryptionConfigScreen,
);
} else if (this.props.mustUpgradeAppMessage) {
if (!this.props.syncTargetAppMinVersion) {
msg = this.renderNotificationMessage(this.props.mustUpgradeAppMessage);
} else if (this.state.didSyncTargetAppMinVersionReleaseLoadFail) {
msg = this.renderNotificationMessage(
_(
'In order to synchronise, Please upgrade your application to version %s. Joplin could not check update information.',
this.props.syncTargetAppMinVersion,
),
);
} else if (this.state.syncTargetAppMinVersionIsRelease === false && shim.isLinux()) {
const callForAction = _('Download it from GitHub Releases');
msg = this.renderNotificationMessage(
_(
'In order to synchronise, Please upgrade your application to version %s: %s or update it using your package manager',
this.props.syncTargetAppMinVersion,
callForAction,
),
callForAction,
() => shim.openUrl('https://github.com/laurent22/joplin/releases'),
);
} else if (this.state.syncTargetAppMinVersionIsRelease !== null) {
const isTargetPreRelease = this.state.syncTargetAppMinVersionIsRelease === false;
const callForAction = isTargetPreRelease ? _('Download it from GitHub Releases') : _('Check for updates');
msg = this.renderNotificationMessage(
_(
'In order to synchronise, Please upgrade your application to version %s: %s',
this.props.syncTargetAppMinVersion,
callForAction,
),
callForAction,
isTargetPreRelease ? () => shim.openUrl('https://github.com/laurent22/joplin/releases') : onCheckForUpdates,
);
} else {
msg = this.renderNotificationMessage(this.props.mustUpgradeAppMessage);
}
} else if (this.props.shouldSwitchToAppleSiliconVersion) {
msg = this.renderNotificationMessage(
_('You are running the Intel version of Joplin on an Apple Silicon processor. Download the Apple Silicon one for better performance.'),
_('Download it now'),
onDownloadAppleSiliconVersion,
);
} else if (this.props.showInvalidJoplinCloudCredential) {
msg = this.renderNotificationMessage(
_('Your Joplin Cloud credentials are invalid, please login.'),
_('Login to Joplin Cloud.'),
onViewJoplinCloudLoginScreen,
_('Disable synchronisation'),
onDisableSync,
);
}
return (
<div style={styles.messageBox}>
<span
style={theme.textStyle}
role='alert'
// role='alert' has an implicit aria-live='assertive', which tells screen readers that changes
// to the warning's content should be announced as soon as possible. However, since it's generally
// okay for announcements related to these notifications to be delayed, use aria-live='polite'.
aria-live='polite'
>{msg}</span>
</div>
);
}
public messageBoxVisible(props: Props = null) {
if (!props) props = this.props;
return props.hasDisabledSyncItems ||
props.showMissingMasterKeyMessage ||
props.hasMissingSyncCredentials ||
props.showNeedUpgradingMasterKeyMessage ||
props.showShouldReencryptMessage ||
props.hasDisabledEncryptionItems ||
this.props.shouldUpgradeSyncTarget ||
props.isSafeMode ||
this.showShareInvitationNotification(props) ||
this.props.needApiAuth ||
!!this.props.mustUpgradeAppMessage ||
props.showInvalidJoplinCloudCredential ||
props.shouldSwitchToAppleSiliconVersion;
public renderNotification() {
return <WarningBanner
height={this.messageBoxHeight()}
onShow={() => this.setState({ messageBoxVisible: true })}
onHide={() => this.setState({ messageBoxVisible: false })}
/>;
}
private resizableLayout_resize(event: { layout: LayoutItem }) {
@@ -772,9 +461,9 @@ class MainScreenComponent extends React.Component<Props, State> {
backgroundColor: theme.backgroundColor,
...this.props.style,
};
const styles = this.styles(this.props.themeId, style.width, style.height, this.messageBoxVisible());
const styles = this.styles(this.props.themeId, style.width, style.height, this.state.messageBoxVisible);
const messageComp = this.renderNotification(theme, styles);
const messageComp = this.renderNotification();
const layoutComp = this.props.mainLayout ? (
<ResizableLayout
@@ -810,36 +499,20 @@ class MainScreenComponent extends React.Component<Props, State> {
}
const mapStateToProps = (state: AppState) => {
const syncInfo = localSyncInfoFromState(state);
const showNeedUpgradingEnabledMasterKeyMessage = !!EncryptionService.instance().masterKeysThatNeedUpgrading(syncInfo.masterKeys.filter((k) => !!k.enabled)).length;
return {
themeId: state.settings.theme,
hasDisabledSyncItems: state.hasDisabledSyncItems,
hasDisabledEncryptionItems: state.hasDisabledEncryptionItems,
showMissingMasterKeyMessage: showMissingMasterKeyMessage(syncInfo, state.notLoadedMasterKeys),
showNeedUpgradingMasterKeyMessage: showNeedUpgradingEnabledMasterKeyMessage,
showShouldReencryptMessage: state.settings['encryption.shouldReencrypt'] >= Setting.SHOULD_REENCRYPT_YES,
shouldUpgradeSyncTarget: state.settings['sync.upgradeState'] === Setting.SYNC_UPGRADE_STATE_SHOULD_DO,
hasMissingSyncCredentials: shouldShowMissingPasswordWarning(state.settings['sync.target'], state.settings),
plugins: state.pluginService.plugins,
pluginHtmlContents: state.pluginService.pluginHtmlContents,
hasNotesBeingSaved: stateUtils.hasNotesBeingSaved(state),
layoutMoveMode: state.layoutMoveMode,
mainLayout: state.mainLayout,
shareInvitations: state.shareService.shareInvitations,
processingShareInvitationResponse: state.shareService.processingShareInvitationResponse,
isSafeMode: state.settings.isSafeMode,
enableLegacyMarkdownEditor: state.settings['editor.legacyMarkdown'],
needApiAuth: state.needApiAuth,
isResettingLayout: state.isResettingLayout,
lastDeletion: state.lastDeletion,
lastDeletionNotificationTime: state.lastDeletionNotificationTime,
mustUpgradeAppMessage: state.mustUpgradeAppMessage,
syncTargetAppMinVersion: syncInfo.appMinVersion,
showInvalidJoplinCloudCredential: state.settings['sync.target'] === 10 && state.mustAuthenticate,
toast: state.toast,
shouldSwitchToAppleSiliconVersion: shim.isAppleSilicon() && shim.isMac() && process.arch !== 'arm64',
};
};
+1
View File
@@ -881,6 +881,7 @@ function useMenu(props: Props) {
separator(),
menuItemDic.showNoteProperties,
menuItemDic.showNoteContentProperties,
menuItemDic.revealInNotebook,
separator(),
menuItemDic.permanentlyDeleteNote,
],
@@ -67,6 +67,7 @@ const mapStateToProps = (state: AppState, connectProps: ConnectProps) => {
'textCheckbox',
'textHeading',
'textHorizontalRule',
'textBlockQuote',
'editor.textTable',
'insertDateTime',
'toggleEditors',
@@ -733,10 +733,10 @@ const TinyMCE = (props: NoteBodyEditorProps, ref: Ref<NoteBodyEditorRef>) => {
noneditable_class: 'joplin-editable', // Can be a regex too
iframe_aria_text: _('Rich Text editor. Press Escape then Tab to escape focus.'),
// #p: Pad empty paragraphs with &nbsp; to prevent them from being removed.
// #p, #h1-#h6: Pad empty paragraphs and headings with &nbsp; to prevent them from being removed.
// *[*]: Allow all elements and attributes -- we already filter in sanitize_html
// See https://www.tiny.cloud/docs/configure/content-filtering/#controlcharacters
valid_elements: '#p,*[*]',
valid_elements: '#p,#h1[*],#h2[*],#h3[*],#h4[*],#h5[*],#h6[*],*[*]',
menubar: false,
relative_urls: false,
@@ -11,7 +11,11 @@ import attachedResources from '@joplin/lib/utils/attachedResources';
import { _ } from '@joplin/lib/locale';
import Logger from '@joplin/utils/Logger';
import { resourceFullPath } from '@joplin/lib/models/utils/resourceUtils';
import { ResourceEntity } from '@joplin/lib/services/database/types';
import { NoteEntity, ResourceEntity } from '@joplin/lib/services/database/types';
import isNoteLockEnabled from '@joplin/lib/services/noteLock/isNoteLockEnabled';
import NoteLockSession from '@joplin/lib/services/noteLock/NoteLockSession';
import { NoteLockNoteEntity } from '@joplin/lib/services/noteLock/NoteLockNote';
import eventManager, { EventName } from '@joplin/lib/eventManager';
import { FileCanvasNode } from '@joplin/lib/services/whiteboard/jsoncanvas';
import { resolveCanvasColor } from '@joplin/lib/services/whiteboard/presetColors';
import { isInternalRef, RefKind, resolveFileRef } from '@joplin/lib/services/whiteboard/resolveRef';
@@ -29,10 +33,14 @@ const resourceUrlFor = (resource: ResourceEntity | null, resourceDirectory: stri
return pathToFileURL(resourceFullPath(resource, resourceDirectory)).href;
};
type LockedState = 'sessionLocked' | 'undecryptable' | null;
interface ResolvedItem {
kind: 'note' | 'resource' | 'unknown';
title: string;
body?: string;
isDecrypted?: boolean;
lockedState?: LockedState;
userUpdatedTime?: number;
deletedTime?: number;
resource?: ResourceEntity;
@@ -43,6 +51,16 @@ const useResolvedRef = (file: string): { resolved: ResolvedItem | null; refetch:
const [refetchCount, setRefetchCount] = useState(0);
const lastLoadedFileRef = useRef<string | null>(null);
// Locked cards need to switch between the message and the body when the session changes.
useEffect(() => {
if (!isNoteLockEnabled()) return undefined;
const onSessionChange = () => setRefetchCount(c => c + 1);
eventManager.on(EventName.NoteLockSessionChange, onSessionChange);
return () => {
eventManager.off(EventName.NoteLockSessionChange, onSessionChange);
};
}, []);
useEffect(() => {
let cancelled = false;
const ref = resolveFileRef(file);
@@ -64,10 +82,34 @@ const useResolvedRef = (file: string): { resolved: ResolvedItem | null; refetch:
return;
}
if (item.type_ === ModelType.Note) {
let body = item.body || '';
let isDecrypted = false;
let lockedState: LockedState = null;
// A locked note's body only decrypts through the gated load; the card never
// shows ciphertext and never prompts to unlock the session itself. Unlocked notes
// load through the gate as well, so that a checkbox toggle can use a gated save.
if (isNoteLockEnabled()) {
body = '';
if ((item as NoteEntity).is_locked && !NoteLockSession.instance().isUnlocked()) {
lockedState = 'sessionLocked';
} else {
try {
const note: NoteLockNoteEntity = await Note.load(ref.id, { useNoteLock: true });
body = note.body || '';
isDecrypted = !!note.isDecrypted;
} catch (error) {
logger.warn(`Could not decrypt linked note ${ref.id}:`, error);
lockedState = 'undecryptable';
}
}
}
if (cancelled) return;
setResolved({
kind: 'note',
title: item.title || 'Untitled',
body: item.body || '',
body,
isDecrypted,
lockedState,
userUpdatedTime: item.user_updated_time,
deletedTime: item.deleted_time,
});
@@ -136,6 +178,7 @@ const FileNode = ({ data, selected }: NodeProps<{ id: string; type: 'wbFile'; da
const linkedNoteId = resolved?.kind === 'note' ? resolveFileRef(node.file).id : null;
const linkedNoteUserUpdatedTime = resolved?.kind === 'note' ? resolved.userUpdatedTime : undefined;
const linkedNoteDeletedTime = resolved?.kind === 'note' ? resolved.deletedTime : undefined;
const linkedNoteIsDecrypted = resolved?.kind === 'note' ? resolved.isDecrypted : undefined;
const savingRef = useRef(false);
const onLinkedNoteBodyChange = useCallback(async (newBody: string) => {
if (!linkedNoteId) return;
@@ -153,9 +196,10 @@ const FileNode = ({ data, selected }: NodeProps<{ id: string; type: 'wbFile'; da
{
id: linkedNoteId,
body: newBody,
isDecrypted: linkedNoteIsDecrypted,
...(linkedNoteUserUpdatedTime ? { user_updated_time: linkedNoteUserUpdatedTime } : {}),
},
{ changeSource: ItemChange.SOURCE_UNSPECIFIED },
} as NoteLockNoteEntity,
{ changeSource: ItemChange.SOURCE_UNSPECIFIED, useNoteLock: true },
);
refetch();
} catch (error) {
@@ -164,7 +208,7 @@ const FileNode = ({ data, selected }: NodeProps<{ id: string; type: 'wbFile'; da
} finally {
savingRef.current = false;
}
}, [linkedNoteId, linkedNoteUserUpdatedTime, linkedNoteDeletedTime, refetch]);
}, [linkedNoteId, linkedNoteUserUpdatedTime, linkedNoteDeletedTime, linkedNoteIsDecrypted, refetch]);
const checkboxRef = useCheckboxToggle({
body: resolved?.kind === 'note' ? (resolved.body ?? '') : '',
onChange: onLinkedNoteBodyChange,
@@ -178,6 +222,17 @@ const FileNode = ({ data, selected }: NodeProps<{ id: string; type: 'wbFile'; da
}
if (resolved?.kind === 'note' && !isInTrash) {
if (resolved.lockedState) {
const message = resolved.lockedState === 'sessionLocked'
? _('This linked item cannot be displayed because the note is locked. Unlock the session to view it')
: _('This linked item cannot be displayed because the note is locked and could not be unlocked');
return (
<>
<div className="header -note-title" title={resolved.title}>{resolved.title}</div>
<div className="body -locked">{message}</div>
</>
);
}
return (
<>
<div className="header -note-title" title={resolved.title}>{resolved.title}</div>
@@ -236,7 +236,7 @@ $hover-bg: rgba(127, 127, 127, 0.12);
line-height: 1.4;
color: var(--joplin-color);
&.-deleted { color: var(--joplin-color-faded); font-style: italic; }
&.-deleted, &.-locked { color: var(--joplin-color-faded); font-style: italic; }
&.-url { word-break: break-all; }
}
@@ -27,10 +27,10 @@ import markupLanguageUtils from '@joplin/lib/utils/markupLanguageUtils';
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 { itemIsReadOnly, noteIsLockedInShare } 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 hasNoteLockKey from '../utils/hasNoteLockKey';
import NoteLockPanel from './NoteLockPanel/NoteLockPanel';
import { themeStyle } from '@joplin/lib/theme';
import { substrWithEllipsis } from '@joplin/lib/string-utils';
@@ -84,7 +84,7 @@ let editorIdCounter = 0;
function NoteEditorContent(props: NoteEditorProps) {
const [showRevisions, setShowRevisions] = useState(false);
const [titleHasBeenManuallyChanged, setTitleHasBeenManuallyChanged] = useState(false);
const [isReadOnly, setIsReadOnly] = useState<boolean>(false);
const [isReadOnlyItem, setIsReadOnlyItem] = useState<boolean>(false);
const [reloadInProgress, setReloadInProgress] = useState(false);
const editorRef = useRef<NoteBodyEditorRef|null>(null);
@@ -178,7 +178,8 @@ function NoteEditorContent(props: NoteEditorProps) {
// 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 };
// The form note came from a gated load, so the marker stays true even when disabling.
const newFormNote = { ...formNoteRef.current, is_locked: event.isLocked ? 1 : 0, noteLockKey, isDecrypted: true };
setFormNote(newFormNote);
void scheduleSaveNote(newFormNote);
};
@@ -375,13 +376,17 @@ function NoteEditorContent(props: NoteEditorProps) {
return parseShareCache(props.shareCacheSetting);
}, [props.shareCacheSetting]);
// The list row covers a form whose refresh was skipped for unsaved edits, the form covers a window whose list no longer holds the note.
const lockedInShare = noteIsLockedInShare(formNote) || noteIsLockedInShare(props.notes.find(n => n.id === formNote.id));
const isReadOnly = isReadOnlyItem || lockedInShare;
useAsyncEffect(async event => {
if (!formNote.id) return;
try {
const result = await itemIsReadOnly(BaseItem, ModelType.Note, ItemChange.SOURCE_UNSPECIFIED, formNote.id, props.syncUserId, shareCache);
if (event.cancelled) return;
setIsReadOnly(result);
setIsReadOnlyItem(result);
} catch (error) {
if (error.code === ErrorCode.NotFound) {
// Can happen if the note has been deleted but a render is
@@ -390,7 +395,7 @@ function NoteEditorContent(props: NoteEditorProps) {
throw error;
}
}
}, [formNote.id, props.syncUserId, shareCache]);
}, [formNote.id, formNote.deleted_time, props.syncUserId, shareCache]);
const onBodyWillChange = useCallback((event: { changeId: number }) => {
handleProvisionalFlag();
@@ -634,7 +639,7 @@ function NoteEditorContent(props: NoteEditorProps) {
return (
<div style={revStyle} ref={containerRef}>
<NoteRevisionViewer customCss={props.customCss} noteId={formNote.id} onBack={noteRevisionViewer_onBack} />
<NoteRevisionViewer customCss={props.customCss} noteId={isNoteLockEnabled() ? effectiveNoteId : formNote.id} onBack={noteRevisionViewer_onBack} />
</div>
);
}
@@ -766,6 +771,7 @@ function NoteEditorContent(props: NoteEditorProps) {
hasNoteLockKey={props.hasNoteLockKey}
dispatch={props.dispatch}
undecryptable={true}
lockedInShare={noteIsLockedInShare(lockedNoteMetadata)}
/>
</div>
);
@@ -781,6 +787,8 @@ function NoteEditorContent(props: NoteEditorProps) {
noteTitle={lockedNoteMetadata.title}
hasNoteLockKey={props.hasNoteLockKey}
dispatch={props.dispatch}
migrationRunning={props.noteLockMigrationRunning}
lockedInShare={noteIsLockedInShare(lockedNoteMetadata)}
/>
</div>
);
@@ -843,7 +851,7 @@ function NoteEditorContent(props: NoteEditorProps) {
setTagsToolbarButtonInfo={props.setTagsToolbarButtonInfo}
selectedNoteTags={props.selectedNoteTags}
/>
<WarningBanner bodyEditor={props.bodyEditor}/>
<WarningBanner bodyEditor={props.bodyEditor} lockedInShare={lockedInShare}/>
</div>
</div>
);
@@ -853,15 +861,6 @@ 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);
@@ -923,6 +922,7 @@ const mapStateToProps = (state: AppState, ownProps: ConnectProps) => {
whiteboardForceMarkdown: windowState.whiteboardForceMarkdown ?? {},
noteLockSessionUnlocked: state.noteLockSessionUnlocked,
hasNoteLockKey: hasNoteLockKey(state.settings['syncInfoCache']),
noteLockMigrationRunning: !!state.noteLockMigrationStatus?.running,
editorNoteReloadTimeRequest: windowState.windowEditorNoteReloadTimeRequest,
};
};
@@ -11,6 +11,8 @@ interface Props {
hasNoteLockKey: boolean;
dispatch: Dispatch;
undecryptable?: boolean;
migrationRunning?: boolean;
lockedInShare?: boolean;
}
export default function NoteLockPanel(props: Props) {
@@ -49,8 +51,12 @@ export default function NoteLockPanel(props: Props) {
}, [props.dispatch]);
const renderAction = () => {
if (props.migrationRunning) {
return <p className="message">{_('This note cannot be read while a note lock key migration is in progress.')}</p>;
}
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>;
return <p className="message">{_('This note could not be unlocked. If it was locked prior to a password reset, the content is no longer recoverable.')}</p>;
}
if (!props.hasNoteLockKey) {
@@ -64,7 +70,7 @@ export default function NoteLockPanel(props: Props) {
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>
<p className="message">{_('This note is locked. Enter your password to unlock your notes for this session.')}</p>
<div className="field">
<LabelledPasswordInput
labelText={_('Note lock password')}
@@ -79,10 +85,11 @@ export default function NoteLockPanel(props: Props) {
};
return (
<div className="note-lock-panel">
<div className={`note-lock-panel ${props.lockedInShare ? '-in-share' : ''}`}>
<i className="icon fas fa-lock" role="img" aria-label={_('Locked note')}></i>
<h2 className="title">{props.noteTitle}</h2>
{renderAction()}
{props.lockedInShare ? <div className="warning-banner banner">{_('This note may not be readable because it is contained within a share.')}</div> : null}
</div>
);
}
@@ -45,3 +45,18 @@
color: var(--joplin-color-error);
margin: 0 0 12px 0;
}
.note-lock-panel.-in-share {
box-sizing: border-box;
padding-bottom: 0;
}
.note-lock-panel.-in-share > .icon {
margin-top: auto;
}
.note-lock-panel > .banner {
align-self: stretch;
margin: auto calc(-1 * var(--joplin-main-padding)) 5px;
text-align: left;
}
@@ -3,8 +3,8 @@ import { _ } from '@joplin/lib/locale';
interface Props {
children: React.ReactNode;
acceptMessage: string;
onAccept: ()=> void;
acceptMessage?: string;
onAccept?: ()=> void;
onDismiss?: ()=> void;
dismissMessage?: string;
visible: boolean;
@@ -17,7 +17,7 @@ const BannerContent: React.FC<Props> = props => {
return <div className='warning-banner'>
{props.children}
&nbsp;&nbsp;<a onClick={props.onAccept} className='warning-banner-link' href="#">[ {props.acceptMessage} ]</a>
{ props.acceptMessage ? <>&nbsp;&nbsp;<a onClick={props.onAccept} className='warning-banner-link' href="#">[ {props.acceptMessage} ]</a></> : null }
&nbsp;&nbsp;{ props.onDismiss ? <a onClick={props.onDismiss} className='warning-banner-link' href="#">[ {props.dismissMessage ?? _('Dismiss')} ]</a> : null }
</div>;
};
@@ -14,6 +14,7 @@ import { NoteBodyEditorType } from '../utils/types';
interface Props {
bodyEditor: NoteBodyEditorType;
lockedInShare: boolean;
editorMigrationVersion: number;
richTextBannerDismissed: boolean;
inEditorRenderingEnabled: boolean;
@@ -55,6 +56,12 @@ const WarningBanner: React.FC<Props> = props => {
>{editorMigrationMessage.label}</BannerContent>
);
const lockedInShareBanner = (
<BannerContent visible={props.lockedInShare}>
{_('This note is read-only because it is locked and contained within a share. To enable editing, it must be moved outside of the share.')}
</BannerContent>
);
const wysiwygBanner = (
<BannerContent
acceptMessage={_('Read more about it')}
@@ -101,6 +108,7 @@ const WarningBanner: React.FC<Props> = props => {
);
return <>
{lockedInShareBanner}
{wysiwygBanner}
{markdownPluginBanner}
{editorMigrationBanner}
@@ -107,6 +107,11 @@ const declarations: CommandDeclaration[] = [
label: () => _('Checkbox'),
iconName: 'icon-to-do-list',
},
{
name: 'textBlockQuote',
label: () => _('Blockquote'),
iconName: 'icon-quote',
},
{
name: 'textHeading',
label: () => _('Heading'),
@@ -1,6 +1,4 @@
@use "./styles/warning-banner.scss";
@use "./styles/warning-banner-link.scss";
@use "./styles/note-title-info-group.scss";
@use "./styles/note-title-wrapper.scss";
@use "./styles/note-editor-wrapper.scss";
@@ -76,6 +76,7 @@ export interface NoteEditorProps {
whiteboardForceMarkdown: Record<string, boolean>;
noteLockSessionUnlocked: boolean;
hasNoteLockKey: boolean;
noteLockMigrationRunning: boolean;
editorNoteReloadTimeRequest: number;
}
@@ -167,6 +168,7 @@ export interface FormNote {
title: string;
body: string;
parent_id: string;
share_id: string;
is_todo: number;
is_conflict?: number;
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Editor-specific content shape (e.g. TinyMCE retains the raw editor object here); per-editor type
@@ -220,6 +222,7 @@ export function defaultFormNote(): FormNote {
return {
id: '',
parent_id: '',
share_id: '',
deleted_time: 0,
title: '',
body: '',
@@ -1,6 +1,6 @@
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 { setupDatabaseAndSynchronizer, supportDir, switchClient, withWarningSilenced, noteLockCipherTextStandIn } 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';
@@ -36,14 +36,17 @@ describe('useFormNote', () => {
beforeEach(async () => {
await setupDatabaseAndSynchronizer(1);
await switchClient(1);
// Pinned off regardless of the build's default so the fixtures below save raw bodies.
Setting.setValue('featureFlag.noteLock', false);
});
// 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.
// Saved with the flag off so the raw body stands in for ciphertext; an ungated save with
// the flag on would encrypt it.
const testNote = await Note.save({ title: 'Locked note', body: 'ciphertext', is_locked: 1 });
Setting.setValue('featureFlag.noteLock', true);
const isUnlockedMock = jest.spyOn(NoteLockSession.instance(), 'isUnlocked').mockReturnValue(false);
const decryptedKeyMock = jest.spyOn(NoteLockSession.instance(), 'decryptedKey').mockReturnValue({ id: 'key-id', plainText: 'key' });
@@ -101,8 +104,8 @@ describe('useFormNote', () => {
});
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 });
Setting.setValue('featureFlag.noteLock', true);
// The save's own change event would otherwise reach the hook's listener and reload the note.
await ItemChange.waitForAllSaved();
@@ -114,9 +117,11 @@ describe('useFormNote', () => {
const render = renderHook(props => useFormNote(props), {
initialProps: { ...defaultFormNoteProps, noteId: testNote.id, noteLockSessionUnlocked: true, onDecryptFailedChange },
});
await waitFor(() => {
expect(render.result.current.decryptFailed).toBe(true);
});
await withWarningSilenced(/Could not decrypt locked note/, async () => {
await waitFor(() => {
expect(render.result.current.decryptFailed).toBe(true);
});
}, { requireWarning: true });
expect(render.result.current.formNote).toMatchObject({
id: '',
body: '',
@@ -188,6 +193,43 @@ describe('useFormNote', () => {
});
it('should show a locked note again after sync re-encrypts it and the decryption worker restores it', async () => {
Setting.setValue('featureFlag.noteLock', true);
const testNote = await Note.save({ title: 'Locked', body: noteLockCipherTextStandIn(), is_locked: 1 });
jest.spyOn(NoteLockSession.instance(), 'isUnlocked').mockReturnValue(true);
jest.spyOn(NoteLockSession.instance(), 'decryptedKey').mockReturnValue({ id: 'key-id', plainText: 'key' });
// Like the real decrypt, a row sync has not decrypted yet has no note lock body and fails.
const decryptBodyMock = jest.spyOn(NoteLockNote, 'decryptBody').mockImplementation(async note => {
if (note.encryption_applied) throw new Error('Invalid encryption identifier');
return { ...note, isDecrypted: true, body: 'secret' };
});
const onReloadInProgressChange = jest.fn();
const props = { ...defaultFormNoteProps, noteId: testNote.id, noteLockSessionUnlocked: true, editorNoteReloadTimeRequest: 0, onReloadInProgressChange };
try {
const formNote = renderHook(hookProps => useFormNote(hookProps), { initialProps: props });
await waitFor(() => expect(formNote.result.current.formNote.body).toBe('secret'));
// Sync overwrites the row with the encrypted item and asks the editor to reload.
await act(async () => {
await Note.save({ id: testNote.id, encryption_cipher_text: 'cipher_text', encryption_applied: 1, body: '', is_locked: 1 });
});
formNote.rerender({ ...props, editorNoteReloadTimeRequest: 1 });
await waitFor(() => expect(formNote.result.current.formNote).toMatchObject({ encryption_applied: 1 }));
// The decryption worker then writes the decrypted item back.
await act(async () => {
await Note.save({ id: testNote.id, title: 'Locked', body: noteLockCipherTextStandIn(), is_locked: 1, encryption_cipher_text: '', encryption_applied: 0 }, { autoTimestamp: false, changeSource: ItemChange.SOURCE_DECRYPTION });
});
await waitFor(() => expect(formNote.result.current.formNote).toMatchObject({ encryption_applied: 0, body: 'secret' }), { timeout: 15_000 });
await waitFor(() => expect(onReloadInProgressChange).toHaveBeenLastCalledWith(false));
formNote.unmount();
} finally {
decryptBodyMock.mockRestore();
Setting.setValue('featureFlag.noteLock', false);
}
});
// Lacking is_conflict has previously caused UI issues. See https://github.com/laurent22/joplin/pull/10913
// for details.
it('should preserve value of is_conflict on save', async () => {
@@ -237,15 +279,17 @@ describe('useFormNote', () => {
await waitFor(() => {
expect(formNote.result.current.formNote.title).toBe('Test Note!');
});
expect(formNote.result.current.formNote.share_id).toBe('');
// Simulate the note being modified outside the editor
// Simulate the note being modified outside the editor, e.g. sync assigning it to a share
await act(async () => {
await Note.save({ id: note.id, title: 'Modified' });
await Note.save({ id: note.id, title: 'Modified', share_id: 'share-1' });
});
await waitFor(() => {
expect(formNote.result.current.formNote.title).toBe('Modified');
});
expect(formNote.result.current.formNote.share_id).toBe('share-1');
expect(onReloadInProgressChange).not.toHaveBeenCalled();
formNote.unmount();
@@ -90,7 +90,10 @@ const loadNoteForForm = async (noteId: string): Promise<{ note: NoteEntity|null;
}
}
}
return { note: await Note.load(noteId), blocked: false, decryptFailed: false };
// An unlocked note still goes through the gate, so the form note carries the marker
// that its gated scheduled saves require.
const note = await Note.load(noteId, { useNoteLock: true });
return { note, blocked: false, decryptFailed: false };
};
type InitNoteStateCallback = (note: NoteEntity, isNew: boolean, force?: boolean)=> Promise<FormNote>;
@@ -258,6 +261,7 @@ export default function useFormNote(dependencies: HookDependencies) {
body: n.body,
is_todo: n.is_todo,
parent_id: n.parent_id,
share_id: n.share_id,
deleted_time: n.deleted_time,
is_conflict: n.is_conflict,
bodyWillChangeId: 0,
@@ -10,6 +10,7 @@ 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 { noteIsLockedInShare } from '@joplin/lib/models/utils/readOnly';
import bridge from '../../services/bridge';
import { _ } from '@joplin/lib/locale';
import useRenderedNote from './utils/useRenderedNote';
@@ -63,8 +64,13 @@ const NoteListItem = (props: NoteItemProps, ref: LegacyRef<HTMLDivElement>) => {
};
if (changeEvent.elementId === 'todo-checkbox') {
const lockState = await Note.load(changeEvent.noteId, { fields: ['is_locked', 'share_id'] });
if (noteIsLockedInShare(lockState)) {
event.target.checked = !changeEvent.value;
bridge().showErrorMessageBox(_('This note is read-only because it is locked and contained within a share. To enable editing, it must be moved outside of the share.'));
return;
}
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;
@@ -0,0 +1,68 @@
import * as React from 'react';
import * as ReactDom from 'react-dom';
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import shim from '@joplin/lib/shim';
import NoteLockMigrationDialog from './Dialog';
import { confirmAndSkipNoteLockKeyMigration, startNoteLockKeyMigration } from '@joplin/lib/services/noteLock/NoteLockKeyMigration';
jest.mock('@joplin/lib/services/noteLock/NoteLockKeyMigration', () => ({
startNoteLockKeyMigration: jest.fn(),
confirmAndSkipNoteLockKeyMigration: jest.fn(),
}));
// The dialog renders through a portal, jsdom has no modal dialog support, and DialogTitle relies on
// the React global the app bundle provides.
shim.setReactDom(ReactDom);
(globalThis as unknown as { React: typeof React }).React = React;
HTMLDialogElement.prototype.showModal = function() { this.open = true; };
HTMLDialogElement.prototype.close = function() { this.open = false; };
const startMock = startNoteLockKeyMigration as jest.Mock;
const skipMock = confirmAndSkipNoteLockKeyMigration as jest.Mock;
const renderDialog = () => {
const dispatch = jest.fn();
render(<NoteLockMigrationDialog themeId={1} dispatch={dispatch}/>);
fireEvent.change(screen.getByLabelText('Note lock password on this device'), { target: { value: 'local' } });
fireEvent.change(screen.getByLabelText('Note lock password on the sync target'), { target: { value: 'target' } });
return dispatch;
};
describe('NoteLockMigrationDialog/Dialog', () => {
beforeEach(() => {
jest.clearAllMocks();
});
test('should close once both passwords are accepted and leave the run to the banner', async () => {
startMock.mockImplementation(async (_local: string, _target: string, _dispatch: unknown, onStarted: ()=> void) => onStarted());
const dispatch = renderDialog();
fireEvent.click(screen.getByText('Migrate'));
await waitFor(() => expect(dispatch).toHaveBeenCalledWith({ type: 'DIALOG_CLOSE', name: 'noteLockMigration' }));
expect(startMock).toHaveBeenCalledWith('local', 'target', dispatch, expect.any(Function));
expect(skipMock).not.toHaveBeenCalled();
});
test('should report a wrong password and stay open', async () => {
startMock.mockRejectedValue(Object.assign(new Error('bad'), { name: 'OperationError' }));
const dispatch = renderDialog();
fireEvent.click(screen.getByText('Migrate'));
expect((await screen.findByRole('alert')).textContent).toContain('Invalid password');
expect(dispatch).not.toHaveBeenCalled();
});
test.each([true, false])('should close only once the skip is confirmed (%s)', async (confirmed) => {
skipMock.mockResolvedValue(confirmed);
const dispatch = renderDialog();
fireEvent.click(screen.getByText('Skip'));
await waitFor(() => expect(skipMock).toHaveBeenCalledWith(dispatch));
if (confirmed) await waitFor(() => expect(dispatch).toHaveBeenCalledWith({ type: 'DIALOG_CLOSE', name: 'noteLockMigration' }));
else expect(dispatch).not.toHaveBeenCalled();
expect(startMock).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,91 @@
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 LabelledPasswordInput from '../PasswordInput/LabelledPasswordInput';
import { confirmAndSkipNoteLockKeyMigration, startNoteLockKeyMigration } from '@joplin/lib/services/noteLock/NoteLockKeyMigration';
interface Props {
themeId: number;
dispatch: Dispatch;
}
export default function(props: Props) {
const [localPassword, setLocalPassword] = useState('');
const [targetPassword, setTargetPassword] = useState('');
const [migrating, setMigrating] = useState(false);
const [errorMessage, setErrorMessage] = useState('');
const onClose = useCallback(() => {
props.dispatch({
type: 'DIALOG_CLOSE',
name: 'noteLockMigration',
});
}, [props.dispatch]);
const onLocalPasswordChange = useCallback((event: React.ChangeEvent<HTMLInputElement>) => {
setLocalPassword(event.target.value);
}, []);
const onTargetPasswordChange = useCallback((event: React.ChangeEvent<HTMLInputElement>) => {
setTargetPassword(event.target.value);
}, []);
const onButtonRowClick = useCallback(async (event: ClickEvent) => {
if (event.buttonName === 'cancel') {
onClose();
return;
}
if (event.buttonName === 'skip') {
if (await confirmAndSkipNoteLockKeyMigration(props.dispatch)) onClose();
return;
}
if (event.buttonName === 'ok') {
setMigrating(true);
setErrorMessage('');
try {
await startNoteLockKeyMigration(localPassword, targetPassword, props.dispatch, onClose);
} catch (error) {
setErrorMessage(error.name === 'OperationError' ? _('Invalid password') : error.message);
setMigrating(false);
}
}
}, [localPassword, targetPassword, onClose, props.dispatch]);
return (
<Dialog onCancel={migrating ? undefined : onClose} className="note-lock-migration-dialog">
<div className="dialog-root">
<DialogTitle title={_('Migrate locked notes')}/>
<div className="dialog-content">
<p>{_('The sync target uses a different note lock key to the one on your device. Your locked notes must be re-encrypted with the synced key before synchronisation can continue. Locked notes cannot be read while they are being migrated.')}</p>
<div className="form">
<LabelledPasswordInput
labelText={_('Note lock password on this device')}
value={localPassword}
onChange={onLocalPasswordChange}
/>
<LabelledPasswordInput
labelText={_('Note lock password on the sync target')}
value={targetPassword}
onChange={onTargetPasswordChange}
/>
</div>
{!!errorMessage && <p className="error-message" role="alert">{errorMessage}</p>}
</div>
<DialogButtonRow
themeId={props.themeId}
onClick={onButtonRowClick}
okButtonLabel={_('Migrate')}
okButtonDisabled={!localPassword || !targetPassword || migrating}
cancelButtonDisabled={migrating}
customButtons={[{ name: 'skip', label: _('Skip'), disabled: migrating }]}
/>
</div>
</Dialog>
);
}
@@ -63,9 +63,9 @@ export default function(props: Props) {
return (
<Dialog onCancel={onClose} className="note-lock-unlock-dialog">
<div className="dialog-root">
<DialogTitle title={_('Unlock encrypted notes')}/>
<DialogTitle title={_('Unlock notes')}/>
<div className="dialog-content">
<p>{_('Enter the note lock password to unlock encrypted notes for this session.')}</p>
<p>{_('Enter your password to unlock your notes for this session.')}</p>
<LabelledPasswordInput
labelText={_('Note lock password')}
value={password}
+60 -12
View File
@@ -27,6 +27,11 @@ import { focus } from '@joplin/lib/utils/focusHandler';
import useDeleteHistoryClick from '@joplin/lib/components/shared/NoteRevisionViewer/useDeleteHistoryClick';
import { getGlobalSettings } from '@joplin/renderer/types';
import Setting from '@joplin/lib/models/Setting';
import isNoteLockEnabled from '@joplin/lib/services/noteLock/isNoteLockEnabled';
import NoteLockService from '@joplin/lib/services/noteLock/NoteLockService';
import NoteLockPanel from './NoteEditor/NoteLockPanel/NoteLockPanel';
import hasNoteLockKey from './utils/hasNoteLockKey';
import { Dispatch } from 'redux';
interface Props {
themeId: number;
@@ -36,6 +41,9 @@ interface Props {
scrollbarSize: ScrollbarSize;
fontFamily: string;
showNoteLinkIcon: boolean;
noteLockSessionUnlocked: boolean;
hasNoteLockKey: boolean;
dispatch: Dispatch;
}
const useNoteContent = (
@@ -47,8 +55,13 @@ const useNoteContent = (
scrollbarSize: ScrollbarSize,
fontFamily: string,
showNoteLinkIcon: boolean,
canDecrypt: boolean,
) => {
const [note, setNote] = useState<NoteEntity>(null);
// The revision note as merged from the diffs, with a locked body still encrypted. Restoring
// this keeps the restored copy a valid locked note, so it is never decrypted for restore.
const [restoreNote, setRestoreNote] = useState<NoteEntity>(null);
const [decryptFailed, setDecryptFailed] = useState(false);
const markupToHtml = useMarkupToHtml({
themeId,
@@ -60,15 +73,37 @@ const useNoteContent = (
});
useAsyncEffect(async (event) => {
// Cleared before the load so a switch never keeps the previous revision's failure or restore target.
setRestoreNote(null);
setDecryptFailed(false);
if (!revisions.length || !currentRevId) {
setNote(null);
} else {
const revIndex = BaseModel.modelIndexById(revisions, currentRevId);
const note = await RevisionService.instance().revisionNote(revisions, revIndex);
if (!note || event.cancelled) return;
setNote(note);
if (isNoteLockEnabled() && revisions[revIndex].is_locked) {
// A revision is a JSON diff object, so a gated load is not possible: the diffs are
// merged first and the resulting body is decrypted manually here.
let displayBody = '';
let failed = false;
if (canDecrypt) {
try {
displayBody = await NoteLockService.instance().decryptString(note.body ?? '');
} catch (error) {
console.warn('Could not decrypt revision content:', error);
failed = true;
}
if (event.cancelled) return;
}
setDecryptFailed(failed);
setNote({ ...note, body: displayBody });
} else {
setNote(note);
}
setRestoreNote(note);
}
}, [revisions, currentRevId, themeId, customCss, viewerRef]);
}, [revisions, currentRevId, themeId, customCss, viewerRef, canDecrypt]);
useQueuedAsyncEffect(async () => {
const noteBody = note?.body ?? _('This note has no history');
@@ -85,10 +120,10 @@ const useNoteContent = (
});
}, [note, viewerRef, markupToHtml, showNoteLinkIcon]);
return note;
return { note, restoreNote, decryptFailed };
};
const NoteRevisionViewerComponent: React.FC<Props> = ({ themeId, noteId, onBack, customCss, scrollbarSize, fontFamily, showNoteLinkIcon }) => {
const NoteRevisionViewerComponent: React.FC<Props> = ({ themeId, noteId, onBack, customCss, scrollbarSize, fontFamily, showNoteLinkIcon, noteLockSessionUnlocked, hasNoteLockKey, dispatch }) => {
const helpButton_onClick = useCallback(() => {}, []);
const viewerRef = useRef<NoteViewerControl|null>(null);
const revisionListRef = useRef<HTMLSelectElement|null>(null);
@@ -98,8 +133,9 @@ const NoteRevisionViewerComponent: React.FC<Props> = ({ themeId, noteId, onBack,
const [restoring, setRestoring] = useState(false);
const [deleting, setDeleting] = useState(false);
const note = useNoteContent(
viewerRef, currentRevId, revisions, themeId, customCss, scrollbarSize, fontFamily, showNoteLinkIcon,
const canDecrypt = noteLockSessionUnlocked && hasNoteLockKey;
const { note, restoreNote, decryptFailed } = useNoteContent(
viewerRef, currentRevId, revisions, themeId, customCss, scrollbarSize, fontFamily, showNoteLinkIcon, canDecrypt,
);
const viewer_domReady = useCallback(async () => {
@@ -112,12 +148,12 @@ const NoteRevisionViewerComponent: React.FC<Props> = ({ themeId, noteId, onBack,
}, [noteId]);
const importButton_onClick = useCallback(async () => {
if (!note) return;
if (!restoreNote) return;
setRestoring(true);
await RevisionService.instance().importRevisionNote(note);
await RevisionService.instance().importRevisionNote(restoreNote);
setRestoring(false);
await shim.showMessageBox(RevisionService.instance().restoreSuccessMessage(note), { type: MessageBoxType.Info });
}, [note]);
await shim.showMessageBox(RevisionService.instance().restoreSuccessMessage(restoreNote), { type: MessageBoxType.Info });
}, [restoreNote]);
const resetScreenState = useCallback(() => {
setRevisions([]);
@@ -188,6 +224,9 @@ const NoteRevisionViewerComponent: React.FC<Props> = ({ themeId, noteId, onBack,
);
}
const revisionLocked = isNoteLockEnabled() && revisions.some(r => r.id === currentRevId && !!r.is_locked);
const showLockPanel = revisionLocked && (!canDecrypt || decryptFailed);
const restoreButtonTitle = _('Restore');
const deleteHistoryButtonTitle = _('Delete history');
const helpMessage = getHelpMessage(restoreButtonTitle);
@@ -201,7 +240,7 @@ const NoteRevisionViewerComponent: React.FC<Props> = ({ themeId, noteId, onBack,
<select disabled={!revisions.length} value={currentRevId} className='revisions' style={theme.dropdownList} onChange={revisionList_onChange} ref={revisionListRef}>
{revisionListItems}
</select>
<button disabled={!revisions.length || restoring} onClick={importButton_onClick} className='restore'style={{ ...theme.buttonStyle, marginLeft: 10, height: theme.inputStyle.height }}>
<button disabled={!restoreNote || restoring || showLockPanel} onClick={importButton_onClick} className='restore'style={{ ...theme.buttonStyle, marginLeft: 10, height: theme.inputStyle.height }}>
{restoreButtonTitle}
</button>
<button disabled={!revisions.length || deleting} onClick={deleteHistoryButton_onClick} className='deleteHistory'style={{ ...theme.buttonStyle, marginLeft: 10, height: theme.inputStyle.height }}>
@@ -211,7 +250,8 @@ const NoteRevisionViewerComponent: React.FC<Props> = ({ themeId, noteId, onBack,
</div>
);
const viewer = <NoteTextViewer themeId={themeId} viewerStyle={{ display: 'flex', flex: 1, borderLeft: 'none' }} ref={viewerRef} onDomReady={viewer_domReady} onIpcMessage={webview_ipcMessage} />;
// The viewer stays mounted while the lock panel shows because its dom-ready event loads the revision list.
const viewer = <NoteTextViewer themeId={themeId} viewerStyle={{ display: showLockPanel ? 'none' : 'flex', flex: 1, borderLeft: 'none' }} ref={viewerRef} onDomReady={viewer_domReady} onIpcMessage={webview_ipcMessage} />;
useEffect(() => {
// We need to force focus here because otherwise the focus is lost and goes back
@@ -222,6 +262,12 @@ const NoteRevisionViewerComponent: React.FC<Props> = ({ themeId, noteId, onBack,
return (
<div className='revision-viewer-root'>
{titleInput}
{showLockPanel ? <NoteLockPanel
noteTitle={note?.title ?? ''}
hasNoteLockKey={hasNoteLockKey}
dispatch={dispatch}
undecryptable={decryptFailed && noteLockSessionUnlocked}
/> : null}
{viewer}
<ReactTooltip place="bottom" delayShow={300} className="help-tooltip" />
</div>
@@ -234,6 +280,8 @@ const mapStateToProps = (state: AppState) => {
scrollbarSize: state.settings['style.scrollbarSize'],
fontFamily: state.settings['style.viewer.fontFamily'],
showNoteLinkIcon: state.settings['notes.showNoteLinkIcon'],
noteLockSessionUnlocked: state.noteLockSessionUnlocked,
hasNoteLockKey: hasNoteLockKey(state.settings['syncInfoCache']),
};
};
@@ -9,6 +9,7 @@ import Setting from '@joplin/lib/models/Setting';
import bridge from '../services/bridge';
import { themeStyle } from '@joplin/lib/theme';
import { OneDriveApiNodeUtils } from '@joplin/lib/onedrive-api-node-utils';
import type SyncTargetOneDrive from '@joplin/lib/SyncTargetOneDrive';
interface Props {
themeId: number;
@@ -43,7 +44,7 @@ class OneDriveLoginScreenComponent extends React.Component<Props, State> {
};
const syncTargetId = Setting.value('sync.target');
const syncTarget = reg.syncTarget(syncTargetId);
const syncTarget = reg.syncTarget(syncTargetId) as SyncTargetOneDrive;
const oneDriveApiUtils = new OneDriveApiNodeUtils(syncTarget.api());
const auth = await oneDriveApiUtils.oauthDance({
log: (s: string) => log(s),
@@ -60,11 +61,11 @@ class OneDriveLoginScreenComponent extends React.Component<Props, State> {
}
public startUrl() {
return reg.syncTarget().api().authCodeUrl(this.redirectUrl());
return (reg.syncTarget() as SyncTargetOneDrive).api().authCodeUrl(this.redirectUrl());
}
public redirectUrl() {
return reg.syncTarget().api().nativeClientRedirectUrl();
return (reg.syncTarget() as SyncTargetOneDrive).api().nativeClientRedirectUrl();
}
public render() {
@@ -8,6 +8,8 @@ interface Props {
value: string;
inputId: string;
onChange: ChangeEventHandler;
onKeyDown?: React.KeyboardEventHandler<HTMLInputElement>;
inputRef?: React.Ref<HTMLInputElement>;
'aria-invalid'?: boolean;
'aria-errormessage'?: string;
@@ -34,6 +36,8 @@ const PasswordInput = (props: Props) => {
type={inputType}
value={props.value}
onChange={props.onChange}
onKeyDown={props.onKeyDown}
ref={props.inputRef}
/>
<button type="button" onClick={onShowPassword} className="showpasswordbutton">
<i className={icon} role='img' aria-label={title} title={title}></i>
+8 -3
View File
@@ -9,6 +9,7 @@ import Dialog from '@joplin/lib/components/Dialog';
import { ChangeEvent } from 'react';
import { formatDateTimeLocalToMs, isValidDate } from '@joplin/utils/time';
import lightTheme from '@joplin/lib/themes/light';
import PasswordInput from './PasswordInput/PasswordInput';
interface Props {
themeId: number;
@@ -321,6 +322,8 @@ export default class PromptDialog extends React.Component<Props, any> {
} else if (this.props.inputType === 'dropdown') {
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- makeAnimated's generic doesn't unify with react-select's expected SelectComponents shape
inputComp = <Select className="item-selector" onMenuOpen={this.select_menuOpen} onMenuClose={this.select_menuClose} styles={styles.select} theme={styles.selectTheme} ref={this.answerInput_} components={makeAnimated() as any} value={this.props.answer} defaultValue={this.props.defaultValue} isClearable={false} options={this.props.autocomplete} onChange={onSelectChange} onKeyDown={onKeyDown} />;
} else if (this.props.inputType === 'password') {
inputComp = <PasswordInput inputId='prompt-dialog-password' inputRef={this.answerInput_} value={this.state.answer} onChange={event => onChange(event)} onKeyDown={event => onKeyDown(event)} />;
} else {
inputComp = <input style={styles.input} ref={this.answerInput_} value={this.state.answer} type="text" onChange={event => onChange(event)} onKeyDown={event => onKeyDown(event)} />;
}
@@ -355,10 +358,12 @@ export default class PromptDialog extends React.Component<Props, any> {
);
}
// A password prompt puts its input below the label at a fixed width, shrinking with the window.
const isPassword = this.props.inputType === 'password';
return (
<Dialog className='prompt-dialog' contentStyle={styles.dialog} onCancel={() => onClose(false, 'cancel')}>
<label style={styles.label}>{this.props.label ? this.props.label : ''}</label>
<div style={{ display: 'inline-block', color: 'black', backgroundColor: theme.backgroundColor }}>
<Dialog className='prompt-dialog' contentStyle={isPassword ? { boxSizing: 'border-box', width: 500, maxWidth: '100%' } : styles.dialog} onCancel={() => onClose(false, 'cancel')}>
<label style={isPassword ? { ...styles.label, display: 'block', marginBottom: 10 } : styles.label}>{this.props.label ? this.props.label : ''}</label>
<div style={{ display: isPassword ? 'block' : 'inline-block', color: 'black', backgroundColor: theme.backgroundColor }}>
{inputComp}
{descComp}
</div>
@@ -98,7 +98,7 @@ export function PublishFolderDialog(props: Props) {
</div>
</div>
{publishedShare && (
<Button disabled={isUnpublishing} tooltip={_('Unpublish notebook')} iconName="fas fa-share-alt" onClick={onUnpublishFolderClick}/>
<Button disabled={isUnpublishing} tooltip={_('Unpublish notebook')} iconName="fas fa-unlink" onClick={onUnpublishFolderClick}/>
)}
</div>
<button
+1 -1
View File
@@ -134,7 +134,7 @@ 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 })}/>
<Button tooltip={_('Unpublish note')} iconName='fas fa-unlink' onClick={() => onUnshareNoteClick({ noteId: note.id })}/>
);
return (
+432
View File
@@ -0,0 +1,432 @@
import Setting from '@joplin/lib/models/Setting';
import shim from '@joplin/lib/shim';
import * as React from 'react';
import { Dispatch } from 'redux';
import checkForUpdates, { isReleaseVersion } from '../checkForUpdates';
import bridge from '../services/bridge';
import restart from '../services/restart';
import invitationRespond from '@joplin/lib/services/share/invitationRespond';
import { _ } from '@joplin/lib/locale';
import { ShareInvitation } from '@joplin/lib/services/share/reducer';
import { MasterKeyEntity } from '@joplin/lib/services/e2ee/types';
import useAsyncEffect from '@joplin/lib/hooks/useAsyncEffect';
import { useEffect, useRef, useState } from 'react';
import Logger from '@joplin/utils/Logger';
import { AppState } from '../app.reducer';
import { localSyncInfoFromState } from '@joplin/lib/services/synchronizer/syncInfoUtils';
import EncryptionService from '@joplin/lib/services/e2ee/EncryptionService';
import { showMissingMasterKeyMessage, showUnknownKeyFormatBanner } from '@joplin/lib/services/e2ee/utils';
import { noteLockKeyConflictFromState } from '@joplin/lib/services/synchronizer/syncInfoUtils';
import { NoteLockMigrationStatus } from '@joplin/lib/reducer';
import { confirmAndSkipNoteLockKeyMigration, notMigratedMessage, withSkippedCount } from '@joplin/lib/services/noteLock/NoteLockKeyMigration';
import shouldShowMissingPasswordWarning from '@joplin/lib/components/shared/config/shouldShowMissingPasswordWarning';
import { connect } from 'react-redux';
const logger = Logger.create('WarningBanner');
interface Props {
dispatch: Dispatch;
isSafeMode: boolean;
hasMissingSyncCredentials: boolean;
shouldUpgradeSyncTarget: boolean;
hasDisabledEncryptionItems: boolean;
showNeedUpgradingMasterKeyMessage: boolean;
showShouldReencryptMessage: boolean;
processingShareInvitationResponse: boolean;
shareInvitations: ShareInvitation[];
hasDisabledSyncItems: boolean;
showUnknownKeyFormatMessage: boolean;
showMissingMasterKeyMessage: boolean;
showNoteLockKeyConflictMessage: boolean;
noteLockMigrationStatus: NoteLockMigrationStatus|null;
mustUpgradeAppMessage: string;
syncTargetAppMinVersion: string;
shouldSwitchToAppleSiliconVersion: boolean;
showInvalidJoplinCloudCredential: boolean;
onShow: ()=> void;
onHide: ()=> void;
height: number;
}
const WarningBanner: React.FC<Props> = props => {
const propsRef = useRef(props);
propsRef.current = props;
const renderNotificationMessage = (
message: string, callForAction: string = null, callForActionHandler: ()=> void = null, callForAction2: string = null, callForActionHandler2: ()=> void = null,
) => {
return <NotificationMessage
message={message}
callForAction={callForAction}
callForActionHandler={callForActionHandler}
callForAction2={callForAction2}
callForActionHandler2={callForActionHandler2}
/>;
};
const onViewStatusScreen = () => {
props.dispatch({
type: 'NAV_GO',
routeName: 'Status',
});
};
const onViewEncryptionConfigScreen = () => {
props.dispatch({
type: 'NAV_GO',
routeName: 'Config',
props: {
defaultSection: 'encryption',
},
});
};
const onMigrateLockedNotes = () => {
props.dispatch({
type: 'DIALOG_OPEN',
name: 'noteLockMigration',
});
};
const onDismissNoteLockMigrationStatus = () => {
props.dispatch({
type: 'NOTE_LOCK_MIGRATION_STATUS_SET',
value: null,
});
};
const onViewJoplinCloudLoginScreen = () => {
props.dispatch({
type: 'NAV_GO',
routeName: 'JoplinCloudLogin',
});
};
const onDisableSync = () => {
Setting.setValue('sync.target', null);
};
const onViewSyncSettingsScreen = () => {
props.dispatch({
type: 'NAV_GO',
routeName: 'Config',
props: {
defaultSection: 'sync',
},
});
};
const onDownloadAppleSiliconVersion = () => {
// The website should redirect to the correct version
shim.openUrl('https://joplinapp.org/download/');
};
const onCheckForUpdates = () => {
void checkForUpdates(false, bridge().mainWindow(), { includePreReleases: false });
};
const onRestartAndUpgrade = async () => {
Setting.setValue('sync.upgradeState', Setting.SYNC_UPGRADE_STATE_MUST_DO);
await Setting.saveAll();
await restart();
};
const onDisableSafeModeAndRestart = async () => {
Setting.setValue('isSafeMode', false);
await Setting.saveAll();
await restart();
};
const onInvitationRespond = async (shareUserId: string, folderId: string, masterKey: MasterKeyEntity, accept: boolean) => {
await invitationRespond(shareUserId, folderId, masterKey, accept);
};
const showShareInvitationNotification = () => {
if (props.processingShareInvitationResponse) return false;
return !!props.shareInvitations.find(i => i.status === 0);
};
const appMinVersionState = useAppMinVersionState({ appMinVersion: props.syncTargetAppMinVersion, mustUpgradeAppMessage: props.mustUpgradeAppMessage });
let msg = null;
if (props.isSafeMode) {
msg = renderNotificationMessage(
_('Safe mode is currently active. Note rendering and all plugins are temporarily disabled.'),
_('Disable safe mode and restart'),
onDisableSafeModeAndRestart,
);
} else if (props.hasMissingSyncCredentials) {
msg = renderNotificationMessage(
_('The synchronisation password is missing.'),
_('Set the password'),
onViewSyncSettingsScreen,
);
} else if (props.noteLockMigrationStatus?.running) {
msg = renderNotificationMessage(_('Re-encrypting your locked notes with the synced key. Migration is in progress...'));
} else if (props.noteLockMigrationStatus?.failed || props.noteLockMigrationStatus?.awaitingDecryption) {
msg = renderNotificationMessage(
notMigratedMessage(props.noteLockMigrationStatus),
_('Retry'),
onMigrateLockedNotes,
_('Skip'),
() => void confirmAndSkipNoteLockKeyMigration(props.dispatch),
);
} else if (props.noteLockMigrationStatus) {
msg = renderNotificationMessage(
withSkippedCount(_('The migration of your locked notes has completed.'), props.noteLockMigrationStatus),
_('Dismiss'),
onDismissNoteLockMigrationStatus,
);
} else if (props.showNoteLockKeyConflictMessage) {
msg = renderNotificationMessage(
_('Synchronisation is stopped because the sync target uses a different note lock key to the one on your device.'),
_('Migrate your locked notes'),
onMigrateLockedNotes,
);
} else if (props.shouldUpgradeSyncTarget) {
msg = renderNotificationMessage(
_('The sync target needs to be upgraded before Joplin can sync. The operation may take a few minutes to complete and the app needs to be restarted. To proceed please click on the link.'),
_('Restart and upgrade'),
onRestartAndUpgrade,
);
} else if (props.hasDisabledEncryptionItems) {
msg = renderNotificationMessage(
_('Some items cannot be decrypted.'),
_('View them now'),
onViewStatusScreen,
);
} else if (props.showNeedUpgradingMasterKeyMessage) {
msg = renderNotificationMessage(
_('One of your master keys use an obsolete encryption method.'),
_('View them now'),
onViewEncryptionConfigScreen,
);
} else if (props.showShouldReencryptMessage) {
msg = renderNotificationMessage(
_('The default encryption method has been changed, you should re-encrypt your data.'),
_('More info'),
onViewEncryptionConfigScreen,
);
} else if (showShareInvitationNotification()) {
const invitation = props.shareInvitations.find(inv => inv.status === 0);
const sharer = invitation.share.user;
msg = renderNotificationMessage(
_('%s (%s) would like to share a notebook with you.', sharer.full_name, sharer.email),
_('Accept'),
() => onInvitationRespond(invitation.id, invitation.share.folder_id, invitation.master_key, true),
_('Reject'),
() => onInvitationRespond(invitation.id, invitation.share.folder_id, invitation.master_key, false),
);
} else if (props.hasDisabledSyncItems) {
msg = renderNotificationMessage(
_('Some items cannot be synchronised.'),
_('View them now'),
onViewStatusScreen,
);
} else if (props.showMissingMasterKeyMessage) {
msg = renderNotificationMessage(
_('One or more master keys need a password.'),
_('Set the password'),
onViewEncryptionConfigScreen,
);
} else if (props.mustUpgradeAppMessage) {
if (!props.syncTargetAppMinVersion) {
msg = renderNotificationMessage(props.mustUpgradeAppMessage);
} else if (appMinVersionState.didReleaseLoadFail) {
msg = renderNotificationMessage(
_(
'In order to synchronise, Please upgrade your application to version %s. Joplin could not check update information.',
props.syncTargetAppMinVersion,
),
);
} else if (appMinVersionState.isRelease === false && shim.isLinux()) {
const callForAction = _('Download it from GitHub Releases');
msg = renderNotificationMessage(
_(
'In order to synchronise, Please upgrade your application to version %s: %s or update it using your package manager',
props.syncTargetAppMinVersion,
callForAction,
),
callForAction,
() => shim.openUrl('https://github.com/laurent22/joplin/releases'),
);
} else if (appMinVersionState.isRelease !== null) {
const isTargetPreRelease = appMinVersionState.isRelease === false;
const callForAction = isTargetPreRelease ? _('Download it from GitHub Releases') : _('Check for updates');
msg = renderNotificationMessage(
_(
'In order to synchronise, Please upgrade your application to version %s: %s',
props.syncTargetAppMinVersion,
callForAction,
),
callForAction,
isTargetPreRelease ? () => shim.openUrl('https://github.com/laurent22/joplin/releases') : onCheckForUpdates,
);
} else {
msg = renderNotificationMessage(props.mustUpgradeAppMessage);
}
} else if (props.showUnknownKeyFormatMessage) {
msg = renderNotificationMessage(
_('One or more encryption keys are stored in an unknown format.'),
_('Manage'),
onViewEncryptionConfigScreen,
);
} else if (props.shouldSwitchToAppleSiliconVersion) {
msg = renderNotificationMessage(
_('You are running the Intel version of Joplin on an Apple Silicon processor. Download the Apple Silicon one for better performance.'),
_('Download it now'),
onDownloadAppleSiliconVersion,
);
} else if (props.showInvalidJoplinCloudCredential) {
msg = renderNotificationMessage(
_('Your Joplin Cloud credentials are invalid, please login.'),
_('Login to Joplin Cloud.'),
onViewJoplinCloudLoginScreen,
_('Disable synchronisation'),
onDisableSync,
);
}
const visible = !!msg;
const lastVisibleRef = useRef(false);
useEffect(() => {
if (visible !== lastVisibleRef.current) {
lastVisibleRef.current = visible;
if (visible) {
propsRef.current.onShow();
} else {
propsRef.current.onHide();
}
}
}, [visible]);
if (!visible) return null;
return (
<div
style={{ height: props.height }}
className='warning-banner -header'
>
<span
className='content'
role='alert'
// role='alert' has an implicit aria-live='assertive', which tells screen readers that changes
// to the warning's content should be announced as soon as possible. However, since it's generally
// okay for announcements related to these notifications to be delayed, use aria-live='polite'.
aria-live='polite'
>{msg}</span>
</div>
);
};
const mapStateToProps = (state: AppState) => {
const syncInfo = localSyncInfoFromState(state);
const showNeedUpgradingEnabledMasterKeyMessage = !!EncryptionService.instance().masterKeysThatNeedUpgrading(syncInfo.masterKeys.filter((k) => !!k.enabled)).length;
return {
hasDisabledSyncItems: state.hasDisabledSyncItems,
hasDisabledEncryptionItems: state.hasDisabledEncryptionItems,
showMissingMasterKeyMessage: showMissingMasterKeyMessage(syncInfo, state.notLoadedMasterKeys),
showUnknownKeyFormatMessage: showUnknownKeyFormatBanner(syncInfo),
showNoteLockKeyConflictMessage: noteLockKeyConflictFromState(state),
noteLockMigrationStatus: state.noteLockMigrationStatus,
showNeedUpgradingMasterKeyMessage: showNeedUpgradingEnabledMasterKeyMessage,
showShouldReencryptMessage: state.settings['encryption.shouldReencrypt'] >= Setting.SHOULD_REENCRYPT_YES,
shouldUpgradeSyncTarget: state.settings['sync.upgradeState'] === Setting.SYNC_UPGRADE_STATE_SHOULD_DO,
hasMissingSyncCredentials: shouldShowMissingPasswordWarning(state.settings['sync.target'], state.settings),
shareInvitations: state.shareService.shareInvitations,
processingShareInvitationResponse: state.shareService.processingShareInvitationResponse,
isSafeMode: state.settings.isSafeMode,
mustUpgradeAppMessage: state.mustUpgradeAppMessage,
syncTargetAppMinVersion: syncInfo.appMinVersion,
showInvalidJoplinCloudCredential: state.settings['sync.target'] === 10 && state.mustAuthenticate,
shouldSwitchToAppleSiliconVersion: shim.isAppleSilicon() && shim.isMac() && process.arch !== 'arm64',
};
};
export default connect(mapStateToProps)(WarningBanner);
type CallForActionHandler = ()=> void;
interface NotificationMessageProps {
message: string;
callForAction: string|null;
callForActionHandler: CallForActionHandler|null;
callForAction2: string|null;
callForActionHandler2: CallForActionHandler|null;
}
const NotificationMessage: React.FC<NotificationMessageProps> = ({ message, callForAction, callForActionHandler, callForAction2, callForActionHandler2 }) => {
if (!callForAction) return <span>{message}</span>;
const cfa = (
<a href="#" className='warning-banner-link -underline' onClick={() => callForActionHandler()}>
{callForAction}
</a>
);
const cfa2 = !callForAction2 ? null : (
<a href="#" className='warning-banner-link -underline' onClick={() => callForActionHandler2()}>
{callForAction2}
</a>
);
if (!callForAction2 && message.includes(callForAction)) {
const actionIndex = message.indexOf(callForAction);
return (
<span>
{message.substring(0, actionIndex)}
{cfa}
{message.substring(actionIndex + callForAction.length)}
</span>
);
}
return (
<span>
{message}{callForAction ? ' ' : ''}
{cfa}{callForAction2 ? ' / ' : ''}{cfa2}
</span>
);
};
interface AppMinVersionIsReleaseProps {
appMinVersion: string;
mustUpgradeAppMessage: string;
}
const useAppMinVersionState = (props: AppMinVersionIsReleaseProps) => {
const version = props.appMinVersion;
const [state, setState] = useState({
isRelease: null,
didReleaseLoadFail: false,
});
useAsyncEffect(async (event) => {
setState({ isRelease: null, didReleaseLoadFail: false });
if (!props.mustUpgradeAppMessage || !version) return;
try {
const isRelease = await isReleaseVersion(version);
if (event.cancelled) return;
setState({
isRelease,
didReleaseLoadFail: isRelease === null,
});
} catch (error) {
logger.error(error);
if (event.cancelled) return;
setState(state => ({
...state,
didReleaseLoadFail: true,
}));
}
}, [version, props.mustUpgradeAppMessage]);
return state;
};
@@ -1,23 +1,29 @@
import * as React from 'react';
import Dialog from '@joplin/lib/components/Dialog';
import { _ } from '@joplin/lib/locale';
interface Props {
message: string;
hasCloseButton: boolean;
onClose: ()=> void;
}
const ModalMessageOverlay: React.FC<Props> = ({ message }) => {
const ModalMessageOverlay: React.FC<Props> = ({ message, hasCloseButton, onClose }) => {
let brIndex = 1;
const lines = message.split('\n').map((line: string) => {
if (!line.trim()) return <br key={`${brIndex++}`}/>;
return <div key={line} className="text">{line}</div>;
});
return <Dialog contentFillsScreen={true}>
<div className="modal-message">
<div className="loading-animation" />
<div className="text" role="status">
return <Dialog contentFillsScreen={true} onCancel={hasCloseButton ? onClose : undefined}>
<div className={`modal-message ${hasCloseButton ? '-with-close-button' : ''}`}>
{!hasCloseButton && <div className="loading-animation" />}
<div className={`text ${hasCloseButton ? 'modal-message-scrollable-content' : ''}`} role="status">
{lines}
</div>
{hasCloseButton && <div className="modal-message-actions">
<button type="button" onClick={onClose}>{_('OK')}</button>
</div>}
</div>
</Dialog>;
};
@@ -32,6 +32,7 @@ interface Props {
appDialogStates: AppStateDialog[];
pluginsLegacy: Record<string, { dialogOpen?: boolean; userData?: unknown }>;
modalMessage: string|null;
modalMessageHasCloseButton: boolean;
customCss: string;
editorNoteStatuses: EditorNoteStatuses;
@@ -127,7 +128,11 @@ const WindowCommandsAndDialogs: React.FC<Props> = props => {
return <>
<div ref={setReferenceElement}/>
{pluginDialog}
{props.modalMessage !== null ? <ModalMessageOverlay message={props.modalMessage}/> : null}
{props.modalMessage !== null ? <ModalMessageOverlay
message={props.modalMessage}
hasCloseButton={props.modalMessageHasCloseButton}
onClose={() => props.dispatch({ type: 'HIDE_MODAL_MESSAGE' })}
/> : null}
<PluginDialogs
themeId={props.themeId}
visibleDialogs={props.visibleDialogs}
@@ -210,5 +215,6 @@ export default connect((state: AppState, ownProps: ConnectProps) => {
editorNoteStatuses: state.editorNoteStatuses,
pluginsLegacy: state.pluginsLegacy,
modalMessage: state.modalOverlayMessage,
modalMessageHasCloseButton: state.modalOverlayHasCloseButton,
};
})(WindowCommandsAndDialogs);
@@ -6,7 +6,7 @@ import bridge from '../../../services/bridge';
export const declaration: CommandDeclaration = {
name: 'disableNoteEncryption',
label: () => _('Disable encryption'),
label: () => _('Remove note lock'),
};
export const runtime = (): CommandRuntime => {
@@ -10,7 +10,7 @@ import bridge from '../../../services/bridge';
export const declaration: CommandDeclaration = {
name: 'enableNoteEncryption',
label: () => _('Enable encryption'),
label: () => _('Lock this note'),
};
export const runtime = (): CommandRuntime => {
@@ -30,7 +30,7 @@ export const runtime = (): CommandRuntime => {
}
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?'), {
const setUpNow = bridge().showConfirmMessageBox(_('Locking a note requires the note lock password, which has not been set up yet. Set it up now?'), {
buttons: [_('Yes'), _('No')],
});
if (setUpNow) {
@@ -65,6 +65,6 @@ export const runtime = (): CommandRuntime => {
bridge().showErrorMessageBox(error.message);
}
},
enabledCondition: 'oneNoteSelected && !noteIsLocked && !noteIsReadOnly && !noteIsDeleted && !inTrash && !inConflictFolder',
enabledCondition: 'oneNoteSelected && !noteIsLocked && !noteIsReadOnly && !noteIsDeleted && !inTrash && !inConflictFolder && !noteLockMigrationRunning',
};
};
@@ -19,6 +19,13 @@ export const runtime = (comp: WindowControl): CommandRuntime => {
if (!noteIds.length) throw new Error('No notes selected for pdf export');
const lockedNoteChoice = await InteropServiceHelper.confirmLockedNoteExport(context.dispatch, noteIds);
if (lockedNoteChoice === 'cancel') return;
if (lockedNoteChoice === 'skipLocked') {
noteIds = (await Note.byIds(noteIds, { fields: ['id', 'is_locked'] })).filter(note => !note.is_locked).map(note => note.id);
if (!noteIds.length) return;
}
let path = null;
if (noteIds.length === 1) {
path = await bridge().showSaveDialog({
@@ -61,6 +68,6 @@ export const runtime = (comp: WindowControl): CommandRuntime => {
}
},
enabledCondition: 'someNotesSelected',
enabledCondition: 'someNotesSelected && (!noteLockContentUnavailable || !noteLockSessionUnlocked)',
};
};
@@ -12,10 +12,25 @@ 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';
import { Dispatch } from 'redux';
import { MasterKeyEntity } from '@joplin/lib/services/e2ee/types';
import { promptForImportedNoteLockKey } from '@joplin/lib/services/noteLock/noteLockPrompts';
import noteLockPrompts from '../../../utils/noteLockPrompts';
import { ErrorCode } from '@joplin/lib/errors';
const packageInfo: PackageInfo = require('../../../packageInfo.js');
const logger = Logger.create('importFrom');
const makeImportNoteLockKeyHandler = (modalMessage: string, dispatch: Dispatch) => async (backupKey: MasterKeyEntity) => {
// The password prompts are DOM dialogs and would render behind the import modal overlay.
void CommandService.instance().execute('hideModalMessage');
try {
return await promptForImportedNoteLockKey(backupKey, noteLockPrompts(dispatch));
} finally {
void CommandService.instance().execute('showModalMessage', modalMessage);
}
};
export const declaration: CommandDeclaration = {
name: 'importFrom',
label: () => _('Import...'),
@@ -143,6 +158,7 @@ export const runtime = (control: WindowControl): CommandRuntime => {
console.warn(error);
},
destinationFolderId: options.destinationFolderId,
onNoteLockKey: makeImportNoteLockKeyHandler(modalMessage, context.dispatch),
};
const service = InteropService.instance();
@@ -151,8 +167,10 @@ export const runtime = (control: WindowControl): CommandRuntime => {
// eslint-disable-next-line no-console
console.info('Import result: ', result);
} catch (error) {
logger.error(error);
bridge().showErrorMessageBox(error.message);
if (error.code !== ErrorCode.Cancelled) {
logger.error(error);
bridge().showErrorMessageBox(error.message);
}
}
void CommandService.instance().execute('hideModalMessage');
@@ -34,6 +34,7 @@ import * as renameTag from './renameTag';
import * as resetLayout from './resetLayout';
import * as restoreFolder from './restoreFolder';
import * as restoreNote from './restoreNote';
import * as revealInNotebook from './revealInNotebook';
import * as revealResourceFile from './revealResourceFile';
import * as search from './search';
import * as setTags from './setTags';
@@ -94,6 +95,7 @@ const index: any[] = [
resetLayout,
restoreFolder,
restoreNote,
revealInNotebook,
revealResourceFile,
search,
setTags,
@@ -5,7 +5,7 @@ import NoteLockSession from '@joplin/lib/services/noteLock/NoteLockSession';
export const declaration: CommandDeclaration = {
name: 'lockEncryptedNotes',
label: () => _('Lock encrypted notes'),
label: () => _('Relock all notes'),
};
export const runtime = (): CommandRuntime => {
@@ -2,6 +2,7 @@ import { CommandRuntime, CommandDeclaration, CommandContext } from '@joplin/lib/
import { _ } from '@joplin/lib/locale';
import { WindowControl } from '../utils/useWindowControl';
import bridge from '../../../services/bridge';
import InteropServiceHelper from '../../../InteropServiceHelper';
export const declaration: CommandDeclaration = {
name: 'print',
@@ -16,6 +17,8 @@ export const runtime = (comp: WindowControl): CommandRuntime => {
try {
if (noteIds.length !== 1) throw new Error(_('Only one note can be printed at a time.'));
// A plugin can run this with any note, so a locked one is only printed once the user agrees, as for the PDF export.
if (await InteropServiceHelper.confirmLockedNoteExport(context.dispatch, noteIds) !== 'export') return;
await comp.printTo('printer', { noteId: noteIds[0] });
} catch (error) {
bridge().showErrorMessageBox(error.message);
Loaded 100 of 449 files, more files were not shown because too many files have changed in this diff. Show more