1
0
mirror of https://github.com/laurent22/joplin.git synced 2026-01-14 00:29:38 +02:00

Compare commits

...

122 Commits

Author SHA1 Message Date
Laurent Cozic
f53bb2d167 Merge branch 'dev' into sharing_bug_2 2025-07-11 17:28:09 +01:00
Laurent Cozic
4231f8cced Chore: asset files 2025-07-11 17:26:01 +01:00
Laurent Cozic
3f9c60dd10 Doc: Fix JSB link 2025-07-11 17:25:42 +01:00
mrjo118
35e189ef6e Desktop, Mobile: Resolves #12594: Move the conflicts folder to the top of the notebook list to improve visibility (#12688) 2025-07-11 10:59:26 +01:00
renovate[bot]
a15bad37b1 Update dependency lint-staged to v15.5.0 (#12693)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-07-11 10:56:47 +01:00
Laurent Cozic
8b4ad0aaf7 Doc: Add Joplin Server Business to plans and add page about it (#12694) 2025-07-10 20:48:56 +01:00
Henry Heino
c3575672b2 Web: Image editor: Fix scrollbars sometimes incorrectly visible (#12692) 2025-07-10 12:35:42 +01:00
Joplin Bot
e840d0c3fd Doc: Auto-update documentation
Auto-updated using release-website.sh
2025-07-10 01:10:52 +00:00
Laurent Cozic
5227ba1adb Merge branch 'release-3.3' into dev 2025-07-10 00:47:30 +01:00
Laurent Cozic
ea49907327 iOS 13.3.9 2025-07-10 00:17:48 +01:00
Laurent Cozic
0dd90a7542 Android 3.3.11 2025-07-09 23:59:54 +01:00
Henry Heino
a962f48b38 Mobile: Biometrics: Fix notebook list can still be accessed when the app is locked (#12691) 2025-07-09 23:47:06 +01:00
renovate[bot]
f68d2bbc7c Update dependency pg to v8.14.1 (#12690)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-07-09 21:15:47 +01:00
renovate[bot]
65c9665a2a Update dependency pg to v8.14.0 (#12689)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-07-09 19:14:50 +01:00
Henry Heino
2c50ad36c5 Desktop: Fix secondary window controls greyed out when first opened (#12685) 2025-07-09 08:43:06 +01:00
Henry Heino
7212269107 Clipper: Fixes #12683: Fix web clipper fails to clip pages that include comments in inline styles (#12686) 2025-07-09 08:42:23 +01:00
renovate[bot]
1387470f2a Update dependency katex to v0.16.22 (#12687)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-07-09 05:59:16 +00:00
Henry Heino
a6d5eb9b8e Chore: Resolves #12283: Server: Add fuzzer for detecting sync bugs (#12592)
Co-authored-by: Laurent Cozic <laurent22@users.noreply.github.com>
2025-07-07 16:07:27 +01:00
pedr
5d1a055d2a Chore: Fixes #12673: Removing warning of obsolete snapshots from OneNote importer tests (#12675) 2025-07-07 16:06:52 +01:00
Henry Heino
36910a2a9b Chore: Desktop: Decrease source map size (#12679) 2025-07-07 16:06:33 +01:00
Joplin Bot
b4a57a10aa Doc: Auto-update documentation
Auto-updated using release-website.sh
2025-07-07 12:33:47 +00:00
Laurent Cozic
bca8cb1c2d Doc: Update sponsors 2025-07-07 12:33:05 +01:00
Joplin Bot
0b489a9c98 Doc: Auto-update documentation
Auto-updated using release-website.sh
2025-07-06 16:59:09 +00:00
renovate[bot]
ce32651794 Update dependency koa to v2.16.1 (#12677)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-07-06 04:55:44 +00:00
Laurent Cozic
f0159cdd89 Doc: Update sponsors 2025-07-05 11:57:37 +01:00
Laurent Cozic
97652fa362 Tools: Setup runForTesting script to also create shares and send to recipient 2025-07-05 11:57:37 +01:00
Laurent Cozic
113b259b81 update 2025-07-05 11:52:27 +01:00
Henry Heino
2af895477f Cli: Refresh shares when running the sync command (#12667) 2025-07-04 18:52:54 +01:00
Laurent Cozic
c7e31d1ac9 update 2025-07-04 16:37:17 +01:00
Laurent Cozic
c51b13ca73 update 2025-07-04 15:37:35 +01:00
Laurent Cozic
f5febb18b4 Tools: Setup runForTesting script to also create shares and send to recipient 2025-07-04 15:35:32 +01:00
renovate[bot]
9d6aa1c739 Update dependency @rollup/plugin-node-resolve to v16.0.1 (#12672)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-07-04 03:03:47 +00:00
renovate[bot]
3b27f84996 Update dependency @rollup/plugin-node-resolve to v16 (#12668)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-07-04 00:09:25 +01:00
Henry Heino
fc38691f3a Desktop: Fixes #12451: Fix incorrect line numbers/files in debug output (#12664) 2025-07-03 17:48:11 +01:00
renovate[bot]
d2274319f9 Update dependency uuid to v11.1.0 (#12665)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-07-03 14:56:17 +01:00
renovate[bot]
a40448fed9 Update dependency uuid to v11 (#12659)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-07-03 01:31:32 +01:00
Henry Heino
5ec79c74e2 Desktop: Disable console wrapper (#12663) 2025-07-02 20:48:46 +01:00
Laurent Cozic
bbba19eb40 All: Fixes #12089: Moving sub-notebook of shared notebook should unshare it (#12647) 2025-07-02 18:14:47 +01:00
Henry Heino
75b89c7e09 Desktop,Cli: Fix data API failure when including both conflicts and deleted notes in results (#12650) 2025-07-02 17:22:02 +01:00
Laurent Cozic
f9af9a724c Server: Ensure shares are writable (#12651) 2025-07-02 16:14:52 +01:00
renovate[bot]
6e7c9c059d Update dependency react-native-share to v12.0.9 (#12656)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-07-02 15:04:24 +01:00
renovate[bot]
69ee435b0b Update dependency react-native-zip-archive to v7 (#12657)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-07-02 14:19:07 +01:00
renovate[bot]
204f1bf509 Update dependency react-native-share to v12 (#12655)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-07-02 09:31:17 +01:00
renovate[bot]
7a7a2c4cec Update dependency react-native-device-info to v14.0.4 (#12654)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-07-02 09:31:10 +01:00
renovate[bot]
441486acaa Update dependency react-native-modal-datetime-picker to v18 (#12652)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-07-02 09:31:01 +01:00
renovate[bot]
4684142df7 Update dependency raw-body to v3 (#12646)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-07-02 00:41:59 +01:00
renovate[bot]
0a871ea44b Update dependency react-native-device-info to v14 (#12649)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-07-02 00:41:46 +01:00
Henry Heino
901fe73c08 Cli: Support managing shared notebooks (#12637) 2025-07-01 22:47:03 +01:00
renovate[bot]
41553eb963 Update dependency nodejs to v23.8.0 (#12641)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-07-01 22:43:59 +01:00
Laurent Cozic
cada200575 CI: Fixed random Joplin Server test failure on CI 2025-07-01 21:42:21 +01:00
renovate[bot]
13711c6a9c Update dependency python to v3.13.1 (#12645)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-07-01 21:25:11 +01:00
renovate[bot]
1a6acee5c8 Update dependency @types/react-dom to v18.3.6 (#12640)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-07-01 18:21:58 +01:00
renovate[bot]
0c2547a780 Update dependency python (#12644)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-07-01 18:21:47 +01:00
renovate[bot]
e0204d672b Update dependency npm-package-json-lint to v8 (#12642)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-07-01 16:15:00 +01:00
renovate[bot]
9c9b06de2d Update dependency @types/react to v19.0.14 (#12636)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-07-01 12:23:54 +01:00
renovate[bot]
58f3344564 Update dependency nodejs to v23 (#12638)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-07-01 12:23:41 +01:00
renovate[bot]
f6fef5a8ec Update dependency madge to v8 (#12629)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-07-01 08:40:43 +01:00
renovate[bot]
e0211045db Update types (#12634)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-07-01 08:39:37 +01:00
renovate[bot]
f757221d44 Update dependency glob to v11.0.1 (#12631)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-07-01 03:31:06 +00:00
Henry Heino
552ecc9064 Android, iOS: Fix camera screen (#12624) 2025-06-30 21:11:59 +01:00
renovate[bot]
7d4864193f Update dependency jsdom to v25 (#12628)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-06-30 21:11:41 +01:00
renovate[bot]
81e2205a53 Update dependency glob to v11 (#12627)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-06-30 21:11:27 +01:00
renovate[bot]
4e89890a23 Update dependency @types/node to v18.19.86 (#12625)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-06-30 21:10:44 +01:00
renovate[bot]
60de33b8be Update dependency @rollup/plugin-replace to v6.0.2 (#12616)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-06-30 17:44:30 +01:00
renovate[bot]
84d6f5dfcb Update dependency @types/uuid to v10 (#12617)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-06-30 17:44:22 +01:00
mrjo118
d0d80c0e4a Mobile: Add missing title to the revision viewer for mobile (#12611) 2025-06-30 14:53:19 +01:00
Laurent Cozic
798c1e1c2b Update renovate.json5 2025-06-30 14:01:07 +01:00
renovate[bot]
1eef44d243 Update dependency @react-native-community/cli-platform-ios to v16 (#12608)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-06-30 13:54:40 +01:00
renovate[bot]
e5adaa7f74 Update dependency @rollup/plugin-replace to v6 (#12612)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-06-30 13:54:09 +01:00
renovate[bot]
671997af96 Update dependency @types/node to v18.19.85 (#12614)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-06-30 13:53:57 +01:00
renovate[bot]
2bf968f9ad Update dependency @crowdin/cli to v4 (#12601)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-06-30 11:55:25 +01:00
renovate[bot]
3e06dd989f Update dependency @rollup/plugin-commonjs to v28.0.3 (#12613)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-06-30 11:50:32 +01:00
renovate[bot]
3459355285 Update dependency @rollup/plugin-commonjs to v28 (#12610)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-06-30 09:01:02 +01:00
renovate[bot]
7406a89dc0 Update dependency @react-native-community/cli-platform-android to v16.0.2 (#12607)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-06-30 01:41:17 +00:00
renovate[bot]
ace662cc79 Update dependency @react-native-community/cli to v16.0.2 (#12605)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-06-29 22:33:57 +00:00
renovate[bot]
0c5d5e59f3 Update bitnami/postgresql Docker tag to v17.3.0 (#12604)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-06-29 20:09:06 +01:00
renovate[bot]
b00aadb542 Update dependency @react-native-community/cli-platform-android to v16 (#12606)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-06-29 20:08:30 +01:00
renovate[bot]
d6883e6ec1 Update dependency re-resizable to v6.11.2 (#12591)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-06-29 18:08:25 +01:00
renovate[bot]
6ac64ca0d9 Update dependency @react-native-community/cli to v16 (#12602)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-06-29 17:34:03 +01:00
renovate[bot]
9890d267a1 Update bitnami/postgresql Docker tag to v17 (#12600)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-06-29 16:40:21 +01:00
Laurent Cozic
1a1335a7d5 Update renovate.json5 2025-06-29 14:56:37 +01:00
renovate[bot]
67288f0b44 Update dependency @pmmmwh/react-refresh-webpack-plugin to v0.5.16 (#12595)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-06-29 13:16:48 +01:00
renovate[bot]
a0cd09cd5b Update dependency node to v18.20.8 (#12596)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-06-29 13:16:33 +01:00
renovate[bot]
6e5623ce6a Update dependency node to v18.20.7 (#12581)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-06-29 10:30:47 +01:00
renovate[bot]
032f26b1c5 Update dependency koa to v2.16.0 (#12590)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-06-29 10:29:48 +01:00
renovate[bot]
d0030a904c Update Rust crate bytes to v1.10.1 (#12589)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-06-28 22:03:24 +00:00
Jozef Gaal
a23d5d10b6 All: Translation: Update sk_SK.po (#12588) 2025-06-28 18:00:31 -04:00
mrjo118
f9ccd15615 Mobile: Add delete line, duplicate line and sort selected lines buttons to editor toolbar (#12555) 2025-06-28 21:01:05 +01:00
pedr
1f9f63d176 CI: Fixes #12440: Disable logging from onenote-converter library by checking if it a test run (#12566) 2025-06-28 20:24:30 +01:00
mrjo118
813f077312 Desktop: Fixes #12419: Ensure min and max validation is enforced when setting is not yet present (#12553) 2025-06-28 20:19:07 +01:00
Henry Heino
6a5c85d3d7 Android: Voice typing: Add setting to allow specifying a glossary (#12370)
Co-authored-by: Laurent Cozic <laurent22@users.noreply.github.com>
2025-06-28 20:06:12 +01:00
Henry Heino
1644f56447 Android: Fixes #12484: Fix cursor jumps to the beginning of inputs on tap (#12499) 2025-06-28 20:05:29 +01:00
renovate[bot]
85518edca1 Update Rust crate bytes to v1.10.0 (#12561)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-06-28 20:03:29 +01:00
renovate[bot]
ebc070b3c7 Update dependency sass to v1.85.1 (#12540)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-06-28 20:02:40 +01:00
Henry Heino
a33fb575fd Chore: Mobile: Add internal support for taking multiple pictures from a camera component (#12357) 2025-06-28 20:01:13 +01:00
Henry Heino
ecc781ee39 Chore: VSCode workspace: Default to tab indentation (#12587) 2025-06-28 20:00:39 +01:00
renovate[bot]
098cabad40 Update Rust crate log to v0.4.27 (#12580)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-06-26 11:35:34 +00:00
renovate[bot]
4d01738029 Update dependency @types/serviceworker to v0.0.127 (#12577)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-06-26 07:47:04 +00:00
renovate[bot]
3433293a0e Update dependency webpack-dev-server to v5.2.1 (#12576)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-06-25 08:31:53 +00:00
renovate[bot]
02fd244096 Update dependency ldapts to v7.3.3 (#12575)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-06-25 02:42:52 +00:00
renovate[bot]
00cd26fd82 Update dependency @types/react to v18.3.20 (#12574)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-06-24 23:43:29 +00:00
renovate[bot]
38ca224a16 Update dependency webpack-dev-server to v5.2.0 (#12543)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-06-24 09:51:43 -07:00
summoner001
0fec577932 All: Translation: Update hu_HU.po (#12562) 2025-06-18 17:07:13 -04:00
renovate[bot]
780d049502 Update react monorepo (#12557)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-06-18 02:04:30 +00:00
renovate[bot]
a5d74e1ee7 Update dependency nanoid to v3.3.11 (#12556)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-06-18 02:01:49 +00:00
renovate[bot]
d6b369b4f4 Update dependency react-select to v5.10.1 (#12542)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-06-17 23:34:11 +00:00
Nick
572e40c635 All: Translation: Update sv.po (#12546) 2025-06-16 14:41:21 -04:00
Henry Heino
4af5c609fd Chore: CI: Disable UI tests on MacOS (#12547) 2025-06-16 10:40:45 -07:00
renovate[bot]
8487fc1a34 Update dependency @types/react to v19.0.11 (#12539)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-06-16 12:02:11 +01:00
Laurent Cozic
a76fad3ddf Chore: Disable cache on CI (#12509) 2025-06-16 10:37:29 +01:00
renovate[bot]
a08af91153 Update dependency react-native-vector-icons to v10.2.0 (#12536)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Laurent Cozic <laurent22@users.noreply.github.com>
2025-06-16 10:37:17 +01:00
renovate[bot]
3bcf221e52 Update dependency react-select to v5.10.0 (#12538)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-06-16 10:20:05 +01:00
renovate[bot]
0dd211c2fd Update dependency react-native-localize to v3.4.1 (#12535)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Laurent Cozic <laurent22@users.noreply.github.com>
2025-06-16 08:36:24 +01:00
renovate[bot]
b6fea2a4e2 Update dependency re-resizable to v6.10.4 (#12534)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Laurent Cozic <laurent22@users.noreply.github.com>
2025-06-16 08:36:16 +01:00
Gustavo V. F.
73eb6cca38 All: Translation: Update pt_BR.po (#12537) 2025-06-15 17:18:04 -04:00
Laurent Cozic
449f49379d Chore: Trying to fix RSA error on CI (#12526) 2025-06-15 22:08:27 +01:00
renovate[bot]
c4b951544b Update dependency node-mocks-http to v1.16.2 (#12523)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Laurent Cozic <laurent22@users.noreply.github.com>
2025-06-15 19:23:40 +01:00
renovate[bot]
5746d4cdf6 Update dependency re-resizable to v6.10.3 (#12532)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-06-15 19:22:49 +01:00
renovate[bot]
71e4f35e79 Update dependency react-native-image-picker to v7.2.3 (#12533)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-06-15 19:22:37 +01:00
renovate[bot]
5169371b68 Update dependency pg to v8.13.3 (#12530)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-06-15 16:18:39 +01:00
renovate[bot]
24845bd7d8 Update dependency nodemailer to v6.10.0 (#12529)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-06-15 14:26:43 +01:00
renovate[bot]
00b7726cda Update dependency ldapts to v7.3.1 (#12517)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-06-15 10:17:12 +01:00
142 changed files with 5497 additions and 1895 deletions

View File

@@ -122,6 +122,8 @@ packages/app-cli/app/command-rmnote.test.js
packages/app-cli/app/command-rmnote.js
packages/app-cli/app/command-set.js
packages/app-cli/app/command-settingschema.js
packages/app-cli/app/command-share.test.js
packages/app-cli/app/command-share.js
packages/app-cli/app/command-sync.js
packages/app-cli/app/command-testing.js
packages/app-cli/app/command-use.js
@@ -130,6 +132,8 @@ packages/app-cli/app/gui/FolderListWidget.js
packages/app-cli/app/gui/StatusBarWidget.js
packages/app-cli/app/services/plugins/PluginRunner.js
packages/app-cli/app/setupCommand.js
packages/app-cli/app/utils/initializeCommandService.js
packages/app-cli/app/utils/shimInitCli.js
packages/app-cli/app/utils/testUtils.js
packages/app-cli/tests/HtmlToMd.js
packages/app-cli/tests/MarkupToHtml.js
@@ -449,7 +453,6 @@ packages/app-desktop/gui/WindowCommandsAndDialogs/commands/exportPdf.js
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/gotoAnything.js
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/hideModalMessage.js
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/index.js
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/leaveSharedFolder.js
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/linkToNote.js
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/moveToFolder.js
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/newFolder.js
@@ -601,6 +604,7 @@ packages/app-desktop/utils/isSafeToOpen.test.js
packages/app-desktop/utils/isSafeToOpen.js
packages/app-desktop/utils/restartInSafeModeFromMain.test.js
packages/app-desktop/utils/restartInSafeModeFromMain.js
packages/app-desktop/utils/sourceMapSetup.js
packages/app-desktop/utils/window/types.js
packages/app-mobile/PluginAssetsLoader.js
packages/app-mobile/commands/dismissPluginPanels.js
@@ -617,12 +621,16 @@ packages/app-mobile/components/BottomDrawer.js
packages/app-mobile/components/CameraView/ActionButtons.js
packages/app-mobile/components/CameraView/Camera/index.jest.js
packages/app-mobile/components/CameraView/Camera/index.js
packages/app-mobile/components/CameraView/Camera/index.web.js
packages/app-mobile/components/CameraView/Camera/types.js
packages/app-mobile/components/CameraView/CameraView.test.js
packages/app-mobile/components/CameraView/CameraView.js
packages/app-mobile/components/CameraView/CameraViewMultiPage.test.js
packages/app-mobile/components/CameraView/CameraViewMultiPage.js
packages/app-mobile/components/CameraView/ScannedBarcodes.js
packages/app-mobile/components/CameraView/types.js
packages/app-mobile/components/CameraView/utils/fitRectIntoBounds.js
packages/app-mobile/components/CameraView/utils/testing.js
packages/app-mobile/components/CameraView/utils/useBarcodeScanner.js
packages/app-mobile/components/Checkbox.js
packages/app-mobile/components/DialogManager/PromptButton.js
@@ -888,6 +896,7 @@ packages/app-mobile/utils/fs-driver/testUtil/createFilesFromPathRecord.js
packages/app-mobile/utils/fs-driver/testUtil/verifyDirectoryMatches.js
packages/app-mobile/utils/getPackageInfo.js
packages/app-mobile/utils/getVersionInfoText.js
packages/app-mobile/utils/hooks/useBackHandler.js
packages/app-mobile/utils/hooks/useKeyboardState.js
packages/app-mobile/utils/hooks/useOnLongPressProps.js
packages/app-mobile/utils/hooks/useReduceMotionEnabled.js
@@ -928,7 +937,6 @@ packages/default-plugins/commands/editPatch.js
packages/default-plugins/utils/getCurrentCommitHash.js
packages/default-plugins/utils/getPathToPatchFileFor.js
packages/default-plugins/utils/readRepositoryJson.js
packages/default-plugins/utils/waitForCliInput.js
packages/editor/CodeMirror/CodeMirror5Emulation/CodeMirror5BuiltInOptions.js
packages/editor/CodeMirror/CodeMirror5Emulation/CodeMirror5Emulation.test.js
packages/editor/CodeMirror/CodeMirror5Emulation/CodeMirror5Emulation.js
@@ -1076,6 +1084,7 @@ packages/lib/commands/deleteNote.js
packages/lib/commands/historyBackward.js
packages/lib/commands/historyForward.js
packages/lib/commands/index.js
packages/lib/commands/leaveSharedFolder.js
packages/lib/commands/openMasterPasswordDialog.js
packages/lib/commands/permanentlyDeleteNote.js
packages/lib/commands/renderMarkup.test.js
@@ -1639,6 +1648,18 @@ packages/tools/checkIgnoredFiles.js
packages/tools/checkLibPaths.test.js
packages/tools/checkLibPaths.js
packages/tools/convertThemesToCss.js
packages/tools/fuzzer/ActionTracker.js
packages/tools/fuzzer/Client.js
packages/tools/fuzzer/ClientPool.js
packages/tools/fuzzer/Server.js
packages/tools/fuzzer/constants.js
packages/tools/fuzzer/sync-fuzzer.js
packages/tools/fuzzer/types.js
packages/tools/fuzzer/utils/SeededRandom.js
packages/tools/fuzzer/utils/getNumberProperty.js
packages/tools/fuzzer/utils/getProperty.js
packages/tools/fuzzer/utils/getStringProperty.js
packages/tools/fuzzer/utils/retryWithCount.js
packages/tools/generate-database-types.js
packages/tools/generate-images.js
packages/tools/git-changelog.test.js

View File

@@ -23,6 +23,7 @@ module.exports = {
'FileSystemCreateWritableOptions': 'readonly',
'FileSystemHandle': 'readonly',
'IDBTransactionMode': 'readonly',
'BigInt': 'readonly',
'globalThis': 'readonly',
// ServiceWorker

View File

@@ -13,7 +13,7 @@ jobs:
with:
# We need to pin the version to 18.15, because 18.16+ fails with this error:
# https://github.com/facebook/react-native/issues/36440
node-version: '18.15.0'
node-version: '18.20.8'
cache: 'yarn'
- name: Install Yarn
@@ -30,7 +30,7 @@ jobs:
# See github-action-main.yml for explanation
- uses: actions/setup-python@v5
with:
python-version: '3.11'
python-version: '3.13'
- name: Set Publish Flag
run: |

View File

@@ -122,7 +122,6 @@ jobs:
- uses: actions/setup-node@v4
with:
node-version: '18'
cache: 'yarn'
- name: Install Yarn
run: |
@@ -154,7 +153,7 @@ jobs:
docker run -p 22300:22300 joplin/server:$(dpkg --print-architecture)-0.0.0 node dist/app.js --env dev &
# Wait for server to start
sleep 30
sleep 120
# Check if status code is correct
# if the actual_status DOES NOT include the expected_status

View File

@@ -50,13 +50,14 @@ runs:
- uses: olegtarasov/get-tag@v2.1.4
- uses: dtolnay/rust-toolchain@stable
if: ${{ runner.os != 'Windows' }}
- uses: actions/setup-node@v4
with:
node-version: '18.18.0'
node-version: '18.20.8'
# Disable the cache on ARM runners. For now, we don't run "yarn install" on these
# environments and this breaks actions/setup-node.
# See https://github.com/laurent22/joplin/commit/47d0d3eb9e89153a609fb5441344da10904c6308#commitcomment-159577783.
cache: ${{ (!contains(runner.os, 'arm') && 'yarn') || '' }}
# cache: ${{ (!contains(runner.os, 'arm') && 'yarn') || '' }}
- name: Install Yarn
shell: bash
@@ -71,4 +72,4 @@ runs:
# Ref: https://github.com/nodejs/node-gyp/issues/2869
- uses: actions/setup-python@v5
with:
python-version: '3.11'
python-version: '3.13'

View File

@@ -9,7 +9,7 @@ jobs:
runs-on: ${{ matrix.os }}
strategy:
matrix:
os: [macos-13, ubuntu-22.04, windows-2025]
os: [ubuntu-22.04, windows-2025]
steps:
- uses: actions/checkout@v4
- name: Setup build environment

25
.gitignore vendored
View File

@@ -97,6 +97,8 @@ packages/app-cli/app/command-rmnote.test.js
packages/app-cli/app/command-rmnote.js
packages/app-cli/app/command-set.js
packages/app-cli/app/command-settingschema.js
packages/app-cli/app/command-share.test.js
packages/app-cli/app/command-share.js
packages/app-cli/app/command-sync.js
packages/app-cli/app/command-testing.js
packages/app-cli/app/command-use.js
@@ -105,6 +107,8 @@ packages/app-cli/app/gui/FolderListWidget.js
packages/app-cli/app/gui/StatusBarWidget.js
packages/app-cli/app/services/plugins/PluginRunner.js
packages/app-cli/app/setupCommand.js
packages/app-cli/app/utils/initializeCommandService.js
packages/app-cli/app/utils/shimInitCli.js
packages/app-cli/app/utils/testUtils.js
packages/app-cli/tests/HtmlToMd.js
packages/app-cli/tests/MarkupToHtml.js
@@ -424,7 +428,6 @@ packages/app-desktop/gui/WindowCommandsAndDialogs/commands/exportPdf.js
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/gotoAnything.js
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/hideModalMessage.js
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/index.js
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/leaveSharedFolder.js
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/linkToNote.js
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/moveToFolder.js
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/newFolder.js
@@ -576,6 +579,7 @@ packages/app-desktop/utils/isSafeToOpen.test.js
packages/app-desktop/utils/isSafeToOpen.js
packages/app-desktop/utils/restartInSafeModeFromMain.test.js
packages/app-desktop/utils/restartInSafeModeFromMain.js
packages/app-desktop/utils/sourceMapSetup.js
packages/app-desktop/utils/window/types.js
packages/app-mobile/PluginAssetsLoader.js
packages/app-mobile/commands/dismissPluginPanels.js
@@ -592,12 +596,16 @@ packages/app-mobile/components/BottomDrawer.js
packages/app-mobile/components/CameraView/ActionButtons.js
packages/app-mobile/components/CameraView/Camera/index.jest.js
packages/app-mobile/components/CameraView/Camera/index.js
packages/app-mobile/components/CameraView/Camera/index.web.js
packages/app-mobile/components/CameraView/Camera/types.js
packages/app-mobile/components/CameraView/CameraView.test.js
packages/app-mobile/components/CameraView/CameraView.js
packages/app-mobile/components/CameraView/CameraViewMultiPage.test.js
packages/app-mobile/components/CameraView/CameraViewMultiPage.js
packages/app-mobile/components/CameraView/ScannedBarcodes.js
packages/app-mobile/components/CameraView/types.js
packages/app-mobile/components/CameraView/utils/fitRectIntoBounds.js
packages/app-mobile/components/CameraView/utils/testing.js
packages/app-mobile/components/CameraView/utils/useBarcodeScanner.js
packages/app-mobile/components/Checkbox.js
packages/app-mobile/components/DialogManager/PromptButton.js
@@ -863,6 +871,7 @@ packages/app-mobile/utils/fs-driver/testUtil/createFilesFromPathRecord.js
packages/app-mobile/utils/fs-driver/testUtil/verifyDirectoryMatches.js
packages/app-mobile/utils/getPackageInfo.js
packages/app-mobile/utils/getVersionInfoText.js
packages/app-mobile/utils/hooks/useBackHandler.js
packages/app-mobile/utils/hooks/useKeyboardState.js
packages/app-mobile/utils/hooks/useOnLongPressProps.js
packages/app-mobile/utils/hooks/useReduceMotionEnabled.js
@@ -903,7 +912,6 @@ packages/default-plugins/commands/editPatch.js
packages/default-plugins/utils/getCurrentCommitHash.js
packages/default-plugins/utils/getPathToPatchFileFor.js
packages/default-plugins/utils/readRepositoryJson.js
packages/default-plugins/utils/waitForCliInput.js
packages/editor/CodeMirror/CodeMirror5Emulation/CodeMirror5BuiltInOptions.js
packages/editor/CodeMirror/CodeMirror5Emulation/CodeMirror5Emulation.test.js
packages/editor/CodeMirror/CodeMirror5Emulation/CodeMirror5Emulation.js
@@ -1051,6 +1059,7 @@ packages/lib/commands/deleteNote.js
packages/lib/commands/historyBackward.js
packages/lib/commands/historyForward.js
packages/lib/commands/index.js
packages/lib/commands/leaveSharedFolder.js
packages/lib/commands/openMasterPasswordDialog.js
packages/lib/commands/permanentlyDeleteNote.js
packages/lib/commands/renderMarkup.test.js
@@ -1614,6 +1623,18 @@ packages/tools/checkIgnoredFiles.js
packages/tools/checkLibPaths.test.js
packages/tools/checkLibPaths.js
packages/tools/convertThemesToCss.js
packages/tools/fuzzer/ActionTracker.js
packages/tools/fuzzer/Client.js
packages/tools/fuzzer/ClientPool.js
packages/tools/fuzzer/Server.js
packages/tools/fuzzer/constants.js
packages/tools/fuzzer/sync-fuzzer.js
packages/tools/fuzzer/types.js
packages/tools/fuzzer/utils/SeededRandom.js
packages/tools/fuzzer/utils/getNumberProperty.js
packages/tools/fuzzer/utils/getProperty.js
packages/tools/fuzzer/utils/getStringProperty.js
packages/tools/fuzzer/utils/retryWithCount.js
packages/tools/generate-database-types.js
packages/tools/generate-images.js
packages/tools/git-changelog.test.js

View File

@@ -1,3 +1,4 @@
{
"cSpell.enabled": true
"cSpell.enabled": true,
"editor.insertSpaces": false
}

View File

@@ -1300,4 +1300,9 @@ footer .bottom-links-row p {
:lang(zh-cn) #plans-section .faq {
display: none;
}
.cfa-button {
margin-top: 10px;
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 430 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 434 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 110 KiB

View File

@@ -1,24 +1,28 @@
<div class="col-12 col-lg-4 account-type-{{priceMonthly.accountType}}">
<div class="col-12 col-lg-4 account-type-{{priceMonthly.accountType}} hosting-type-{{hostingType}}">
<div class="price-container {{#featured}}price-container-blue{{/featured}}">
<div class="price-row">
<div class="plan-type">
<img src="{{imageBaseUrl}}/{{iconName}}.png"/>&nbsp;{{title}}
<div class="price-row">
<div class="plan-type">
<img src="{{imageBaseUrl}}/{{iconName}}.png"/>&nbsp;{{title}}
</div>
{{#priceMonthly.formattedMonthlyAmount}}
<div class="plan-price plan-price-monthly">
{{priceMonthly.formattedMonthlyAmount}}<sub class="per-month">&nbsp;<span translate>/month</span>{{#footnote}} (*){{/footnote}}</sub>
</div>
<div class="plan-price plan-price-yearly">
{{priceYearly.formattedMonthlyAmount}}<sub class="per-month">&nbsp;<span translate>/month</span>{{#footnote}} (*){{/footnote}}</sub>
</div>
{{/priceMonthly.formattedMonthlyAmount}}
</div>
<div class="plan-price plan-price-monthly">
{{priceMonthly.formattedMonthlyAmount}}<sub class="per-month">&nbsp;<span translate>/month</span>{{#footnote}} (*){{/footnote}}</sub>
{{#priceYearly.formattedMonthlyAmount}}
<div class="plan-price-yearly-per-year">
<div>
({{priceYearly.formattedAmount}}<sub class="per-year">&nbsp;<span translate>/year</span></sub>)
</div>
</div>
<div class="plan-price plan-price-yearly">
{{priceYearly.formattedMonthlyAmount}}<sub class="per-month">&nbsp;<span translate>/month</span>{{#footnote}} (*){{/footnote}}</sub>
</div>
</div>
<div class="plan-price-yearly-per-year">
<div>
({{priceYearly.formattedAmount}}<sub class="per-year">&nbsp;<span translate>/year</span></sub>)
</div>
</div>
{{/priceYearly.formattedMonthlyAmount}}
{{#featureLabelsOn}}
<p><i class="fas fa-check feature feature-on"></i>{{.}}</p>
@@ -29,7 +33,11 @@
{{/featureLabelsOff}}
<p class="text-center subscribe-wrapper">
<a id="subscribeButton-{{name}}" href="{{cfaUrl}}" class="button-link btn-white subscribeButton">{{cfaLabel}}</a>
<a id="subscribeButton-{{name}}" href="{{cfaUrl}}" class="button-link btn-white subscribeButton cfa-button">{{cfaLabel}}</a>
{{#learnMoreUrl}}
<a id="learnMore-{{name}}" href="{{learnMoreUrl}}" class="button-link btn-white learnMoreButton cfa-button">Learn more</a>
{{/learnMoreUrl}}
</p>
{{#footnote}}<sub>(*) {{.}}</sub>{{/footnote}}

View File

@@ -1,23 +1,91 @@
<div id="plans-section" class="env-{{env}}">
<style>
.toggle-container {
display: flex;
border: 2px solid black;
border-radius: 100px;
overflow: hidden;
cursor: pointer;
margin-top: 20px;
max-width: 600px;
margin-left: auto;
margin-right: auto;
}
.toggle-option {
flex: 1;
padding: 10px 20px;
text-align: center;
transition: background 0.3s, color 0.3s;
user-select: none;
white-space: nowrap;
}
.active {
background: black;
color: white;
}
.inactive {
background: white;
color: black;
}
@media (max-width: 480px) {
.toggle-container {
flex-direction: column;
width: 100%;
border-radius: 10px;
}
}
</style>
<div class="container">
<div class="row">
<div class="col-12 title-box">
<h1 translate class="text-center">
Joplin Cloud <span class="frame-bg frame-bg-yellow">plans</span>
Our synchronisation and sharing <span class="frame-bg frame-bg-yellow">solutions</span>
</h1>
<p translate class="text-center sub-title">
<a href="https://joplincloud.com">Joplin Cloud</a> allows you to synchronise your notes across devices. It also lets you publish notes, and collaborate on notebooks with your friends, family or colleagues.
Synchronise and share your notes with our range of plans.
</p>
</div>
</div>
<div class="toggle-container" id="toggle">
<div class="toggle-option active toggle-button-managed">Managed hosting</div>
<div class="toggle-option inactive toggle-button-self">Self-hosting</div>
</div>
<noscript>
<div class="alert alert-danger alert-env-dev" role="alert" style='text-align: center; margin-top: 10px;'>
To use this page please enable JavaScript!
</div>
</noscript>
<div style="display: flex; justify-content: center; margin-top: 1.2em">
<div class="row hosting-type-managed">
<div class="col-12 title-box">
<h1 translate class="text-center">
Joplin Cloud
</h1>
<p translate class="text-center sub-title">
<a href="https://joplincloud.com">Joplin Cloud</a> allows you to synchronise your notes across devices. It also lets you publish notes, and collaborate on notebooks with your friends, family or colleagues.
</p>
</div>
</div>
<div class="row hosting-type-self">
<div class="col-12 title-box">
<h1 translate class="text-center">
Joplin Server Business
</h1>
<p translate class="text-center sub-title">
Joplin Server Business is a synchronisation server that you can install on your own infrastructure, so that your data remains private and secure within your business.
</p>
</div>
</div>
<div style="display: flex; justify-content: center; margin-top: 1.2em" class="hosting-type-managed">
<div class="form-check form-check-inline">
<input id="pay-monthly-radio" class="form-check-input" type="radio" name="pay-radio" checked value="monthly">
<label translate style="font-weight: bold" class="form-check-label" for="pay-monthly-radio">
@@ -46,7 +114,11 @@
{{> plan}}
{{/plans.teams}}
<p translate class="joplin-cloud-login-info">Already have a Joplin Cloud account? <a href="https://joplincloud.com">Login now</a></p>
{{#plans.joplinServerBusiness}}
{{> plan}}
{{/plans.joplinServerBusiness}}
<p translate class="joplin-cloud-login-info hosting-type-managed">Already have a Joplin Cloud account? <a href="https://joplincloud.com">Login now</a></p>
</div>
<div class="row">
@@ -148,4 +220,30 @@
});
});
</script>
<script>
const setHostingType = (type) => {
const other = type === 'managed' ? 'self' : 'managed';
$('.toggle-button-' + type).addClass('active');
$('.toggle-button-' + type).removeClass('inactive');
$('.toggle-button-' + other).addClass('inactive');
$('.toggle-button-' + other).removeClass('active');
$('.hosting-type-' + type).show();
$('.hosting-type-' + other).hide();
}
$('.toggle-button-managed').click((event) => {
event.preventDefault();
setHostingType('managed');
});
$('.toggle-button-self').click((event) => {
event.preventDefault();
setHostingType('self');
});
setHostingType('managed');
</script>
</div>

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://www.hosting.de/nextcloud/?mtm_campaign=managed-nextcloud&amp;mtm_kwd=joplinapp&amp;mtm_source=joplinapp-webseite&amp;mtm_medium=banner"><img title="Hosting.de" width="256" src="https://joplinapp.org/images/sponsors/HostingDe.png"/></a> <a href="https://citricsheep.com"><img title="Citric Sheep" width="256" src="https://joplinapp.org/images/sponsors/CitricSheep.png"/></a> <a href="https://sorted.travel/?utm_source=joplinapp"><img title="Sorted Travel" width="256" src="https://joplinapp.org/images/sponsors/SortedTravel.png"/></a> <a href="https://celebian.com"><img title="Celebian" width="256" src="https://joplinapp.org/images/sponsors/Celebian.png"/></a> <a href="https://bestkru.com"><img title="BestKru" width="256" src="https://joplinapp.org/images/sponsors/BestKru.png"/></a> <a href="https://www.socialfollowers.uk/buy-tiktok-followers/"><img title="Social Followers" width="256" src="https://joplinapp.org/images/sponsors/SocialFollowers.png"/></a> <a href="https://stormlikes.com/"><img title="Stormlikes" width="256" src="https://joplinapp.org/images/sponsors/Stormlikes.png"/></a> <a href="https://route4me.com"><img title="Route4Me" width="256" src="https://joplinapp.org/images/sponsors/Route4Me.png"/></a> <a href="https://casinoreviews.net"><img title="Casino Reviews" width="256" src="https://joplinapp.org/images/sponsors/CasinoReviews.png"/></a> <a href="https://topagency.webflow.io"><img title="WebDesignAgency" width="256" src="https://joplinapp.org/images/sponsors/WebDesignAgency.png" alt="topagency"/></a> <a href="https://realgambling.ca/"><img title="RealGambling.ca" width="256" src="https://joplinapp.org/images/sponsors/RealGambling.png" alt="RealGambling.ca"/></a> <a href="https://essaypro.com/"><img title="write an essay online with EssayPro" width="256" src="https://joplinapp.org/images/sponsors/EssayPro.png" alt="write an essay online with EssayPro"/></a> <a href="https://www.slotozilla.com/nz/no-deposit-bonus"><img title="casino without making any upfront cost" width="256" src="https://joplinapp.org/images/sponsors/Slotozilla.png" alt="casino without making any upfront cost"/></a> <a href="https://www.reddit.com/r/tiktokRise/"><img title="Tiktok Rise" width="256" src="https://joplinapp.org/images/sponsors/TiktokRise.jpg" alt="Tiktok Rise"/></a> <a href="https://essaywriter.pro"><img title="write my essay services by EssayWriter" width="256" src="https://joplinapp.org/images/sponsors/EssayWriterPro.png" alt="write my essay services by EssayWriter"/></a> <a href="https://essayservice.com"><img title="quick and reliable service to write my paper for me" width="256" src="https://joplinapp.org/images/sponsors/EssayService.png" alt="quick and reliable service to write my paper for me"/></a> <a href="https://writepaper.com/"><img title="best service to write my paper for me" width="256" src="https://joplinapp.org/images/sponsors/WritePaper.png" alt="best service to write my paper for me"/></a> <a href="https://paperwriter.com/"><img title="high-quality paper writing service PaperWriter" width="256" src="https://joplinapp.org/images/sponsors/PaperWriter.png" alt="high-quality paper writing service PaperWriter"/></a>
<a href="https://seirei.ne.jp"><img title="Serei Network" width="256" src="https://joplinapp.org/images/sponsors/SeireiNetwork.png"/></a> <a href="https://www.hosting.de/nextcloud/?mtm_campaign=managed-nextcloud&amp;mtm_kwd=joplinapp&amp;mtm_source=joplinapp-webseite&amp;mtm_medium=banner"><img title="Hosting.de" width="256" src="https://joplinapp.org/images/sponsors/HostingDe.png"/></a> <a href="https://citricsheep.com"><img title="Citric Sheep" width="256" src="https://joplinapp.org/images/sponsors/CitricSheep.png"/></a> <a href="https://sorted.travel/?utm_source=joplinapp"><img title="Sorted Travel" width="256" src="https://joplinapp.org/images/sponsors/SortedTravel.png"/></a> <a href="https://celebian.com"><img title="Celebian" width="256" src="https://joplinapp.org/images/sponsors/Celebian.png"/></a> <a href="https://bestkru.com"><img title="BestKru" width="256" src="https://joplinapp.org/images/sponsors/BestKru.png"/></a> <a href="https://www.socialfollowers.uk/buy-tiktok-followers/"><img title="Social Followers" width="256" src="https://joplinapp.org/images/sponsors/SocialFollowers.png"/></a> <a href="https://stormlikes.com/"><img title="Stormlikes" width="256" src="https://joplinapp.org/images/sponsors/Stormlikes.png"/></a> <a href="https://route4me.com"><img title="Route4Me" width="256" src="https://joplinapp.org/images/sponsors/Route4Me.png"/></a> <a href="https://topagency.webflow.io"><img title="WebDesignAgency" width="256" src="https://joplinapp.org/images/sponsors/WebDesignAgency.png" alt="topagency"/></a> <a href="https://realgambling.ca/"><img title="RealGambling.ca" width="256" src="https://joplinapp.org/images/sponsors/RealGambling.png" alt="RealGambling.ca"/></a> <a href="https://essaypro.com/"><img title="write an essay online with EssayPro" width="256" src="https://joplinapp.org/images/sponsors/EssayPro.png" alt="write an essay online with EssayPro"/></a> <a href="https://www.slotozilla.com/nz/no-deposit-bonus"><img title="casino without making any upfront cost" width="256" src="https://joplinapp.org/images/sponsors/Slotozilla.png" alt="casino without making any upfront cost"/></a> <a href="https://essaywriter.pro"><img title="write my essay services by EssayWriter" width="256" src="https://joplinapp.org/images/sponsors/EssayWriterPro.png" alt="write my essay services by EssayWriter"/></a> <a href="https://essayservice.com"><img title="quick and reliable service to write my paper for me" width="256" src="https://joplinapp.org/images/sponsors/EssayService.png" alt="quick and reliable service to write my paper for me"/></a> <a href="https://writepaper.com/"><img title="best service to write my paper for me" width="256" src="https://joplinapp.org/images/sponsors/WritePaper.png" alt="best service to write my paper for me"/></a> <a href="https://paperwriter.com/"><img title="high-quality paper writing service PaperWriter" width="256" src="https://joplinapp.org/images/sponsors/PaperWriter.png" alt="high-quality paper writing service PaperWriter"/></a> <a href="https://homeworkguy.org/someone-to-take-my-online-class"><img title="someone to take my online class" width="256" src="https://joplinapp.org/images/sponsors/HomeworkGuy.png" alt="someone to take my online class"/></a> <a href="https://www.bestetf.net/"><img title="BestETF" width="256" src="https://joplinapp.org/images/sponsors/BestEtf.png" alt="BestETF"/></a> <a href="https://freespinny.io/free-spins-no-deposit/"><img title="Freespinny.io Free Spins Bonus site" width="256" src="https://joplinapp.org/images/sponsors/Freespinny.png" alt="Freespinny.io Free Spins Bonus site"/></a>
<!-- SPONSORS-ORG -->
* * *

View File

@@ -9,14 +9,14 @@
"vips.dev": {
"platforms": ["aarch64-darwin"],
},
"nodejs": "latest",
"nodejs": "23.8.0",
"pkg-config": "latest",
"darwin.apple_sdk.frameworks.Foundation": { // satisfies missing CoreText/CoreText.h
// https://github.com/NixOS/nixpkgs/blob/master/pkgs/os-specific/darwin/apple-sdk/default.nix
"version": "",
"platforms": ["aarch64-darwin", "x86_64-darwin"],
},
"python": "latest",
"python": "3.13.1",
"bat": "latest",
"electron": {
"version": "latest",

View File

@@ -21,7 +21,7 @@ version: '2'
services:
postgresql-master:
image: 'bitnami/postgresql:16.6.0'
image: 'bitnami/postgresql:17.3.0'
ports:
- '5432:5432'
environment:
@@ -38,7 +38,7 @@ services:
- POSTGRESQL_EXTRA_FLAGS=-c work_mem=100000 -c log_statement=all
postgresql-slave:
image: 'bitnami/postgresql:16.6.0'
image: 'bitnami/postgresql:17.3.0'
ports:
- '5433:5432'
depends_on:

View File

@@ -38,6 +38,7 @@
"linter-precommit": "eslint --resolve-plugins-relative-to . --fix --ext .js --ext .jsx --ext .ts --ext .tsx",
"linter": "eslint --resolve-plugins-relative-to . --fix --quiet --ext .js --ext .jsx --ext .ts --ext .tsx",
"packageJsonLint": "node ./packages/tools/packageJsonLint.js",
"syncFuzzer": "node ./packages/tools/fuzzer/sync-fuzzer.js",
"postinstall": "husky && gulp build",
"postPreReleasesToForum": "node ./packages/tools/postPreReleasesToForum",
"publishAll": "git pull && yarn buildParallel && lerna version --yes --no-private --no-git-tag-version && gulp completePublishAll",
@@ -65,7 +66,7 @@
"watchWebsite": "nodemon --delay 1 --watch Assets/WebsiteAssets --watch packages/tools/website --watch packages/tools/website/utils --watch packages/doc-builder/build --ext md,ts,js,mustache,css,tsx,gif,png,svg --exec \"node packages/tools/website/build.js && http-server --port 8077 ../joplin-website/docs -a localhost\""
},
"devDependencies": {
"@crowdin/cli": "3",
"@crowdin/cli": "4",
"@joplin/utils": "~2.12",
"@seiyab/eslint-plugin-react-hooks": "4.5.1-beta.0",
"@typescript-eslint/eslint-plugin": "6.21.0",
@@ -79,13 +80,13 @@
"eslint-plugin-react": "7.34.3",
"execa": "5.1.1",
"fs-extra": "11.2.0",
"glob": "10.4.5",
"glob": "11.0.1",
"gulp": "4.0.2",
"husky": "9.1.7",
"lerna": "3.22.1",
"lint-staged": "15.4.3",
"madge": "7.0.0",
"npm-package-json-lint": "7.1.0",
"lint-staged": "15.5.0",
"madge": "8.0.0",
"npm-package-json-lint": "8.0.0",
"typescript": "5.4.5"
},
"dependencies": {

View File

@@ -6,7 +6,7 @@ import Folder from '@joplin/lib/models/Folder';
import BaseItem from '@joplin/lib/models/BaseItem';
import Note from '@joplin/lib/models/Note';
import Tag from '@joplin/lib/models/Tag';
import Setting from '@joplin/lib/models/Setting';
import Setting, { Env } from '@joplin/lib/models/Setting';
import { reg } from '@joplin/lib/registry.js';
import { dirname, fileExtension } from '@joplin/lib/path-utils';
import { splitCommandString } from '@joplin/utils';
@@ -16,6 +16,7 @@ import RevisionService from '@joplin/lib/services/RevisionService';
import shim from '@joplin/lib/shim';
import setupCommand from './setupCommand';
import { FolderEntity, NoteEntity } from '@joplin/lib/services/database/types';
import initializeCommandService from './utils/initializeCommandService';
const { cliUtils } = require('./cli-utils.js');
const Cache = require('@joplin/lib/Cache');
const { splitCommandBatch } = require('@joplin/lib/string-utils');
@@ -76,6 +77,12 @@ class Application extends BaseApplication {
}
}
public async loadItemOrFail(type: ModelType | 'folderOrNote', pattern: string) {
const output = await this.loadItem(type, pattern);
if (!output) throw new Error(_('Cannot find "%s".', pattern));
return output;
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
public async loadItems(type: ModelType | 'folderOrNote', pattern: string, options: any = null): Promise<(FolderEntity | NoteEntity)[]> {
if (type === 'folderOrNote') {
@@ -412,8 +419,15 @@ class Application extends BaseApplication {
this.initRedux();
// Since the settings need to be loaded before the store is created, it will never
// receive the SETTING_UPDATE_ALL even, which mean state.settings will not be
// initialised. So we manually call dispatchUpdateAll() to force an update.
Setting.dispatchUpdateAll();
if (!shim.sharpEnabled()) this.logger().warn('Sharp is disabled - certain image-related features will not be available');
initializeCommandService(this.store(), Setting.value('env') === Env.Dev);
// If we have some arguments left at this point, it's a command
// so execute it.
if (argv.length) {
@@ -452,11 +466,6 @@ class Application extends BaseApplication {
this.gui_.setLogger(this.logger());
await this.gui_.start();
// Since the settings need to be loaded before the store is created, it will never
// receive the SETTING_UPDATE_ALL even, which mean state.settings will not be
// initialised. So we manually call dispatchUpdateAll() to force an update.
Setting.dispatchUpdateAll();
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
await refreshFolders((action: any) => this.store().dispatch(action), '');

View File

@@ -13,7 +13,7 @@ describe('command-done', () => {
});
it('should make a note as "done"', async () => {
const note = await Note.save({ title: 'hello', is_todo: 1, todo_completed: 0 });
const note = await Note.save({ title: 'hello', is_todo: 1, todo_completed: 0, parent_id: '' });
const command = setupCommandForTesting(Command);

View File

@@ -26,6 +26,7 @@ class Command extends BaseCommand {
['-v, --verbose', 'More verbose output for the `target-status` command'],
['-o, --output <directory>', 'Output directory'],
['--retry-failed-items', 'Applies to `decrypt` command - retries decrypting items that previously could not be decrypted.'],
['-f, --force', 'Do not ask for input on failure'],
];
}
@@ -67,7 +68,7 @@ class Command extends BaseCommand {
this.stdout(line.join('\n'));
break;
} catch (error) {
if (error.code === 'masterKeyNotLoaded') {
if (error.code === 'masterKeyNotLoaded' && !args.options.force) {
const ok = await askForMasterKey(error);
if (!ok) return;
continue;

View File

@@ -26,8 +26,7 @@ class Command extends BaseCommand {
const pattern = args['notebook'];
const force = args.options && args.options.force === true;
const folder = await app().loadItem(BaseModel.TYPE_FOLDER, pattern);
if (!folder) throw new Error(_('Cannot find "%s".', pattern));
const folder = await app().loadItemOrFail(BaseModel.TYPE_FOLDER, pattern);
const permanent = args.options?.permanent === true || !!folder.deleted_time;
const ellipsizedFolderTitle = substrWithEllipsis(folder.title, 0, 32);

View File

@@ -0,0 +1,179 @@
import { setupDatabaseAndSynchronizer, switchClient } from '@joplin/lib/testing/test-utils';
import mockShareService, { ApiMock } from '@joplin/lib/testing/share/mockShareService';
import { setupCommandForTesting, setupApplication } from './utils/testUtils';
import Folder from '@joplin/lib/models/Folder';
import ShareService from '@joplin/lib/services/share/ShareService';
import BaseItem from '@joplin/lib/models/BaseItem';
import { ModelType } from '@joplin/lib/BaseModel';
import { ShareInvitation, ShareUserStatus, StateShare } from '@joplin/lib/services/share/reducer';
import app from './app';
const Command = require('./command-share');
const setUpCommand = () => {
const output: string[] = [];
const stdout = (content: string) => {
output.push(...content.split('\n'));
};
const command = setupCommandForTesting(Command, stdout);
return { command, output };
};
const shareId = 'test-id';
const defaultFolderShare: StateShare = {
id: shareId,
type: ModelType.Folder,
folder_id: 'some-folder-id-here',
note_id: undefined,
master_key_id: undefined,
user: {
full_name: 'Test user',
email: 'test@localhost',
id: 'some-user-id',
},
};
const mockShareServiceForFolderSharing = (eventHandlerOverrides: Partial<ApiMock>&{ onExec?: undefined }) => {
const invitations: ShareInvitation[] = [];
mockShareService({
getShareInvitations: async () => ({
items: invitations,
}),
getShares: async () => ({ items: [defaultFolderShare] }),
getShareUsers: async (_id: string) => ({ items: [] }),
postShareUsers: async (_id, _body) => { },
postShares: async () => ({ id: shareId }),
...eventHandlerOverrides,
}, ShareService.instance(), app().store());
return {
addInvitation: (invitation: Partial<ShareInvitation>) => {
const defaultInvitation: ShareInvitation = {
share: defaultFolderShare,
id: 'some-invitation-id',
master_key: undefined,
status: ShareUserStatus.Waiting,
can_read: 1,
can_write: 1,
};
invitations.push({ ...defaultInvitation, ...invitation });
},
};
};
describe('command-share', () => {
beforeEach(async () => {
await setupDatabaseAndSynchronizer(1);
await switchClient(1);
await setupApplication();
BaseItem.shareService_ = ShareService.instance();
});
test('should allow adding a user to a share', async () => {
const folder = await Folder.save({ title: 'folder1' });
let lastShareUserUpdate: unknown|null = null;
mockShareServiceForFolderSharing({
getShares: async () => {
const isShared = !!lastShareUserUpdate;
if (isShared) {
return {
items: [{ ...defaultFolderShare, folder_id: folder.id }],
};
} else {
return { items: [] };
}
},
// Called when a new user is added to a share
postShareUsers: async (_id, body) => {
lastShareUserUpdate = body;
},
});
const { command } = setUpCommand();
// Should share read-write by default
await command.action({
'command': 'add',
'notebook': 'folder1',
'user': 'test@localhost',
options: {},
});
expect(lastShareUserUpdate).toMatchObject({
email: 'test@localhost',
can_write: 1,
can_read: 1,
});
// Should also support sharing as read only
await command.action({
'command': 'add',
'notebook': 'folder1',
'user': 'test2@localhost',
options: {
'read-only': true,
},
});
expect(lastShareUserUpdate).toMatchObject({
email: 'test2@localhost',
can_write: 0,
can_read: 1,
});
});
test.each([
{
label: 'should list a single pending invitation',
invitations: [{ id: 'test', status: ShareUserStatus.Waiting }],
expectedOutput: [
'Incoming shares:',
'\tWaiting: Notebook some-folder-id-here from test@localhost',
'All shared folders:',
'\tNone',
].join('\n'),
},
{
label: 'should list accepted invitations for non-existent folders with [None] as the folder title',
invitations: [
{ id: 'test2', status: ShareUserStatus.Accepted },
],
expectedOutput: [
'Incoming shares:',
'\tAccepted: Notebook [None] from test@localhost',
'All shared folders:',
'\tNone',
].join('\n'),
},
{
label: 'should not list rejected shares',
invitations: [
{ id: 'test3', status: ShareUserStatus.Rejected },
],
expectedOutput: [
'Incoming shares:',
'\tNone',
'All shared folders:',
'\tNone',
].join('\n'),
},
])('share invitations: $label', async ({ invitations, expectedOutput }) => {
const mock = mockShareServiceForFolderSharing({});
for (const invitation of invitations) {
mock.addInvitation(invitation);
}
await ShareService.instance().refreshShareInvitations();
const { command, output } = setUpCommand();
await command.action({
'command': 'list',
options: {},
});
expect(output.join('\n')).toBe(expectedOutput);
});
});

View File

@@ -0,0 +1,297 @@
import { _ } from '@joplin/lib/locale';
import BaseCommand from './base-command';
import app from './app';
import { reg } from '@joplin/lib/registry';
import Logger from '@joplin/utils/Logger';
import ShareService from '@joplin/lib/services/share/ShareService';
import { ModelType } from '@joplin/lib/BaseModel';
import { FolderEntity } from '@joplin/lib/services/database/types';
import { ShareUserStatus } from '@joplin/lib/services/share/reducer';
import Folder from '@joplin/lib/models/Folder';
import invitationRespond from '@joplin/lib/services/share/invitationRespond';
import CommandService from '@joplin/lib/services/CommandService';
import { substrWithEllipsis } from '@joplin/lib/string-utils';
const logger = Logger.create('command-share');
type Args = {
command: string;
// eslint-disable-next-line id-denylist -- The "notebook" identifier comes from the UI.
notebook?: string;
user?: string;
options: {
'read-only'?: boolean;
json?: boolean;
force?: boolean;
};
};
const folderTitle = (folder: FolderEntity|null) => {
return folder ? substrWithEllipsis(folder.title, 0, 32) : _('[None]');
};
const getShareState = () => app().store().getState().shareService;
const getShareFromFolderId = (folderId: string) => {
const shareState = getShareState();
const allShares = shareState.shares;
const share = allShares.find(share => share.folder_id === folderId);
return share;
};
const getShareUsers = (folderId: string) => {
const share = getShareFromFolderId(folderId);
if (!share) {
throw new Error(`No share found for folder ${folderId}`);
}
return getShareState().shareUsers[share.id];
};
class Command extends BaseCommand {
public usage() {
return 'share <command> [notebook] [user]';
}
public description() {
return [
_('Shares or unshares the specified [notebook] with [user]. Requires Joplin Cloud or Joplin Server.'),
_('Commands: `add`, `remove`, `list`, `delete`, `accept`, `leave`, and `reject`.'),
].join('\n');
}
public options() {
return [
['--read-only', _('Don\'t allow the share recipient to write to the shared notebook. Valid only for the `add` subcommand.')],
['-f, --force', _('Do not ask for user confirmation.')],
['--json', _('Prefer JSON output.')],
];
}
public async action(args: Args) {
const commandShareAdd = async (folder: FolderEntity, email: string) => {
await reg.waitForSyncFinishedThenSync();
const share = await ShareService.instance().shareFolder(folder.id);
const permissions = {
can_read: 1,
can_write: args.options['read-only'] ? 0 : 1,
};
logger.debug('Sharing folder', folder.id, 'with', email, 'permissions=', permissions);
await ShareService.instance().addShareRecipient(share.id, share.master_key_id, email, permissions);
await ShareService.instance().refreshShares();
await ShareService.instance().refreshShareUsers(share.id);
await reg.waitForSyncFinishedThenSync();
};
const commandShareRemove = async (folder: FolderEntity, email: string) => {
await ShareService.instance().refreshShares();
const share = getShareFromFolderId(folder.id);
if (!share) {
throw new Error(`No share found for folder ${folder.id}`);
}
await ShareService.instance().refreshShareUsers(share.id);
const shareUsers = getShareUsers(folder.id);
if (!shareUsers) {
throw new Error(`No share found for folder ${folder.id}`);
}
const targetUser = shareUsers.find(user => user.user?.email === email);
if (!targetUser) {
throw new Error(`No recipient found with email ${email}`);
}
await ShareService.instance().deleteShareRecipient(targetUser.id);
this.stdout(_('Removed %s from share.', targetUser.user.email));
};
const commandShareList = async () => {
let folder = null;
if (args.notebook) {
folder = await app().loadItemOrFail(ModelType.Folder, args.notebook);
}
await ShareService.instance().maintenance();
if (folder) {
const share = getShareFromFolderId(folder.id);
await ShareService.instance().refreshShareUsers(share.id);
const shareUsers = getShareUsers(folder.id);
const output = {
folderTitle: folderTitle(folder),
sharedWith: (shareUsers ?? []).map(user => ({
email: user.user.email,
readOnly: user.can_read && !user.can_write,
})),
};
if (args.options.json) {
this.stdout(JSON.stringify(output));
} else {
this.stdout(_('Folder "%s" is shared with:', output.folderTitle));
for (const user of output.sharedWith) {
this.stdout(`\t${user.email}\t${user.readOnly ? _('(Read-only)') : ''}`);
}
}
} else {
const shareState = getShareState();
const output = {
invitations: shareState.shareInvitations.map(invitation => ({
accepted: invitation.status === ShareUserStatus.Accepted,
waiting: invitation.status === ShareUserStatus.Waiting,
rejected: invitation.status === ShareUserStatus.Rejected,
folderId: invitation.share.folder_id,
fromUser: {
email: invitation.share.user?.email,
},
})),
shares: shareState.shares.map(share => ({
isFolder: !!share.folder_id,
isNote: !!share.note_id,
itemId: share.folder_id ?? share.note_id,
fromUser: {
email: share.user?.email,
},
})),
};
if (args.options.json) {
this.stdout(JSON.stringify(output));
} else {
this.stdout(_('Incoming shares:'));
let loggedInvitation = false;
for (const invitation of output.invitations) {
let message;
if (invitation.waiting) {
message = _('Waiting: Notebook %s from %s', invitation.folderId, invitation.fromUser.email);
}
if (invitation.accepted) {
const folder = await Folder.load(invitation.folderId);
message = _('Accepted: Notebook %s from %s', folderTitle(folder), invitation.fromUser.email);
}
if (message) {
this.stdout(`\t${message}`);
loggedInvitation = true;
}
}
if (!loggedInvitation) {
this.stdout(`\t${_('None')}`);
}
this.stdout(_('All shared folders:'));
if (output.shares.length) {
for (const share of output.shares) {
let title;
if (share.isFolder) {
title = folderTitle(await Folder.load(share.itemId));
} else {
title = share.itemId;
}
if (share.fromUser?.email) {
this.stdout(`\t${_('%s from %s', title, share.fromUser?.email)}`);
} else {
this.stdout(`\t${title} - ${share.itemId}`);
}
}
} else {
this.stdout(`\t${_('None')}`);
}
}
}
};
const commandShareAcceptOrReject = async (folderId: string, accept: boolean) => {
await ShareService.instance().maintenance();
const shareState = getShareState();
const invitations = shareState.shareInvitations.filter(invitation => {
return invitation.share.folder_id === folderId && invitation.status === ShareUserStatus.Waiting;
});
if (invitations.length === 0) throw new Error('No such invitation found');
// If there are multiple invitations for the same folder, stop early to avoid
// accepting the wrong invitation.
if (invitations.length > 1) throw new Error('Multiple invitations found with the same ID');
const invitation = invitations[0];
this.stdout(accept ? _('Accepting share...') : _('Rejecting share...'));
await invitationRespond(invitation.id, invitation.share.folder_id, invitation.master_key, accept);
};
const commandShareAccept = (folderId: string) => (
commandShareAcceptOrReject(folderId, true)
);
const commandShareReject = (folderId: string) => (
commandShareAcceptOrReject(folderId, false)
);
const commandShareDelete = async (folder: FolderEntity) => {
const force = args.options.force;
const ok = force ? true : await this.prompt(
_('Unshare notebook "%s"? This may cause other users to lose access to the notebook.', folderTitle(folder)),
{ booleanAnswerDefault: 'n' },
);
if (!ok) return;
logger.info('Unsharing folder', folder.id);
await ShareService.instance().unshareFolder(folder.id);
await reg.scheduleSync();
};
if (args.command === 'add' || args.command === 'remove' || args.command === 'delete') {
if (!args.notebook) throw new Error('[notebook] is required');
const folder = await app().loadItemOrFail(ModelType.Folder, args.notebook);
if (args.command === 'delete') {
return commandShareDelete(folder);
} else {
if (!args.user) throw new Error('[user] is required');
const email = args.user;
if (args.command === 'add') {
return commandShareAdd(folder, email);
} else if (args.command === 'remove') {
return commandShareRemove(folder, email);
}
}
}
if (args.command === 'leave') {
const folder = args.notebook ? await app().loadItemOrFail(ModelType.Folder, args.notebook) : null;
await ShareService.instance().maintenance();
return CommandService.instance().execute(
'leaveSharedFolder', folder?.id, { force: args.options.force },
);
}
if (args.command === 'list') {
return commandShareList();
}
if (args.command === 'accept') {
return commandShareAccept(args.notebook);
}
if (args.command === 'reject') {
return commandShareReject(args.notebook);
}
throw new Error(`Unknown subcommand: ${args.command}`);
}
}
module.exports = Command;

View File

@@ -17,6 +17,7 @@ import { pathExists, writeFile } from 'fs-extra';
import { checkIfLoginWasSuccessful, generateApplicationConfirmUrl } from '@joplin/lib/services/joplinCloudUtils';
import Logger from '@joplin/utils/Logger';
import { uuidgen } from '@joplin/lib/uuid';
import ShareService from '@joplin/lib/services/share/ShareService';
const logger = Logger.create('command-sync');
@@ -230,6 +231,10 @@ class Command extends BaseCommand {
return cleanUp();
}
// Refresh share invitations -- if running without a GUI, some of the
// maintenance tasks may otherwise be skipped.
await ShareService.instance().maintenance();
this.stdout(_('Starting synchronisation...'));
const contextKey = `sync.${this.syncTargetId_}.context`;

View File

@@ -22,7 +22,7 @@ const Setting = require('@joplin/lib/models/Setting').default;
const Revision = require('@joplin/lib/models/Revision').default;
const Logger = require('@joplin/utils/Logger').default;
const FsDriverNode = require('@joplin/lib/fs-driver-node').default;
const { shimInit } = require('@joplin/lib/shim-init-node.js');
const shimInitCli = require('./utils/shimInitCli').default;
const shim = require('@joplin/lib/shim').default;
const { _ } = require('@joplin/lib/locale');
const FileApiDriverLocal = require('@joplin/lib/file-api-driver-local').default;
@@ -73,7 +73,7 @@ function appVersion() {
return p.version;
}
shimInit({ sharp, keytar, appVersion, nodeSqlite });
shimInitCli({ sharp, keytar, appVersion, nodeSqlite });
const logger = new Logger();
Logger.initializeGlobalLogger(logger);

View File

@@ -0,0 +1,14 @@
import CommandService from '@joplin/lib/services/CommandService';
import stateToWhenClauseContext from '@joplin/lib/services/commands/stateToWhenClauseContext';
import libCommands from '@joplin/lib/commands/index';
import { State } from '@joplin/lib/reducer';
import { Store } from 'redux';
export default function initializeCommandService(store: Store<State>, devMode: boolean) {
CommandService.instance().initialize(store, devMode, stateToWhenClauseContext);
for (const command of libCommands) {
CommandService.instance().registerDeclaration(command.declaration);
CommandService.instance().registerRuntime(command.declaration.name, command.runtime());
}
}

View File

@@ -0,0 +1,32 @@
import shim, { ShowMessageBoxOptions } from '@joplin/lib/shim';
import type { ShimInitOptions } from '@joplin/lib/shim-init-node';
import app from '../app';
import { _ } from '@joplin/lib/locale';
const { shimInit } = require('@joplin/lib/shim-init-node.js');
const shimInitCli = (options: ShimInitOptions) => {
shimInit(options);
shim.showMessageBox = async (message: string, options: ShowMessageBoxOptions) => {
const gui = app()?.gui();
let answers = options.buttons ?? [_('Ok'), _('Cancel')];
if (options.type === 'error' || options.type === 'info') {
answers = [];
}
message += answers.length ? `(${answers.join(', ')})` : '';
const answer = await gui.prompt(options.title ?? '', `${message} `, { answers });
if (answers.includes(answer)) {
return answers.indexOf(answer);
} else if (answer) {
return answers.findIndex(a => a.startsWith(answer));
}
return -1;
};
};
export default shimInitCli;

View File

@@ -15,4 +15,7 @@ export const setupApplication = async () => {
// such notebook.
await Folder.save({ title: 'default' });
await app().refreshCurrentFolder();
// Some tests also need access to the Redux store
app().initRedux();
};

View File

@@ -55,6 +55,7 @@
"node-rsa": "1.1.1",
"open": "8.4.2",
"proper-lockfile": "4.1.2",
"redux": "4.2.1",
"server-destroy": "1.0.1",
"sharp": "0.33.5",
"sprintf-js": "1.1.3",
@@ -72,7 +73,7 @@
"@joplin/tools": "~3.4",
"@types/fs-extra": "11.0.4",
"@types/jest": "29.5.12",
"@types/node": "18.19.67",
"@types/node": "18.19.86",
"@types/proper-lockfile": "^4.1.2",
"gulp": "4.0.2",
"jest": "29.7.0",

View File

@@ -0,0 +1 @@
<p><span style="/* Comment */ text-decoration: underline;">Test</span>. In the past, <span style="font-size: auto;/* Test! */">comments</span> in CSS have caused issues.</p>

View File

@@ -0,0 +1 @@
<ins>Test</ins>. In the past, comments in CSS have caused issues.

View File

@@ -343,6 +343,14 @@ export default class ElectronAppWrapper {
}, 1000);
}
const sendWindowFocused = (focusedWebContents: WebContents) => {
const joplinId = this.windowIdFromWebContents(focusedWebContents);
if (joplinId !== null) {
this.win_.webContents.send('window-focused', joplinId);
}
};
const addWindowEventHandlers = (webContents: WebContents) => {
// will-frame-navigate is fired by clicking on a link within the BrowserWindow.
webContents.on('will-frame-navigate', event => {
@@ -376,13 +384,10 @@ export default class ElectronAppWrapper {
addWindowEventHandlers(event.webContents);
});
webContents.on('focus', () => {
const joplinId = this.windowIdFromWebContents(webContents);
if (joplinId !== null) {
this.win_.webContents.send('window-focused', joplinId);
}
});
const onFocus = () => {
sendWindowFocused(webContents);
};
webContents.on('focus', onFocus);
};
addWindowEventHandlers(this.win_.webContents);
@@ -454,6 +459,10 @@ export default class ElectronAppWrapper {
this.win_.close();
}
});
if (window.isFocused()) {
sendWindowFocused(window.webContents);
}
});
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied

View File

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

View File

@@ -10,6 +10,7 @@ window.__REACT_DEVTOOLS_GLOBAL_HOOK__ = {
onCommitFiberUnmount: function() {},
};
require('./utils/sourceMapSetup');
const app = require('./app').default;
const Folder = require('@joplin/lib/models/Folder').default;
const Resource = require('@joplin/lib/models/Resource').default;
@@ -39,32 +40,6 @@ window.React = React;
const main = async () => {
if (bridge().env() === 'dev') {
const newConsole = function(oldConsole) {
const output = {};
const fnNames = ['assert', 'clear', 'context', 'count', 'countReset', 'debug', 'dir', 'dirxml', 'error', 'group', 'groupCollapsed', 'groupEnd', 'info', 'log', 'memory', 'profile', 'profileEnd', 'table', 'time', 'timeEnd', 'timeLog', 'timeStamp', 'trace', 'warn'];
for (const fnName of fnNames) {
if (fnName === 'warn') {
output.warn = function(...text) {
const s = [...text].join('');
// React spams the console with walls of warnings even outside of strict mode, and even after having renamed
// unsafe methods to UNSAFE_xxxx, so we need to hack the console to remove them...
if (s.indexOf('Warning: componentWillReceiveProps has been renamed, and is not recommended for use') === 0) return;
if (s.indexOf('Warning: componentWillUpdate has been renamed, and is not recommended for use.') === 0) return;
oldConsole.warn(...text);
};
} else {
output[fnName] = function(...text) {
return oldConsole[fnName](...text);
};
}
}
return output;
}(window.console);
window.console = newConsole;
}
// eslint-disable-next-line no-console
console.info(`Environment: ${bridge().env()}`);

View File

@@ -1,5 +1,6 @@
// This is the basic initialization for the Electron MAIN process
require('./utils/sourceMapSetup');
const electronApp = require('electron').app;
require('@electron/remote/main').initialize();
const ElectronAppWrapper = require('./ElectronAppWrapper').default;

View File

@@ -12,7 +12,7 @@
"electronRebuild": "gulp electronRebuild",
"tsc": "tsc --project tsconfig.json",
"watch": "tsc --watch --preserveWatchOutput --project tsconfig.json",
"start": "gulp before-start && electron . --env dev --log-level debug --open-dev-tools --no-welcome",
"start": "gulp before-start && JOPLIN_SOURCE_MAP_ENABLED=1 electron . --env dev --log-level debug --open-dev-tools --no-welcome",
"test": "jest",
"test-ui": "gulp before-start && playwright test",
"test-ci": "yarn test",
@@ -132,8 +132,8 @@
"devDependencies": {
"7zip-bin": "5.2.0",
"@axe-core/playwright": "4.10.1",
"@electron/notarize": "2.3.2",
"@electron/rebuild": "3.6.0",
"@electron/notarize": "2.5.0",
"@electron/rebuild": "3.7.1",
"@fortawesome/fontawesome-free": "5.15.4",
"@joeattardi/emoji-button": "4.6.4",
"@joplin/default-plugins": "~3.4",
@@ -147,9 +147,9 @@
"@testing-library/react-hooks": "8.0.1",
"@types/jest": "29.5.12",
"@types/mustache": "4.2.5",
"@types/node": "18.19.67",
"@types/react": "18.3.18",
"@types/react-dom": "18.3.5",
"@types/node": "18.19.86",
"@types/react": "18.3.20",
"@types/react-dom": "18.3.6",
"@types/react-redux": "7.1.33",
"@types/styled-components": "5.1.32",
"@types/tesseract.js": "2.0.0",
@@ -162,11 +162,11 @@
"debounce": "1.2.1",
"electron": "35.5.1",
"electron-builder": "24.13.3",
"electron-updater": "6.2.1",
"electron-updater": "6.6.0",
"electron-window-state": "5.0.3",
"esbuild": "^0.25.3",
"formatcoords": "1.1.3",
"glob": "10.4.5",
"glob": "11.0.1",
"gulp": "4.0.2",
"highlight.js": "11.11.1",
"immer": "9.0.21",
@@ -184,11 +184,11 @@
"node-rsa": "1.1.1",
"pdfjs-dist": "3.11.174",
"pretty-bytes": "5.6.0",
"re-resizable": "6.9.17",
"re-resizable": "6.11.2",
"react": "18.3.1",
"react-dom": "18.3.1",
"react-redux": "8.1.3",
"react-select": "5.8.0",
"react-select": "5.10.1",
"react-test-renderer": "18.3.1",
"react-toggle-button": "2.2.0",
"react-tooltip": "4.5.1",
@@ -196,6 +196,7 @@
"reselect": "4.1.8",
"roboto-fontface": "0.10.0",
"smalltalk": "2.5.1",
"source-map-support": "0.5.21",
"styled-components": "5.3.11",
"styled-system": "5.1.5",
"taboverride": "4.0.3",

View File

@@ -39,6 +39,12 @@
# ./runForTesting.sh 1 createTeams,createData,resetTeam,sync && ./runForTesting.sh 2 resetTeam,sync && ./runForTesting.sh 1
# ----------------------------------------------------------------------------------
# User 1 shares a folder with user 2
# ----------------------------------------------------------------------------------
# ./runForTesting.sh 1 createUsers,createData,reset,shareWithUser2,sync && ./runForTesting.sh 2 reset,sync && ./runForTesting.sh 1
# ----------------------------------------------------------------------------------
# Testing the CLI app with commands:
# ----------------------------------------------------------------------------------
@@ -123,6 +129,13 @@ do
echo 'use "shared"' >> "$CMD_FILE"
echo 'mknote "note 1"' >> "$CMD_FILE"
echo 'mknote "note 2"' >> "$CMD_FILE"
echo 'mkbook --parent "shared" "sub"' >> "$CMD_FILE"
echo 'use "sub"' >> "$CMD_FILE"
echo 'mknote "note 3"' >> "$CMD_FILE"
elif [[ $CMD == "shareWithUser2" ]]; then
echo 'share add "shared" user2@example.com' >> "$CMD_FILE"
elif [[ $CMD == "reset" ]]; then
@@ -169,6 +182,12 @@ do
fi
done
echo '----------------------------------------------------'
echo 'Running commands:'
echo '';
cat "$CMD_FILE"
echo '----------------------------------------------------'
cd "$ROOT_DIR/packages/app-cli"
yarn start --profile "$PROFILE_DIR" batch "$CMD_FILE"

View File

@@ -1,9 +1,12 @@
import { filename, toForwardSlashes } from '@joplin/utils/path';
import * as esbuild from 'esbuild';
import { existsSync } from 'fs';
import { existsSync, readFileSync } from 'fs';
import { writeFile } from 'fs/promises';
import { dirname, join, relative } from 'path';
const baseDir = dirname(__dirname);
const baseNodeModules = join(baseDir, 'node_modules');
// Note: Roughly based on js-draw's use of esbuild:
// https://github.com/personalizedrefrigerator/js-draw/blob/6fe6d6821402a08a8d17f15a8f48d95e5d7b084f/packages/build-tool/src/BundledFile.ts#L64
const makeBuildContext = (entryPoint: string, renderer: boolean, computeFileSizeStats: boolean) => {
@@ -12,9 +15,10 @@ const makeBuildContext = (entryPoint: string, renderer: boolean, computeFileSize
outfile: `${filename(entryPoint)}.bundle.js`,
bundle: true,
minify: true,
keepNames: true,
keepNames: true, // Preserve original function names -- useful for debugging
format: 'iife', // Immediately invoked function expression
sourcemap: true,
sourcesContent: false, // Do not embed full source file content in the .map file
metafile: computeFileSizeStats,
platform: 'node',
target: ['node20.0'],
@@ -27,8 +31,6 @@ const makeBuildContext = (entryPoint: string, renderer: boolean, computeFileSize
name: 'joplin--relative-imports-for-externals',
setup: build => {
const externalRegex = /^(.*\.node|sqlite3|electron|@electron\/remote\/.*|electron\/.*|@mapbox\/node-pre-gyp|jsdom)$/;
const baseDir = dirname(__dirname);
const baseNodeModules = join(baseDir, 'node_modules');
build.onResolve({ filter: externalRegex }, args => {
// Electron packages don't need relative requires
if (args.path === 'electron' || args.path.startsWith('electron/')) {
@@ -65,8 +67,6 @@ const makeBuildContext = (entryPoint: string, renderer: boolean, computeFileSize
// Rewrite imports to prefer .js files to .ts. Otherwise, certain files are duplicated in the final bundle
name: 'joplin--prefer-js-imports',
setup: build => {
const baseDir = dirname(__dirname);
const baseNodeModules = join(baseDir, 'node_modules');
// Rewrite all relative imports
build.onResolve({ filter: /^\./ }, args => {
try {
@@ -89,6 +89,31 @@ const makeBuildContext = (entryPoint: string, renderer: boolean, computeFileSize
});
},
},
{
name: 'joplin--smaller-source-map-size',
setup: build => {
// Exclude dependencies from node_modules. This significantly reduces the size of the
// source map, improving startup performance.
//
// See https://github.com/evanw/esbuild/issues/1685#issuecomment-944916409
// and https://github.com/evanw/esbuild/issues/4130
const emptyMapData = Buffer.from(
JSON.stringify({ version: 3, sources: [null], mappings: 'AAAA' }),
'utf-8',
).toString('base64');
const emptyMapUrl = `data:application/json;base64,${emptyMapData}`;
build.onLoad({ filter: /node_modules.*js$/ }, args => {
return {
contents: [
readFileSync(args.path, 'utf8'),
`//# sourceMappingURL=${emptyMapUrl}`,
].join('\n'),
loader: 'default',
};
});
},
},
],
});
};

View File

@@ -0,0 +1,6 @@
// source-map-support can add 1-3 seconds to the application startup
// time -- disable it unless requested:
if (process.env.JOPLIN_SOURCE_MAP_ENABLED) {
require('source-map-support').install();
}

View File

@@ -18,7 +18,6 @@ import net.cozic.joplin.audio.SpeechToTextPackage
import net.cozic.joplin.versioninfo.SystemVersionInformationPackage
import net.cozic.joplin.share.SharePackage
import net.cozic.joplin.ssl.SslPackage
import net.cozic.joplin.textinput.TextInputPackage
class MainApplication : Application(), ReactApplication {
override val reactNativeHost: ReactNativeHost = ReactNativeHostWrapper(this, object : DefaultReactNativeHost(this) {
@@ -27,7 +26,6 @@ class MainApplication : Application(), ReactApplication {
// Packages that cannot be autolinked yet can be added manually here, for example:
add(SharePackage())
add(SslPackage())
add(TextInputPackage())
add(SystemVersionInformationPackage())
add(SpeechToTextPackage())
}

View File

@@ -1,63 +0,0 @@
package net.cozic.joplin.textinput;
import android.text.Selection;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import com.facebook.react.bridge.NativeModule;
import com.facebook.react.bridge.ReactApplicationContext;
import com.facebook.react.bridge.ReadableArray;
import com.facebook.react.uimanager.ViewManager;
import com.facebook.react.views.textinput.ReactEditText;
import com.facebook.react.views.textinput.ReactTextInputManager;
import java.util.Collections;
import java.util.List;
/**
* This class provides a workaround for <a href="https://github.com/facebook/react-native/issues/29911">
* https://github.com/facebook/react-native/issues/29911</a>
*
* The reason the editor is scrolled seems to be due to this block in
* <pre>android.widget.Editor#onFocusChanged:</pre>
*
* <pre>
* // The DecorView does not have focus when the 'Done' ExtractEditText button is
* // pressed. Since it is the ViewAncestor's mView, it requests focus before
* // ExtractEditText clears focus, which gives focus to the ExtractEditText.
* // This special case ensure that we keep current selection in that case.
* // It would be better to know why the DecorView does not have focus at that time.
* if (((mTextView.isInExtractedMode()) || mSelectionMoved)
* && selStart >= 0 && selEnd >= 0) {
* Selection.setSelection((Spannable)mTextView.getText(),selStart,selEnd);
* }
* </pre>
* When using native Android TextView mSelectionMoved is false so this block is skipped,
* with RN however it's true and this is where the scrolling comes from.
*
* The below workaround resets the selection before a focus event is passed on to the native component.
* This way when the above condition is reached <pre>selStart == selEnd == -1</pre> and no scrolling
* happens.
*/
public class TextInputPackage implements com.facebook.react.ReactPackage {
@NonNull
@Override
public List<NativeModule> createNativeModules(@NonNull ReactApplicationContext reactContext) {
return Collections.emptyList();
}
@NonNull
@Override
public List<ViewManager> createViewManagers(@NonNull ReactApplicationContext reactContext) {
return Collections.singletonList(new ReactTextInputManager() {
@Override
public void receiveCommand(ReactEditText reactEditText, String commandId, @Nullable ReadableArray args) {
if ("focus".equals(commandId) || "focusTextInput".equals(commandId)) {
Selection.removeSelection(reactEditText.getText());
}
super.receiveCommand(reactEditText, commandId, args);
}
});
}
}

View File

@@ -136,17 +136,16 @@ const ActionButtons: React.FC<Props> = props => {
</View>
);
return <>
<View style={styles.buttonRowContainerTop}>
<IconButton
{props.onCancelPhoto && <IconButton
themeId={props.themeId}
iconName='ionicon arrow-back'
containerStyle={styles.buttonContainer}
iconStyle={styles.buttonContent}
onPress={props.onCancelPhoto}
description={_('Back')}
/>
/>}
</View>
{props.cameraReady ? cameraActions : <ActivityIndicator/>}
</>;

View File

@@ -9,10 +9,12 @@ import { TextInput } from 'react-native';
const Camera = (props: Props, ref: ForwardedRef<CameraRef>) => {
useImperativeHandle(ref, () => ({
takePictureAsync: async () => {
const path = `${shim.fsDriver().getCacheDirectoryPath()}/test-photo.svg`;
const parentDir = shim.fsDriver().getCacheDirectoryPath();
await shim.fsDriver().mkdir(parentDir);
const path = `${parentDir}/test-photo.svg`;
await shim.fsDriver().writeFile(
path,
`<svg viewBox="0 0 232 78" width="232" height="78" version="1.1" baseProfile="full" xmlns="http://www.w3.org/2000/svg">
`<svg viewBox="0 -70 232 78" width="232" height="78" version="1.1" baseProfile="full" xmlns="http://www.w3.org/2000/svg">
<text style="font-family: serif; font-size: 104px; fill: rgb(128, 51, 128);">Test!</text>
</svg>`,
'utf8',

View File

@@ -0,0 +1,5 @@
// Use the mock camera component on web -- for now, the default Camera
// component is Android/iOS only
import Camera from './index.jest';
export default Camera;

View File

@@ -4,6 +4,7 @@ import { CameraResult } from './types';
import { fireEvent, render, screen } from '../../utils/testing/testingLibrary';
import createMockReduxStore from '../../utils/testing/createMockReduxStore';
import TestProviderStack from '../testing/TestProviderStack';
import { acceptCameraPermission, rejectCameraPermission, setQrCodeData, startCamera } from './utils/testing';
interface WrapperProps {
onPhoto?: (result: CameraResult)=> void;
@@ -24,26 +25,6 @@ const CameraViewWrapper: React.FC<WrapperProps> = props => {
</TestProviderStack>;
};
const rejectCameraPermission = () => {
const rejectPermissionButton = screen.getByRole('button', { name: 'Reject permission' });
fireEvent.press(rejectPermissionButton);
};
const acceptCameraPermission = () => {
const acceptPermissionButton = screen.getByRole('button', { name: 'Accept permission' });
fireEvent.press(acceptPermissionButton);
};
const startCamera = () => {
const startCameraButton = screen.getByRole('button', { name: 'On camera ready' });
fireEvent.press(startCameraButton);
};
const setQrCodeData = (data: string) => {
const qrCodeDataInput = screen.getByPlaceholderText('QR code data');
fireEvent.changeText(qrCodeDataInput, data);
};
describe('CameraView', () => {
test('should hide permissions error if camera permission is granted', async () => {
const view = render(<CameraViewWrapper/>);
@@ -85,3 +66,4 @@ describe('CameraView', () => {
view.unmount();
});
});

View File

@@ -1,5 +1,5 @@
import * as React from 'react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useCallback, useMemo, useRef, useState } from 'react';
import { connect } from 'react-redux';
import { Text, StyleSheet, Linking, View, Platform, useWindowDimensions } from 'react-native';
@@ -10,15 +10,15 @@ import ActionButtons from './ActionButtons';
import { CameraDirection } from '@joplin/lib/models/settings/builtInMetadata';
import Setting from '@joplin/lib/models/Setting';
import { LinkButton, PrimaryButton } from '../buttons';
import BackButtonService from '../../services/BackButtonService';
import { themeStyle } from '../global-style';
import fitRectIntoBounds from './utils/fitRectIntoBounds';
import useBarcodeScanner from './utils/useBarcodeScanner';
import ScannedBarcodes from './ScannedBarcodes';
import { CameraRef } from './Camera/types';
import Camera from './Camera';
import { CameraResult } from './types';
import Camera from './Camera/index';
import { CameraResult, OnInsertBarcode } from './types';
import Logger from '@joplin/utils/Logger';
import useBackHandler from '../../utils/hooks/useBackHandler';
const logger = Logger.create('CameraView');
@@ -28,8 +28,10 @@ interface Props {
cameraType: CameraDirection;
cameraRatio: string;
onPhoto: (data: CameraResult)=> void;
onCancel: ()=> void;
onInsertBarcode: (barcodeText: string)=> void;
// If null, cancelling should be handled by the parent
// component
onCancel: (()=> void)|null;
onInsertBarcode: OnInsertBarcode;
}
interface UseStyleProps {
@@ -107,16 +109,7 @@ const CameraViewComponent: React.FC<Props> = props => {
const cameraRef = useRef<CameraRef|null>(null);
const [cameraReady, setCameraReady] = useState(false);
useEffect(() => {
const handler = () => {
props.onCancel();
return true;
};
BackButtonService.addHandler(handler);
return () => {
BackButtonService.removeHandler(handler);
};
}, [props.onCancel]);
useBackHandler(props.onCancel);
const onCameraReverse = useCallback(() => {
const newDirection = props.cameraType === CameraDirection.Front ? CameraDirection.Back : CameraDirection.Front;
@@ -166,7 +159,7 @@ const CameraViewComponent: React.FC<Props> = props => {
overlay = <View style={styles.errorContainer}>
<Text>{_('Missing camera permission')}</Text>
<LinkButton onPress={() => Linking.openSettings()}>{_('Open settings')}</LinkButton>
<PrimaryButton onPress={props.onCancel}>{_('Go back')}</PrimaryButton>
{props.onCancel && <PrimaryButton onPress={props.onCancel}>{_('Go back')}</PrimaryButton>}
</View>;
} else {
overlay = <>

View File

@@ -0,0 +1,85 @@
import * as React from 'react';
import Setting from '@joplin/lib/models/Setting';
import CameraViewMultiPage, { OnComplete } from './CameraViewMultiPage';
import { CameraResult, OnInsertBarcode } from './types';
import { Store } from 'redux';
import { AppState } from '../../utils/types';
import TestProviderStack from '../testing/TestProviderStack';
import createMockReduxStore from '../../utils/testing/createMockReduxStore';
import { fireEvent, render, screen, waitFor } from '@testing-library/react-native';
import { startCamera, takePhoto } from './utils/testing';
interface WrapperProps {
onCancel?: ()=> void;
onInsertBarcode?: OnInsertBarcode;
onComplete?: OnComplete;
}
let store: Store<AppState>;
const WrappedCamera: React.FC<WrapperProps> = ({
onCancel = jest.fn(),
onComplete = jest.fn(),
onInsertBarcode = jest.fn(),
}) => {
return <TestProviderStack store={store}>
<CameraViewMultiPage
themeId={Setting.THEME_LIGHT}
onCancel={onCancel}
onComplete={onComplete}
onInsertBarcode={onInsertBarcode}
/>
</TestProviderStack>;
};
const getNextButton = () => screen.getByRole('button', { name: 'Next' });
const queryPhotoCount = () => screen.queryByTestId('photo-count');
describe('CameraViewMultiPage', () => {
beforeEach(() => {
store = createMockReduxStore();
});
test('next button should be disabled until a photo has been taken', async () => {
render(<WrappedCamera/>);
expect(getNextButton()).toBeDisabled();
startCamera();
// Should still be disabled after starting the camera
expect(getNextButton()).toBeDisabled();
await takePhoto();
await waitFor(() => {
expect(getNextButton()).not.toBeDisabled();
});
});
test('should show a count of the number of photos taken', async () => {
render(<WrappedCamera/>);
startCamera();
expect(queryPhotoCount()).toBeNull();
for (let i = 1; i < 3; i++) {
await takePhoto();
await waitFor(() => {
expect(queryPhotoCount()).toHaveTextContent(String(i));
});
}
});
test('pressing "Next" should call onComplete with photo URI(s)', async () => {
const onComplete = jest.fn();
render(<WrappedCamera onComplete={onComplete}/>);
startCamera();
await takePhoto();
await waitFor(() => {
expect(getNextButton()).not.toBeDisabled();
});
fireEvent.press(getNextButton());
const imageResults: CameraResult[] = onComplete.mock.lastCall[0];
expect(imageResults).toHaveLength(1);
expect(imageResults[0].uri).toBeTruthy();
});
});

View File

@@ -0,0 +1,146 @@
import * as React from 'react';
import { CameraResult } from './types';
import { View, StyleSheet, Platform, ImageBackground, ViewStyle, TextStyle } from 'react-native';
import CameraView from './CameraView';
import { useCallback, useMemo, useState } from 'react';
import { themeStyle } from '../global-style';
import { Button, Text } from 'react-native-paper';
import { _ } from '@joplin/lib/locale';
import useAsyncEffect from '@joplin/lib/hooks/useAsyncEffect';
import shim from '@joplin/lib/shim';
export type OnComplete = (photos: CameraResult[])=> void;
interface Props {
themeId: number;
onCancel: ()=> void;
onComplete: OnComplete;
onInsertBarcode: (barcodeText: string)=> void;
}
const useStyle = (themeId: number) => {
return useMemo(() => {
const theme = themeStyle(themeId);
return StyleSheet.create({
camera: {
flex: 1,
},
root: {
flex: 1,
backgroundColor: theme.backgroundColor,
},
bottomRow: {
flexDirection: 'row',
alignItems: 'center',
},
photoWrapper: {
flexGrow: 1,
minHeight: 82,
flexDirection: 'row',
justifyContent: 'center',
},
imagePreview: {
maxWidth: 70,
flexShrink: 1,
flexGrow: 1,
alignContent: 'center',
justifyContent: 'center',
},
imageCountText: {
marginLeft: 'auto',
marginRight: 'auto',
marginTop: 'auto',
padding: 2,
borderRadius: 4,
backgroundColor: theme.backgroundColor2,
color: theme.color2,
},
});
}, [themeId]);
};
interface PhotoProps {
source: CameraResult;
backgroundStyle: ViewStyle;
textStyle: TextStyle;
label: number;
}
const PhotoPreview: React.FC<PhotoProps> = ({ source, label, backgroundStyle, textStyle }) => {
const [uri, setUri] = useState('');
useAsyncEffect(async (event) => {
if (Platform.OS === 'web') {
const file = await shim.fsDriver().fileAtPath(source.uri);
if (event.cancelled) return;
const uri = URL.createObjectURL(file);
setUri(uri);
event.onCleanup(() => {
URL.revokeObjectURL(uri);
});
} else {
setUri(source.uri);
}
}, [source]);
return <ImageBackground
style={backgroundStyle}
resizeMode='contain'
source={{ uri }}
accessibilityLabel={_('%d photo(s) taken', label)}
>
<Text
style={textStyle}
testID='photo-count'
>{label}</Text>
</ImageBackground>;
};
const CameraViewMultiPage: React.FC<Props> = ({
onInsertBarcode, onCancel, onComplete, themeId,
}) => {
const [photos, setPhotos] = useState<CameraResult[]>([]);
const onPhoto = useCallback((data: CameraResult) => {
setPhotos(photos => [...photos, data]);
}, []);
const onDonePressed = useCallback(() => {
onComplete(photos);
}, [photos, onComplete]);
const styles = useStyle(themeId);
const renderLastPhoto = () => {
if (!photos.length) return null;
return <PhotoPreview
label={photos.length}
source={photos[photos.length - 1]}
backgroundStyle={styles.imagePreview}
textStyle={styles.imageCountText}
/>;
};
return <View style={styles.root}>
<CameraView
onCancel={null}
onInsertBarcode={onInsertBarcode}
style={styles.camera}
onPhoto={onPhoto}
/>
<View style={styles.bottomRow}>
<Button icon='arrow-left' onPress={onCancel}>{_('Back')}</Button>
<View style={styles.photoWrapper}>
{renderLastPhoto()}
</View>
<Button
icon='arrow-right'
disabled={photos.length === 0}
onPress={onDonePressed}
>{_('Next')}</Button>
</View>
</View>;
};
export default CameraViewMultiPage;

View File

@@ -8,11 +8,12 @@ import DismissibleDialog, { DialogSize } from '../DismissibleDialog';
import { Chip, Text } from 'react-native-paper';
import { isCallbackUrl, parseCallbackUrl } from '@joplin/lib/callbackUrlUtils';
import CommandService from '@joplin/lib/services/CommandService';
import { OnInsertBarcode } from './types';
interface Props {
themeId: number;
codeScanner: BarcodeScanner;
onInsertCode: (codeText: string)=> void;
onInsertCode: OnInsertBarcode;
}
const useStyles = () => {

View File

@@ -1,4 +1,6 @@
export type OnInsertBarcode = (barcodeText: string)=> void;
export interface CameraResult {
uri: string;
type: string;

View File

@@ -0,0 +1,28 @@
// Utilities for use with the CameraView.jest.tsx mock
import { fireEvent, screen } from '@testing-library/react-native';
export const rejectCameraPermission = () => {
const rejectPermissionButton = screen.getByRole('button', { name: 'Reject permission' });
fireEvent.press(rejectPermissionButton);
};
export const acceptCameraPermission = () => {
const acceptPermissionButton = screen.getByRole('button', { name: 'Accept permission' });
fireEvent.press(acceptPermissionButton);
};
export const startCamera = () => {
const startCameraButton = screen.getByRole('button', { name: 'On camera ready' });
fireEvent.press(startCameraButton);
};
export const takePhoto = async () => {
const takePhotoButton = await screen.findByRole('button', { name: 'Take photo' });
fireEvent.press(takePhotoButton);
};
export const setQrCodeData = (data: string) => {
const qrCodeDataInput = screen.getByPlaceholderText('QR code data');
fireEvent.changeText(qrCodeDataInput, data);
};

View File

@@ -24,6 +24,9 @@ const builtInCommandNames = [
EditorCommandType.IndentMore,
`editor.${EditorCommandType.SwapLineDown}`,
`editor.${EditorCommandType.SwapLineUp}`,
`editor.${EditorCommandType.DeleteLine}`,
`editor.${EditorCommandType.DuplicateLine}`,
`editor.${EditorCommandType.SortSelectedLines}`,
'-',
'insertDateTime',
'-',

View File

@@ -10,6 +10,9 @@ const omitFromDefault: string[] = [
'editor.textHeading5',
`editor.${EditorCommandType.SwapLineDown}`,
`editor.${EditorCommandType.SwapLineUp}`,
`editor.${EditorCommandType.DeleteLine}`,
`editor.${EditorCommandType.DuplicateLine}`,
`editor.${EditorCommandType.SortSelectedLines}`,
];
// The "hide keyboard" button is only needed on iOS, so only show it there by default.

View File

@@ -60,6 +60,7 @@ const useCss = (editorTheme: Theme) => {
body, html {
padding: 0;
margin: 0;
overflow: hidden;
}
/* Hide the scrollbar. See scrollbar accessibility concerns

View File

@@ -107,6 +107,21 @@ const declarations: CommandDeclaration[] = [
label: () => _('Swap line up'),
iconName: 'material chevron-double-up',
},
{
name: `editor.${EditorCommandType.DeleteLine}`,
label: () => _('Delete line'),
iconName: 'material close',
},
{
name: `editor.${EditorCommandType.DuplicateLine}`,
label: () => _('Duplicate line'),
iconName: 'material content-duplicate',
},
{
name: `editor.${EditorCommandType.SortSelectedLines}`,
label: () => _('Sort selected lines'),
iconName: 'material sort-alphabetical-ascending',
},
{
name: EditorCommandType.ToggleSearch,
label: () => _('Search'),

View File

@@ -34,7 +34,6 @@ import { themeStyle, editorFont } from '../../global-style';
import shared, { BaseNoteScreenComponent, Props as BaseProps } from '@joplin/lib/components/shared/note-screen-shared';
import SelectDateTimeDialog from '../../SelectDateTimeDialog';
import ShareExtension from '../../../utils/ShareExtension.js';
import CameraView from '../../CameraView/CameraView';
import { FolderEntity, NoteEntity, ResourceEntity } from '@joplin/lib/services/database/types';
import Logger from '@joplin/utils/Logger';
import ImageEditor from '../../NoteEditor/ImageEditor/ImageEditor';
@@ -68,6 +67,7 @@ import getActivePluginEditorView from '@joplin/lib/services/plugins/utils/getAct
import EditorPluginHandler from '@joplin/lib/services/plugins/EditorPluginHandler';
import AudioRecordingBanner from '../../voiceTyping/AudioRecordingBanner';
import SpeechToTextBanner from '../../voiceTyping/SpeechToTextBanner';
import CameraView from '../../CameraView/CameraView';
import ShareNoteDialog from '../ShareNoteDialog';
import stateToWhenClauseContext from '../../../services/commands/stateToWhenClauseContext';
import { defaultWindowId } from '@joplin/lib/reducer';
@@ -837,6 +837,7 @@ class NoteScreenComponent extends BaseScreenComponent<ComponentProps, State> imp
pickerResponse: PickerResponse,
fileType: string,
): Promise<ResourceEntity|null> {
logger.debug('Attaching file:', pickerResponse?.uri);
if (!pickerResponse) {
// User has cancelled
return null;
@@ -918,11 +919,17 @@ class NoteScreenComponent extends BaseScreenComponent<ComponentProps, State> imp
return resource;
}
private cameraView_onPhoto(data: CameraResult) {
void this.attachFile(
data,
'image',
);
private async cameraView_onPhoto(data: CameraResult|CameraResult[]) {
if (!Array.isArray(data)) {
data = [data];
}
for (const item of data) {
await this.attachFile(
item,
'image',
);
}
this.setState({ showCamera: false });
}
@@ -1524,10 +1531,10 @@ class NoteScreenComponent extends BaseScreenComponent<ComponentProps, State> imp
if (this.state.showCamera) {
return <CameraView
style={{ flex: 1 }}
onPhoto={this.cameraView_onPhoto}
onInsertBarcode={this.cameraView_onInsertBarcode}
onCancel={this.cameraView_onCancel}
style={{ flex: 1 }}
/>;
} else if (this.state.showImageEditor) {
return <ImageEditor

View File

@@ -1,6 +1,6 @@
import * as React from 'react';
import { connect } from 'react-redux';
import { View, StyleSheet } from 'react-native';
import { View, StyleSheet, SafeAreaView, ScrollView } from 'react-native';
import { AppState } from '../../utils/types';
import useAsyncEffect from '@joplin/lib/hooks/useAsyncEffect';
import Revision from '@joplin/lib/models/Revision';
@@ -102,6 +102,30 @@ const useStyles = (themeId: number) => {
root: {
...theme.rootStyle,
},
titleContainer: {
paddingLeft: theme.marginLeft,
paddingRight: theme.marginRight,
borderTopColor: theme.dividerColor,
borderTopWidth: 1,
borderBottomColor: theme.dividerColor,
borderBottomWidth: 1,
},
titleViewContainer: {
flex: 0,
flexDirection: 'row',
flexBasis: 'auto',
},
titleText: {
flex: 1,
marginTop: 0,
paddingLeft: 0,
color: theme.color,
backgroundColor: theme.backgroundColor,
fontWeight: 'bold',
fontSize: theme.fontSize,
paddingTop: 10,
paddingBottom: 10,
},
});
}, [themeId]);
};
@@ -188,6 +212,16 @@ const NoteRevisionViewer: React.FC<Props> = props => {
>{restoreButtonTitle}</PrimaryButton>
);
const titleComponent = (
<SafeAreaView style={styles.titleContainer}>
<ScrollView horizontal showsHorizontalScrollIndicator={false}>
<View style={styles.titleViewContainer}>
<Text style={styles.titleText}>{note?.title ?? ''}</Text>
</View>
</ScrollView>
</SafeAreaView>
);
return <View style={styles.root}>
<ScreenHeader menuOptions={menuOptions} title={_('Note history')} />
<View style={styles.controls}>
@@ -212,6 +246,7 @@ const NoteRevisionViewer: React.FC<Props> = props => {
onPress={onHelpPress}
/>
</View>
{note ? titleComponent : ''}
<NoteBodyViewer
style={styles.noteViewer}
noteBody={note?.body ?? _('No revision selected')}

View File

@@ -48,7 +48,7 @@ const useVoiceTyping = ({ locale, provider, onSetPreview, onText }: UseVoiceTypi
const [redownloadCounter, setRedownloadCounter] = useState(0);
useQueuedAsyncEffect(async (event: AsyncEffectEvent) => {
useQueuedAsyncEffect(async (event) => {
try {
// Reset the error: If starting voice typing again resolves the error, the error
// should be hidden (and voice typing should start).

View File

@@ -533,7 +533,7 @@
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_ENTITLEMENTS = Joplin/Joplin.entitlements;
CURRENT_PROJECT_VERSION = 141;
CURRENT_PROJECT_VERSION = 142;
DEVELOPMENT_TEAM = A9BXAFS6CT;
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Joplin/Info.plist;
@@ -568,7 +568,7 @@
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_ENTITLEMENTS = Joplin/Joplin.entitlements;
CURRENT_PROJECT_VERSION = 141;
CURRENT_PROJECT_VERSION = 142;
DEVELOPMENT_TEAM = A9BXAFS6CT;
INFOPLIST_FILE = Joplin/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 15.6;
@@ -767,7 +767,7 @@
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 141;
CURRENT_PROJECT_VERSION = 142;
DEBUG_INFORMATION_FORMAT = dwarf;
DEVELOPMENT_TEAM = A9BXAFS6CT;
GCC_C_LANGUAGE_STANDARD = gnu11;
@@ -810,7 +810,7 @@
CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements;
CODE_SIGN_STYLE = Automatic;
COPY_PHASE_STRIP = NO;
CURRENT_PROJECT_VERSION = 141;
CURRENT_PROJECT_VERSION = 142;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
DEVELOPMENT_TEAM = A9BXAFS6CT;
GCC_C_LANGUAGE_STANDARD = gnu11;

View File

@@ -51,16 +51,16 @@
"punycode": "2.3.1",
"react": "19.0.0",
"react-native": "0.79.2",
"react-native-device-info": "10.14.0",
"react-native-device-info": "14.0.4",
"react-native-dropdownalert": "5.1.0",
"react-native-exit-app": "2.0.0",
"react-native-file-viewer": "2.1.5",
"react-native-fingerprint-scanner": "6.0.0",
"react-native-fs": "2.20.0",
"react-native-get-random-values": "1.11.0",
"react-native-image-picker": "7.1.1",
"react-native-localize": "3.2.1",
"react-native-modal-datetime-picker": "17.1.0",
"react-native-image-picker": "7.2.3",
"react-native-localize": "3.4.1",
"react-native-modal-datetime-picker": "18.0.0",
"react-native-paper": "5.13.4",
"react-native-popup-menu": "0.17.0",
"react-native-quick-actions": "0.3.13",
@@ -68,14 +68,14 @@
"react-native-rsa-native": "2.0.5",
"react-native-safe-area-context": "5.4.0",
"react-native-securerandom": "1.0.1",
"react-native-share": "10.2.1",
"react-native-share": "12.0.9",
"react-native-sqlite-storage": "6.0.1",
"react-native-url-polyfill": "2.0.0",
"react-native-vector-icons": "10.1.0",
"react-native-vector-icons": "10.2.0",
"react-native-version-info": "1.1.1",
"react-native-vosk": "0.1.12",
"react-native-webview": "13.13.5",
"react-native-zip-archive": "6.1.2",
"react-native-zip-archive": "7.0.1",
"react-redux": "8.1.3",
"redux": "4.2.1",
"rn-fetch-blob": "0.12.0",
@@ -94,9 +94,9 @@
"@joplin/tools": "~3.4",
"@js-draw/material-icons": "1.30.0",
"@pmmmwh/react-refresh-webpack-plugin": "^0.5.15",
"@react-native-community/cli": "15.0.1",
"@react-native-community/cli-platform-android": "15.1.3",
"@react-native-community/cli-platform-ios": "15.0.1",
"@react-native-community/cli": "16.0.2",
"@react-native-community/cli-platform-android": "16.0.2",
"@react-native-community/cli-platform-ios": "16.0.2",
"@react-native/babel-preset": "0.79.2",
"@react-native/metro-config": "0.79.2",
"@react-native/typescript-config": "0.79.2",
@@ -104,10 +104,10 @@
"@testing-library/react-native": "13.2.0",
"@types/fs-extra": "11.0.4",
"@types/jest": "29.5.12",
"@types/node": "18.19.67",
"@types/react": "19.0.10",
"@types/node": "18.19.86",
"@types/react": "19.0.14",
"@types/react-redux": "7.1.33",
"@types/serviceworker": "0.0.126",
"@types/serviceworker": "0.0.127",
"@types/tar-stream": "3.1.3",
"babel-jest": "29.7.0",
"babel-loader": "9.1.3",
@@ -120,7 +120,7 @@
"jest-environment-jsdom": "29.7.0",
"jetifier": "2.0.0",
"js-draw": "1.30.0",
"jsdom": "24.1.3",
"jsdom": "25.0.1",
"nodemon": "3.1.9",
"punycode": "2.3.1",
"react-dom": "19.0.0",
@@ -137,7 +137,7 @@
"url-loader": "4.1.1",
"webpack": "5.97.1",
"webpack-cli": "5.1.4",
"webpack-dev-server": "5.0.4"
"webpack-dev-server": "5.2.1"
},
"engines": {
"node": ">=18"

View File

@@ -1,5 +1,5 @@
module.exports = {
hash:"e857ce4f63c45b5c1d25eb9a76c2127d", files: {
hash:"39ce682c4ff5dd85d571d0e99718648f", files: {
'highlight.js/atom-one-dark-reasonable.css': { data: require('./highlight.js/atom-one-dark-reasonable.css.base64.js'), mime: 'text/css', encoding: 'base64' },
'highlight.js/atom-one-light.css': { data: require('./highlight.js/atom-one-light.css.base64.js'), mime: 'text/css', encoding: 'base64' },
'katex/fonts/KaTeX_AMS-Regular.woff2': { data: require('./katex/fonts/KaTeX_AMS-Regular.woff2.base64.js'), mime: 'application/octet-stream', encoding: 'base64' },

View File

@@ -1 +1 @@
module.exports = {"hash":"e857ce4f63c45b5c1d25eb9a76c2127d","files":["highlight.js/atom-one-dark-reasonable.css","highlight.js/atom-one-light.css","katex/fonts/KaTeX_AMS-Regular.woff2","katex/fonts/KaTeX_Caligraphic-Bold.woff2","katex/fonts/KaTeX_Caligraphic-Regular.woff2","katex/fonts/KaTeX_Fraktur-Bold.woff2","katex/fonts/KaTeX_Fraktur-Regular.woff2","katex/fonts/KaTeX_Main-Bold.woff2","katex/fonts/KaTeX_Main-BoldItalic.woff2","katex/fonts/KaTeX_Main-Italic.woff2","katex/fonts/KaTeX_Main-Regular.woff2","katex/fonts/KaTeX_Math-BoldItalic.woff2","katex/fonts/KaTeX_Math-Italic.woff2","katex/fonts/KaTeX_SansSerif-Bold.woff2","katex/fonts/KaTeX_SansSerif-Italic.woff2","katex/fonts/KaTeX_SansSerif-Regular.woff2","katex/fonts/KaTeX_Script-Regular.woff2","katex/fonts/KaTeX_Size1-Regular.woff2","katex/fonts/KaTeX_Size2-Regular.woff2","katex/fonts/KaTeX_Size3-Regular.woff2","katex/fonts/KaTeX_Size4-Regular.woff2","katex/fonts/KaTeX_Typewriter-Regular.woff2","katex/katex.css","mermaid/mermaid.min.js","mermaid/mermaid_render.js"]}
module.exports = {"hash":"39ce682c4ff5dd85d571d0e99718648f","files":["highlight.js/atom-one-dark-reasonable.css","highlight.js/atom-one-light.css","katex/fonts/KaTeX_AMS-Regular.woff2","katex/fonts/KaTeX_Caligraphic-Bold.woff2","katex/fonts/KaTeX_Caligraphic-Regular.woff2","katex/fonts/KaTeX_Fraktur-Bold.woff2","katex/fonts/KaTeX_Fraktur-Regular.woff2","katex/fonts/KaTeX_Main-Bold.woff2","katex/fonts/KaTeX_Main-BoldItalic.woff2","katex/fonts/KaTeX_Main-Italic.woff2","katex/fonts/KaTeX_Main-Regular.woff2","katex/fonts/KaTeX_Math-BoldItalic.woff2","katex/fonts/KaTeX_Math-Italic.woff2","katex/fonts/KaTeX_SansSerif-Bold.woff2","katex/fonts/KaTeX_SansSerif-Italic.woff2","katex/fonts/KaTeX_SansSerif-Regular.woff2","katex/fonts/KaTeX_Script-Regular.woff2","katex/fonts/KaTeX_Size1-Regular.woff2","katex/fonts/KaTeX_Size2-Regular.woff2","katex/fonts/KaTeX_Size3-Regular.woff2","katex/fonts/KaTeX_Size4-Regular.woff2","katex/fonts/KaTeX_Typewriter-Regular.woff2","katex/katex.css","mermaid/mermaid.min.js","mermaid/mermaid_render.js"]}

File diff suppressed because one or more lines are too long

View File

@@ -1353,7 +1353,6 @@ class AppComponent extends React.Component<AppComponentProps, AppComponentState>
isOpen={this.props.showSideMenu}
disableGestures={disableSideMenuGestures}
>
<StatusBar barStyle={statusBarStyle} />
<View style={{ flexGrow: 1, flexShrink: 1, flexBasis: '100%' }}>
<SafeAreaView style={{ flex: 0, backgroundColor: theme.backgroundColor2 }}/>
<SafeAreaView style={{ flex: 1 }}>
@@ -1362,11 +1361,6 @@ class AppComponent extends React.Component<AppComponentProps, AppComponentState>
</View>
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied */}
<DropdownAlert alert={(func: any) => (this.dropdownAlert_ = func)} />
{ !shouldShowMainContent && <BiometricPopup
dispatch={this.props.dispatch}
themeId={this.props.themeId}
sensorInfo={this.state.sensorInfo}
/> }
</SafeAreaView>
</View>
</SideMenu>
@@ -1416,12 +1410,21 @@ class AppComponent extends React.Component<AppComponentProps, AppComponentState>
},
}}>
<DialogManager themeId={this.props.themeId}>
<StatusBar barStyle={statusBarStyle} />
<MenuProvider
style={{ flex: 1 }}
closeButtonLabel={_('Dismiss')}
>
<FocusControl.MainAppContent style={{ flex: 1 }}>
{mainContent}
{shouldShowMainContent ? mainContent : (
<SafeAreaView>
<BiometricPopup
dispatch={this.props.dispatch}
themeId={this.props.themeId}
sensorInfo={this.state.sensorInfo}
/>
</SafeAreaView>
)}
</FocusControl.MainAppContent>
</MenuProvider>
</DialogManager>

View File

@@ -2,28 +2,62 @@ import { setupDatabase } from '@joplin/lib/testing/test-utils';
import whisper from './whisper';
import { dirname, join } from 'path';
import { exists, mkdir, remove, writeFile } from 'fs-extra';
import Setting from '@joplin/lib/models/Setting';
import { NativeModules } from 'react-native';
const SpeechToTextModule = NativeModules.SpeechToTextModule;
jest.mock('react-native', () => {
const reactNative = jest.requireActual('react-native');
let lastPrompt: string|null = null;
// Set properties on reactNative rather than creating a new object with
// {...reactNative, ...}. Creating a new object triggers deprecation warnings.
// See https://github.com/facebook/react-native/issues/28839.
reactNative.NativeModules.SpeechToTextModule = {
convertNext: () => 'Test. This is test output. Test!',
runTests: ()=> {},
openSession: jest.fn(() => {
openSession: jest.fn((_path, _locale, prompt) => {
lastPrompt = prompt;
const someId = 1234;
return someId;
}),
closeSession: jest.fn(),
startRecording: jest.fn(),
convertAvailable: jest.fn(() => ''),
testing__lastPrompt: () => {
return lastPrompt;
},
};
return reactNative;
});
interface ModelConfig {
output: {
stringReplacements: string[][];
regexReplacements: string[][];
};
}
const defaultModelConfig: ModelConfig = {
output: { stringReplacements: [], regexReplacements: [] },
};
const createMockModel = async (config: ModelConfig = defaultModelConfig) => {
const whisperBaseDirectory = dirname(whisper.modelLocalFilepath('en'));
await mkdir(whisperBaseDirectory);
const modelDirectory = join(whisperBaseDirectory, 'model');
await mkdir(modelDirectory);
await writeFile(join(modelDirectory, 'model.bin'), 'mock model', 'utf-8');
await writeFile(join(modelDirectory, 'config.json'), JSON.stringify(config), 'utf-8');
return modelDirectory;
};
describe('whisper', () => {
beforeEach(async () => {
await setupDatabase(0);
@@ -45,14 +79,7 @@ describe('whisper', () => {
});
test('should apply post-processing replacements specified in the model config', async () => {
const whisperBaseDirectory = dirname(whisper.modelLocalFilepath('en'));
await mkdir(whisperBaseDirectory);
const modelDirectory = join(whisperBaseDirectory, 'model');
await mkdir(modelDirectory);
await writeFile(join(modelDirectory, 'model.bin'), 'mock model', 'utf-8');
await writeFile(join(modelDirectory, 'config.json'), JSON.stringify({
const modelDirectory = await createMockModel({
output: {
stringReplacements: [
['Test', 'replaced'],
@@ -61,7 +88,7 @@ describe('whisper', () => {
['replace[d]', 'replaced again!'],
],
},
}), 'utf-8');
});
let lastFinalizedText = '';
const onFinalize = jest.fn((text: string) => {
@@ -85,4 +112,29 @@ describe('whisper', () => {
lastFinalizedText,
).toBe('\n\nreplaced again!. This is test output. replaced again!!');
});
it.each([
{ glossary: '', expectedPrompt: '' },
{ glossary: 'test', expectedPrompt: 'Glossary: test' },
{ glossary: 'Joplin, app', expectedPrompt: 'Glossary: Joplin, app' },
// Should not include the "Glossary:" prefix if there's no translation for it
{ glossary: 'Joplin, app', expectedPrompt: 'Joplin, app', locale: 'testLocale-test' },
])('should construct a prompt from the user-specified glossary (%j)', async ({ glossary, expectedPrompt, locale }) => {
Setting.setValue('voiceTyping.glossary', glossary);
const modelDirectory = await createMockModel();
const session = await whisper.build({
modelPath: modelDirectory,
callbacks: {
onFinalize: () => {
return session.stop();
},
onPreview: jest.fn(),
},
locale: locale ?? 'en',
});
await session.start();
expect(SpeechToTextModule.testing__lastPrompt()).toBe(expectedPrompt);
});
});

View File

@@ -5,7 +5,7 @@ import { rtrimSlashes } from '@joplin/utils/path';
import { dirname, join } from 'path';
import { NativeModules } from 'react-native';
import { SpeechToTextCallbacks, VoiceTypingProvider, VoiceTypingSession } from './VoiceTyping';
import { languageCodeOnly } from '@joplin/lib/locale';
import { languageCodeOnly, stringByLocale } from '@joplin/lib/locale';
const logger = Logger.create('voiceTyping/whisper');
@@ -178,8 +178,30 @@ class Whisper implements VoiceTypingSession {
}
}
const getGlossaryPrompt = (locale: string) => {
const glossary = Setting.value('voiceTyping.glossary');
if (!glossary) return '';
// Re-define the "_" localization function so that it uses the transcription locale (as opposed to the UI locale).
const _ = (text: string) => {
return stringByLocale(locale, text);
};
let glossaryPrefix = _('Glossary:');
// Prefer no prefix if no appropriate translation of "Glossary:" is available:
if (glossaryPrefix === 'Glossary:' && languageCodeOnly(locale) !== 'en') {
glossaryPrefix = '';
}
return `${glossaryPrefix} ${glossary}`.trim();
};
const getPrompt = (locale: string, localeToPrompt: Map<string, string>) => {
return localeToPrompt.get(languageCodeOnly(locale)) ?? '';
const basePrompt = localeToPrompt.get(languageCodeOnly(locale));
return [
basePrompt,
getGlossaryPrompt(locale),
].filter(part => !!part).join(' ');
};
const modelLocalDirectory = () => {

View File

@@ -0,0 +1,20 @@
import { useEffect } from 'react';
import BackButtonService from '../../services/BackButtonService';
type OnBackPress = ()=>(void|boolean);
const useBackHandler = (onBackPress: OnBackPress|null) => {
useEffect(() => {
if (!onBackPress) return () => {};
const handler = () => {
return !!(onBackPress() ?? true);
};
BackButtonService.addHandler(handler);
return () => {
BackButtonService.removeHandler(handler);
};
}, [onBackPress]);
};
export default useBackHandler;

View File

@@ -8,9 +8,9 @@ import { chdir, cwd } from 'process';
import { execCommand } from '@joplin/utils';
import { glob } from 'glob';
import readRepositoryJson from './utils/readRepositoryJson';
import waitForCliInput from './utils/waitForCliInput';
import getPathToPatchFileFor from './utils/getPathToPatchFileFor';
import getCurrentCommitHash from './utils/getCurrentCommitHash';
import { waitForCliInput } from '@joplin/utils/cli';
interface Options {
beforeInstall: (buildDir: string, pluginName: string)=> Promise<void>;

View File

@@ -1,7 +1,7 @@
import { execCommand } from '@joplin/utils';
import waitForCliInput from '../utils/waitForCliInput';
import { copy } from 'fs-extra';
import { join } from 'path';
import { waitForCliInput } from '@joplin/utils/cli';
import buildDefaultPlugins from '../buildDefaultPlugins';
import getPathToPatchFileFor from '../utils/getPathToPatchFileFor';

View File

@@ -17,7 +17,7 @@
"@joplin/lib": "~3.4",
"@testing-library/react-hooks": "8.0.1",
"@types/jest": "29.5.12",
"@types/react": "18.3.18",
"@types/react": "18.3.20",
"@types/react-redux": "7.1.33",
"@types/styled-components": "5.1.32",
"jest": "29.7.0",

View File

@@ -46,7 +46,7 @@
},
"devDependencies": {
"@types/jest": "29.5.12",
"@types/node": "18.19.67",
"@types/node": "18.19.86",
"@typescript-eslint/eslint-plugin": "6.21.0",
"@typescript-eslint/parser": "6.21.0",
"coveralls": "3.1.1",

View File

@@ -17,7 +17,7 @@
},
"devDependencies": {
"@types/jest": "29.5.12",
"@types/node": "18.19.67",
"@types/node": "18.19.86",
"jest": "29.7.0",
"typescript": "5.4.5"
},

View File

@@ -98,7 +98,7 @@ export default class ClipperServer {
});
}
public async findAvailablePort() {
public async findAvailablePort(): Promise<number> {
const tcpPortUsed = require('tcp-port-used');
let state = null;

View File

@@ -448,7 +448,8 @@ export default class Synchronizer {
// Before synchronising make sure all share_id properties are set
// correctly so as to share/unshare the right items.
try {
await Folder.updateAllShareIds(this.resourceService());
if (this.shareService_) await this.shareService_.maintenance();
await Folder.updateAllShareIds(this.resourceService(), this.shareService_ ? this.shareService_.shares : []);
if (this.shareService_) await this.shareService_.checkShareConsistency();
} catch (error) {
if (error && error.code === ErrorCode.IsReadOnly) {

View File

@@ -2,6 +2,7 @@
import * as deleteNote from './deleteNote';
import * as historyBackward from './historyBackward';
import * as historyForward from './historyForward';
import * as leaveSharedFolder from './leaveSharedFolder';
import * as openMasterPasswordDialog from './openMasterPasswordDialog';
import * as permanentlyDeleteNote from './permanentlyDeleteNote';
import * as renderMarkup from './renderMarkup';
@@ -14,6 +15,7 @@ const index: any[] = [
deleteNote,
historyBackward,
historyForward,
leaveSharedFolder,
openMasterPasswordDialog,
permanentlyDeleteNote,
renderMarkup,

View File

@@ -1,8 +1,8 @@
import { CommandRuntime, CommandDeclaration, CommandContext } from '@joplin/lib/services/CommandService';
import { _ } from '@joplin/lib/locale';
import ShareService from '@joplin/lib/services/share/ShareService';
import { CommandRuntime, CommandDeclaration, CommandContext } from '../services/CommandService';
import { _ } from '../locale';
import ShareService from '../services/share/ShareService';
import Logger from '@joplin/utils/Logger';
import shim from '@joplin/lib/shim';
import shim from '../shim';
const logger = Logger.create('leaveSharedFolder');
@@ -11,10 +11,16 @@ export const declaration: CommandDeclaration = {
label: () => _('Leave notebook...'),
};
interface Options {
force?: boolean;
}
export const runtime = (): CommandRuntime => {
return {
execute: async (_context: CommandContext, folderId: string = null) => {
const answer = await shim.showConfirmationDialog(_('This will remove the notebook from your collection and you will no longer have access to its content. Do you wish to continue?'));
execute: async (_context: CommandContext, folderId: string = null, { force = false }: Options = {}) => {
const answer = force ? true : await shim.showConfirmationDialog(
_('This will remove the notebook from your collection and you will no longer have access to its content. Do you wish to continue?'),
);
if (!answer) return;
try {

View File

@@ -1,8 +1,11 @@
import shim from '../shim';
const { useEffect } = shim.react();
type CleanupCallback = ()=> void;
export interface AsyncEffectEvent {
cancelled: boolean;
onCleanup: (callback: CleanupCallback)=> void;
}
export type EffectFunction = (event: AsyncEffectEvent)=> Promise<void>;
@@ -10,10 +13,24 @@ export type EffectFunction = (event: AsyncEffectEvent)=> Promise<void>;
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
export default function(effect: EffectFunction, dependencies: any[]) {
useEffect(() => {
const event: AsyncEffectEvent = { cancelled: false };
const onCleanupCallbacks: CleanupCallback[] = [];
const event: AsyncEffectEvent = {
cancelled: false,
onCleanup: (callback) => {
if (event.cancelled) {
callback();
} else {
onCleanupCallbacks.push(callback);
}
},
};
void effect(event);
return () => {
event.cancelled = true;
for (const callback of onCleanupCallbacks) {
callback();
}
};
// eslint-disable-next-line @seiyab/react-hooks/exhaustive-deps -- Old code before rule was applied
}, dependencies);

View File

@@ -1,13 +1,21 @@
const testPathIgnorePatterns = [
'<rootDir>/node_modules/',
'<rootDir>/rnInjectedJs/',
'<rootDir>/vendor/',
];
if (!process.env.IS_CONTINUOUS_INTEGRATION) {
// We don't require all developers to have Rust to run the project, so we skip this test if not running in CI
testPathIgnorePatterns.push('<rootDir>/services/interop/InteropService_Importer_OneNote.*');
}
module.exports = {
testMatch: [
'**/*.test.js',
],
testPathIgnorePatterns: [
'<rootDir>/node_modules/',
'<rootDir>/rnInjectedJs/',
'<rootDir>/vendor/',
],
testPathIgnorePatterns: testPathIgnorePatterns,
testEnvironment: 'node',

View File

@@ -733,4 +733,4 @@ const stringByLocale = (locale: string, s: string, ...args: any[]): string => {
}
};
export { _, _n, supportedLocales, languageName, currentLocale, localesFromLanguageCode, languageCodeOnly, countryDisplayName, localeStrings, setLocale, supportedLocalesToLanguages, defaultLocale, closestSupportedLocale, languageCode, countryCodeOnly };
export { _, _n, supportedLocales, languageName, currentLocale, localesFromLanguageCode, languageCodeOnly, countryDisplayName, localeStrings, setLocale, supportedLocalesToLanguages, defaultLocale, closestSupportedLocale, stringByLocale, languageCode, countryCodeOnly };

View File

@@ -16,6 +16,7 @@ export enum MarkdownTableJustify {
export interface MarkdownTableHeader {
name: string;
label: string;
labelUrl?: string;
filter?: (content: string)=> string;
disableEscape?: boolean;
disableHtmlEscape?: boolean;
@@ -159,7 +160,11 @@ const markdownUtils = {
const lineMd = [];
for (let i = 0; i < headers.length; i++) {
const h = headers[i];
headersMd.push(stringPadding(h.label, minCellWidth, ' ', stringPadding.RIGHT));
let label = h.label;
if (h.labelUrl) {
label = `[${h.label}](${h.labelUrl})`;
}
headersMd.push(stringPadding(label, minCellWidth, ' ', stringPadding.RIGHT));
const justify = h.justify ? h.justify : MarkdownTableJustify.Left;

View File

@@ -6,9 +6,29 @@ import shim from '../shim';
import Resource from '../models/Resource';
import { FolderEntity, NoteEntity, ResourceEntity } from '../services/database/types';
import ResourceService from '../services/ResourceService';
import { StateShare } from '../services/share/reducer';
const testImagePath = `${supportDir}/photo.jpg`;
const makeStateShares = (folderIds: string[] | string, shareIds: string|string[] = 'abcd1234'): StateShare[] => {
folderIds = (typeof folderIds === 'string') ? [folderIds] : folderIds;
shareIds = (typeof shareIds === 'string') ? [shareIds] : shareIds;
const output: StateShare[] = [];
for (let i = 0; i < folderIds.length; i++) {
output.push({
folder_id: folderIds[i],
master_key_id: '',
id: shareIds[i],
note_id: '',
type: 3,
});
}
return output;
};
describe('models/Folder.sharing', () => {
beforeEach(async () => {
@@ -40,7 +60,7 @@ describe('models/Folder.sharing', () => {
]);
await Folder.save({ id: folder.id, share_id: 'abcd1234' });
await Folder.updateAllShareIds(resourceService());
await Folder.updateAllShareIds(resourceService(), makeStateShares(folder.id));
const allItems = await allNotesFolders();
for (const item of allItems) {
@@ -86,7 +106,7 @@ describe('models/Folder.sharing', () => {
await Folder.save({ id: folder1.id, share_id: 'abcd1234' });
await Folder.updateAllShareIds(resourceService());
await Folder.updateAllShareIds(resourceService(), makeStateShares(folder1.id));
folder1 = await Folder.loadByTitle('folder 1');
const folder2 = await Folder.loadByTitle('folder 2');
@@ -120,7 +140,7 @@ describe('models/Folder.sharing', () => {
await Folder.save({ id: folder1.id, share_id: 'abcd1234' });
await Folder.updateAllShareIds(resourceService());
await Folder.updateAllShareIds(resourceService(), makeStateShares(folder1.id));
folder1 = await Folder.loadByTitle('folder 1');
let folder2 = await Folder.loadByTitle('folder 2');
@@ -132,7 +152,7 @@ describe('models/Folder.sharing', () => {
// Move the folder outside the shared folder
await Folder.save({ id: folder2.id, parent_id: folder3.id });
await Folder.updateAllShareIds(resourceService());
await Folder.updateAllShareIds(resourceService(), makeStateShares(folder1.id));
folder2 = await Folder.loadByTitle('folder 2');
expect(folder2.share_id).toBe('');
@@ -140,12 +160,45 @@ describe('models/Folder.sharing', () => {
{
await Folder.save({ id: folder2.id, parent_id: folder1.id });
await Folder.updateAllShareIds(resourceService());
await Folder.updateAllShareIds(resourceService(), makeStateShares(folder1.id));
folder2 = await Folder.loadByTitle('folder 2');
expect(folder2.share_id).toBe('abcd1234');
}
}));
it('should unshare a subfolder of a shared folder when it is moved to the root', (async () => {
let folder1 = await createFolderTree('', [
{
title: 'folder 1',
children: [
{
title: 'folder 2',
children: [],
},
],
},
]);
await Folder.save({ id: folder1.id, share_id: 'abcd1234' });
const stateShares: StateShare[] = makeStateShares(folder1.id);
await Folder.updateAllShareIds(resourceService(), stateShares);
folder1 = await Folder.loadByTitle('folder 1');
let folder2 = await Folder.loadByTitle('folder 2');
expect(folder1.share_id).toBe('abcd1234');
expect(folder2.share_id).toBe('abcd1234');
// Move the subfolder to the root
await Folder.save({ id: folder2.id, parent_id: '' });
await Folder.updateAllShareIds(resourceService(), stateShares);
folder2 = await Folder.loadByTitle('folder 2');
expect(folder2.share_id).toBe('');
}));
it('should apply the share ID to all notes', (async () => {
const folder1 = await createFolderTree('', [
{
@@ -179,7 +232,7 @@ describe('models/Folder.sharing', () => {
await Folder.save({ id: folder1.id, share_id: 'abcd1234' });
await Folder.updateAllShareIds(resourceService());
await Folder.updateAllShareIds(resourceService(), makeStateShares(folder1.id));
const note1: NoteEntity = await Note.loadByTitle('note 1');
const note2: NoteEntity = await Note.loadByTitle('note 2');
@@ -209,7 +262,7 @@ describe('models/Folder.sharing', () => {
]);
await Folder.save({ id: folder1.id, share_id: 'abcd1234' });
await Folder.updateAllShareIds(resourceService());
await Folder.updateAllShareIds(resourceService(), makeStateShares(folder1.id));
const note1: NoteEntity = await Note.loadByTitle('note 1');
const folder2: FolderEntity = await Folder.loadByTitle('folder 2');
expect(note1.share_id).toBe('abcd1234');
@@ -217,7 +270,7 @@ describe('models/Folder.sharing', () => {
// Move the note outside of the shared folder
await Note.save({ id: note1.id, parent_id: folder2.id });
await Folder.updateAllShareIds(resourceService());
await Folder.updateAllShareIds(resourceService(), makeStateShares(folder1.id));
{
const note1: NoteEntity = await Note.loadByTitle('note 1');
@@ -227,7 +280,7 @@ describe('models/Folder.sharing', () => {
// Move the note back inside the shared folder
await Note.save({ id: note1.id, parent_id: folder1.id });
await Folder.updateAllShareIds(resourceService());
await Folder.updateAllShareIds(resourceService(), makeStateShares(folder1.id));
{
const note1: NoteEntity = await Note.loadByTitle('note 1');
@@ -255,7 +308,7 @@ describe('models/Folder.sharing', () => {
]);
await Folder.save({ id: folder1.id, share_id: 'abcd1234' });
await Folder.updateAllShareIds(resourceService());
await Folder.updateAllShareIds(resourceService(), makeStateShares(folder1.id));
let note1: NoteEntity = await Note.loadByTitle('note 1');
let note2: NoteEntity = await Note.loadByTitle('note 2');
@@ -265,7 +318,7 @@ describe('models/Folder.sharing', () => {
expect(note2.share_id).toBe('abcd1234');
await Note.save({ id: note1.id, parent_id: folder2.id });
await Folder.updateAllShareIds(resourceService());
await Folder.updateAllShareIds(resourceService(), makeStateShares(folder1.id));
note1 = await Note.loadByTitle('note 1');
note2 = await Note.loadByTitle('note 2');
@@ -273,6 +326,60 @@ describe('models/Folder.sharing', () => {
expect(note2.share_id).toBe('abcd1234');
}));
it('should clear the share ID if that share no longer exists', (async () => {
const folder1 = await createFolderTree('', [
{
title: 'folder 1',
children: [
{
title: 'note 1',
},
{
title: 'note 2',
},
{
title: 'subfolder',
children: [],
},
],
},
{
title: 'folder 2',
children: [],
},
]);
let note1: NoteEntity = await Note.loadByTitle('note 1');
await shim.attachFileToNote(note1, testImagePath);
await resourceService().indexNoteResources();
await Folder.save({ id: folder1.id, share_id: 'abcd1234' });
await Folder.updateAllShareIds(resourceService(), makeStateShares(folder1.id));
note1 = await Note.loadByTitle('note 1');
let note2: NoteEntity = await Note.loadByTitle('note 2');
let resource: ResourceEntity = (await Resource.all())[0];
let subFolder: FolderEntity = await Folder.loadByTitle('subfolder');
expect(note1.share_id).toBe('abcd1234');
expect(note2.share_id).toBe('abcd1234');
expect(resource.share_id).toBe('abcd1234');
expect(subFolder.share_id).toBe('abcd1234');
await Folder.updateAllShareIds(resourceService(), []);
note1 = await Note.loadByTitle('note 1');
note2 = await Note.loadByTitle('note 2');
resource = (await Resource.all())[0];
subFolder = await Folder.loadByTitle('subfolder');
expect(note1.share_id).toBe('');
expect(note2.share_id).toBe('');
expect(resource.share_id).toBe('');
expect(subFolder.share_id).toBe('');
}));
it('should apply the note share ID to its resources', async () => {
const resourceService = new ResourceService();
@@ -295,7 +402,7 @@ describe('models/Folder.sharing', () => {
]);
await Folder.save({ id: folder.id, share_id: 'abcd1234' });
await Folder.updateAllShareIds(resourceService);
await Folder.updateAllShareIds(resourceService, makeStateShares(folder.id));
const folder2: FolderEntity = await Folder.loadByTitle('folder 2');
const note1: NoteEntity = await Note.loadByTitle('note 1');
@@ -313,7 +420,7 @@ describe('models/Folder.sharing', () => {
const previousBlobUpdatedTime = (await Resource.load(resourceId)).blob_updated_time;
await msleep(1);
await Folder.updateAllShareIds(resourceService);
await Folder.updateAllShareIds(resourceService, makeStateShares(folder.id));
{
const resource: ResourceEntity = await Resource.load(resourceId);
@@ -324,7 +431,7 @@ describe('models/Folder.sharing', () => {
await Note.save({ id: note1.id, parent_id: folder2.id });
await resourceService.indexNoteResources();
await Folder.updateAllShareIds(resourceService);
await Folder.updateAllShareIds(resourceService, makeStateShares(folder.id));
{
const resource: ResourceEntity = await Resource.load(resourceId);
@@ -392,7 +499,7 @@ describe('models/Folder.sharing', () => {
// We need to index the resources to populate the note_resources table
await resourceService.indexNoteResources();
await Folder.updateAllShareIds(resourceService);
await Folder.updateAllShareIds(resourceService, makeStateShares(folder1.id, 'share1'));
// BEFORE:
//
@@ -464,7 +571,7 @@ describe('models/Folder.sharing', () => {
await resourceService.indexNoteResources();
await Folder.updateAllShareIds(resourceService);
await Folder.updateAllShareIds(resourceService, makeStateShares([folder1.id, folder2.id], ['1', '2']));
await Folder.updateNoLongerSharedItems(['1']);

View File

@@ -7,7 +7,7 @@ import Note from './Note';
import Database from '../database';
import BaseItem from './BaseItem';
import Resource from './Resource';
import { isRootSharedFolder } from '../services/share/reducer';
import { isRootSharedFolder, StateShare } from '../services/share/reducer';
import Logger from '@joplin/utils/Logger';
import syncDebugLog from '../services/synchronizer/syncDebugLog';
import ResourceService from '../services/ResourceService';
@@ -357,7 +357,7 @@ export default class Folder extends BaseItem {
if (options && options.includeConflictFolder) {
const conflictCount = await Note.conflictedCount();
if (conflictCount) output.push(this.conflictFolder());
if (conflictCount) output.unshift(this.conflictFolder());
}
return output;
@@ -417,18 +417,75 @@ export default class Folder extends BaseItem {
return this.db().selectAll(sql, [folderId]);
}
public static async rootSharedFolders(): Promise<FolderEntity[]> {
return this.db().selectAll('SELECT id, share_id FROM folders WHERE parent_id = \'\' AND share_id != \'\'');
public static async rootSharedFolders(activeShares: StateShare[]): Promise<FolderEntity[]> {
return this.removeDuplicateRootFolders(await this.db().selectAll('SELECT id, share_id FROM folders WHERE parent_id = \'\' AND share_id != \'\''), activeShares);
}
public static async rootShareFoldersByKeyId(keyId: string): Promise<FolderEntity[]> {
return this.db().selectAll('SELECT id, share_id FROM folders WHERE master_key_id = ?', [keyId]);
}
public static async updateFolderShareIds(): Promise<void> {
// We need this function for this situation:
//
// - Folder is shared
// - Subfolder is created in the shared folder
// - Subfolder is moved to the root
//
// In that situation the subfolder will have "parent_id" = "" and so will be considered a "root
// shared folder". However it is not - a "root shared folder" is one that has been explicitly
// shared by the user.
//
// So we have this function to check for root folders that have the same "shared_id" - it
// indicates that one of them was a child of the other. We remove the formerly children folders.
private static removeDuplicateRootFolders(rootFolders: FolderEntity[], activeShares: StateShare[]) {
const folderIdsToRemove: string[] = [];
for (let i = 0; i < rootFolders.length - 1; i++) {
const f1 = rootFolders[i];
for (let j = i + 1; j < rootFolders.length; j++) {
const f2 = rootFolders[j];
if (f1.share_id === f2.share_id) {
logger.info('Found two root folders with the same share_id:', f1, f2);
const share = activeShares.find(s => s.id === f1.share_id);
if (!share) {
logger.warn('Could not find matching share object');
continue;
}
if (share.folder_id === f1.id) {
folderIdsToRemove.push(f2.id);
} else if (share.folder_id === f2.id) {
folderIdsToRemove.push(f1.id);
} else {
logger.warn('Could not find folder associated with share:', share);
}
}
}
}
if (folderIdsToRemove.length) {
logger.info('Removing folders from the list of root folders:', folderIdsToRemove);
const newRootFolders: FolderEntity[] = [];
for (const f of rootFolders) {
if (!folderIdsToRemove.includes(f.id)) {
newRootFolders.push(f);
}
}
return newRootFolders;
}
return rootFolders;
}
public static async updateFolderShareIds(activeShares: StateShare[]): Promise<void> {
// Get all the sub-folders of the shared folders, and set the share_id
// property.
const rootFolders = await this.rootSharedFolders();
const activeShareIds = activeShares.map(s => s.id);
const rootFolders = (await this.rootSharedFolders(activeShares)).filter(f => activeShareIds.includes(f.share_id));
let sharedFolderIds: string[] = [];
@@ -482,19 +539,26 @@ export default class Folder extends BaseItem {
logger.debug('updateFolderShareIds:', report);
}
public static async updateNoteShareIds() {
public static async updateNoteShareIds(activeShares: StateShare[]) {
// Find all the notes where the share_id is not the same as the
// parent share_id because we only need to update those.
const rows = await this.db().selectAll(`
const rows1 = await this.db().selectAll(`
SELECT notes.id, folders.share_id, notes.parent_id
FROM notes
LEFT JOIN folders ON notes.parent_id = folders.id
WHERE notes.share_id != folders.share_id
`);
logger.debug('updateNoteShareIds: notes to update:', rows.length);
const rows2 = await this.db().selectAll(`
SELECT notes.id, notes.parent_id
FROM notes
WHERE notes.share_id != '' AND notes.share_id NOT IN (${BaseModel.escapeIdsForSql(activeShares.map(s => s.id))})
`);
for (const row of rows) {
logger.debug('updateNoteShareIds: notes to update (1)', rows1);
logger.debug('updateNoteShareIds: notes to update (2)', rows2);
for (const row of rows1.concat(rows2)) {
await Note.save({
id: row.id,
share_id: row.share_id || '',
@@ -652,9 +716,9 @@ export default class Folder extends BaseItem {
throw new Error('Failed to update resource share IDs');
}
public static async updateAllShareIds(resourceService: ResourceService) {
await this.updateFolderShareIds();
await this.updateNoteShareIds();
public static async updateAllShareIds(resourceService: ResourceService, activeShares: StateShare[]) {
await this.updateFolderShareIds(activeShares);
await this.updateNoteShareIds(activeShares);
await this.updateResourceShareIds(resourceService);
}

View File

@@ -456,4 +456,18 @@ describe('models/Setting', () => {
await Setting.saveAll();
}
});
test('should enforce min and max values for when the setting is already in the cache and when it is not', async () => {
await Setting.reset();
Setting.setValue('revisionService.ttlDays', 0);
expect(Setting.value('revisionService.ttlDays')).toBe(1);
Setting.setValue('revisionService.ttlDays', 100000);
expect(Setting.value('revisionService.ttlDays')).toBe(99999);
await Setting.reset();
Setting.setValue('revisionService.ttlDays', 100000);
expect(Setting.value('revisionService.ttlDays')).toBe(99999);
Setting.setValue('revisionService.ttlDays', 0);
expect(Setting.value('revisionService.ttlDays')).toBe(1);
});
});

View File

@@ -657,11 +657,18 @@ class Setting extends BaseModel {
value = this.formatValue(key, value);
value = this.filterValue(key, value);
const md = this.settingMetadata(key);
const enforceLimits = (value: SettingValueType<T>) => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Partial refactor of old code before rule was applied
if ('minimum' in md && value < md.minimum) value = md.minimum as any;
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Partial refactor of old code before rule was applied
if ('maximum' in md && value > md.maximum) value = md.maximum as any;
return value;
};
for (let i = 0; i < this.cache_.length; i++) {
const c = this.cache_[i];
if (c.key === key) {
const md = this.settingMetadata(key);
if (md.isEnum === true) {
if (!this.isAllowedEnumOption(key, value)) {
throw new Error(_('Invalid option value: "%s". Possible values are: %s.', value, this.enumOptionsDoc(key)));
@@ -675,12 +682,7 @@ class Setting extends BaseModel {
// Don't log this to prevent sensitive info (passwords, auth tokens...) to end up in logs
// logger.info('Setting: ' + key + ' = ' + c.value + ' => ' + value);
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Partial refactor of old code before rule was applied
if ('minimum' in md && value < md.minimum) value = md.minimum as any;
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Partial refactor of old code before rule was applied
if ('maximum' in md && value > md.maximum) value = md.maximum as any;
c.value = value;
c.value = enforceLimits(value);
this.dispatch({
type: 'SETTING_UPDATE_ONE',
@@ -694,6 +696,8 @@ class Setting extends BaseModel {
}
}
value = enforceLimits(value);
this.cache_.push({
key: key,
value: this.formatValue(key, value),

View File

@@ -14,6 +14,11 @@ const customCssFilePath = (Setting: typeof SettingType, filename: string): strin
return `${Setting.value('rootProfileDir')}/${filename}`;
};
const showVoiceTypingSettings = () => (
// For now, iOS and web don't support voice typing.
shim.mobilePlatform() === 'android'
);
export enum CameraDirection {
Back,
Front,
@@ -1803,8 +1808,7 @@ const builtInMetadata = (Setting: typeof SettingType) => {
appTypes: [AppType.Mobile],
description: () => _('Leave it blank to download the language files from the default website'),
label: () => _('Voice typing language files (URL)'),
// For now, iOS and web don't support voice typing.
show: () => shim.mobilePlatform() === 'android',
show: showVoiceTypingSettings,
section: 'note',
},
@@ -1815,8 +1819,7 @@ const builtInMetadata = (Setting: typeof SettingType) => {
appTypes: [AppType.Mobile],
label: () => _('Preferred voice typing provider'),
isEnum: true,
// For now, iOS and web don't support voice typing.
show: () => shim.mobilePlatform() === 'android',
show: showVoiceTypingSettings,
section: 'note',
options: () => {
@@ -1827,6 +1830,17 @@ const builtInMetadata = (Setting: typeof SettingType) => {
},
},
'voiceTyping.glossary': {
value: '',
type: SettingItemType.String,
public: true,
appTypes: [AppType.Mobile],
label: () => _('Voice typing: Glossary'),
description: () => _('A comma-separated list of words. May be used for uncommon words, to help voice typing spell them correctly.'),
show: (settings) => showVoiceTypingSettings() && settings['voiceTyping.preferredProvider'].startsWith('whisper'),
section: 'note',
},
'trash.autoDeletionEnabled': {
value: true,
type: SettingItemType.Bool,

View File

@@ -20,7 +20,7 @@ export interface WhereQuery {
export default async function(db: any, tableName: string, pagination: Pagination, whereQuery: WhereQuery = null, fields: string[] = null): Promise<ModelFeedPage> {
fields = fields ? fields.slice() : ['id'];
const where = whereQuery ? [whereQuery.sql] : [];
const where = whereQuery && whereQuery.sql ? [whereQuery.sql] : [];
const sqlParams = whereQuery && whereQuery.params ? whereQuery.params.slice() : [];
if (!pagination.order.length) throw new Error('Pagination order must be provided');

View File

@@ -12,7 +12,7 @@
"tsc": "tsc --project tsconfig.json",
"watch": "tsc --watch --preserveWatchOutput --project tsconfig.json",
"generatePluginTypes": "rm -rf ./plugin_types && yarn tsc --declaration --declarationDir ./plugin_types --project tsconfig.json",
"test": "jest --verbose=false",
"test": "node --security-revert=CVE-2023-46809 ./node_modules/.bin/jest --verbose=false",
"test-ci": "yarn test"
},
"devDependencies": {
@@ -25,14 +25,14 @@
"@types/jsdom": "21.1.7",
"@types/markdown-it": "13.0.9",
"@types/mustache": "4.2.5",
"@types/node": "18.19.67",
"@types/node": "18.19.86",
"@types/node-rsa": "1.1.4",
"@types/react": "18.3.18",
"@types/uuid": "9.0.7",
"@types/react": "18.3.20",
"@types/uuid": "10.0.0",
"clean-html": "1.5.0",
"jest": "29.7.0",
"jest-expect-message": "1.1.3",
"jsdom": "23.2.0",
"jsdom": "25.0.1",
"pdfjs-dist": "3.11.174",
"react": "18.3.1",
"react-test-renderer": "18.3.1",
@@ -81,7 +81,7 @@
"moment": "2.30.1",
"multiparty": "4.2.3",
"mustache": "4.2.0",
"nanoid": "3.3.10",
"nanoid": "3.3.11",
"node-fetch": "2.6.7",
"node-notifier": "10.0.1",
"node-persist": "3.1.3",
@@ -101,7 +101,7 @@
"tcp-port-used": "1.0.2",
"uglifycss": "0.0.29",
"url-parse": "1.5.10",
"uuid": "9.0.1",
"uuid": "11.1.0",
"word-wrap": "1.2.5",
"xml2js": "0.4.23"
},

View File

@@ -1,7 +1,7 @@
import Note from '../../models/Note';
import Folder from '../../models/Folder';
import { remove, readFile } from 'fs-extra';
import { createTempDir, setupDatabaseAndSynchronizer, supportDir, switchClient } from '../../testing/test-utils';
import { createTempDir, setupDatabaseAndSynchronizer, supportDir, switchClient, withWarningSilenced } from '../../testing/test-utils';
import { NoteEntity } from '../database/types';
import { MarkupToHtml } from '@joplin/renderer';
import BaseModel from '../../BaseModel';
@@ -22,9 +22,7 @@ const expectWithInstructions = <T>(value: T) => {
return expect(value, instructionMessage);
};
// We don't require all developers to have Rust to run the project, so we skip this test if not running in CI
const skipIfNotCI = process.env.IS_CONTINUOUS_INTEGRATION ? it : it.skip;
// This file is ignored if not running in CI. Look at onenote-converter/README.md and jest.config.js for more information
describe('InteropService_Importer_OneNote', () => {
let tempDir: string;
async function importNote(path: string) {
@@ -52,7 +50,7 @@ describe('InteropService_Importer_OneNote', () => {
afterEach(async () => {
await remove(tempDir);
});
skipIfNotCI('should import a simple OneNote notebook', async () => {
it('should import a simple OneNote notebook', async () => {
const notes = await importNote(`${supportDir}/onenote/simple_notebook.zip`);
const folders = await Folder.all();
@@ -69,7 +67,7 @@ describe('InteropService_Importer_OneNote', () => {
expectWithInstructions(mainNote.body).toMatchSnapshot(mainNote.title);
});
skipIfNotCI('should preserve indentation of subpages in Section page', async () => {
it('should preserve indentation of subpages in Section page', async () => {
const notes = await importNote(`${supportDir}/onenote/subpages.zip`);
const sectionPage = notes.find(n => n.title === 'Section');
@@ -89,7 +87,7 @@ describe('InteropService_Importer_OneNote', () => {
expectWithInstructions(menuLines[7].trim()).toBe(`<li class="l2"><a href=":/${pageTwoB.id}" target="content" title="Page 2-b">${pageTwoB.title}</a>`);
});
skipIfNotCI('should created subsections', async () => {
it('should created subsections', async () => {
const notes = await importNote(`${supportDir}/onenote/subsections.zip`);
const folders = await Folder.all();
@@ -107,7 +105,7 @@ describe('InteropService_Importer_OneNote', () => {
expectWithInstructions(notesFromParentSection.length).toBe(2);
});
skipIfNotCI('should expect notes to be rendered the same', async () => {
it('should expect notes to be rendered the same', async () => {
let idx = 0;
const originalIdGenerator = BaseModel.setIdGenerator(() => String(idx++));
const notes = await importNote(`${supportDir}/onenote/complex_notes.zip`);
@@ -124,7 +122,7 @@ describe('InteropService_Importer_OneNote', () => {
BaseModel.setIdGenerator(originalIdGenerator);
});
skipIfNotCI('should render the proper tree for notebook with group sections', async () => {
it('should render the proper tree for notebook with group sections', async () => {
const notes = await importNote(`${supportDir}/onenote/group_sections.zip`);
const folders = await Folder.all();
@@ -152,7 +150,7 @@ describe('InteropService_Importer_OneNote', () => {
expectWithInstructions(notes.filter(n => n.parent_id === sectionD1.id).length).toBe(1);
});
skipIfNotCI.each([
it.each([
'svg_with_text_and_style.html',
'many_svgs.html',
])('should extract svgs', async (filename: string) => {
@@ -179,7 +177,7 @@ describe('InteropService_Importer_OneNote', () => {
expectWithInstructions(importer.extractSvgs(content, titleGenerator())).toMatchSnapshot();
});
skipIfNotCI('should ignore broken characters at the start of paragraph', async () => {
it('should ignore broken characters at the start of paragraph', async () => {
let idx = 0;
const originalIdGenerator = BaseModel.setIdGenerator(() => String(idx++));
const notes = await importNote(`${supportDir}/onenote/bug_broken_character.zip`);
@@ -189,7 +187,7 @@ describe('InteropService_Importer_OneNote', () => {
BaseModel.setIdGenerator(originalIdGenerator);
});
skipIfNotCI('should remove hyperlink from title', async () => {
it('should remove hyperlink from title', async () => {
let idx = 0;
const originalIdGenerator = BaseModel.setIdGenerator(() => String(idx++));
const notes = await importNote(`${supportDir}/onenote/remove_hyperlink_on_title.zip`);
@@ -200,7 +198,7 @@ describe('InteropService_Importer_OneNote', () => {
BaseModel.setIdGenerator(originalIdGenerator);
});
skipIfNotCI('should group link parts even if they have different css styles', async () => {
it('should group link parts even if they have different css styles', async () => {
const notes = await importNote(`${supportDir}/onenote/remove_hyperlink_on_title.zip`);
const noteToTest = notes.find(n => n.title === 'Tips from a Pro Using Trees for Dramatic Landscape Photography');
@@ -209,7 +207,7 @@ describe('InteropService_Importer_OneNote', () => {
expectWithInstructions(noteToTest.body.includes('<a href="onenote:https://d.docs.live.net/c8d3bbab7f1acf3a/Documents/Photography/风景.one#Tips%20from%20a%20Pro%20Using%20Trees%20for%20Dramatic%20Landscape%20Photography&section-id={262ADDFB-A4DC-4453-A239-0024D6769962}&page-id={88D803A5-4F43-48D4-9B16-4C024F5787DC}&end" style="">Tips from a Pro: Using Trees for Dramatic Landscape Photography</a>')).toBe(true);
});
skipIfNotCI('should render links properly by ignoring wrongly set indices when the first character is a hyperlink marker', async () => {
it('should render links properly by ignoring wrongly set indices when the first character is a hyperlink marker', async () => {
let idx = 0;
const originalIdGenerator = BaseModel.setIdGenerator(() => String(idx++));
const notes = await importNote(`${supportDir}/onenote/hyperlink_marker_as_first_character.zip`);
@@ -220,10 +218,10 @@ describe('InteropService_Importer_OneNote', () => {
BaseModel.setIdGenerator(originalIdGenerator);
});
skipIfNotCI('should be able to create notes from corrupted attachment', async () => {
it('should be able to create notes from corrupted attachment', async () => {
let idx = 0;
const originalIdGenerator = BaseModel.setIdGenerator(() => String(idx++));
const notes = await importNote(`${supportDir}/onenote/corrupted_attachment.zip`);
const notes = await withWarningSilenced(/OneNoteConverter:/, async () => importNote(`${supportDir}/onenote/corrupted_attachment.zip`));
expectWithInstructions(notes.length).toBe(2);
@@ -233,7 +231,7 @@ describe('InteropService_Importer_OneNote', () => {
BaseModel.setIdGenerator(originalIdGenerator);
});
skipIfNotCI('should render audio as links to resource', async () => {
it('should render audio as links to resource', async () => {
let idx = 0;
const originalIdGenerator = BaseModel.setIdGenerator(() => String(idx++));
const notes = await importNote(`${supportDir}/onenote/note_with_audio_embedded.zip`);
@@ -246,10 +244,10 @@ describe('InteropService_Importer_OneNote', () => {
BaseModel.setIdGenerator(originalIdGenerator);
});
skipIfNotCI('should use default value for EntityGuid and InkBias if not found', async () => {
it('should use default value for EntityGuid and InkBias if not found', async () => {
let idx = 0;
const originalIdGenerator = BaseModel.setIdGenerator(() => String(idx++));
const notes = await importNote(`${supportDir}/onenote/ink_bias_and_entity_guid.zip`);
const notes = await withWarningSilenced(/OneNoteConverter:/, async () => importNote(`${supportDir}/onenote/ink_bias_and_entity_guid.zip`));
// InkBias bug
expect(notes.find(n => n.title === 'Marketing Funnel & Training').body).toMatchSnapshot();

View File

@@ -11,7 +11,7 @@ import NoteTag from '../../models/NoteTag';
import ResourceService from '../../services/ResourceService';
import SearchEngine from '../search/SearchEngine';
const { MarkupToHtml } = require('@joplin/renderer');
import { ResourceEntity } from '../database/types';
import { NoteEntity, ResourceEntity } from '../database/types';
const createFolderForPagination = async (num: number, time: number) => {
await Folder.save({
@@ -961,4 +961,25 @@ describe('services/rest/Api', () => {
await SearchEngine.instance().destroy();
}));
it('should not fail when both deleted and conflict notes are included', (async () => {
const folder = await Folder.save({});
const note1 = await Note.save({ parent_id: folder.id });
await msleep(1);
const note2 = await Note.save({ parent_id: folder.id, deleted_time: 1 });
await msleep(1);
const note3 = await Note.save({ parent_id: folder.id, is_conflict: 1 });
const r1 = await api.route(RequestMethod.GET, 'notes', {
limit: 3,
include_conflicts: '1',
include_deleted: '1',
});
expect(r1.items.map((item: NoteEntity) => item.id)).toEqual([
note1.id,
note2.id,
note3.id,
]);
}));
});

View File

@@ -18,6 +18,17 @@ import Setting from '../../models/Setting';
import { ModelType } from '../../BaseModel';
import { remoteNotesFoldersResources } from '../../testing/test-utils-synchronizer';
import mockShareService from '../../testing/share/mockShareService';
import { StateShare } from './reducer';
const makeStateShares = (folderId: string, shareId = 'abcd1234'): StateShare[] => {
return [{
folder_id: folderId,
master_key_id: '',
id: shareId,
note_id: '',
type: 3,
}];
};
interface TestShareFolderServiceOptions {
master_key_id?: string;
@@ -95,7 +106,7 @@ describe('ShareService', () => {
await service.shareNote(note.id, false);
await msleep(1);
await Folder.updateAllShareIds(resourceService());
await Folder.updateAllShareIds(resourceService(), []);
await synchronizerStart();
@@ -157,7 +168,7 @@ describe('ShareService', () => {
async function testShareFolder(service: ShareService) {
const { folder, note, resource } = await prepareNoteFolderResource();
const share = await service.shareFolder(folder.id);
const share = await service.shareFolder(folder.id, makeStateShares(folder.id, 'share_1'));
expect(share.id).toBe('share_1');
expect((await Folder.load(folder.id)).share_id).toBe('share_1');
expect((await Note.load(note.id)).share_id).toBe('share_1');
@@ -185,9 +196,9 @@ describe('ShareService', () => {
BaseItem.shareService_ = shareService;
Resource.shareService_ = shareService;
await shareService.shareFolder(folder.id);
const share = await shareService.shareFolder(folder.id, makeStateShares(folder.id, 'share_1'));
await Folder.updateAllShareIds(resourceService());
await Folder.updateAllShareIds(resourceService(), makeStateShares(folder.id, share.id));
// The share service should automatically create a new encryption key
// specifically for that shared folder
@@ -313,12 +324,12 @@ describe('ShareService', () => {
const resourceService = new ResourceService();
await Folder.save({ id: folder1.id, share_id: '123456789' });
await Folder.updateAllShareIds(resourceService);
await Folder.updateAllShareIds(resourceService, []);
const cleanup = simulateReadOnlyShareEnv('123456789');
const shareService = testShareFolderService();
await shareService.leaveSharedFolder(folder1.id, 'somethingrandom');
await shareService.leaveSharedFolder(folder1.id, 'somethingrandom', BaseItem.syncShareCache.shares);
expect(await Folder.count()).toBe(0);
expect(await Note.count()).toBe(0);

View File

@@ -98,7 +98,9 @@ export default class ShareService {
return this.api_;
}
public async shareFolder(folderId: string): Promise<ApiShare> {
public async shareFolder(folderId: string, stateShares: StateShare[]|null = null): Promise<ApiShare> {
if (stateShares === null) stateShares = this.shares;
const folder = await Folder.load(folderId);
if (!folder) throw new Error(`No such folder: ${folderId}`);
@@ -137,7 +139,7 @@ export default class ShareService {
// Note: race condition if the share is created but the app crashes
// before setting share_id on the folder. See unshareFolder() for info.
await Folder.save({ id: folder.id, share_id: share.id });
await Folder.updateAllShareIds(ResourceService.instance());
await Folder.updateAllShareIds(ResourceService.instance(), stateShares);
return share;
}
@@ -182,7 +184,7 @@ export default class ShareService {
// It's ok if updateAllShareIds() doesn't run because it's executed on
// each sync too.
await Folder.updateAllShareIds(ResourceService.instance());
await Folder.updateAllShareIds(ResourceService.instance(), this.shares);
}
// This is when a share recipient decides to leave the shared folder.
@@ -206,17 +208,19 @@ export default class ShareService {
// If `folderShareUserId` is provided, the function will check that the user
// does not own the share. It would be an error to leave such a folder
// (instead "unshareFolder" should be called).
public async leaveSharedFolder(folderId: string, folderShareUserId: string = null): Promise<void> {
public async leaveSharedFolder(folderId: string, folderShareUserId: string = null, stateShares: StateShare[]|null = null): Promise<void> {
if (folderShareUserId !== null) {
const userId = Setting.value('sync.userId');
if (folderShareUserId === userId) throw new Error('Cannot leave own notebook');
}
if (stateShares === null) stateShares = this.shares;
const folder = await Folder.load(folderId);
// We call this to make sure all items are correctly linked before we
// call deleteAllByShareId()
await Folder.updateAllShareIds(ResourceService.instance());
await Folder.updateAllShareIds(ResourceService.instance(), stateShares);
const source = 'ShareService.leaveSharedFolder';
await Folder.delete(folderId, { deleteChildren: false, disableReadOnlyCheck: true, sourceDescription: source });
@@ -228,7 +232,7 @@ export default class ShareService {
// necessary otherwise sync will try to update items that are not longer
// accessible and will throw the error "Could not find share with ID: xxxx")
public async checkShareConsistency() {
const rootSharedFolders = await Folder.rootSharedFolders();
const rootSharedFolders = await Folder.rootSharedFolders(this.shares);
let hasRefreshedShares = false;
let shares = this.shares;

View File

@@ -98,7 +98,7 @@ function setupProxySettings(options: any) {
proxySettings.proxyUrl = options.proxyUrl;
}
interface ShimInitOptions {
export interface ShimInitOptions {
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
sharp: any;
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied

View File

@@ -3,20 +3,33 @@ import reducer, { State, defaultState } from '../../reducer';
import ShareService from '../../services/share/ShareService';
import { encryptionService } from '../test-utils';
import JoplinServerApi, { ExecOptions } from '../../JoplinServerApi';
import { ShareInvitation, StateShare } from '../../services/share/reducer';
import { ShareInvitation, StateShare, StateShareUser } from '../../services/share/reducer';
const testReducer = (state = defaultState, action: unknown) => {
return reducer(state, action);
};
type Query = Record<string, unknown>;
type OnShareGetListener = (query: Query)=> Promise<{ items: Partial<StateShare>[] }>;
type OnSharePostListener = (query: Query)=> Promise<{ id: string }>;
type OnInvitationGetListener = (query: Query)=> Promise<{ items: Partial<ShareInvitation>[] }>;
interface ShareStateResponse {
items: Partial<StateShare>[];
}
interface ShareInvitationResponse {
items: Partial<ShareInvitation>[];
}
interface ShareUsersResponse {
items: Partial<StateShareUser>[];
}
type Json = Record<string, unknown>;
type OnShareGetListener = (query: Json)=> Promise<ShareStateResponse>;
type OnSharePostListener = (query: Json)=> Promise<{ id: string }>;
type OnInvitationGetListener = (query: Json)=> Promise<ShareInvitationResponse>;
type OnShareUsersGetListener = (shareId: string)=> Promise<ShareUsersResponse>;
type OnShareUsersPostListener = (shareId: string, body: Json)=> Promise<void>;
type OnApiExecListener = (
method: string,
path: string,
query: Query,
query: Json,
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Needs to interface with old code from before the rule was applied
body: any,
headers: Record<string, unknown>,
@@ -27,6 +40,8 @@ export type ApiMock = {
getShares: OnShareGetListener;
postShares: OnSharePostListener;
getShareInvitations: OnInvitationGetListener;
getShareUsers?: OnShareUsersGetListener;
postShareUsers?: OnShareUsersPostListener;
onUnhandled?: OnApiExecListener;
onExec?: undefined;
@@ -37,6 +52,8 @@ export type ApiMock = {
getShareInvitations?: undefined;
getShares?: undefined;
postShares?: undefined;
getShareUsers?: undefined;
postShareUsers?: undefined;
};
// Initializes a share service with mocks
@@ -57,6 +74,16 @@ const mockShareService = (apiCallHandler: ApiMock, service?: ShareService, store
return apiCallHandler.getShareInvitations(query);
}
const shareUsersMatch = path.match(/^api\/shares\/([^/]+)\/users$/);
const shareId = shareUsersMatch?.[1];
if (shareId) {
if (method === 'GET' && apiCallHandler.getShareUsers) {
return apiCallHandler.getShareUsers(shareId);
}
if (method === 'POST' && apiCallHandler.postShareUsers) {
return apiCallHandler.postShareUsers(shareId, body);
}
}
if (apiCallHandler.onUnhandled) {
return apiCallHandler.onUnhandled(method, path, query, body, headers, options);

View File

@@ -9,6 +9,7 @@ export enum PlanName {
Basic = 'basic',
Pro = 'pro',
Teams = 'teams',
JoplinServerBusiness = 'joplinServerBusiness',
}
interface PlanFeature {
@@ -17,6 +18,7 @@ interface PlanFeature {
basic: boolean;
pro: boolean;
teams: boolean;
joplinServerBusiness?: boolean;
basicInfo?: string;
proInfo?: string;
teamsInfo?: string;
@@ -25,11 +27,16 @@ interface PlanFeature {
teamsInfoShort?: string;
}
enum PlanHostingType {
Managed = 'managed',
Self = 'self',
}
export interface Plan {
name: string;
title: string;
priceMonthly: StripePublicConfigPrice;
priceYearly: StripePublicConfigPrice;
priceMonthly?: StripePublicConfigPrice;
priceYearly?: StripePublicConfigPrice;
featured: boolean;
iconName: string;
featuresOn: FeatureId[];
@@ -39,6 +46,8 @@ export interface Plan {
cfaLabel: string;
cfaUrl: string;
footnote: string;
learnMoreUrl?: string;
hostingType: PlanHostingType;
}
export enum PricePeriod {
@@ -155,26 +164,29 @@ const features = (): Record<FeatureId, PlanFeature> => {
basic: true,
pro: true,
teams: true,
joplinServerBusiness: true,
},
sync: {
title: _('Sync as many devices as you want'),
basic: true,
pro: true,
teams: true,
joplinServerBusiness: true,
},
clipper: {
title: _('Web Clipper'),
description: _('The [Web Clipper](%s) is a browser extension that allows you to save web pages and screenshots from your browser.', 'https://joplinapp.org/help/apps/clipper'),
basic: true,
pro: true,
teams: true,
},
// clipper: {
// title: _('Web Clipper'),
// description: _('The [Web Clipper](%s) is a browser extension that allows you to save web pages and screenshots from your browser.', 'https://joplinapp.org/help/apps/clipper'),
// basic: false,
// pro: false,
// teams: false,
// },
collaborate: {
title: _('Collaborate on a notebook with others'),
description: _('This allows another user to share a notebook with you, and you can then both collaborate on it. It does not however allow you to share a notebook with someone else, unless you have the feature "%s".', shareNotebookTitle),
basic: true,
pro: true,
teams: true,
joplinServerBusiness: true,
},
share: {
title: shareNotebookTitle,
@@ -182,6 +194,7 @@ const features = (): Record<FeatureId, PlanFeature> => {
basic: false,
pro: true,
teams: true,
joplinServerBusiness: true,
},
emailToNote: {
title: _('Email to Note'),
@@ -189,6 +202,7 @@ const features = (): Record<FeatureId, PlanFeature> => {
basic: false,
pro: true,
teams: true,
joplinServerBusiness: true,
},
customBanner: {
title: _('Customise the note publishing banner'),
@@ -196,6 +210,7 @@ const features = (): Record<FeatureId, PlanFeature> => {
basic: false,
pro: true,
teams: true,
joplinServerBusiness: true,
},
multiUsers: {
title: _('Manage multiple users'),
@@ -203,6 +218,7 @@ const features = (): Record<FeatureId, PlanFeature> => {
basic: false,
pro: false,
teams: true,
joplinServerBusiness: true,
},
consolidatedBilling: {
title: _('Consolidated billing'),
@@ -217,12 +233,28 @@ const features = (): Record<FeatureId, PlanFeature> => {
basic: false,
pro: false,
teams: true,
joplinServerBusiness: true,
},
prioritySupport: {
title: _('Priority support'),
basic: false,
pro: false,
teams: true,
joplinServerBusiness: true,
},
selfHosted: {
title: _('Self-hosted'),
basic: false,
pro: false,
teams: false,
joplinServerBusiness: true,
},
sourceCodeAvailable: {
title: _('Source code available'),
basic: false,
pro: false,
teams: false,
joplinServerBusiness: true,
},
};
};
@@ -303,6 +335,11 @@ export const createFeatureTableMd = () => {
name: 'teams',
label: 'Teams',
},
{
name: 'joplinServerBusiness',
label: 'Joplin Server Business',
labelUrl: 'https://joplinapp.org/help/apps/joplin_server_business',
},
];
const rows: MarkdownTableRow[] = [];
@@ -332,6 +369,7 @@ export const createFeatureTableMd = () => {
basic: getCellInfo(PlanName.Basic, feature),
pro: getCellInfo(PlanName.Pro, feature),
teams: getCellInfo(PlanName.Teams, feature),
joplinServerBusiness: getCellInfo(PlanName.JoplinServerBusiness, feature),
};
rows.push(row);
@@ -362,6 +400,7 @@ export function getPlans(stripeConfig: StripePublicConfig): Record<PlanName, Pla
cfaLabel: _('Try it now'),
cfaUrl: '',
footnote: '',
hostingType: PlanHostingType.Managed,
},
pro: {
@@ -384,6 +423,7 @@ export function getPlans(stripeConfig: StripePublicConfig): Record<PlanName, Pla
cfaLabel: _('Try it now'),
cfaUrl: '',
footnote: '',
hostingType: PlanHostingType.Managed,
},
teams: {
@@ -406,6 +446,23 @@ export function getPlans(stripeConfig: StripePublicConfig): Record<PlanName, Pla
cfaLabel: _('Try it now'),
cfaUrl: '',
footnote: _('Per user. Minimum of 2 users.'),
hostingType: PlanHostingType.Managed,
},
joplinServerBusiness: {
name: 'joplinServerBusiness',
title: _('Joplin Server Business'),
featured: false,
iconName: 'business-icon',
featuresOn: getFeatureIdsByPlan(PlanName.JoplinServerBusiness, true),
featuresOff: getFeatureIdsByPlan(PlanName.JoplinServerBusiness, false),
featureLabelsOn: getFeatureLabelsByPlan(PlanName.JoplinServerBusiness, true),
featureLabelsOff: getFeatureLabelsByPlan(PlanName.JoplinServerBusiness, false),
cfaLabel: _('Get a quote'),
cfaUrl: 'mailto:jsb-inquiry@joplin.cloud?subject=Joplin%20Server%20Business%20inquiry',
footnote: '',
learnMoreUrl: 'https://joplinapp.org/help/apps/joplin_server_business',
hostingType: PlanHostingType.Self,
},
};
}

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