1
0
mirror of https://github.com/laurent22/joplin.git synced 2026-02-04 07:53:44 +02:00

Compare commits

...

126 Commits

Author SHA1 Message Date
Laurent Cozic
d4ea277d17 Add support for preview and TinyMCE 2026-02-03 18:05:40 +00:00
Laurent Cozic
26ce17e0d8 update 2026-02-03 15:24:24 +00:00
renovate[bot]
c278b45c78 Update dependency nodejs to v24.8.0 (#14229)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-02-01 23:54:22 +00:00
renovate[bot]
0dafd21db0 Update dependency electron-updater to v6.6.8 (#14239)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-02-01 23:53:58 +00:00
Sebastian
490d35919c All: Translation: Update de_DE.po (#14242) 2026-02-01 06:24:59 -05:00
Nick
4c1ca5480d All: Translation: Update sv.po (#14241) 2026-02-01 06:20:55 -05:00
Joplin Bot
d414c6354a Doc: Auto-update documentation
Auto-updated using release-website.sh
2026-02-01 02:36:23 +00:00
rnbastos
7651d8e3c4 All: Translation: Update pt_BR.po (#14238) 2026-01-31 17:23:25 -05:00
renovate[bot]
d5c72c13cb Update dependency @types/serviceworker to v0.0.164 (#14237)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-31 09:49:32 +00:00
renovate[bot]
4377634e7b Update dependency esbuild to v0.25.12 (#14236)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-31 04:59:05 +00:00
renovate[bot]
69ec5c7f86 Update dependency @types/serviceworker to v0.0.163 (#14234)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-30 17:50:22 +00:00
renovate[bot]
f02b0f48d8 Update dependency react-refresh to v0.18.0 (#14233)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-30 14:33:32 +01:00
renovate[bot]
4d77c1385f Update dependency sass to v1.93.3 (#14228)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-29 16:51:56 +00:00
renovate[bot]
c83f9ddeac Update dependency dayjs to v1.11.19 (#14227)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-29 14:49:50 +00:00
renovate[bot]
1b9c11df7b Update dependency @types/serviceworker to v0.0.162 (#14225)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-29 09:28:36 +00:00
Nick
333a8723e8 All: Translation: Update sv.po (#14220) 2026-01-28 18:14:28 -05:00
Laurent Cozic
e030c8271d Chore: Try to fix app-desktop tests on local 2026-01-28 12:55:08 +00:00
renovate[bot]
560bc31445 Update dependency gettext-extractor to v4.0.1 (#14217)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-28 01:45:26 +00:00
renovate[bot]
c71aeb74b2 Update dependency gettext-extractor to v4 (#14213)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-27 23:36:41 +00:00
renovate[bot]
ffaf2acb66 Update dependency @rollup/plugin-replace to v6.0.3 (#14212)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-27 18:16:02 +00:00
renovate[bot]
f442f1fb23 Update dependency @types/serviceworker to v0.0.161 (#14206)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-27 11:12:24 +00:00
renovate[bot]
81a1451820 Update dependency react-native-safe-area-context to v5.6.2 (#14202)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-27 02:16:37 +00:00
renovate[bot]
b3a3d71461 Update dependency @react-native-community/datetimepicker to v8.4.7 (#14191)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-26 22:47:00 +00:00
bwat47
1db38c3232 Desktop, Mobile: Fixes #13933: Markdown editor: Scroll jumps in notes with many inline images (#13955) 2026-01-26 15:21:08 +00:00
Fardin96
42e645eb70 Mobile: Fixes #13243: Align tag search-input-clear behavior across input methods (#14042) 2026-01-26 15:12:06 +00:00
mrjo118
3860f44d06 Mobile: Fixes #14153: Prevent the back button sometimes disappearing when switching between editors (#14164) 2026-01-26 15:09:37 +00:00
Henry Heino
4df0f8668d Desktop,Mobile: Resolves #14158: Markdown Editor: Make code block highlighting closer to the viewer (#14168) 2026-01-26 15:06:37 +00:00
Henry Heino
306d0fddd8 Desktop: OneNote import: Import invalid attachments as empty attachments (#14177) 2026-01-26 15:06:24 +00:00
Henry Heino
56d12b28f2 All: Unlinked resource deletion: Fix resources attached only via reference links are auto-deleted (#14178) 2026-01-26 15:06:15 +00:00
Henry Heino
6c5ea4872a Desktop,Mobile: Markdown editor: Fix error logged in "hide markdown" mode for certain markup (#14179) 2026-01-26 15:06:06 +00:00
Henry Heino
9856e8ae93 Chore: Sync fuzzer: Test adding, removing resources from notes (#14185) 2026-01-26 15:05:50 +00:00
Henry Heino
5712da4c0f Desktop,Mobile: Fixes #14009: Markdown editor: Upgrade most CodeMirror dependencies (#14186) 2026-01-26 15:04:35 +00:00
Henry Heino
4f7ee56444 Desktop: Fixes #13793: Make conflicts caused by resource duplication less likely (#14188) 2026-01-26 15:04:26 +00:00
Laurent Cozic
8e2b6ca296 Chore: Improved performance log consistency, and log to console on Android production too 2026-01-26 14:47:19 +00:00
Laurent Cozic
0172bb0ad8 Chore: Fix error in release-android script when the main apk is not built 2026-01-26 14:46:33 +00:00
Laurent Cozic
1d38e443ba Chore: Do not create GitHub release when there is no APK to publish 2026-01-26 13:58:57 +00:00
Laurent Cozic
5ad19b7261 Chore: Make Android app profileable 2026-01-26 13:46:27 +00:00
Arda Kılıçdağı
70293478a2 All: Translation: Update tr_TR.po (#14193) 2026-01-25 18:14:39 -05:00
custiq
3aaa20254f All: Translation: Update fi_FI.po (#14189) 2026-01-24 23:46:32 -05:00
renovate[bot]
42c248f7ca Update dependency @types/serviceworker to v0.0.160 (#14190)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-24 09:11:54 +00:00
Joplin Bot
ac1e94a8df Doc: Auto-update documentation
Auto-updated using release-website.sh
2026-01-23 06:47:06 +00:00
renovate[bot]
daff4496cf Update dependency turndown to v7.2.2 (#14181)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-23 02:15:02 +00:00
Joplin Bot
1e00078228 Doc: Auto-update documentation
Auto-updated using release-website.sh
2026-01-23 01:56:25 +00:00
renovate[bot]
03a1de9370 Update dependency @rollup/plugin-commonjs to v28.0.9 (#14175)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-22 17:46:44 +00:00
renovate[bot]
55ef256c65 Update dependency rate-limiter-flexible to v7.4.0 (#14174)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-22 17:46:38 +00:00
renovate[bot]
6d115db16f Update dependency @types/yargs to v17.0.34 (#14173)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-22 16:25:50 +00:00
renovate[bot]
5853031fde Update dependency @types/serviceworker to v0.0.159 (#14172)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-22 09:05:01 +00:00
renovate[bot]
47db2ae962 Update dependency @types/nodemailer to v6.4.21 (#14171)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-22 09:02:54 +00:00
Laurent Cozic
b960a2a8b0 Doc: Updated JSB contact link 2026-01-21 09:27:55 +00:00
renovate[bot]
fcaa7d2a98 Update dependency lint-staged to v16.2.6 (#14165)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-20 18:18:59 +00:00
renovate[bot]
99284ae135 Update dependency lint-staged to v16.2.0 (#14162)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-20 13:20:03 +00:00
Joplin Bot
66ae58c81b Doc: Auto-update documentation
Auto-updated using release-website.sh
2026-01-19 18:43:09 +00:00
Laurent Cozic
484d6a866d Doc: Remove "(Pre-release)" marker from Android changelog since all versions are pre-releases 2026-01-19 18:03:05 +00:00
Laurent Cozic
b45fd09e38 Merge branch 'release-3.5' into dev 2026-01-19 16:44:41 +00:00
Laurent Cozic
903a369c13 Android 3.5.9 2026-01-19 16:43:41 +00:00
Laurent Cozic
1fb79315e4 Chore: lock files 2026-01-19 16:13:04 +00:00
Henry Heino
4dc021b523 Android: Remove unnecessary READ_PHONE_STATE permission (#14157) 2026-01-19 16:04:56 +00:00
Joplin Bot
bbb4b46dd9 Doc: Auto-update documentation
Auto-updated using release-website.sh
2026-01-19 02:01:31 +00:00
renovate[bot]
063dc46f50 Update dependency dotenv to v17.2.3 (#14155)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-19 00:02:36 +00:00
renovate[bot]
aa400b52be Update dependency short-uuid to v5 (#14156)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-19 00:02:26 +00:00
renovate[bot]
be7de2f08a Update dependency dotenv to v17.2.2 (#14145)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-18 22:01:23 +00:00
renovate[bot]
f8a129e4dc Update dependency npm-package-json-lint to v9 (#14146)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-18 22:01:11 +00:00
Laurent Cozic
c5d9646908 Desktop release v3.6.2 2026-01-18 11:33:16 +00:00
Henry Heino
876ec80911 Desktop: Fixes #14084: .onepkg import: Fix Unicode issues, support Linux and MacOS (#14094)
Co-authored-by: Laurent Cozic <laurent22@users.noreply.github.com>
2026-01-18 11:31:48 +00:00
mrjo118
4051f88ce7 Chore: Fix intermittent Synchronizer.revisions test failure (#14096)
Co-authored-by: Laurent Cozic <laurent22@users.noreply.github.com>
2026-01-18 11:31:42 +00:00
Laurent Cozic
f194c111e4 All: Fixes #14144: Application crashes when profile database has been analyzed 2026-01-18 11:30:05 +00:00
Henry Heino
e386246bc9 Chore: Sync fuzzer: Improve error logging (#14108)
Co-authored-by: Laurent Cozic <laurent22@users.noreply.github.com>
2026-01-18 11:29:32 +00:00
Henry Heino
292b269f1d Desktop: Resolves #14086: Accessibility: Include accessibility information in exported PDFs (#14111)
Co-authored-by: Laurent Cozic <laurent22@users.noreply.github.com>
2026-01-18 11:29:25 +00:00
renovate[bot]
b2fc43da2b Update dependency short-uuid to v4.2.2 (#14114)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Laurent Cozic <laurent22@users.noreply.github.com>
2026-01-18 11:29:17 +00:00
Henry Heino
4a23a1ed3e Desktop: Fixes #14092: Built-in plugins: Upgrade Freehand Drawing to v4.3.0 (#14123)
Co-authored-by: Laurent Cozic <laurent22@users.noreply.github.com>
2026-01-18 11:29:07 +00:00
Henry Heino
c8878a18bf Desktop, Mobile: Editor: Inline rendering: Render inline HTML (colorized text, superscript, subscript, strikethrough) (#14133) 2026-01-18 11:28:15 +00:00
Henry Heino
340fba7af5 Server: Fixes #14107: Fix warning when unsharing folder (#14134) 2026-01-18 11:25:52 +00:00
Henry Heino
271c4f4a2a Server: Fixes #14131: Allow changing the password for the admin account when SAML is enabled (#14135) 2026-01-18 11:25:38 +00:00
Henry Heino
c9dba20f59 Chore: Sync fuzzer: Allow specifying a set of initial actions (#14136)
Co-authored-by: Laurent Cozic <laurent22@users.noreply.github.com>
2026-01-18 11:25:07 +00:00
renovate[bot]
b474cc206a Update dependency dotenv to v17 (#14138)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-18 11:24:46 +00:00
Milo Ivir
9d4df8cc6e All: Translation: Update hr_HR.po (#14140) 2026-01-17 20:57:39 -05:00
Joplin Bot
a4ddfe1f58 Doc: Auto-update documentation
Auto-updated using release-website.sh
2026-01-17 18:38:35 +00:00
renovate[bot]
7d15215e66 Update dependency react-native-device-info to v14.1.1 (#14132)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-17 14:23:02 +00:00
Laurent Cozic
449555c8e9 Desktop release v3.5.12 2026-01-17 11:21:02 +00:00
Laurent Cozic
5b74e206ed Desktop release v3.6.1 2026-01-17 11:19:55 +00:00
Laurent Cozic
9873d02b0b Chore: Setup new release 3.6 2026-01-17 11:19:43 +00:00
Laurent Cozic
57b7d98d8a Merge branch 'release-3.5' into dev 2026-01-17 11:18:39 +00:00
Laurent Cozic
f075b561a2 All: Add more error information when the profile is corrupted 2026-01-17 11:17:43 +00:00
renovate[bot]
483d051de0 Update dependency rate-limiter-flexible to v7.3.2 (#14130)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-16 15:59:43 +00:00
renovate[bot]
106cd2778f Update dependency rate-limiter-flexible to v7.3.1 (#14128)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-16 13:57:49 +00:00
Eric Duarte
c3aea2db80 All: Translation: Update ca.po (#14129) 2026-01-16 08:10:45 -05:00
Liffindra Angga Zaaldian
3f067b0f77 All: Translation: Update id_ID.po (#14127)
Co-authored-by: Laurent Cozic <laurent22@users.noreply.github.com>
2026-01-16 08:08:49 -05:00
Laurent Cozic
15cf025bc2 All: Resolves #14106: Improve Fountain notes exported as PDF (#14120) 2026-01-16 11:30:54 +00:00
Henry Heino
4677586e3b Desktop: Rich Text Editor: Fix cut, copy, paste, and select all menu items (#14125) 2026-01-16 11:30:39 +00:00
Laurent Cozic
b8c5b7a153 Doc: Added Contribution Scope Policy 2026-01-16 10:19:32 +00:00
renovate[bot]
e46e634c2e Update dependency style-to-js to v1.1.18 (#14118)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Laurent Cozic <laurent22@users.noreply.github.com>
2026-01-16 09:44:18 +00:00
Henry Heino
b3cf4e5a35 Chore: Fix CI (#14124) 2026-01-16 09:43:30 +00:00
Laurent Cozic
8589e10d6e Chore: Trying to fix CI 2026-01-15 14:23:00 +00:00
renovate[bot]
18942f0d6a Update dependency babel-plugin-react-native-web to v0.21.2 (#14104)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-15 13:25:14 +00:00
Eric Duarte
3be354cdcb All: Translation: Update es_ES.po (#14117) 2026-01-15 08:22:26 -05:00
renovate[bot]
0575f1aa3e Update dependency react-native-web to v0.21.2 (#14113)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-15 05:12:54 +00:00
renovate[bot]
caa9baa460 Update dependency react-native-localize to v3.5.4 (#14112)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-15 02:04:45 +00:00
renovate[bot]
b5284804d8 Update dependency qrcode to v1.5.4 (#14109)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-15 00:07:22 +00:00
renovate[bot]
6053b4296c Update dependency esbuild to v0.25.11 (#14101)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-14 13:56:24 +00:00
renovate[bot]
615fec1d2c Update dependency @rollup/plugin-node-resolve to v16.0.3 (#14100)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-14 13:54:35 +00:00
Laurent Cozic
0bbcd9a59b All: Add support for external embeds, eg. YouTube videos (#14012) 2026-01-14 13:52:17 +00:00
renovate[bot]
6931b32f17 Update dependency @rollup/plugin-commonjs to v28.0.8 (#14099)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-14 12:09:18 +00:00
renovate[bot]
17ac501ddb Update dependency @types/serviceworker to v0.0.158 (#14060)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Laurent Cozic <laurent22@users.noreply.github.com>
2026-01-14 09:32:41 +00:00
renovate[bot]
94161c5f93 Update dependency @types/react to v18.3.26 (#14050)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Laurent Cozic <laurent22@users.noreply.github.com>
2026-01-14 09:32:34 +00:00
Jozef Gaal
196255e960 All: Translation: Update sk_SK.po (#14095) 2026-01-13 19:55:13 -05:00
Self Not Found
f936390ee4 All: Translation: Update zh_CN.po (#14091) 2026-01-13 16:48:20 -05:00
Laurent Cozic
5638c4b812 Chore: Fixed various typo and grammar mistakes 2026-01-13 16:28:24 +00:00
Linkosred
4222caa423 Docs : Add video tutorial link for several pages of the documentation (#14068) 2026-01-13 16:20:52 +00:00
Henry Heino
bc705acc5c Windows: Fixes #13430: Experimental auto-updater: Fix application crash on update failure (#14083) 2026-01-13 16:19:51 +00:00
Laurent Cozic
f1c968c19a Chore: Remove usage of watchman when running Jest tests (#14087) 2026-01-13 15:40:53 +00:00
Laurent Cozic
26c5a6181e Chore: Retry Apple Silicon test build when it fails (#14088) 2026-01-13 15:40:36 +00:00
Laurent Cozic
a3bf0cfdeb Server: Add support for MFA (#14081) 2026-01-13 14:14:46 +00:00
Joplin Bot
606b397326 Doc: Auto-update documentation
Auto-updated using release-website.sh
2026-01-13 01:53:11 +00:00
krevad
fbd157283d All: Translation: Update sv.po (#14082) 2026-01-12 19:38:43 -05:00
Laurent Cozic
2e879f65fc Chore: Fixed Markdown filename 2026-01-12 19:32:54 +00:00
Joplin Bot
c727156a46 Doc: Auto-update documentation
Auto-updated using release-website.sh
2026-01-12 18:43:05 +00:00
Laurent Cozic
4e31f1918d Doc: Added instructions on how to create a custom profile for deployments 2026-01-12 16:18:17 +00:00
Laurent Cozic
a1cdf67779 Chore: Also release pkg files for macOS 2026-01-12 16:08:56 +00:00
Laurent Cozic
5cb1db197f Doc: Add release notes 3.5 2026-01-12 15:12:18 +00:00
Joplin Bot
05c3065c72 Doc: Auto-update documentation
Auto-updated using release-website.sh
2026-01-10 12:49:51 +00:00
Laurent Cozic
25a5be09bf Merge branch 'release-3.5' into dev 2026-01-10 10:16:56 +00:00
Laurent Cozic
f0a3f73ddb iOS 13.5.3 2026-01-10 10:09:40 +00:00
ERYpTION
3bba2f6b2a All: Translation: Update da_DK.po (#14073) 2026-01-09 17:22:08 -05:00
summoner
ca9addcda0 ALL: Translation: Update hu-HU.po (#14069) 2026-01-09 16:16:33 -05:00
Nick
c42a49c1cf All: Translation: Update sv.po (#14064) 2026-01-09 15:58:20 -05:00
Laurent Cozic
a1e056670d Chore: Ignore .watchman-cookie- files 2026-01-09 17:45:27 +00:00
283 changed files with 10422 additions and 4965 deletions

View File

@@ -1045,6 +1045,8 @@ packages/editor/CodeMirror/extensions/links/utils/getUrlAtPosition.js
packages/editor/CodeMirror/extensions/links/utils/openLink.js
packages/editor/CodeMirror/extensions/markdownDecorationExtension.test.js
packages/editor/CodeMirror/extensions/markdownDecorationExtension.js
packages/editor/CodeMirror/extensions/markdownFrontMatterExtension.test.js
packages/editor/CodeMirror/extensions/markdownFrontMatterExtension.js
packages/editor/CodeMirror/extensions/markdownHighlightExtension.test.js
packages/editor/CodeMirror/extensions/markdownHighlightExtension.js
packages/editor/CodeMirror/extensions/markdownMathExtension.test.js
@@ -1061,6 +1063,8 @@ packages/editor/CodeMirror/extensions/rendering/replaceBulletLists.js
packages/editor/CodeMirror/extensions/rendering/replaceCheckboxes.js
packages/editor/CodeMirror/extensions/rendering/replaceDividers.js
packages/editor/CodeMirror/extensions/rendering/replaceFormatCharacters.js
packages/editor/CodeMirror/extensions/rendering/replaceInlineHtml.test.js
packages/editor/CodeMirror/extensions/rendering/replaceInlineHtml.js
packages/editor/CodeMirror/extensions/rendering/types.js
packages/editor/CodeMirror/extensions/rendering/utils/makeBlockReplaceExtension.js
packages/editor/CodeMirror/extensions/rendering/utils/makeInlineReplaceExtension.js
@@ -1101,6 +1105,7 @@ packages/editor/CodeMirror/utils/getSearchState.js
packages/editor/CodeMirror/utils/growSelectionToNode.js
packages/editor/CodeMirror/utils/handleLinkEditRequests.js
packages/editor/CodeMirror/utils/handlePasteEvent.js
packages/editor/CodeMirror/utils/htmlNodeInfo.js
packages/editor/CodeMirror/utils/isCursorAtBeginning.js
packages/editor/CodeMirror/utils/isInSyntaxNode.js
packages/editor/CodeMirror/utils/markdown/codeBlockLanguages/allLanguages.js
@@ -1805,8 +1810,11 @@ packages/renderer/MdToHtml/renderMedia.js
packages/renderer/MdToHtml/rules/abc.js
packages/renderer/MdToHtml/rules/checkbox.js
packages/renderer/MdToHtml/rules/code_inline.js
packages/renderer/MdToHtml/rules/externalEmbed.js
packages/renderer/MdToHtml/rules/fence.js
packages/renderer/MdToHtml/rules/fountain.js
packages/renderer/MdToHtml/rules/frontmatter.test.js
packages/renderer/MdToHtml/rules/frontmatter.js
packages/renderer/MdToHtml/rules/highlight_keywords.js
packages/renderer/MdToHtml/rules/html_image.js
packages/renderer/MdToHtml/rules/image.js
@@ -1840,22 +1848,29 @@ packages/tools/checkIgnoredFiles.js
packages/tools/checkLibPaths.test.js
packages/tools/checkLibPaths.js
packages/tools/convertThemesToCss.js
packages/tools/fuzzer/ActionRunner.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/doRandomAction.js
packages/tools/fuzzer/model/FolderRecord.js
packages/tools/fuzzer/model/ResourceRecord.js
packages/tools/fuzzer/sync-fuzzer.js
packages/tools/fuzzer/types.js
packages/tools/fuzzer/utils/ProgressBar.js
packages/tools/fuzzer/utils/SeededRandom.js
packages/tools/fuzzer/utils/diffSortedStringArrays.test.js
packages/tools/fuzzer/utils/diffSortedStringArrays.js
packages/tools/fuzzer/utils/extractResourceIds.js
packages/tools/fuzzer/utils/getNumberProperty.js
packages/tools/fuzzer/utils/getProperty.js
packages/tools/fuzzer/utils/getStringProperty.js
packages/tools/fuzzer/utils/hangingIndent.js
packages/tools/fuzzer/utils/logDiffDebug.js
packages/tools/fuzzer/utils/openDebugSession.js
packages/tools/fuzzer/utils/randomId.test.js
packages/tools/fuzzer/utils/randomId.js
packages/tools/fuzzer/utils/randomString.js
packages/tools/fuzzer/utils/retryWithCount.js
packages/tools/generate-database-types.js

View File

@@ -48,6 +48,7 @@ jobs:
CSC_LINK: ${{ secrets.APPLE_CSC_LINK }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GH_REPO: ${{ github.repository }}
GITHUB_EVENT_NAME: ${{ github.event_name }}
IS_CONTINUOUS_INTEGRATION: 1
BUILD_SEQUENCIAL: 1
PUBLISH_ENABLED: ${{ env.PUBLISH_ENABLED }}
@@ -57,25 +58,38 @@ jobs:
yarn install
cd packages/app-desktop
npm pkg set 'build.mac.artifactName'='${productName}-${version}-${arch}.${ext}'
npm pkg delete 'build.mac.target'
npm pkg set 'build.mac.target[0].target'='dmg'
npm pkg set 'build.mac.target[0].arch[0]'='arm64'
npm pkg set 'build.mac.target[1].target'='zip'
npm pkg set 'build.mac.target[1].arch[0]'='arm64'
if [[ "$PUBLISH_ENABLED" == "true" ]]; then
echo "Building and publishing desktop application..."
PYTHON_PATH=$(which python) USE_HARD_LINKS=false yarn dist --mac --arm64
# Only enable pkg build in the main repository CI. As of 01/15/2026, pkg
# build fails when running on external pull requests.
if [[ "$GITHUB_EVENT_NAME" != "pull_request" ]]; then
npm pkg set 'build.mac.target[2].target'='pkg'
npm pkg set 'build.mac.target[2].arch[0]'='arm64'
fi
yarn modifyReleaseAssets --repo="$GH_REPO" --tag="$GIT_TAG_NAME" --token="$GITHUB_TOKEN"
else
echo "Building but *not* publishing desktop application..."
build_dist() {
if [[ "$PUBLISH_ENABLED" == "true" ]]; then
echo "Building and publishing desktop application..."
PYTHON_PATH=$(which python) USE_HARD_LINKS=false yarn dist --mac --arm64
# We also want to disable signing the app in this case, because
# it doesn't work and we don't need it.
# https://www.electron.build/code-signing#how-to-disable-code-signing-during-the-build-process-on-macos
yarn modifyReleaseAssets --repo="$GH_REPO" --tag="$GIT_TAG_NAME" --token="$GITHUB_TOKEN"
else
echo "Building but *not* publishing desktop application..."
export CSC_IDENTITY_AUTO_DISCOVERY=false
npm pkg set 'build.mac.identity'=null --json
# We also want to disable signing the app in this case, because
# it doesn't work and we don't need it.
# https://www.electron.build/code-signing#how-to-disable-code-signing-during-the-build-process-on-macos
PYTHON_PATH=$(which python) USE_HARD_LINKS=false yarn dist --mac --arm64 --publish=never
fi
export CSC_IDENTITY_AUTO_DISCOVERY=false
npm pkg set 'build.mac.identity'=null --json
PYTHON_PATH=$(which python) USE_HARD_LINKS=false yarn dist --mac --arm64 --publish=never
fi
}
build_dist || build_dist

18
.gitignore vendored
View File

@@ -53,6 +53,7 @@ lerna-debug.log
docs/**/*.mustache
.idea
/readme/i18n
.watchman-cookie-*
# Yarn stuff
# https://yarnpkg.com/getting-started/qa#which-files-should-be-gitignored
@@ -1018,6 +1019,8 @@ packages/editor/CodeMirror/extensions/links/utils/getUrlAtPosition.js
packages/editor/CodeMirror/extensions/links/utils/openLink.js
packages/editor/CodeMirror/extensions/markdownDecorationExtension.test.js
packages/editor/CodeMirror/extensions/markdownDecorationExtension.js
packages/editor/CodeMirror/extensions/markdownFrontMatterExtension.test.js
packages/editor/CodeMirror/extensions/markdownFrontMatterExtension.js
packages/editor/CodeMirror/extensions/markdownHighlightExtension.test.js
packages/editor/CodeMirror/extensions/markdownHighlightExtension.js
packages/editor/CodeMirror/extensions/markdownMathExtension.test.js
@@ -1034,6 +1037,8 @@ packages/editor/CodeMirror/extensions/rendering/replaceBulletLists.js
packages/editor/CodeMirror/extensions/rendering/replaceCheckboxes.js
packages/editor/CodeMirror/extensions/rendering/replaceDividers.js
packages/editor/CodeMirror/extensions/rendering/replaceFormatCharacters.js
packages/editor/CodeMirror/extensions/rendering/replaceInlineHtml.test.js
packages/editor/CodeMirror/extensions/rendering/replaceInlineHtml.js
packages/editor/CodeMirror/extensions/rendering/types.js
packages/editor/CodeMirror/extensions/rendering/utils/makeBlockReplaceExtension.js
packages/editor/CodeMirror/extensions/rendering/utils/makeInlineReplaceExtension.js
@@ -1074,6 +1079,7 @@ packages/editor/CodeMirror/utils/getSearchState.js
packages/editor/CodeMirror/utils/growSelectionToNode.js
packages/editor/CodeMirror/utils/handleLinkEditRequests.js
packages/editor/CodeMirror/utils/handlePasteEvent.js
packages/editor/CodeMirror/utils/htmlNodeInfo.js
packages/editor/CodeMirror/utils/isCursorAtBeginning.js
packages/editor/CodeMirror/utils/isInSyntaxNode.js
packages/editor/CodeMirror/utils/markdown/codeBlockLanguages/allLanguages.js
@@ -1778,8 +1784,11 @@ packages/renderer/MdToHtml/renderMedia.js
packages/renderer/MdToHtml/rules/abc.js
packages/renderer/MdToHtml/rules/checkbox.js
packages/renderer/MdToHtml/rules/code_inline.js
packages/renderer/MdToHtml/rules/externalEmbed.js
packages/renderer/MdToHtml/rules/fence.js
packages/renderer/MdToHtml/rules/fountain.js
packages/renderer/MdToHtml/rules/frontmatter.test.js
packages/renderer/MdToHtml/rules/frontmatter.js
packages/renderer/MdToHtml/rules/highlight_keywords.js
packages/renderer/MdToHtml/rules/html_image.js
packages/renderer/MdToHtml/rules/image.js
@@ -1813,22 +1822,29 @@ packages/tools/checkIgnoredFiles.js
packages/tools/checkLibPaths.test.js
packages/tools/checkLibPaths.js
packages/tools/convertThemesToCss.js
packages/tools/fuzzer/ActionRunner.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/doRandomAction.js
packages/tools/fuzzer/model/FolderRecord.js
packages/tools/fuzzer/model/ResourceRecord.js
packages/tools/fuzzer/sync-fuzzer.js
packages/tools/fuzzer/types.js
packages/tools/fuzzer/utils/ProgressBar.js
packages/tools/fuzzer/utils/SeededRandom.js
packages/tools/fuzzer/utils/diffSortedStringArrays.test.js
packages/tools/fuzzer/utils/diffSortedStringArrays.js
packages/tools/fuzzer/utils/extractResourceIds.js
packages/tools/fuzzer/utils/getNumberProperty.js
packages/tools/fuzzer/utils/getProperty.js
packages/tools/fuzzer/utils/getStringProperty.js
packages/tools/fuzzer/utils/hangingIndent.js
packages/tools/fuzzer/utils/logDiffDebug.js
packages/tools/fuzzer/utils/openDebugSession.js
packages/tools/fuzzer/utils/randomId.test.js
packages/tools/fuzzer/utils/randomId.js
packages/tools/fuzzer/utils/randomString.js
packages/tools/fuzzer/utils/retryWithCount.js
packages/tools/generate-database-types.js

View File

@@ -0,0 +1,21 @@
# Add a minSdkVersion to prevent the dangerous READ_PHONE_STATE
# permission from being added.
# See:
# - Upstream issue report: https://github.com/oblador/react-native-vector-icons/issues/1861
# - About the permission: https://developer.android.com/reference/android/Manifest.permission#READ_PHONE_STATE
# - StackOverflow post with discussion and alternate workarounds: https://stackoverflow.com/questions/39668549/why-has-the-read-phone-state-permission-been-added
diff --git a/android/build.gradle b/android/build.gradle
index a16b4ad6d1871cf5cf73ef7ebeaf8bd4d662b134..9871afb5fbf8e687370e08f54d884ecd7dde7e7c 100644
--- a/android/build.gradle
+++ b/android/build.gradle
@@ -37,6 +37,10 @@ android {
}
compileSdkVersion safeExtGet('compileSdkVersion', 31)
+
+ defaultConfig {
+ minSdkVersion safeExtGet('minSdkVersion', 24)
+ }
}
dependencies {

View File

@@ -0,0 +1,21 @@
# Add a minSdkVersion to prevent the dangerous READ_PHONE_STATE
# permission from being added.
# See:
# - Upstream issue report: https://github.com/oblador/react-native-vector-icons/issues/1861
# - About the permission: https://developer.android.com/reference/android/Manifest.permission#READ_PHONE_STATE
# - StackOverflow post with discussion and alternate workarounds: https://stackoverflow.com/questions/39668549/why-has-the-read-phone-state-permission-been-added
diff --git a/android/build.gradle b/android/build.gradle
index d42bd23123644cc324051e9c7ec4635de286315a..640996df60fe7769f69b30b35f771eb9cf0b75d4 100644
--- a/android/build.gradle
+++ b/android/build.gradle
@@ -37,6 +37,10 @@ android {
}
compileSdkVersion safeExtGet('compileSdkVersion', 31)
+
+ defaultConfig {
+ minSdkVersion safeExtGet('minSdkVersion', 24)
+ }
}
dependencies {

View File

@@ -0,0 +1,21 @@
# Add a minSdkVersion to prevent the dangerous READ_PHONE_STATE
# permission from being added.
# See:
# - Upstream issue report: https://github.com/oblador/react-native-vector-icons/issues/1861
# - About the permission: https://developer.android.com/reference/android/Manifest.permission#READ_PHONE_STATE
# - StackOverflow post with discussion and alternate workarounds: https://stackoverflow.com/questions/39668549/why-has-the-read-phone-state-permission-been-added
diff --git a/android/build.gradle b/android/build.gradle
index 170ec0ff9befe0f9155aaf5e1b84133cfd87be99..e6a0ab4a019ee67c5af7761ae8bb35f18b05c590 100644
--- a/android/build.gradle
+++ b/android/build.gradle
@@ -37,6 +37,10 @@ android {
}
compileSdkVersion safeExtGet('compileSdkVersion', 31)
+
+ defaultConfig {
+ minSdkVersion safeExtGet('minSdkVersion', 24)
+ }
}
dependencies {

View File

@@ -0,0 +1,21 @@
# Add a minSdkVersion to prevent the dangerous READ_PHONE_STATE
# permission from being added.
# See:
# - Upstream issue report: https://github.com/oblador/react-native-vector-icons/issues/1861
# - About the permission: https://developer.android.com/reference/android/Manifest.permission#READ_PHONE_STATE
# - StackOverflow post with discussion and alternate workarounds: https://stackoverflow.com/questions/39668549/why-has-the-read-phone-state-permission-been-added
diff --git a/android/build.gradle b/android/build.gradle
index 3b22f9de66795ee01dbaa29655727ee7ddba3cc8..325daa88d33f066b3826e5031ce281793710af2d 100644
--- a/android/build.gradle
+++ b/android/build.gradle
@@ -37,6 +37,10 @@ android {
}
compileSdkVersion safeExtGet('compileSdkVersion', 31)
+
+ defaultConfig {
+ minSdkVersion safeExtGet('minSdkVersion', 24)
+ }
}
dependencies {

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 101 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 120 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 111 KiB

View File

@@ -1,4 +1,47 @@
<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[Joplin]]></title><description><![CDATA[Joplin, the open source note-taking application]]></description><link>https://joplinapp.org</link><generator>RSS for Node</generator><lastBuildDate>Mon, 22 Sep 2025 00:00:00 GMT</lastBuildDate><atom:link href="https://joplinapp.org/rss.xml" rel="self" type="application/rss+xml"/><pubDate>Mon, 22 Sep 2025 00:00:00 GMT</pubDate><item><title><![CDATA[What's new in Joplin 3.4]]></title><description><![CDATA[<p>Joplin 3.4 includes many bug fixes and improvements, with a focus on the mobile app.</p>
<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[Joplin]]></title><description><![CDATA[Joplin, the open source note-taking application]]></description><link>https://joplinapp.org</link><generator>RSS for Node</generator><lastBuildDate>Sun, 11 Jan 2026 00:00:00 GMT</lastBuildDate><atom:link href="https://joplinapp.org/rss.xml" rel="self" type="application/rss+xml"/><pubDate>Sun, 11 Jan 2026 00:00:00 GMT</pubDate><item><title><![CDATA[What's new in Joplin 3.5]]></title><description><![CDATA[<h2>Improvements across desktop and mobile<a name="improvements-across-desktop-and-mobile" href="#improvements-across-desktop-and-mobile" class="heading-anchor">🔗</a></h2>
<h3>More stable and consistent Markdown editing<a name="more-stable-and-consistent-markdown-editing" href="#more-stable-and-consistent-markdown-editing" class="heading-anchor">🔗</a></h3>
<p>The Markdown editor has been refined to feel more stable and closer to the final rendered view. Headings in the editor now more closely match how they appear when viewing a note, reducing the visual jump between editing and reading. Layout issues have also been addressed so elements like rendered checkboxes and images no longer cause the editor to shift unexpectedly while typing.</p>
<p>The ABC music notation plugin appeared to be popular but had some limitations. With this new version, ABC is now part of the app, which means it can now work from published notes, and from the Rich Text editor!</p>
<p><img src="https://raw.githubusercontent.com/laurent22/joplin/dev/Assets/WebsiteAssets/images/news/20260111-abc.png" alt="ABC music notation rendered directly in Joplin, showing a short musical phrase displayed from plain-text ABC syntax"></p>
<h3>Smoother switching between notes<a name="smoother-switching-between-notes" href="#smoother-switching-between-notes" class="heading-anchor">🔗</a></h3>
<p>Switching between notes is now less disruptive. Joplin restores cursor position and scroll location more reliably, making it easier to move back and forth between notes—especially when working with longer documents or comparing content—without losing your place.</p>
<h3>Case insensitive tags<a name="case-insensitive-tags" href="#case-insensitive-tags" class="heading-anchor">🔗</a></h3>
<p>Tags are now treated in a case-insensitive way, which helps prevent duplicate tags caused by differences in capitalisation, while still allowing mixed-case tag names. All this time we were hoping that @dpoulton <a href="https://discourse.joplinapp.org/t/tags-lower-case-only/4220/106">would just get used to lowercase tags</a>, but 5 years later it looks like it's not happening ;) So thank you @mrjo118 for implementing it!</p>
<p><img src="https://raw.githubusercontent.com/laurent22/joplin/dev/Assets/WebsiteAssets/images/news/20260111-lowercase-tags.png" alt="Joplin tag list demonstrating case-insensitive tags, with mixed-case tag names merged into a single tag."></p>
<h3>More reliable syncing and sharing<a name="more-reliable-syncing-and-sharing" href="#more-reliable-syncing-and-sharing" class="heading-anchor">🔗</a></h3>
<p>Syncing and sharing have been made more robust in everyday use. Joplin now handles repeated syncs more efficiently, avoids unnecessary data usage, and is better at detecting and syncing all changes, particularly when using WebDAV and S3 sync targets.</p>
<p>Moreover filesystem synchronisation is now more reliable, in particular when used alongside tools like SyncThing on both mobile and desktop.</p>
<h3>Accessibility and readability improvements<a name="accessibility-and-readability-improvements" href="#accessibility-and-readability-improvements" class="heading-anchor">🔗</a></h3>
<p>Accessibility has seen further refinements in this release. Dark mode readability has been improved, common editor elements are clearer, and animations are reduced or disabled when system “reduce motion” settings are enabled, making the app more comfortable to use for a wider range of users. Keyboard navigation has also been improved on the desktop application.</p>
<h2>Desktop-specific improvements<a name="desktop-specific-improvements" href="#desktop-specific-improvements" class="heading-anchor">🔗</a></h2>
<h3>Easier profile management<a name="easier-profile-management" href="#easier-profile-management" class="heading-anchor">🔗</a></h3>
<p>Managing multiple profiles on desktop is now simpler thanks to a new, more user-friendly profile management interface. This removes the need to manually edit configuration files and makes switching between different setups easier and safer.</p>
<p><img src="https://raw.githubusercontent.com/laurent22/joplin/dev/Assets/WebsiteAssets/images/news/20260111-profiles.png" alt="Desktop profile management screen in Joplin showing multiple profiles with options to rename or delete them."></p>
<h3>Significantly improved OneNote import<a name="significantly-improved-onenote-import" href="#significantly-improved-onenote-import" class="heading-anchor">🔗</a></h3>
<p>Importing content from OneNote is now more reliable and accurate. Support has been expanded to cover more OneNote file formats, and many edge cases have been addressed so imported notes more closely match their original structure and content. This makes migrating from OneNote to Joplin smoother and more trustworthy.</p>
<h3>Better tools for organising large note collections<a name="better-tools-for-organising-large-note-collections" href="#better-tools-for-organising-large-note-collections" class="heading-anchor">🔗</a></h3>
<p>Desktop users can now select multiple notebooks at once, making it easier to reorganise notebook structures, move groups of notes, or clean up larger collections without working notebook by notebook.</p>
<p><img src="https://raw.githubusercontent.com/laurent22/joplin/dev/Assets/WebsiteAssets/images/news/20260111-multi-select.png" alt="Joplin desktop sidebar with several notebooks selected at the same time for bulk organisation."></p>
<h3>Polished editing experience on desktop<a name="polished-editing-experience-on-desktop" href="#polished-editing-experience-on-desktop" class="heading-anchor">🔗</a></h3>
<p>Both the Markdown and Rich Text editors have been further refined. Cursor behaviour is more predictable, visual consistency between editing and viewing has improved, and several layout and rendering issues have been fixed to reduce interruptions while writing.</p>
<h3>More reliable search and navigation<a name="more-reliable-search-and-navigation" href="#more-reliable-search-and-navigation" class="heading-anchor">🔗</a></h3>
<p>Search and navigation on desktop have been improved with fixes that ensure search results behave consistently and remain visible when moving between windows or views.</p>
<h3>Improved math support in WebClipper<a name="improved-math-support-in-webclipper" href="#improved-math-support-in-webclipper" class="heading-anchor">🔗</a></h3>
<p>The WebClipper is not forgotten in this release - clipping certain math formulas, in particular from Wikipedia but also other websites, has been improved. Additionally, certain scientific articles are now also better handled by the WebClipper.</p>
<h2>Mobile-specific improvements<a name="mobile-specific-improvements" href="#mobile-specific-improvements" class="heading-anchor">🔗</a></h2>
<h3>A more powerful Rich Text Editor on mobile<a name="a-more-powerful-rich-text-editor-on-mobile" href="#a-more-powerful-rich-text-editor-on-mobile" class="heading-anchor">🔗</a></h3>
<p>The mobile Rich Text Editor continues to improve, with new and expanded support for tables, code blocks, and other structured content. These changes make it easier to create and edit more complex notes directly on mobile devices.</p>
<p><img src="https://raw.githubusercontent.com/laurent22/joplin/dev/Assets/WebsiteAssets/images/news/20260111-rte1.png" alt="Joplin mobile Rich Text Editor showing table editing controls and an embedded code block inside a note."></p>
<p><img src="https://raw.githubusercontent.com/laurent22/joplin/dev/Assets/WebsiteAssets/images/news/20260111-rte2.png" alt="Mobile code block editor in Joplin with a Python code snippet displayed in an editable dialog."></p>
<h3>Easier tag management on mobile<a name="easier-tag-management-on-mobile" href="#easier-tag-management-on-mobile" class="heading-anchor">🔗</a></h3>
<p>Managing tags on mobile is now more practical. You can rename and delete tags directly from the app, and searching through tags is easier, helping keep large tag lists organised over time.</p>
<p><img src="https://raw.githubusercontent.com/laurent22/joplin/dev/Assets/WebsiteAssets/images/news/20260111-mobile-tags.png" alt="Joplin mobile tag management screen showing a tag options menu with rename and delete actions."></p>
<h3>Improved stability and usability on mobile devices<a name="improved-stability-and-usability-on-mobile-devices" href="#improved-stability-and-usability-on-mobile-devices" class="heading-anchor">🔗</a></h3>
<p>Several fixes improve overall stability and usability on mobile, particularly on smaller screens. Issues causing UI elements to appear off-screen have been addressed, and the app behaves more consistently in situations that previously caused hangs or visual glitches.</p>
<h2>Bug fixes and security fixes across platforms<a name="bug-fixes-and-security-fixes-across-platforms" href="#bug-fixes-and-security-fixes-across-platforms" class="heading-anchor">🔗</a></h2>
<h3>A large number of stability, correctness and security fixes<a name="a-large-number-of-stability-correctness-and-security-fixes" href="#a-large-number-of-stability-correctness-and-security-fixes" class="heading-anchor">🔗</a></h3>
<p>Joplin 3.5 includes about 114 bug fixes across desktop and mobile, addressing issues in editing, syncing, importing, rendering, and general stability. Many fixes target edge cases that could lead to crashes, inconsistent behaviour, or rare data loss scenarios. Moreover, this version includes several vulnerability fixes to make the applications more secure.</p>
]]></description><link>https://joplinapp.org/news/20260111-release-3-5</link><guid isPermaLink="false">20260111-release-3-5</guid><pubDate>Sun, 11 Jan 2026 00:00:00 GMT</pubDate><twitter-text></twitter-text></item><item><title><![CDATA[What's new in Joplin 3.4]]></title><description><![CDATA[<p>Joplin 3.4 includes many bug fixes and improvements, with a focus on the mobile app.</p>
<h2>Mobile<a name="mobile" href="#mobile" class="heading-anchor">🔗</a></h2>
<h3>Rich Text Editor<a name="rich-text-editor" href="#rich-text-editor" class="heading-anchor">🔗</a></h3>
<p>The mobile app now includes a beta <a href="https://joplinapp.org/help/apps/rich_text_editor">Rich Text Editor</a>! The new editor renders formatting/math/images within the editor:</p>
@@ -481,42 +524,4 @@ sys 0m38.013s</p>
<p>This is a bit of an extra constraint but it is hard to avoid. Contributor License Agreements are very common for GPL or AGPL projects. For example Apache, Canonical or Python all require their contributors to sign a CLA.</p>
<h2>Questions?<a name="questions" href="#questions" class="heading-anchor">🔗</a></h2>
<p>If you have any questions please let us know. Overall we believe this is a positive improvements for Joplin as it means any work derives from it will also benefit the project.</p>
]]></description><link>https://joplinapp.org/news/20221221-agpl</link><guid isPermaLink="false">20221221-agpl</guid><pubDate>Wed, 21 Dec 2022 00:00:00 GMT</pubDate><twitter-text>Joplin is switching to the GNU Affero General Public License v3 (AGPL-3.0)</twitter-text></item><item><title><![CDATA[What's new in Joplin 2.9]]></title><description><![CDATA[<h2>Proxy support<a name="proxy-support" href="#proxy-support" class="heading-anchor">🔗</a></h2>
<p>Both the desktop and mobile application now support proxies thanks to the work of Jason Williams. This will allow you to use the apps in particular when you are behind a company proxy.</p>
<p><img src="https://raw.githubusercontent.com/laurent22/joplin/dev/Assets/WebsiteAssets/images/news/20221216-proxy-support.png" alt=""></p>
<h2>New PDF viewer<a name="new-pdf-viewer" href="#new-pdf-viewer" class="heading-anchor">🔗</a></h2>
<p>The desktop application now features a new PDF viewer thanks to the work of Asrient during GSoC.</p>
<p>The main advantage for now is that this viewer preserves the last PDF page that was read. In the next version, the viewer will also include a way to annotate PDF files.</p>
<h2>Multi-language spell checking<a name="multi-language-spell-checking" href="#multi-language-spell-checking" class="heading-anchor">🔗</a></h2>
<p>The desktop app include a multi-language spell checking features, which allows you, for example, to spell-check notes in your native language and in English.</p>
<h2>New mobile text editor<a name="new-mobile-text-editor" href="#new-mobile-text-editor" class="heading-anchor">🔗</a></h2>
<p>Writing formatted notes on mobile has always been cumbersome due to the need to enter special format characters like <code>*</code> or <code>[</code>, etc.</p>
<p>Thanks to the work of Henry Heino during GSoC, writing notes on the go is now easier thanks to an improved Markdown editor.</p>
<p><img src="https://raw.githubusercontent.com/laurent22/joplin/dev/Assets/WebsiteAssets/images/news/20221216-mobile-beta-editor.png" alt=""></p>
<p>The most visible feature is the addition of a toolbar, which helps input those special characters, like on desktop.</p>
<p>Moreover Henry made a lot of subtle but useful improvements to the editor, for example to improve the note appearance, to improve list continuation, etc. Search within a note is now also supported as well as spell-checking.</p>
<p>At a more technical level, Henry also added many test units to ensure that the editor remains robust and reliable.</p>
<p>To enable the feature, go to the configuration screen and selected &quot;Opt-in to the editor beta&quot;. It is already very stable so we will probably promote it to be the main editor from the next version.</p>
<h2>Improved alignment of notebook icons<a name="improved-alignment-of-notebook-icons" href="#improved-alignment-of-notebook-icons" class="heading-anchor">🔗</a></h2>
<p>Previously, when you would assign an icon to a notebook, it would shift the title to the right, but notebook without an icon would not. It means that notebooks with and without an icon would not be vertically aligned.</p>
<p>To tidy things up, this new version adds a default icons to notebooks without an explicitly assigned icon. This result in the notebook titles being correctly vertically aligned.</p>
<p>Note that this feature is only enabled if you use custom icons - otherwise it will simply display the notebook titles without any default icons, as before.</p>
<p><img src="https://raw.githubusercontent.com/laurent22/joplin/dev/Assets/WebsiteAssets/images/news/20221216-notebook-icons.png" alt=""></p>
<h2>Improved handling of file attachments<a name="improved-handling-of-file-attachments" href="#improved-handling-of-file-attachments" class="heading-anchor">🔗</a></h2>
<p>Self Not Found made a number of small but useful improvements to attachment handling, including increasing the maximum size to 200MB, adding support for attaching multiple files, and fixing issues with synchronising attachments via proxy.</p>
<h2>Fixed filesystem sync on mobile<a name="fixed-filesystem-sync-on-mobile" href="#fixed-filesystem-sync-on-mobile" class="heading-anchor">🔗</a></h2>
<p>This was a long and complex change due to the need to support new Android APIs but hopefully that should now be working again, thanks to the work of jd1378.</p>
<p>So you can now sync again your notes with Syncthing and other file-based synchronisation systems.</p>
<h2>And more...<a name="and-more" href="#and-more" class="heading-anchor">🔗</a></h2>
<p>In total this new desktop version includes 36 improvements, bug fixes, and security fixes.</p>
<p>As always, a lot of work went into the Android and iOS app too, which include 37 improvements, bug fixes, and security fixes.</p>
<p>See here for the changelogs:</p>
<ul>
<li><a href="https://joplinapp.org/help/about/changelog/desktop">Desktop app changelog</a></li>
<li><a href="https://joplinapp.org/help/about/changelog/android/">Android app changelog</a></li>
</ul>
<h2>About the Android version<a name="about-the-android-version" href="#about-the-android-version" class="heading-anchor">🔗</a></h2>
<p>Unfortunately we cannot publish the Android version because it is based on a framework version that Google does not accept. To upgrade the app a lot of changes are needed and another round of pre-releases, and therefore there will not be a 2.9 version for Google Play. You may however download the official APK directly from there: <a href="https://github.com/laurent22/joplin-android/releases/tag/android-v2.9.8">Android 2.9 Official Release</a></p>
<p>This is the reality of app stores in general - small developers being imposed never ending new requirements by all-powerful companies, and by the time a version is finally ready we can't even publish it because yet more requirements are in place.</p>
<p>For the record the current 2.9 app works perfectly fine. It targets Android 11, which is only 2 years old and is still supported (and installed on millions of phones). Google requires us to target Android 12 which only came out last year.</p>
]]></description><link>https://joplinapp.org/news/20221216-release-2-9</link><guid isPermaLink="false">20221216-release-2-9</guid><pubDate>Fri, 16 Dec 2022 00:00:00 GMT</pubDate><twitter-text>What&apos;s new in Joplin 2.9</twitter-text></item></channel></rss>
]]></description><link>https://joplinapp.org/news/20221221-agpl</link><guid isPermaLink="false">20221221-agpl</guid><pubDate>Wed, 21 Dec 2022 00:00:00 GMT</pubDate><twitter-text>Joplin is switching to the GNU Affero General Public License v3 (AGPL-3.0)</twitter-text></item></channel></rss>

View File

@@ -9,7 +9,7 @@
"vips.dev": {
"platforms": ["aarch64-darwin"],
},
"nodejs": "24.5.0",
"nodejs": "24.8.0",
"pkg-config": "latest",
"python": "3.13.3",
"bat": "latest",

6
jest.config.base.js Normal file
View File

@@ -0,0 +1,6 @@
// This is the base Jest configuration - all
// jest.config.js files should inherit from it.
module.exports = {
watchman: false,
};

View File

@@ -16,6 +16,7 @@
"./packages/app-cli/**/*.mo": true,
"./packages/app-cli/**/build/": true,
"./packages/app-cli/**/config.json": true,
"**/.watchman-cookie-*": true,
"./packages/app-cli/**/linkToLocal.sh": true,
"./packages/app-cli/**/node_modules/": true,
"./packages/app-cli/**/out.txt": true,

View File

@@ -86,9 +86,9 @@
"gulp": "4.0.2",
"husky": "9.1.7",
"lerna": "3.22.1",
"lint-staged": "16.1.6",
"lint-staged": "16.2.6",
"madge": "8.0.0",
"npm-package-json-lint": "8.0.0",
"npm-package-json-lint": "9.0.0",
"typescript": "5.8.3"
},
"dependencies": {

View File

@@ -24,7 +24,10 @@
// 4. Remove tests one by one to narrow it down to the one with the async
// call that's causing problem.
const baseConfig = require('../../jest.config.base.js');
module.exports = {
...baseConfig,
testMatch: [
'**/tests/HtmlToHtml.js',
'**/tests/HtmlToMd.js',

View File

@@ -35,15 +35,15 @@
],
"owner": "Laurent Cozic"
},
"version": "3.5.1",
"version": "3.6.0",
"bin": "./main.js",
"engines": {
"node": ">=10.0.0"
},
"dependencies": {
"@joplin/lib": "~3.5",
"@joplin/renderer": "~3.5",
"@joplin/utils": "~3.5",
"@joplin/lib": "~3.6",
"@joplin/renderer": "~3.6",
"@joplin/utils": "~3.6",
"aws-sdk": "2.1340.0",
"chalk": "4.1.2",
"compare-version": "0.1.2",
@@ -70,7 +70,7 @@
"yargs-parser": "21.1.1"
},
"devDependencies": {
"@joplin/tools": "~3.5",
"@joplin/tools": "~3.6",
"@types/fs-extra": "11.0.4",
"@types/jest": "29.5.14",
"@types/node": "18.19.130",

View File

@@ -0,0 +1,8 @@
<div class="joplin-editable">
<span class="joplin-source" data-joplin-source-open="" data-joplin-source-close="">https://www.youtube.com/watch?v=iJqe9pC-z-Y</span>
<div class="joplin-youtube-player-rendered">
<iframe src="https://www.youtube-nocookie.com/embed/iJqe9pC-z-Y" title="YouTube video player" frameborder="0" allowfullscreen></iframe>
</div>
</div>

View File

@@ -0,0 +1 @@
https://www.youtube.com/watch?v=iJqe9pC-z-Y

Binary file not shown.

View File

@@ -1,7 +1,7 @@
{
"manifest_version": 3,
"name": "Joplin Web Clipper [DEV]",
"version": "3.5.0",
"version": "3.6.0",
"description": "Capture and save web pages and screenshots from your browser to Joplin.",
"homepage_url": "https://joplinapp.org",
"content_security_policy": {

View File

@@ -260,6 +260,15 @@ export default class ElectronAppWrapper {
require('@electron/remote/main').enable(this.win_.webContents);
// Add Referer header for YouTube embeds to fix Error 153
this.win_.webContents.session.webRequest.onBeforeSendHeaders(
{ urls: ['*://*.youtube.com/*', '*://*.youtube-nocookie.com/*'] },
(details, callback) => {
details.requestHeaders['Referer'] = 'https://joplinapp.org/';
callback({ requestHeaders: details.requestHeaders });
},
);
if (!screen.getDisplayMatching(this.win_.getBounds())) {
const { width: windowWidth, height: windowHeight } = this.win_.getBounds();
const { width: primaryDisplayWidth, height: primaryDisplayHeight } = screen.getPrimaryDisplay().workArea;

View File

@@ -95,6 +95,9 @@ export default class InteropServiceHelper {
// Allows users to override the CSS page size.
// See https://github.com/laurent22/joplin/issues/13096
preferCSSPageSize: true,
// Include accessibility information in the output:
generateTaggedPDF: true,
});
resolve(data);
} catch (error) {

View File

@@ -742,7 +742,7 @@ const TinyMCE = (props: NoteBodyEditorProps, ref: Ref<NoteBodyEditorRef>) => {
'media-src \'self\' blob: data: *', // Audio and video players
// Disallow certain unused features
'child-src \'none\'', // Should not contain sub-frames
'child-src https://*.youtube.com https://*.youtube-nocookie.com', // Allow YouTube embeds
'object-src \'none\'', // Objects can be used for script injection
'form-action \'none\'', // No submitting forms

View File

@@ -22,4 +22,8 @@ export const joplinCommandToTinyMceCommands: JoplinCommandToTinyMceCommands = {
'search': { name: 'SearchReplace' },
'attachFile': { name: 'joplinAttach' },
'insertDateTime': true,
'textCopy': true,
'textCut': true,
'textPaste': true,
'textSelectAll': true,
};

View File

@@ -51,6 +51,15 @@ function newBlockSource(language = '', content = '', previousSource: SourceInfo
} else {
fence = '$$';
}
} else if (language === 'frontmatter') {
// Frontmatter uses --- delimiters instead of code fences
return {
openCharacters: '---\n',
closeCharacters: '\n---\n',
content: content,
node: null,
language: language,
};
}
const fenceLanguage = language === 'katex' ? '' : language;

View File

@@ -17,19 +17,19 @@ describe('editorCommandDeclarations', () => {
test.each([
[
{},
true,
{ textBold: true },
],
[
{
markdownEditorPaneVisible: false,
},
false,
{ textBold: false },
],
[
{
noteIsReadOnly: true,
},
false,
{ textBold: false },
],
[
// In the Markdown editor, but only the viewer is visible
@@ -37,7 +37,7 @@ describe('editorCommandDeclarations', () => {
markdownEditorPaneVisible: false,
richTextEditorVisible: false,
},
false,
{ textBold: false },
],
[
// In the Markdown editor, and the viewer is visible
@@ -45,7 +45,7 @@ describe('editorCommandDeclarations', () => {
markdownEditorPaneVisible: true,
richTextEditorVisible: false,
},
true,
{ textBold: true },
],
[
// In the RT editor
@@ -53,7 +53,7 @@ describe('editorCommandDeclarations', () => {
markdownEditorPaneVisible: false,
richTextEditorVisible: true,
},
true,
{ textBold: true },
],
[
// In the Markdown editor, and the command palette is visible
@@ -63,14 +63,57 @@ describe('editorCommandDeclarations', () => {
gotoAnythingVisible: true,
modalDialogVisible: true,
},
true,
{ textBold: true },
],
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
])('should create the enabledCondition', (context: Record<string, any>, expected: boolean) => {
const condition = enabledCondition('textBold');
const wc = new WhenClause(condition);
const actual = wc.evaluate({ ...baseContext, ...context });
expect(actual).toBe(expected);
[
// In the Markdown editor, and the command palette is visible
{
markdownEditorPaneVisible: true,
richTextEditorVisible: false,
gotoAnythingVisible: true,
modalDialogVisible: true,
},
{ textBold: true },
],
[
// Rich Text Editor, HTML note
{
markdownEditorPaneVisible: false,
richTextEditorVisible: true,
noteIsMarkdown: false,
},
{
textCopy: true,
textPaste: true,
textSelectAll: true,
},
],
[
// Rich Text Editor, read-only note
{
markdownEditorPaneVisible: false,
richTextEditorVisible: true,
noteIsReadOnly: true,
},
{
textBold: false,
textPaste: false,
// TODO: textCopy should be enabled in read-only notes:
// textCopy: false,
},
],
])('should correctly determine whether command is enabled (case %#)', (context, expectedStates) => {
const actualStates = [];
for (const commandName of Object.keys(expectedStates)) {
const condition = enabledCondition(commandName);
const wc = new WhenClause(condition);
const actual = wc.evaluate({ ...baseContext, ...context });
actualStates.push([commandName, actual]);
}
const expectedStatesArray = Object.entries(expectedStates);
expect(actualStates).toEqual(expectedStatesArray);
});
});

View File

@@ -4,6 +4,10 @@ import { joplinCommandToTinyMceCommands } from './NoteBody/TinyMCE/utils/joplinC
const workWithHtmlNotes = [
'attachFile',
'textCopy',
'textCut',
'textPaste',
'textSelectAll',
];
export const enabledCondition = (commandName: string) => {

View File

@@ -8,7 +8,7 @@
default-src 'self' joplin-content://* ;
connect-src 'self' * http://* https://* joplin-content://* blob: ;
style-src 'unsafe-inline' 'self' blob: joplin-content://* https://* http://* ;
child-src 'self' joplin-content://* ;
child-src 'self' joplin-content://* https://*.youtube.com https://*.youtube-nocookie.com ;
script-src 'self' 'unsafe-inline' joplin-content://* ;
media-src 'self' * blob: data: https://* http://* joplin-content://* ;
img-src 'self' blob: data: http://* https://* joplin-content://* ;

View File

@@ -1,7 +1,10 @@
// For a detailed explanation regarding each configuration property, visit:
// https://jestjs.io/docs/en/configuration.html
const baseConfig = require('../../jest.config.base.js');
module.exports = {
...baseConfig,
// All imported modules in your tests should be mocked automatically
// automock: false,
@@ -128,7 +131,9 @@ module.exports = {
testEnvironment: 'jsdom',
// Options that will be passed to the testEnvironment
// testEnvironmentOptions: {},
testEnvironmentOptions: {
customExportConditions: ['node', 'require'],
},
// Adds a location field to test results
// testLocationInResults: false,

View File

@@ -1,6 +1,6 @@
{
"name": "@joplin/app-desktop",
"version": "3.5.11",
"version": "3.6.2",
"description": "Joplin for Desktop",
"main": "main.bundle.js",
"private": true,
@@ -92,6 +92,12 @@
"x64"
]
},
{
"target": "pkg",
"arch": [
"x64"
]
},
{
"target": "zip",
"arch": [
@@ -139,19 +145,19 @@
"@electron/rebuild": "3.7.2",
"@fortawesome/fontawesome-free": "5.15.4",
"@joeattardi/emoji-button": "4.6.4",
"@joplin/default-plugins": "~3.5",
"@joplin/editor": "~3.5",
"@joplin/lib": "~3.5",
"@joplin/renderer": "~3.5",
"@joplin/tools": "~3.5",
"@joplin/utils": "~3.5",
"@joplin/default-plugins": "~3.6",
"@joplin/editor": "~3.6",
"@joplin/lib": "~3.6",
"@joplin/renderer": "~3.6",
"@joplin/tools": "~3.6",
"@joplin/utils": "~3.6",
"@playwright/test": "1.55.1",
"@sentry/electron": "4.24.0",
"@testing-library/react-hooks": "8.0.1",
"@types/jest": "29.5.14",
"@types/mustache": "4.2.6",
"@types/node": "18.19.130",
"@types/react": "18.3.25",
"@types/react": "18.3.26",
"@types/react-dom": "18.3.7",
"@types/react-redux": "7.1.33",
"@types/styled-components": "5.1.32",
@@ -163,7 +169,7 @@
"debounce": "1.2.1",
"electron": "39.2.3",
"electron-builder": "24.13.3",
"electron-updater": "6.6.2",
"electron-updater": "6.6.8",
"electron-window-state": "5.0.3",
"esbuild": "^0.25.3",
"formatcoords": "1.1.3",
@@ -208,7 +214,7 @@
},
"dependencies": {
"@electron/remote": "2.1.3",
"@joplin/onenote-converter": "~3.5",
"@joplin/onenote-converter": "~3.6",
"fs-extra": "11.3.2",
"keytar": "7.9.0",
"node-fetch": "2.6.7",

View File

@@ -140,7 +140,10 @@ export default class AutoUpdaterService implements AutoUpdaterServiceInterface {
// electron's autoUpdater appends automatically the platform's yml file to the link so we should remove it
assetUrl = assetUrl.substring(0, assetUrl.lastIndexOf('/'));
autoUpdater.setFeedURL({ provider: 'generic', url: assetUrl });
await autoUpdater.checkForUpdates();
const result = await autoUpdater.checkForUpdates();
// Wait for the installation to finish. By default, .checkForUpdates runs in the background
await result.downloadPromise;
} catch (error) {
this.logger_.error(`Update download url failed: ${error.message}`);
this.isUpdateInProgress = false;

View File

@@ -0,0 +1,90 @@
'use strict';
Object.defineProperty(exports, '__esModule', { value: true });
exports.default = notarizeFile;
const fs_1 = require('fs');
const notarize_1 = require('@electron/notarize');
const execCommand = require('./execCommand');
const child_process_1 = require('child_process');
const util_1 = require('util');
const execAsync = (0, util_1.promisify)(child_process_1.exec);
// Same appId in electron-builder.
const appId = 'net.cozic.joplin-desktop';
function isDesktopAppTag(tagName) {
if (!tagName) { return false; }
return tagName[0] === 'v';
}
async function notarizeFile(filePath) {
if (process.platform !== 'darwin') { return; }
console.info(`Checking if notarization should be done on: ${filePath}`);
if (!process.env.IS_CONTINUOUS_INTEGRATION || !isDesktopAppTag(process.env.GIT_TAG_NAME)) {
console.info(`Either not running in CI or not processing a desktop app tag - skipping notarization. process.env.IS_CONTINUOUS_INTEGRATION = ${process.env.IS_CONTINUOUS_INTEGRATION}; process.env.GIT_TAG_NAME = ${process.env.GIT_TAG_NAME}`);
return;
}
if (!process.env.APPLE_ID || !process.env.APPLE_ID_PASSWORD) {
console.warn('Environment variables APPLE_ID and APPLE_ID_PASSWORD not found - notarization will NOT be done.');
return;
}
if (!(0, fs_1.existsSync)(filePath)) {
throw new Error(`Cannot find file at: ${filePath}`);
}
// Every x seconds we print something to stdout, otherwise CI may timeout
// the task after 10 minutes, and Apple notarization can take more time.
const waitingIntervalId = setInterval(() => {
console.info('.');
}, 60000);
const isPkg = filePath.endsWith('.pkg');
console.info(`Notarizing ${filePath}`);
try {
if (isPkg) {
await execAsync(`xcrun notarytool submit "${filePath}" ` +
`--apple-id "${process.env.APPLE_ID}" ` +
`--password "${process.env.APPLE_ID_PASSWORD}" ` +
`--team-id "${process.env.APPLE_ASC_PROVIDER}" ` +
'--wait', { maxBuffer: 1024 * 1024 });
} else {
await (0, notarize_1.notarize)({
appBundleId: appId,
appPath: filePath,
// Apple Developer email address
appleId: process.env.APPLE_ID,
// App-specific password: https://support.apple.com/en-us/HT204397
appleIdPassword: process.env.APPLE_ID_PASSWORD,
// When Apple ID is attached to multiple providers (eg if the
// account has been used to build multiple apps for different
// companies), in that case the provider "Team Short Name" (also
// known as "ProviderShortname") must be provided.
//
// Use this to get it:
//
// xcrun altool --list-providers -u APPLE_ID -p APPLE_ID_PASSWORD
// ascProvider: process.env.APPLE_ASC_PROVIDER,
// In our case, the team ID is the same as the legacy ASC_PROVIDER
teamId: process.env.APPLE_ASC_PROVIDER,
tool: 'notarytool',
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
});
}
} catch (error) {
console.error(error);
process.exit(1);
}
clearInterval(waitingIntervalId);
// It appears that electron-notarize doesn't staple the app, but without
// this we were still getting the malware warning when launching the app.
// Stapling the app means attaching the notarization ticket to it, so that
// if the user is offline, macOS can still check if the app was notarized.
// So it seems to be more or less optional, but at least in our case it
// wasn't.
console.info('Stapling notarization ticket to the file...');
const staplerCmd = `xcrun stapler staple "${filePath}"`;
console.info(`> ${staplerCmd}`);
console.info(await execCommand(staplerCmd));
console.info(`Validating stapled file: ${filePath}`);
try {
await execAsync(`spctl -a -vv -t install "${filePath}"`);
} catch (error) {
console.error(`Failed validating stapled file: ${filePath}:`, error);
}
console.info(`Done notarizing ${filePath}`);
}
// # sourceMappingURL=notarizeFile.js.map

View File

@@ -90,7 +90,7 @@ android {
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 2097788
versionName "3.5.8"
versionName "3.6.0"
ndk {
abiFilters "armeabi-v7a", "x86", "arm64-v8a", "x86_64"
}
@@ -132,6 +132,17 @@ android {
minifyEnabled enableProguardInReleaseBuilds
proguardFiles getDefaultProguardFile("proguard-android.txt"), "proguard-rules.pro"
}
profileable {
// Release-like build that allows profiling with Android Studio Profiler
initWith release
signingConfig signingConfigs.debug
// Required for Android Studio Profiler to attach
debuggable false
// Keeps symbols for better stack traces in profiler
minifyEnabled false
// Use release variants of dependencies that don't have profileable
matchingFallbacks = ['release']
}
}
}

View File

@@ -46,6 +46,9 @@
android:theme="@style/AppTheme"
android:supportsRtl="true">
<!-- Enable profiling in release builds (Android 10+) -->
<profileable android:shell="true" />
<!--
2018-12-16: Changed android:launchMode from "singleInstance" to "singleTop" for Firebase notification
Previously singleInstance was necessary to prevent multiple instance of the RN app from running at the same time, but maybe no longer needed.

View File

@@ -438,9 +438,8 @@ const useInputEventHandlers = ({
const onSubmit = useCallback(() => {
if (selectedResult) {
onItemSelected(selectedResult, selectedIndex);
setSearch('');
}
}, [onItemSelected, selectedResult, selectedIndex, setSearch]);
}, [onItemSelected, selectedResult, selectedIndex]);
// For now, onKeyPress only works on web.
// See https://github.com/react-native-community/discussions-and-proposals/issues/249

View File

@@ -694,10 +694,17 @@ class ScreenHeaderComponent extends PureComponent<ScreenHeaderProps, ScreenHeade
</Menu>
);
// Updating the state of this component can result in the left most element becoming hidden, so add a dummy as the first element to prevent this
// See https://github.com/laurent22/joplin/issues/14153
const zeroWidthSpacer = (
<View style={{ width: 0 }} pointerEvents="none"/>
);
return (
<View style={this.styles().outerContainer}>
<View style={this.styles().aboveHeader}/>
<View style={this.styles().innerContainer}>
{zeroWidthSpacer}
{sideMenuComp}
{backButtonComp}
{renderUndoButton()}

View File

@@ -511,7 +511,7 @@
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_ENTITLEMENTS = Joplin/Joplin.entitlements;
CURRENT_PROJECT_VERSION = 148;
CURRENT_PROJECT_VERSION = 149;
DEVELOPMENT_TEAM = A9BXAFS6CT;
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Joplin/Info.plist;
@@ -520,7 +520,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 13.5.2;
MARKETING_VERSION = 13.6.0;
OTHER_LDFLAGS = (
"$(inherited)",
"-ObjC",
@@ -546,7 +546,7 @@
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_ENTITLEMENTS = Joplin/Joplin.entitlements;
CURRENT_PROJECT_VERSION = 148;
CURRENT_PROJECT_VERSION = 149;
DEVELOPMENT_TEAM = A9BXAFS6CT;
INFOPLIST_FILE = Joplin/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 15.6;
@@ -554,7 +554,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 13.5.2;
MARKETING_VERSION = 13.6.0;
OTHER_LDFLAGS = (
"$(inherited)",
"-ObjC",
@@ -747,7 +747,7 @@
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 148;
CURRENT_PROJECT_VERSION = 149;
DEBUG_INFORMATION_FORMAT = dwarf;
DEVELOPMENT_TEAM = A9BXAFS6CT;
GCC_C_LANGUAGE_STANDARD = gnu11;
@@ -758,7 +758,7 @@
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
MARKETING_VERSION = 13.5.2;
MARKETING_VERSION = 13.6.0;
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
MTL_FAST_MATH = YES;
OTHER_LDFLAGS = (
@@ -790,7 +790,7 @@
CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements;
CODE_SIGN_STYLE = Automatic;
COPY_PHASE_STRIP = NO;
CURRENT_PROJECT_VERSION = 148;
CURRENT_PROJECT_VERSION = 149;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
DEVELOPMENT_TEAM = A9BXAFS6CT;
GCC_C_LANGUAGE_STANDARD = gnu11;
@@ -801,7 +801,7 @@
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
MARKETING_VERSION = 13.5.2;
MARKETING_VERSION = 13.6.0;
MTL_FAST_MATH = YES;
OTHER_LDFLAGS = (
"$(inherited)",

View File

@@ -1406,7 +1406,7 @@ PODS:
- React-jsiexecutor
- React-RCTFBReactNativeSpec
- ReactCommon/turbomodule/core
- react-native-alarm-notification (3.5.0):
- react-native-alarm-notification (3.6.0):
- React
- react-native-document-picker (10.1.7):
- DoubleConversion
@@ -1514,7 +1514,7 @@ PODS:
- Yoga
- react-native-rsa-native (2.0.5):
- React
- react-native-saf-x (3.5.1):
- react-native-saf-x (3.6.0):
- React-Core
- react-native-safe-area-context (5.6.1):
- React-Core
@@ -1904,7 +1904,7 @@ PODS:
- React-Core
- RNDateTimePicker (8.4.5):
- React-Core
- RNDeviceInfo (14.0.4):
- RNDeviceInfo (14.1.1):
- React-Core
- RNExitApp (2.0.0):
- React-Core
@@ -1912,7 +1912,7 @@ PODS:
- React-Core
- RNFS (2.20.0):
- React-Core
- RNLocalize (3.5.2):
- RNLocalize (3.5.4):
- React-Core
- RNQuickAction (0.3.13):
- React
@@ -2355,7 +2355,7 @@ SPEC CHECKSUMS:
React-logger: 8edfcedc100544791cd82692ca5a574240a16219
React-Mapbuffer: c3f4b608e4a59dd2f6a416ef4d47a14400194468
React-microtasksnativemodule: 054f34e9b82f02bd40f09cebd4083828b5b2beb6
react-native-alarm-notification: a4326a743df72a94d361a4c3a21515556f650341
react-native-alarm-notification: 846df1df72eca38e711409b9c064a5c635ff1c32
react-native-document-picker: b6419b766863408dacbdf5e97b2f3a694c611150
react-native-geolocation: ec15ffebc53790314885eb9e5f2132132fbc2600
react-native-get-random-values: d16467cf726c618e9c7a8c3c39c31faa2244bbba
@@ -2364,7 +2364,7 @@ SPEC CHECKSUMS:
react-native-netinfo: cec9c4e86083cb5b6aba0e0711f563e2fbbff187
react-native-quick-crypto: b475b71e7fa4dbf3446be55e8ad4ef2c58ac4f7f
react-native-rsa-native: a7931cdda1f73a8576a46d7f431378c5550f0c38
react-native-saf-x: 404f0f9a29cc6bf21d88582e054c45a11b28c22b
react-native-saf-x: 50d176763ed692b379c190bf55ae7293a3ee09bb
react-native-safe-area-context: 2243039f43d10cb1ea30ec5ac57fc6d1448413f4
react-native-sqlite-storage: 0c84826214baaa498796c7e46a5ccc9a82e114ed
react-native-vector-icons: a45ecc326ec090450f152dfc7076ce1173331ce5
@@ -2409,11 +2409,11 @@ SPEC CHECKSUMS:
RNCClipboard: f6679d470d0da2bce2a37b0af7b9e0bf369ecda5
RNCPushNotificationIOS: 6c4ca3388c7434e4a662b92e4dfeeee858e6f440
RNDateTimePicker: 8c12d12e8660697c2e176d2f98775764431c141f
RNDeviceInfo: d863506092aef7e7af3a1c350c913d867d795047
RNDeviceInfo: bcce8752b5043a623fe3c26789679b473f705d3c
RNExitApp: 4432b9b7cc5ccec9f91c94e507849891282befd4
RNFileViewer: 4b5d83358214347e4ab2d4ca8d5c1c90d869e251
RNFS: 89de7d7f4c0f6bafa05343c578f61118c8282ed8
RNLocalize: 3c4d0abd777a546fa77bdb6caef85a87fb9ea349
RNLocalize: d7859f87f1083349c73aa089e360af33ef89efc2
RNQuickAction: c2c8f379e614428be0babe4d53a575739667744d
RNSecureRandom: b64d263529492a6897e236a22a2c4249aa1b53dc
RNShare: 40ace3f87cd881869e8085aced9dc16b425c74aa

View File

@@ -1,4 +1,8 @@
const baseConfig = require('../../jest.config.base.js');
module.exports = {
...baseConfig,
preset: 'react-native',
'moduleFileExtensions': [

View File

@@ -2,7 +2,7 @@
"name": "@joplin/app-mobile",
"description": "Joplin for Mobile",
"license": "AGPL-3.0-or-later",
"version": "3.5.0",
"version": "3.6.0",
"private": true,
"scripts": {
"start": "BROWSERSLIST_IGNORE_OLD_DATA=true react-native start --reset-cache",
@@ -22,24 +22,24 @@
},
"dependencies": {
"@bam.tech/react-native-image-resizer": "3.0.11",
"@joplin/editor": "~3.5",
"@joplin/lib": "~3.5",
"@joplin/react-native-alarm-notification": "~3.5",
"@joplin/react-native-saf-x": "~3.5",
"@joplin/renderer": "~3.5",
"@joplin/utils": "~3.5",
"@joplin/editor": "~3.6",
"@joplin/lib": "~3.6",
"@joplin/react-native-alarm-notification": "~3.6",
"@joplin/react-native-saf-x": "~3.6",
"@joplin/renderer": "~3.6",
"@joplin/utils": "~3.6",
"@js-draw/material-icons": "1.33.0",
"@react-native-clipboard/clipboard": "1.16.3",
"@react-native-community/datetimepicker": "8.4.5",
"@react-native-community/datetimepicker": "8.4.7",
"@react-native-community/geolocation": "3.4.0",
"@react-native-community/netinfo": "11.4.1",
"@react-native-community/push-notification-ios": "1.11.0",
"@react-native-documents/picker": "10.1.7",
"@react-native-vector-icons/fontawesome5": "12.3.0",
"@react-native-vector-icons/fontawesome5": "patch:@react-native-vector-icons/fontawesome5@npm%3A12.3.0#~/.yarn/patches/@react-native-vector-icons-fontawesome5-npm-12.3.0-a1ca46610f.patch",
"@react-native-vector-icons/get-image": "12.3.0",
"@react-native-vector-icons/ionicons": "12.3.0",
"@react-native-vector-icons/material-design-icons": "12.4.0",
"@react-native-vector-icons/material-icons": "12.4.0",
"@react-native-vector-icons/ionicons": "patch:@react-native-vector-icons/ionicons@npm%3A12.3.0#~/.yarn/patches/@react-native-vector-icons-ionicons-npm-12.3.0-9bd4746f3f.patch",
"@react-native-vector-icons/material-design-icons": "patch:@react-native-vector-icons/material-design-icons@npm%3A12.4.0#~/.yarn/patches/@react-native-vector-icons-material-design-icons-npm-12.4.0-890f7f618b.patch",
"@react-native-vector-icons/material-icons": "patch:@react-native-vector-icons/material-icons@npm%3A12.4.0#~/.yarn/patches/@react-native-vector-icons-material-icons-npm-12.4.0-94138e627b.patch",
"assert-browserify": "2.0.0",
"buffer": "6.0.3",
"color": "3.2.1",
@@ -59,21 +59,21 @@
"punycode": "2.3.1",
"react": "19.0.0",
"react-native": "0.79.2",
"react-native-device-info": "14.0.4",
"react-native-device-info": "14.1.1",
"react-native-dropdownalert": "5.2.0",
"react-native-exit-app": "2.0.0",
"react-native-file-viewer": "2.1.5",
"react-native-fs": "2.20.0",
"react-native-get-random-values": "1.11.0",
"react-native-image-picker": "8.2.1",
"react-native-localize": "3.5.2",
"react-native-localize": "3.5.4",
"react-native-modal-datetime-picker": "18.0.0",
"react-native-paper": "5.14.5",
"react-native-popup-menu": "0.17.0",
"react-native-quick-actions": "0.3.13",
"react-native-quick-crypto": "0.7.17",
"react-native-rsa-native": "2.0.5",
"react-native-safe-area-context": "5.6.1",
"react-native-safe-area-context": "5.6.2",
"react-native-securerandom": "1.0.1",
"react-native-share": "12.2.0",
"react-native-sqlite-storage": "6.0.1",
@@ -97,7 +97,7 @@
"@babel/plugin-transform-export-namespace-from": "7.27.1",
"@babel/preset-env": "7.25.3",
"@babel/runtime": "7.25.0",
"@joplin/tools": "~3.5",
"@joplin/tools": "~3.6",
"@joplin/turndown": "~4.0.80",
"@joplin/turndown-plugin-gfm": "~1.0.62",
"@pmmmwh/react-refresh-webpack-plugin": "^0.6.0",
@@ -114,13 +114,13 @@
"@types/node": "18.19.130",
"@types/react": "19.0.14",
"@types/react-redux": "7.1.33",
"@types/serviceworker": "0.0.157",
"@types/serviceworker": "0.0.164",
"@types/tar-stream": "3.1.4",
"babel-jest": "29.7.0",
"babel-loader": "9.1.3",
"babel-plugin-module-resolver": "4.1.0",
"babel-plugin-react-native-web": "0.21.1",
"esbuild": "0.25.10",
"babel-plugin-react-native-web": "0.21.2",
"esbuild": "0.25.12",
"fast-deep-equal": "3.1.3",
"fs-extra": "11.3.2",
"gulp": "4.0.2",
@@ -131,8 +131,8 @@
"nodemon": "3.1.10",
"punycode": "2.3.1",
"react-dom": "19.0.0",
"react-native-web": "0.21.1",
"react-refresh": "0.17.0",
"react-native-web": "0.21.2",
"react-refresh": "0.18.0",
"react-test-renderer": "19.0.0",
"sharp": "0.34.4",
"sqlite3": "5.1.6",

View File

@@ -1,6 +1,6 @@
import PluginAssetsLoader from '../PluginAssetsLoader';
import AlarmService from '@joplin/lib/services/AlarmService';
import Logger, { TargetType } from '@joplin/utils/Logger';
import Logger, { LogLevel, TargetType } from '@joplin/utils/Logger';
import BaseModel from '@joplin/lib/BaseModel';
import BaseService from '@joplin/lib/services/BaseService';
import ResourceService from '@joplin/lib/services/ResourceService';
@@ -200,11 +200,8 @@ const buildStartupTasks = (
const mainLogger = new Logger();
mainLogger.addTarget(TargetType.Database, { database: logDatabase, source: 'm' });
mainLogger.setLevel(Logger.LEVEL_INFO);
if (Setting.value('env') === 'dev') {
mainLogger.addTarget(TargetType.Console);
mainLogger.setLevel(Logger.LEVEL_DEBUG);
}
mainLogger.addTarget(TargetType.Console);
mainLogger.setLevel(Setting.value('env') === 'dev' ? LogLevel.Debug : LogLevel.Info);
Logger.initializeGlobalLogger(mainLogger);
initLib(mainLogger);

View File

@@ -12,7 +12,7 @@
default-src 'self' ;
connect-src 'self' * http://* https://* blob: ;
style-src 'unsafe-inline' 'self' blob: ;
child-src 'self' ;
child-src 'self' https://*.youtube.com https://*.youtube-nocookie.com ;
script-src 'self' 'unsafe-eval' 'unsafe-inline' ;
media-src 'self' blob: data: https://* http://* ;
img-src 'self' blob: data: http://* https://* ;

View File

@@ -1,6 +1,6 @@
{
"name": "@joplin/default-plugins",
"version": "3.5.0",
"version": "3.6.0",
"description": "Default plugins bundler",
"private": true,
"scripts": {
@@ -13,13 +13,13 @@
"url": "git+https://github.com/laurent22/joplin.git"
},
"devDependencies": {
"@types/yargs": "17.0.33",
"joplin-plugin-freehand-drawing": "4.2.0",
"@types/yargs": "17.0.34",
"joplin-plugin-freehand-drawing": "4.3.0",
"ts-node": "10.9.2",
"typescript": "5.8.3"
},
"dependencies": {
"@joplin/utils": "~3.5",
"@joplin/utils": "~3.6",
"fs-extra": "11.3.2",
"yargs": "17.7.2"
}

View File

@@ -7,6 +7,7 @@ import { deleteMarkupBackward, markdown, markdownLanguage } from '@codemirror/la
import { GFM as GitHubFlavoredMarkdownExtension } from '@lezer/markdown';
import markdownMathExtension from './extensions/markdownMathExtension';
import markdownHighlightExtension from './extensions/markdownHighlightExtension';
import markdownFrontMatterExtension from './extensions/markdownFrontMatterExtension';
import lookUpLanguage from './utils/markdown/codeBlockLanguages/lookUpLanguage';
import { html } from '@codemirror/lang-html';
import { defaultKeymap, emacsStyleKeymap } from '@codemirror/commands';
@@ -30,6 +31,9 @@ const configFromSettings = (settings: EditorSettings, context: RenderedContentCo
extensions: [
GitHubFlavoredMarkdownExtension,
// FrontMatter support (YAML blocks at start of document)
markdownFrontMatterExtension,
settings.markdownMarkEnabled ? markdownHighlightExtension : [],
// Don't highlight KaTeX if the user disabled it

View File

@@ -3,39 +3,17 @@ import { EditorSelection } from '@codemirror/state';
import { EditorView } from '@codemirror/view';
import uslug from '@joplin/fork-uslug/lib/uslug';
import { SyntaxNodeRef } from '@lezer/common';
import htmlNodeInfo from '../utils/htmlNodeInfo';
const jumpToHash = (view: EditorView, hash: string) => {
const state = view.state;
const timeout = 1_000; // Maximum time to spend parsing the syntax tree
let targetLocation: number|undefined = undefined;
const removeQuotes = (quoted: string) => quoted.replace(/^["'](.*)["']$/, '$1');
const makeEnterNode = (offset: number) => (node: SyntaxNodeRef) => {
const nodeToText = (node: SyntaxNodeRef) => {
return state.sliceDoc(node.from + offset, node.to + offset);
};
// Returns the attribute with the given name for [node]
const getHtmlNodeAttr = (node: SyntaxNodeRef, attrName: string) => {
if (node.from === node.to) return null; // Empty
const content = node.node.resolveInner(node.from + 1);
// Search for the "id" attribute
const attributes = content.getChildren('Attribute');
for (const attribute of attributes) {
const nameNode = attribute.getChild('AttributeName');
const valueNode = attribute.getChild('AttributeValue');
if (nameNode && valueNode) {
const name = nodeToText(nameNode).toLowerCase().replace(/^"(.*)"$/, '$1');
if (name === attrName) {
return removeQuotes(nodeToText(valueNode));
}
}
}
return null;
};
const found = targetLocation !== undefined;
if (found) return false; // Skip this node
@@ -46,13 +24,14 @@ const jumpToHash = (view: EditorView, hash: string) => {
.replace(/^#+\s/, '') // Leading #s in headers
.replace(/\n-+$/, ''); // Trailing --s in headers
matches = hash === uslug(nodeText);
} else if (node.name === 'HTMLTag' || node.name === 'HTMLBlock') {
} else if (node.name === 'HTMLBlock') {
// CodeMirror adds HTML information to Markdown documents using overlays attached
// to HTMLTag and HTMLBlock nodes.
// Use .enter to enter the overlay and visit the HTML nodes:
node.node.enter(node.from, 1).toTree().iterate({ enter: makeEnterNode(node.from) });
} else if (node.name === 'OpenTag') {
matches = getHtmlNodeAttr(node, 'id') === hash || getHtmlNodeAttr(node, 'name') === hash;
} else if (node.name === 'OpenTag' || node.name === 'HTMLTag') {
const htmlNodeDetails = htmlNodeInfo(node, state);
matches = htmlNodeDetails.getAttr('id') === hash || htmlNodeDetails.getAttr('name') === hash;
}
if (matches) {

View File

@@ -32,6 +32,10 @@ const mathBlockDecoration = Decoration.line({
attributes: { class: 'cm-mathBlock', ...noSpellCheckAttrs },
});
const frontMatterDecoration = Decoration.line({
attributes: { class: 'cm-frontMatter', ...noSpellCheckAttrs },
});
const inlineMathDecoration = Decoration.mark({
attributes: { class: 'cm-inlineMath', ...noSpellCheckAttrs },
});
@@ -116,6 +120,7 @@ const nodeNameToLineDecoration: Record<string, Decoration> = {
'FencedCode': codeBlockDecoration,
'CodeBlock': codeBlockDecoration,
'BlockMath': mathBlockDecoration,
'FrontMatter': frontMatterDecoration,
'Blockquote': blockQuoteDecoration,
'OrderedList': orderedListDecoration,
'BulletList': unorderedListDecoration,
@@ -152,6 +157,7 @@ const multilineNodes = {
'FencedCode': true,
'CodeBlock': true,
'BlockMath': true,
'FrontMatter': true,
'Blockquote': true,
'OrderedList': true,
'BulletList': true,

View File

@@ -0,0 +1,105 @@
import { EditorSelection, EditorState } from '@codemirror/state';
import { frontMatterTagName, frontMatterContentTagName, frontMatterMarkerTagName } from './markdownFrontMatterExtension';
import createTestEditor from '../testing/createTestEditor';
import findNodesWithName from '../testing/findNodesWithName';
// Creates an EditorState with FrontMatter and markdown extensions
const createEditorState = async (initialText: string, expectedTags: string[]): Promise<EditorState> => {
return (await createTestEditor(initialText, EditorSelection.cursor(0), expectedTags)).state;
};
describe('MarkdownFrontMatterExtension', () => {
jest.retryTimes(2);
it('should parse a basic FrontMatter block at the start of the document', async () => {
const documentText = '---\ntitle: Test\n---\n\n# Heading';
const editor = await createEditorState(documentText, [frontMatterTagName, 'ATXHeading1']);
const frontMatterNodes = findNodesWithName(editor, frontMatterTagName);
expect(frontMatterNodes.length).toBe(1);
expect(frontMatterNodes[0].from).toBe(0);
expect(frontMatterNodes[0].to).toBe('---\ntitle: Test\n---'.length);
});
it('should parse FrontMatter with multiple properties', async () => {
const frontMatter = '---\ntitle: Test\ndate: 2024-01-01\ntags: [one, two]\n---';
const documentText = `${frontMatter}\n\nContent here.`;
const editor = await createEditorState(documentText, [frontMatterTagName]);
const frontMatterNodes = findNodesWithName(editor, frontMatterTagName);
expect(frontMatterNodes.length).toBe(1);
expect(frontMatterNodes[0].from).toBe(0);
expect(frontMatterNodes[0].to).toBe(frontMatter.length);
});
it('should not parse FrontMatter if not at document start', async () => {
const documentText = 'Some text\n\n---\ntitle: Test\n---';
const editor = await createEditorState(documentText, ['Paragraph']);
const frontMatterNodes = findNodesWithName(editor, frontMatterTagName);
// Should not be recognized as FrontMatter since it's not at the start
expect(frontMatterNodes.length).toBe(0);
});
it('should not parse FrontMatter without closing delimiter', async () => {
// Test document with --- at start but no closing delimiter
// This should be parsed as a horizontal rule followed by content
const documentText = '# Heading\n\n---\ntitle: Test';
const editor = await createEditorState(documentText, ['ATXHeading1', 'HorizontalRule']);
const frontMatterNodes = findNodesWithName(editor, frontMatterTagName);
// FrontMatter only works at the very start of the document, so this should not be recognized
expect(frontMatterNodes.length).toBe(0);
});
it('should handle empty FrontMatter block', async () => {
const documentText = '---\n---\n\n# Heading';
const editor = await createEditorState(documentText, [frontMatterTagName, 'ATXHeading1']);
const frontMatterNodes = findNodesWithName(editor, frontMatterTagName);
expect(frontMatterNodes.length).toBe(1);
expect(frontMatterNodes[0].from).toBe(0);
expect(frontMatterNodes[0].to).toBe('---\n---'.length);
});
it('should have FrontMatterContent as child node', async () => {
const documentText = '---\nkey: value\n---';
const editor = await createEditorState(documentText, [frontMatterTagName]);
const frontMatterNodes = findNodesWithName(editor, frontMatterTagName);
const contentNodes = findNodesWithName(editor, frontMatterContentTagName);
expect(frontMatterNodes.length).toBe(1);
// Content node may be replaced by YAML parser, but if not, it should exist
// The presence depends on whether YAML language was loaded
expect(contentNodes.length).toBeGreaterThanOrEqual(0);
});
it('should not confuse horizontal rules with FrontMatter', async () => {
const documentText = '# Title\n\n---\n\nSome text';
const editor = await createEditorState(documentText, ['ATXHeading1', 'HorizontalRule']);
const frontMatterNodes = findNodesWithName(editor, frontMatterTagName);
const hrNodes = findNodesWithName(editor, 'HorizontalRule');
expect(frontMatterNodes.length).toBe(0);
expect(hrNodes.length).toBe(1);
});
it('should create FrontMatterMarker nodes for the delimiters', async () => {
const documentText = '---\ntitle: Test\n---\n\n# Heading';
const editor = await createEditorState(documentText, [frontMatterTagName, frontMatterMarkerTagName]);
const markerNodes = findNodesWithName(editor, frontMatterMarkerTagName);
// Should have two markers: opening and closing ---
expect(markerNodes.length).toBe(2);
// Opening marker
expect(markerNodes[0].from).toBe(0);
expect(markerNodes[0].to).toBe(3); // '---'.length
// Closing marker
expect(markerNodes[1].from).toBe('---\ntitle: Test\n'.length);
expect(markerNodes[1].to).toBe('---\ntitle: Test\n---'.length);
});
});

View File

@@ -0,0 +1,128 @@
// Extension for parsing and highlighting YAML FrontMatter blocks at the start of a document.
//
// A FrontMatter block is delimited by --- at the very start of the document:
// ---
// title: My Document
// date: 2024-01-01
// ---
import { Tag } from '@lezer/highlight';
import { parseMixed, SyntaxNodeRef, Input, NestedParse, ParseWrapper } from '@lezer/common';
import { MarkdownConfig, BlockContext, Line, LeafBlock, MarkdownExtension } from '@lezer/markdown';
import { StreamLanguage } from '@codemirror/language';
import { yaml } from '@codemirror/legacy-modes/mode/yaml';
export const frontMatterTagName = 'FrontMatter';
export const frontMatterContentTagName = 'FrontMatterContent';
export const frontMatterMarkerTagName = 'FrontMatterMarker';
export const frontMatterTag = Tag.define();
// Create the YAML language parser using the legacy mode
const yamlLanguage = StreamLanguage.define(yaml);
// Wraps a YAML parser for the FrontMatter content.
// This replaces [nodeTag] from the syntax tree with a region handled by the YAML parser.
const wrappedYamlParser = (nodeTag: string): ParseWrapper => {
return parseMixed((node: SyntaxNodeRef, _input: Input): NestedParse => {
if (node.name !== nodeTag) {
return null;
}
return {
parser: yamlLanguage.parser,
};
});
};
// Regex to match the FrontMatter delimiter (--- at start of line)
const frontMatterDelimiterRegex = /^---\s*$/;
const frontMatterConfig: MarkdownConfig = {
defineNodes: [
{
name: frontMatterTagName,
style: frontMatterTag,
},
{
name: frontMatterContentTagName,
},
{
name: frontMatterMarkerTagName,
style: frontMatterTag,
},
],
parseBlock: [{
name: frontMatterTagName,
before: 'HorizontalRule',
parse(cx: BlockContext, line: Line): boolean {
// FrontMatter must be at the very start of the document
if (cx.lineStart !== 0) {
return false;
}
// Check if the first line is ---
if (!frontMatterDelimiterRegex.test(line.text)) {
return false;
}
// Store the opening delimiter position
const openingMarkerStart = cx.lineStart;
const openingMarkerEnd = cx.lineStart + line.text.length;
const contentStart = openingMarkerEnd + 1; // Start after the opening --- and newline
let foundEnd = false;
// Consume lines until we find the closing ---
while (cx.nextLine()) {
if (frontMatterDelimiterRegex.test(line.text)) {
foundEnd = true;
break;
}
}
if (!foundEnd) {
// No closing delimiter found - not a valid FrontMatter block
return false;
}
// The content is between the two --- delimiters
const contentEnd = cx.lineStart; // Start of the closing --- line
// Closing delimiter positions
const closingMarkerStart = cx.lineStart;
const closingMarkerEnd = cx.lineStart + line.text.length;
// Create marker elements for the --- delimiters
const openingMarkerElem = cx.elt(frontMatterMarkerTagName, openingMarkerStart, openingMarkerEnd);
const closingMarkerElem = cx.elt(frontMatterMarkerTagName, closingMarkerStart, closingMarkerEnd);
// Create the content element (the YAML content between delimiters)
const contentElem = cx.elt(frontMatterContentTagName, contentStart, contentEnd);
// Create the container element spanning from start of first --- to end of last ---
const containerElement = cx.elt(
frontMatterTagName,
0, // Start at document beginning
closingMarkerEnd, // End after closing ---
[openingMarkerElem, contentElem, closingMarkerElem],
);
cx.addElement(containerElement);
// Move past the closing delimiter
cx.nextLine();
return true;
},
// FrontMatter blocks can end leaf blocks like paragraphs
endLeaf(_cx: BlockContext, line: Line, _leaf: LeafBlock): boolean {
return frontMatterDelimiterRegex.test(line.text);
},
}],
wrap: wrappedYamlParser(frontMatterContentTagName),
};
const markdownFrontMatterExtension: MarkdownExtension = [frontMatterConfig];
export default markdownFrontMatterExtension;

View File

@@ -6,6 +6,33 @@ import makeBlockReplaceExtension from './utils/makeBlockReplaceExtension';
const imageClassName = 'cm-md-image';
class ImageHeightCache {
private readonly cache = new Map<string, number>();
private readonly maxEntries = 500;
public get(key: string): number | undefined {
const value = this.cache.get(key);
if (value !== undefined) {
// Refresh recency
this.cache.delete(key);
this.cache.set(key, value);
}
return value;
}
public set(key: string, height: number): void {
if (this.cache.has(key)) {
this.cache.delete(key);
} else if (this.cache.size >= this.maxEntries) {
const firstKey = this.cache.keys().next().value;
if (firstKey) this.cache.delete(firstKey);
}
this.cache.set(key, height);
}
}
const imageHeightCache = new ImageHeightCache();
class ImageWidget extends WidgetType {
private resolvedSrc_: string;
@@ -41,9 +68,16 @@ class ImageWidget extends WidgetType {
const updateImageUrl = () => {
if (this.resolvedSrc_) {
// Use a background-image style property rather than img[src=]. This
// simplifies setting the image to the correct size/position.
image.src = this.resolvedSrc_;
// When the image loads, measure and cache the height
image.onload = () => {
// Measure container height (what CodeMirror uses for scroll calculations).
if (dom.isConnected) {
imageHeightCache.set(this.cacheKey, dom.offsetHeight);
}
dom.style.minHeight = '';
};
}
};
@@ -56,10 +90,16 @@ class ImageWidget extends WidgetType {
updateImageUrl();
}
// Apply cached height as min-height to prevent collapse during load.
const cached = imageHeightCache.get(this.cacheKey);
if (cached) {
dom.style.minHeight = `${cached}px`;
}
return true;
}
public toDOM() {
public toDOM(_view: EditorView) {
const container = document.createElement('div');
container.classList.add(imageClassName);
@@ -72,8 +112,12 @@ class ImageWidget extends WidgetType {
return container;
}
private get cacheKey() {
return `${this.src_}_${this.width_ ?? ''}_${this.reloadCounter_}`;
}
public get estimatedHeight() {
return -1;
return imageHeightCache.get(this.cacheKey) ?? -1;
}
}

View File

@@ -4,6 +4,7 @@ import replaceBulletLists from './replaceBulletLists';
import replaceCheckboxes from './replaceCheckboxes';
import replaceDividers from './replaceDividers';
import replaceFormatCharacters from './replaceFormatCharacters';
import replaceInlineHtml from './replaceInlineHtml';
export default () => {
return [
@@ -13,5 +14,6 @@ export default () => {
replaceBackslashEscapes,
replaceDividers,
addFormattingClasses,
replaceInlineHtml,
];
};

View File

@@ -26,6 +26,9 @@ class DividerWidget extends WidgetType {
const dividerLineMark = Decoration.line({ class: dividerLineClassName });
// Node names that should be rendered as dividers
const dividerNodeNames = ['HorizontalRule', 'FrontMatterMarker'];
const replaceDividers = [
EditorView.theme({
[`& .cm-line.${dividerLineClassName}`]: {
@@ -47,7 +50,7 @@ const replaceDividers = [
}),
makeInlineReplaceExtension({
createDecoration: (node) => {
if (node.name === 'HorizontalRule') {
if (dividerNodeNames.includes(node.name)) {
return new DividerWidget();
}
return null;
@@ -55,7 +58,7 @@ const replaceDividers = [
}),
makeInlineReplaceExtension({
createDecoration: (node) => {
if (node.name === 'HorizontalRule') {
if (dividerNodeNames.includes(node.name)) {
return dividerLineMark;
}
return null;

View File

@@ -0,0 +1,32 @@
import { EditorSelection } from '@codemirror/state';
import createTestEditor from '../../testing/createTestEditor';
import replaceInlineHtml from './replaceInlineHtml';
const createEditor = async (initialMarkdown: string, expectedTags: string[] = ['HTMLTag']) => {
const editor = await createTestEditor(
initialMarkdown,
EditorSelection.cursor(0),
expectedTags,
[replaceInlineHtml],
);
return editor;
};
describe('replaceInlineHtml', () => {
test.each([
{ markdown: '<sup>Test</sup>', expectedDomTags: 'sup' },
{ markdown: '<strike>Test</strike>', expectedDomTags: 'strike' },
{ markdown: 'Test: <span style="color: red;">Test</span>', expectedDomTags: 'span[style]' },
{ markdown: 'Test: <span style="color: rgb(123, 0, 0);">Test</span>', expectedDomTags: 'span[style]' },
{
markdown: '<sup>Test *test*...</sup>',
expectedDomTags: 'sup',
initialSyntaxTags: ['HTMLTag', 'Emphasis'],
},
])('should render inline HTML (case %#)', async ({ markdown, expectedDomTags: expectedTagsQuery, initialSyntaxTags }) => {
// Add additional newlines: Ensure that the cursor isn't initially on the same line as the content to be rendered:
const editor = await createEditor(`\n\n${markdown}\n\n`, initialSyntaxTags);
expect(editor.contentDOM.querySelector(expectedTagsQuery)).toBeTruthy();
});
});

View File

@@ -0,0 +1,90 @@
import makeInlineReplaceExtension from './utils/makeInlineReplaceExtension';
import { Decoration } from '@codemirror/view';
import htmlNodeInfo, { HtmlNodeInfo } from '../../utils/htmlNodeInfo';
import { SyntaxNodeRef } from '@lezer/common';
import { EditorState } from '@codemirror/state';
const hideDecoration = Decoration.replace({});
type OnRenderTagContent = (openingTag: HtmlNodeInfo)=> Decoration;
const createHtmlReplacementExtension = (tagName: string, onRenderContent: OnRenderTagContent) => {
const isMatchingTag = (info: HtmlNodeInfo) => {
return info.tagName().toLowerCase() === tagName;
};
const isMatchingOpeningTag = (info: HtmlNodeInfo) => {
return isMatchingTag(info) && info.opening;
};
const isMatchingClosingTag = (info: HtmlNodeInfo) => {
return isMatchingTag(info) && info.closing;
};
const findClosingTag = (openingTag: SyntaxNodeRef, state: EditorState) => {
const openingTagInfo = htmlNodeInfo(openingTag, state);
// Self-closing?
if (openingTagInfo.closing) {
return openingTag;
}
let cursor = openingTag.node.nextSibling;
let nestedTagCounter = 1;
// Find the matching closing tag
for (; !!cursor && nestedTagCounter > 0; cursor = cursor.nextSibling) {
const info = htmlNodeInfo(cursor, state);
if (info && isMatchingOpeningTag(info)) {
nestedTagCounter ++;
} else if (info && isMatchingClosingTag(info)) {
nestedTagCounter --;
}
if (nestedTagCounter === 0) {
break;
}
}
return cursor;
};
const hideTags = makeInlineReplaceExtension({
createDecoration: (node, state) => {
const info = htmlNodeInfo(node, state);
return info && isMatchingTag(info) ? hideDecoration : null;
},
});
const styleContent = makeInlineReplaceExtension({
createDecoration: (node, state) => {
const info = htmlNodeInfo(node, state);
if (!info || !isMatchingOpeningTag(info)) return null;
return onRenderContent(info);
},
getDecorationRange(node, state) {
const closingTag = findClosingTag(node, state);
if (closingTag) {
return [node.to, closingTag.from];
} else {
return null;
}
},
});
return [hideTags, styleContent];
};
export default [
createHtmlReplacementExtension('sub', () => Decoration.mark({ tagName: 'sub' })),
createHtmlReplacementExtension('sup', () => Decoration.mark({ tagName: 'sup' })),
createHtmlReplacementExtension('strike', () => Decoration.mark({ tagName: 'strike' })),
createHtmlReplacementExtension('span', (info) => {
const styles = info.getAttr('style') ?? '';
const colorMatch = styles.match(/color:\s*(#?[a-z0-9A-Z]+|rgba?\([0-9, ]+\))(;|$)/);
return Decoration.mark({
attributes: {
style: colorMatch ? `color: ${colorMatch[1]};` : '',
},
});
}),
].flat();

View File

@@ -7,6 +7,7 @@ import forceFullParse from './forceFullParse';
import loadLanguages from './loadLanguages';
import markdownMathExtension from '../extensions/markdownMathExtension';
import markdownHighlightExtension from '../extensions/markdownHighlightExtension';
import markdownFrontMatterExtension from '../extensions/markdownFrontMatterExtension';
// Creates and returns a minimal editor with markdown extensions. Waits to return the editor
// until all syntax tree tags in `expectedSyntaxTreeTags` exist.
@@ -26,7 +27,7 @@ const createTestEditor = async (
selection: EditorSelection.create(initialSelection),
extensions: [
markdown({
extensions: [markdownMathExtension, markdownHighlightExtension, GithubFlavoredMarkdownExt],
extensions: [markdownMathExtension, markdownHighlightExtension, markdownFrontMatterExtension, GithubFlavoredMarkdownExt],
addKeymap: addMarkdownKeymap,
}),
indentUnit.of('\t'),

View File

@@ -139,25 +139,7 @@ const createTheme = (theme: EditorTheme): Extension[] => {
},
'& .cm-codeBlock': {
'&.cm-regionFirstLine, &.cm-regionLastLine': {
borderRadius: '3px',
},
'&:not(.cm-regionFirstLine)': {
borderTop: 'none',
borderTopLeftRadius: 0,
borderTopRightRadius: 0,
},
'&:not(.cm-regionLastLine)': {
borderBottom: 'none',
borderBottomLeftRadius: 0,
borderBottomRightRadius: 0,
},
borderWidth: '1px',
borderStyle: 'solid',
borderColor: theme.colorFaded,
backgroundColor: 'rgba(155, 155, 155, 0.1)',
backgroundColor: 'rgba(155, 155, 155, 0.07)',
...monospaceStyle,
},
@@ -269,8 +251,8 @@ const createTheme = (theme: EditorTheme): Extension[] => {
},
{
tag: tags.comment,
opacity: 0.9,
fontStyle: 'italic',
color: isDarkTheme ? '#b18eb1' : '#6d7086',
},
{
tag: tags.link,
@@ -281,26 +263,23 @@ const createTheme = (theme: EditorTheme): Extension[] => {
fontStyle: 'italic',
},
// Content of code blocks
// Content of code blocks. This should roughly match the colors used by the default
// highlight.js theme in the note viewer, while also preserving at least 4.5:1 contrast.
{
tag: tags.keyword,
color: isDarkTheme ? '#ff7' : '#740',
},
{
tag: tags.operator,
color: isDarkTheme ? '#f7f' : '#805',
color: isDarkTheme ? '#F92672' : '#a626a4',
},
{
tag: tags.literal,
color: isDarkTheme ? '#aaf' : '#037',
},
{
tag: tags.operator,
color: isDarkTheme ? '#fa9' : '#490',
tag: tags.number,
color: isDarkTheme ? '#d19a66' : '#986801',
},
{
tag: tags.typeName,
color: isDarkTheme ? '#7ff' : '#a00',
color: isDarkTheme ? '#d19a66' : '#986801',
},
{
tag: tags.inserted,
@@ -312,13 +291,21 @@ const createTheme = (theme: EditorTheme): Extension[] => {
},
{
tag: tags.propertyName,
color: isDarkTheme ? '#d96' : '#940',
color: isDarkTheme ? '#61aeee' : '#406be5',
},
{
tag: tags.string,
color: isDarkTheme ? '#98c379' : '#50a14f',
},
{
// CSS class names (and class names in other languages)
tag: tags.className,
color: isDarkTheme ? '#d8a' : '#904',
},
{
tag: tags.macroName,
color: isDarkTheme ? '#e6c07b' : '#986801',
},
]);
return [

View File

@@ -0,0 +1,88 @@
import { EditorState } from '@codemirror/state';
import { SyntaxNodeRef } from '@lezer/common';
export interface HtmlNodeInfo {
node: SyntaxNodeRef;
opening: boolean;
closing: boolean;
from: number;
to: number;
tagName: ()=> string;
getAttr: (attributeName: string)=> string;
}
type OnGetNodeContent = (node: SyntaxNodeRef)=> string;
const removeQuotes = (quoted: string) => quoted.replace(/^["'](.*)["']$/, '$1');
const getHtmlNodeAttr = (node: SyntaxNodeRef, attrName: string, getText: OnGetNodeContent) => {
if (node.from === node.to) return null; // Empty
const content = node.node.resolveInner(node.from + 1);
// Search for the "id" attribute
const attributes = content.getChildren('Attribute');
for (const attribute of attributes) {
const nameNode = attribute.getChild('AttributeName');
const valueNode = attribute.getChild('AttributeValue');
if (nameNode && valueNode) {
const name = getText(nameNode).toLowerCase().replace(/^"(.*)"$/, '$1');
if (name === attrName) {
return removeQuotes(getText(valueNode));
}
}
}
return null;
};
// Utility function to access CodeMirror HTML node information, based on
// the corresponding Markdown node.
const htmlNodeInfo = (node: SyntaxNodeRef, state: EditorState, offset = 0): HtmlNodeInfo|null => {
// Already an HTML node?
if (node.name === 'OpenTag' || node.name === 'CloseTag' || node.name === 'SelfClosingTag') {
const getNodeText = (childNode: SyntaxNodeRef) => state.sliceDoc(childNode.from + offset, childNode.to + offset);
const selfClosing = node.name === 'SelfClosingTag';
return {
node,
opening: node.name === 'OpenTag' || selfClosing,
closing: node.name === 'CloseTag' || selfClosing,
from: node.from + offset,
to: node.to + offset,
tagName: () => {
const nodeText = getNodeText(node).trim();
const tagNameMatch = nodeText.match(/^<\/?([^>\s]+)/);
if (tagNameMatch) {
return tagNameMatch[1];
}
return null;
},
getAttr: (name: string) => {
return getHtmlNodeAttr(node, name, getNodeText);
},
};
}
// Convert Markdown HTML nodes to HTML nodes
if (node.name === 'HTMLTag' || node.name === 'HTMLBlock') {
const globalOffset = node.from + offset;
let resolved: HtmlNodeInfo|null = null;
// CodeMirror adds HTML information to Markdown documents using overlays attached
// to HTMLTag and HTMLBlock nodes.
// Use .enter to enter the overlay and visit the HTML nodes:
node.node.enter(node.from, 1).toTree().iterate({
enter: (subNode) => {
resolved ??= htmlNodeInfo(subNode, state, globalOffset);
return !resolved;
},
});
return resolved;
}
return null;
};
export default htmlNodeInfo;

View File

@@ -1,4 +1,8 @@
const baseConfig = require('../../jest.config.base.js');
module.exports = {
...baseConfig,
'moduleFileExtensions': [
'ts',
'tsx',

View File

@@ -1,6 +1,6 @@
{
"name": "@joplin/editor",
"version": "3.5.0",
"version": "3.6.0",
"description": "Web-based markdown editor",
"private": true,
"scripts": {
@@ -14,12 +14,12 @@
"url": "git+https://github.com/laurent22/joplin.git"
},
"devDependencies": {
"@joplin/lib": "~3.5",
"@joplin/utils": "~3.5",
"@joplin/lib": "~3.6",
"@joplin/utils": "~3.6",
"@testing-library/react-hooks": "8.0.1",
"@types/jest": "29.5.14",
"@types/node": "18.19.130",
"@types/react": "18.3.25",
"@types/react": "18.3.26",
"@types/react-redux": "7.1.33",
"@types/styled-components": "5.1.32",
"jest": "29.7.0",
@@ -28,21 +28,21 @@
"typescript": "5.8.3"
},
"dependencies": {
"@codemirror/autocomplete": "6.18.3",
"@codemirror/commands": "6.7.1",
"@codemirror/lang-html": "6.4.9",
"@codemirror/lang-markdown": "6.3.1",
"@codemirror/autocomplete": "6.20.0",
"@codemirror/commands": "6.10.1",
"@codemirror/lang-html": "6.4.11",
"@codemirror/lang-markdown": "6.5.0",
"@codemirror/language": "6.10.4",
"@codemirror/language-data": "6.3.1",
"@codemirror/legacy-modes": "6.4.2",
"@codemirror/lint": "6.8.3",
"@codemirror/legacy-modes": "6.5.2",
"@codemirror/lint": "6.9.2",
"@codemirror/search": "6.5.8",
"@codemirror/state": "6.4.1",
"@codemirror/state": "6.5.4",
"@codemirror/view": "6.35.0",
"@joplin/fork-uslug": "^2.0.0",
"@lezer/common": "1.2.3",
"@lezer/highlight": "1.2.1",
"@lezer/markdown": "1.3.2",
"@lezer/common": "1.5.0",
"@lezer/highlight": "1.2.3",
"@lezer/markdown": "1.6.3",
"@replit/codemirror-vim": "6.2.1",
"dompurify": "3.2.7",
"orderedmap": "2.1.1",

View File

@@ -1,4 +1,8 @@
const baseConfig = require('../../jest.config.base.js');
module.exports = {
...baseConfig,
testMatch: [
'**/*.test.js',
],

View File

@@ -1,7 +1,7 @@
{
"manifest_version": 1,
"id": "<%= pluginId %>",
"app_min_version": "3.5",
"app_min_version": "3.6",
"version": "1.0.0",
"name": "<%= pluginName %>",
"description": "<%= pluginDescription %>",

View File

@@ -1,4 +1,8 @@
const baseConfig = require('../../jest.config.base.js');
module.exports = {
...baseConfig,
testMatch: [
'**/*.test.js',
],

View File

@@ -1,6 +1,6 @@
{
"name": "generator-joplin",
"version": "3.5.1",
"version": "3.6.0",
"description": "Scaffolds out a new Joplin plugin",
"homepage": "https://github.com/laurent22/joplin/tree/dev/packages/generator-joplin",
"author": {
@@ -30,8 +30,8 @@
"yosay": "2.0.2"
},
"devDependencies": {
"@joplin/lib": "~3.5",
"@joplin/tools": "~3.5",
"@joplin/lib": "~3.6",
"@joplin/tools": "~3.6",
"jest": "29.7.0",
"ts-node": "10.9.2"
},

View File

@@ -1,4 +1,8 @@
const baseConfig = require('../../jest.config.base.js');
module.exports = {
...baseConfig,
testMatch: ['**/*.test.js'],
testPathIgnorePatterns: ['<rootDir>/node_modules/'],
};

View File

@@ -1,6 +1,6 @@
{
"name": "@joplin/htmlpack",
"version": "3.5.1",
"version": "3.6.0",
"description": "Pack an HTML file and all its linked resources into a single HTML file",
"main": "dist/index.js",
"types": "index.ts",

View File

@@ -1,6 +1,6 @@
import Resource from './models/Resource';
import shim from './shim';
import Database from './database';
import Database, { Row } from './database';
import { SqlQuery } from './services/database/types';
import addMigrationFile from './services/database/addMigrationFile';
import sqlStringToLines from './services/database/sqlStringToLines';
@@ -314,34 +314,44 @@ export default class JoplinDatabase extends Database {
throw new Error(`\`notes_fts\` (${countFieldsNotesFts} fields) must have the same number of fields as \`items_fts\` (${countFieldsItemsFts} fields) for the search engine BM25 algorithm to work`);
}
const tableRows = await this.selectAll('SELECT name FROM sqlite_master WHERE type=\'table\'');
interface TableRow {
name: string;
}
const tableRows: TableRow[] = await this.selectAll('SELECT name FROM sqlite_master WHERE type=\'table\'');
for (let i = 0; i < tableRows.length; i++) {
let pragmas: Row[] = [];
const tableName = tableRows[i].name;
if (tableName === 'android_metadata') continue;
if (tableName === 'table_fields') continue;
if (tableName === 'sqlite_sequence') continue;
if (tableName.indexOf('notes_fts') === 0) continue;
if (tableName.indexOf('items_fts') === 0) continue;
if (tableName === 'notes_spellfix') continue;
if (tableName === 'search_aux') continue;
try {
if (tableName === 'android_metadata') continue;
if (tableName === 'table_fields') continue;
if (tableName.startsWith('sqlite_')) continue;
if (tableName.indexOf('notes_fts') === 0) continue;
if (tableName.indexOf('items_fts') === 0) continue;
if (tableName === 'notes_spellfix') continue;
if (tableName === 'search_aux') continue;
const pragmas = await this.selectAll(`PRAGMA table_info("${tableName}")`);
pragmas = await this.selectAll(`PRAGMA table_info("${tableName}")`);
for (let i = 0; i < pragmas.length; i++) {
const item = pragmas[i];
// In SQLite, if the default value is a string it has double quotes around it, so remove them here
let defaultValue = item.dflt_value;
if (typeof defaultValue === 'string' && defaultValue.length >= 2 && defaultValue[0] === '"' && defaultValue[defaultValue.length - 1] === '"') {
defaultValue = defaultValue.substr(1, defaultValue.length - 2);
for (let i = 0; i < pragmas.length; i++) {
const item = pragmas[i];
// In SQLite, if the default value is a string it has double quotes around it, so remove them here
let defaultValue = item.dflt_value;
if (typeof defaultValue === 'string' && defaultValue.length >= 2 && defaultValue[0] === '"' && defaultValue[defaultValue.length - 1] === '"') {
defaultValue = defaultValue.substr(1, defaultValue.length - 2);
}
const q = Database.insertQuery('table_fields', {
table_name: tableName,
field_name: item.name,
field_type: Database.enumId('fieldType', item.type),
field_default: defaultValue,
});
queries.push(q);
}
const q = Database.insertQuery('table_fields', {
table_name: tableName,
field_name: item.name,
field_type: Database.enumId('fieldType', item.type),
field_default: defaultValue,
});
queries.push(q);
} catch (error) {
error.message = `On table: ${tableName}: Pragma: ${JSON.stringify(pragmas)}: ${error.message}`;
throw error;
}
}

View File

@@ -130,7 +130,7 @@ export default class PerformanceLogger {
const startTime = performance.now();
this.lastLogTime_ = startTime;
PerformanceLogger.logDebug_(`${name}: Start at ${formatAbsoluteTime(startTime)}`);
PerformanceLogger.log_(`${name}: Start at ${formatAbsoluteTime(startTime)}`);
const onEnd = () => {
const now = performance.now();
@@ -140,12 +140,7 @@ export default class PerformanceLogger {
performance.measure(name, `${uniqueTaskId}-start`, `${uniqueTaskId}-end`);
}
const duration = now - startTime;
// Increase the log level for long-running tasks
const isLong = duration >= Second / 10;
const log = isLong ? PerformanceLogger.log_ : PerformanceLogger.logDebug_;
log(`${name}: End at ${formatAbsoluteTime(now)} (took ${formatTaskDuration(now - startTime)})`);
PerformanceLogger.log_(`${name}: End at ${formatAbsoluteTime(now)} (took ${formatTaskDuration(now - startTime)})`);
};
return {
onEnd,

View File

@@ -26,11 +26,6 @@ export interface ArchiveExtractOptions {
extractTo: string;
}
export interface CabExtractOptions extends ArchiveExtractOptions {
// Only files matching the pattern will be extracted
fileNamePattern: string;
}
export interface ZipEntry {
entryName: string;
name: string;
@@ -276,8 +271,4 @@ export default class FsDriverBase {
public async zipExtract(_options: ArchiveExtractOptions): Promise<ZipEntry[]> {
throw new Error('Not implemented: zipExtract');
}
public async cabExtract(_options: CabExtractOptions) {
throw new Error('Not implemented: cabExtract.');
}
}

View File

@@ -1,8 +1,6 @@
import AdmZip = require('adm-zip');
import FsDriverBase, { Stat, ZipEntry, ArchiveExtractOptions, CabExtractOptions } from './fs-driver-base';
import FsDriverBase, { Stat, ZipEntry, ArchiveExtractOptions } from './fs-driver-base';
import time from './time';
import { execCommand } from '@joplin/utils';
import { extname } from 'path';
const md5File = require('md5-file');
const fs = require('fs-extra');
@@ -218,25 +216,4 @@ export default class FsDriverNode extends FsDriverBase {
zip.extractAllTo(options.extractTo, false);
return zip.getEntries();
}
public async cabExtract(options: CabExtractOptions) {
if (process.platform !== 'win32') {
throw new Error('Extracting CAB archives is only supported on Windows.');
}
const source = this.resolve(options.source);
const extractTo = this.resolve(options.extractTo);
if (extname(source).toLowerCase() !== '.cab') {
throw new Error(`Invalid file extension. Expected .CAB. Was ${extname(source)}`);
}
// See https://learn.microsoft.com/en-us/windows-server/administration/windows-commands/expand
await execCommand([
'expand.exe',
source,
`-f:${options.fileNamePattern}`,
extractTo,
], { quiet: true });
}
}

View File

@@ -1,3 +1,4 @@
const baseConfig = require('../../jest.config.base.js');
const testPathIgnorePatterns = [
'<rootDir>/node_modules/',
@@ -11,6 +12,7 @@ if (!process.env.IS_CONTINUOUS_INTEGRATION) {
}
module.exports = {
...baseConfig,
testMatch: [
'**/*.test.js',
],

View File

@@ -520,6 +520,50 @@ describe('models/Folder.sharing', () => {
expect(note4.user_updated_time).toBe(userUpdatedTimes[note4.id]);
});
it('should prefer duplicating resources in unshared folders to shared folders', async () => {
const resourceService = new ResourceService();
const folder1 = await createFolderTree('', [
{
title: 'folder 1', // Share 1
children: [
{
title: 'note 1',
},
],
},
{
title: 'folder 2', // Not shared
children: [
{
title: 'note 2',
},
],
},
]);
let note1: NoteEntity = await Note.loadByTitle('note 1');
let note2: NoteEntity = await Note.loadByTitle('note 2');
await Folder.save({ id: folder1.id, share_id: 'share1' });
note1 = await shim.attachFileToNote(note1, testImagePath);
note2 = await Note.save({ id: note2.id, body: note1.body });
await msleep(1);
await resourceService.indexNoteResources(); // Populate note_resources
await Folder.updateAllShareIds(resourceService, []);
// After
expect(await Resource.all()).toHaveLength(2);
// note1 should have the same body
expect(await Note.load(note1.id)).toMatchObject({ body: note1.body, share_id: 'share1' });
// note2's body should be updated
expect(await Note.load(note2.id)).not.toMatchObject({ body: note2.body, share_id: '' });
});
it('should clear share_ids for items that are no longer part of an existing share', async () => {
await createFolderTree('', [
{

View File

@@ -639,12 +639,21 @@ export default class Folder extends BaseItem {
// one note. If it is not, we create duplicate resources so that
// each note has its own separate resource.
// Order unshared items first: This makes conflicts less likely, since shared
// items are more likely to be duplicated by multiple users.
const orderingSql = 'ORDER BY is_shared ASC';
const noteResourceAssociations = await this.db().selectAll(`
SELECT resource_id, note_id, notes.share_id
SELECT
resource_id,
note_id,
notes.share_id,
(notes.share_id != '') AS is_shared
FROM note_resources
LEFT JOIN notes ON notes.id = note_resources.note_id
WHERE resource_id IN (${this.escapeIdsForSql(resourceIds)})
AND is_associated = 1
${orderingSql}
`) as NoteResourceRow[];
const resourceIdToNotes: Record<string, NoteResourceRow[]> = {};

View File

@@ -1105,6 +1105,7 @@ const builtInMetadata = (Setting: typeof SettingType) => {
'markdown.plugin.emoji': { storage: SettingStorage.File, isGlobal: true, value: false, type: SettingItemType.Bool, section: 'markdownPlugins', public: true, appTypes: [AppType.Mobile, AppType.Desktop], label: () => `${_('Enable markdown emoji')}${wysiwygNo}` },
'markdown.plugin.insert': { storage: SettingStorage.File, isGlobal: true, value: false, type: SettingItemType.Bool, section: 'markdownPlugins', public: true, appTypes: [AppType.Mobile, AppType.Desktop], label: () => `${_('Enable ++insert++ syntax')}${wysiwygYes}` },
'markdown.plugin.multitable': { storage: SettingStorage.File, isGlobal: true, value: false, type: SettingItemType.Bool, section: 'markdownPlugins', public: true, appTypes: [AppType.Mobile, AppType.Desktop], label: () => `${_('Enable multimarkdown table extension')}${wysiwygNo}` },
'markdown.plugin.externalEmbed': { storage: SettingStorage.File, isGlobal: true, value: true, type: SettingItemType.Bool, section: 'markdownPlugins', public: true, appTypes: [AppType.Mobile, AppType.Desktop], label: () => `${_('Enable external embeds (e.g. YouTube Videos)')}${wysiwygYes}` },
// For now, applies only to the Markdown viewer
'renderer.fileUrls': {

View File

@@ -1,6 +1,6 @@
{
"name": "@joplin/lib",
"version": "3.5.1",
"version": "3.6.0",
"description": "Joplin Core library",
"author": "Laurent Cozic",
"homepage": "",
@@ -27,7 +27,7 @@
"@types/mustache": "4.2.6",
"@types/node": "18.19.130",
"@types/node-rsa": "1.1.4",
"@types/react": "18.3.25",
"@types/react": "18.3.26",
"@types/uuid": "10.0.0",
"jest": "29.7.0",
"jest-expect-message": "1.1.3",
@@ -46,12 +46,12 @@
"@joplin/fork-htmlparser2": "^4.1.60",
"@joplin/fork-sax": "^1.2.64",
"@joplin/fork-uslug": "^2.0.3",
"@joplin/htmlpack": "^3.5.1",
"@joplin/onenote-converter": "^3.5.1",
"@joplin/renderer": "^3.5.1",
"@joplin/htmlpack": "~3.6",
"@joplin/onenote-converter": "~3.6",
"@joplin/renderer": "~3.6",
"@joplin/turndown": "^4.0.82",
"@joplin/turndown-plugin-gfm": "^1.0.64",
"@joplin/utils": "^3.5.1",
"@joplin/utils": "~3.6",
"adm-zip": "0.5.16",
"async-mutex": "0.5.0",
"base-64": "1.0.0",

View File

@@ -48,7 +48,20 @@ describe('services/ResourceService', () => {
expect(!(await NoteResource.all()).length).toBe(true);
}));
it('should not delete resource if still associated with at least one note', (async () => {
it.each([
{
linkStyle: 'image 1',
markupTag: (id: string) => `![image](:/${id})`,
},
{
linkStyle: 'image 2',
markupTag: (id: string) => `![image][image]\n\n[image]: :/${id}`,
},
{
linkStyle: 'html link',
markupTag: (id: string) => `<a href=":/${id}">test</a>`,
},
])('should not delete resource if still associated with at least one note (link style: $linkStyle)', (async ({ markupTag }) => {
const service = new ResourceService();
const folder1 = await Folder.save({ title: 'folder1' });
@@ -63,7 +76,7 @@ describe('services/ResourceService', () => {
await service.indexNoteResources();
await Note.save({ id: note2.id, body: Resource.markupTag(resource1) });
await Note.save({ id: note2.id, body: markupTag(resource1.id) });
await service.indexNoteResources();

View File

@@ -145,8 +145,7 @@ export default class InteropService {
fileExtensions: [
'zip',
'one',
// .onepkg is a CAB archive, which Joplin can currently only extract on Windows
...(shim.isWindows() ? ['onepkg'] : []),
'onepkg',
],
sources: [FileSystemItem.File],
isNoteArchive: false, // Tells whether the file can contain multiple notes (eg. Enex or Jex format)

View File

@@ -42,6 +42,19 @@ const normalizeNoteForSnapshot = (body: string) => {
return removeItemIds(removeDefaultCss(body));
};
// A single Markdown string is much easier to visually compare during snapshot testing.
// Prefer notesToMarkdownString to normalizeNoteForSnapshot when the exact output HTML
// doesn't matter.
const notesToMarkdownString = (notes: NoteEntity[]) => {
const converter = new HtmlToMd();
return notes.map(note => {
return [
`# Note: ${note.title}`,
converter.parse(normalizeNoteForSnapshot(note.body)),
].join('\n\n');
}).sort().join('\n\n\n');
};
// 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;
@@ -329,4 +342,10 @@ describe('InteropService_Importer_OneNote', () => {
expect(normalizeNoteForSnapshot(importedNote.body)).toMatchSnapshot('EmbeddedFiles');
});
it('should correctly import .onepkg notebooks', async () => {
const notes = await importNote(`${supportDir}/onenote/test.onepkg`);
expect(notesToMarkdownString(notes)).toMatchSnapshot();
});
});

View File

@@ -47,30 +47,13 @@ export default class InteropService_Importer_OneNote extends InteropService_Impo
if (fileExtension === '.zip') {
logger.info('Unzipping files...');
await shim.fsDriver().zipExtract({ source: sourcePath, extractTo: targetPath });
} else if (fileExtension === '.one') {
} else if (fileExtension === '.one' || fileExtension === '.onepkg') {
logger.info('Copying file...');
const outputDirectory = join(targetPath, fileNameNoExtension);
await shim.fsDriver().mkdir(outputDirectory);
await shim.fsDriver().copy(sourcePath, join(outputDirectory, basename(sourcePath)));
} else if (fileExtension === '.onepkg') {
// Change the file extension so that the archive can be extracted
const archivePath = join(targetPath, `${fileNameNoExtension}.cab`);
await shim.fsDriver().copy(sourcePath, archivePath);
const extractPath = join(targetPath, fileNameNoExtension);
await shim.fsDriver().mkdir(extractPath);
await shim.fsDriver().cabExtract({
source: archivePath,
extractTo: extractPath,
// Only the .one files are used--there's no need to extract
// other files.
fileNamePattern: '*.one',
});
await this.fixIncorrectLatin1Decoding_(extractPath);
} else {
throw new Error(`Unknown file extension: ${fileExtension}`);
}
@@ -101,7 +84,7 @@ export default class InteropService_Importer_OneNote extends InteropService_Impo
const notebookFilePath = join(unzipTempDirectory, notebookFile.path);
// In some cases, the OneNote zip file can include folders and other files
// that shouldn't be imported directly. Skip these:
if (!['.one', '.onetoc2'].includes(extname(notebookFilePath).toLowerCase())) {
if (!['.one', '.onepkg', '.onetoc2'].includes(extname(notebookFilePath).toLowerCase())) {
logger.info('Skipping non-OneNote file:', notebookFile.path);
skippedFiles.push(notebookFile.path);
continue;
@@ -323,47 +306,4 @@ export default class InteropService_Importer_OneNote extends InteropService_Impo
changed: true,
};
}
// Works around a decoding issue in which file names are extracted as latin1 strings,
// rather than UTF-8 strings. For example, OneNote seems to encode filenames as UTF-8 in .onepkg files.
// However, EXPAND.EXE reads the filenames as latin1. As a result, "é.one" becomes
// "é.one" when extracted from the archive.
// This workaround re-encodes filenames as UTF-8.
private async fixIncorrectLatin1Decoding_(parentDir: string) {
// Only seems to be necessary on Windows.
if (!shim.isWindows()) return;
const fixEncoding = async (basePath: string, fileName: string) => {
const originalPath = join(basePath, fileName);
let newPath;
let fixedFileName = Buffer.from(fileName, 'latin1').toString('utf8');
if (fixedFileName !== fileName) {
// In general, the path shouldn't start with "."s or contain path separators.
// However, if it does, these characters might cause import errors, so remove them:
fixedFileName = fixedFileName.replace(/^\.+/, '');
fixedFileName = fixedFileName.replace(/[/\\]/g, ' ');
// Avoid path traversal: Ensure that the file path is contained within the base directory
const newFullPathSafe = shim.fsDriver().resolveRelativePathWithinDir(basePath, fixedFileName);
await shim.fsDriver().move(originalPath, newFullPathSafe);
newPath = newFullPathSafe;
} else {
newPath = originalPath;
}
if (await shim.fsDriver().isDirectory(originalPath)) {
const children = await shim.fsDriver().readDirStats(newPath, { recursive: false });
for (const child of children) {
await fixEncoding(originalPath, child.path);
}
}
};
const stats = await shim.fsDriver().readDirStats(parentDir, { recursive: false });
for (const stat of stats) {
await fixEncoding(parentDir, stat.path);
}
}
}

View File

@@ -153,6 +153,133 @@ jeudi 23 octobre 2025
- [x] Documenter configuration synchro JBS saml pour un utilisateur (case cochée)"
`;
exports[`InteropService_Importer_OneNote should correctly import .onepkg notebooks 1`] = `
"# Note: A
A
- [Test](:/id-here "Test")
# Note: A test
A test
A test
Tuesday, January 13, 2026
1:44 PM
…test…
# Note: Another section
Another section
- [Page 1](:/id-here "Page 1")
- [Page 2](:/id-here "Page 2")
# Note: B
B
- [Test page](:/id-here "Test page")
# Note: Page 1
Page 1
Page 1
Tuesday, January 13, 2026
1:42 PM
Test
# Note: Page 2
Page 2
Page 2
Tuesday, January 13, 2026
1:42 PM
![](:/id-here)
&nbsp;
# Note: Test
Test
Test
Tuesday, January 13, 2026
1:44 PM
# Note: Test page
Test page
Test page
Tuesday, January 13, 2026
1:45 PM
# Note: Testing…
Testing…
Testing…
Friday, November 28, 2025
2:47 PM
&nbsp;
&nbsp;
Link to page: [Page 2](onenote:https://d.docs.live.net/4c230b31b0dfb50f/Documents/OneNote%20Notebooks/test/Another%20section.one#Page%202&section-id={C271F3B1-5F22-457F-9DEA-F2B938D9B3D7}&page-id={62800B88-EC08-4170-BDB6-885CBB47FF99}&end)
# Note: Tést!
Tést!
- [Testing…](:/id-here "Testing…")
- [A test](:/id-here "A test")
# Note: Untitled Page 1
Untitled Page
Tuesday, January 13, 2026
1:45 PM
# Note: ⅀⸨ Unicode ⸩
⅀⸨ Unicode ⸩
- [Untitled Page 1](:/id-here "Untitled Page 1")"
`;
exports[`InteropService_Importer_OneNote should correctly import math formulas: Math 1`] = `
" Math

View File

@@ -214,12 +214,10 @@ async function tryToGuessExtFromMimeType(response: any, mediaPath: string) {
return newMediaPath;
}
const getFileExtension = (url: string, isDataUrl: boolean) => {
let fileExt = isDataUrl ? mimeUtils.toFileExtension(mimeUtils.fromDataUrl(url)) : safeFileExtension(fileExtension(url).toLowerCase());
if (!mimeUtils.fromFileExtension(fileExt)) fileExt = ''; // If the file extension is unknown - clear it.
if (fileExt) fileExt = `.${fileExt}`;
return fileExt;
};

View File

@@ -253,16 +253,16 @@ describe('Synchronizer.revisions', () => {
const getNoteRevisions = () => {
return Revision.allByType(BaseModel.TYPE_NOTE, note.id);
};
jest.advanceTimersByTime(200);
jest.advanceTimersByTime(500);
await Note.save({ id: note.id, title: 'note REV0' });
jest.advanceTimersByTime(200);
jest.advanceTimersByTime(500);
await revisionService().collectRevisions(); // REV0
expect(await getNoteRevisions()).toHaveLength(1);
const interimTime = Date.now();
jest.advanceTimersByTime(200);
jest.advanceTimersByTime(500);
await Note.save({ id: note.id, title: 'note REV1' });
await revisionService().collectRevisions(); // REV1
@@ -273,6 +273,10 @@ describe('Synchronizer.revisions', () => {
await switchClient(2);
await synchronizerStart();
// Prevent a race condition whereby a revision is downloaded via the sync, then one of the same revisions is updated within the same millisecond via
// deleteOldRevisions, and therefore is not uploaded via the sync because the sync_time matches
jest.advanceTimersByTime(500);
const revisions = await getNoteRevisions();
expect(revisions).toHaveLength(2);
expect(revisions[0].title_diff).toBe('[{"diffs":[[1,"note REV0"]],"start1":0,"start2":0,"length1":0,"length2":9}]');

View File

@@ -79,6 +79,7 @@ describe('urlUtils', () => {
['Bla [](:/11111111111111111111111111111111 "Some title") bla [](:/22222222222222222222222222222222 "something else") bla', ['11111111111111111111111111111111', '22222222222222222222222222222222']],
['Bla <img src=":/fcca2938a96a22570e8eae2565bc6b0b"/> bla [](:/22222222222222222222222222222222) bla', ['fcca2938a96a22570e8eae2565bc6b0b', '22222222222222222222222222222222']],
['Bla <img src=":/fcca2938a96a22570e8eae2565bc6b0b"/> bla <a href=":/33333333333333333333333333333333"/>Some note link</a> blu [](:/22222222222222222222222222222222) bla', ['fcca2938a96a22570e8eae2565bc6b0b', '33333333333333333333333333333333', '22222222222222222222222222222222']],
['Link to [a test note] and [another] note.\n\n[a test note]: :/fcca2938a96a22570e8eae2565bc6b0b\n[another]: :/f04a2938a26822570e8eae2505bc6b0c', ['fcca2938a96a22570e8eae2565bc6b0b', 'f04a2938a26822570e8eae2505bc6b0c']],
['nothing here', []],
['', []],
];

View File

@@ -94,13 +94,20 @@ export const fileUrlToResourceUrl = (fileUrl: string, resourceDir: string) => {
};
export const extractResourceUrls = (text: string) => {
const markdownLinksRE = /\]\((.*?)\)/g;
const markdownLinkRegexes = [
// Standard [link](...)-style links
/\]\((.*?)\)/g,
// Reference links
/\]:(.*?)(?:[\n]|$)/g,
];
const output = [];
let result = null;
while ((result = markdownLinksRE.exec(text)) !== null) {
const resourceUrlInfo = parseResourceUrl(result[1]);
if (resourceUrlInfo) output.push(resourceUrlInfo);
for (const regex of markdownLinkRegexes) {
while ((result = regex.exec(text)) !== null) {
const resourceUrlInfo = parseResourceUrl(result[1].trim());
if (resourceUrlInfo) output.push(resourceUrlInfo);
}
}
const htmlRegexes = [

View File

@@ -459,7 +459,7 @@ export function getPlans(stripeConfig: StripePublicConfig): Record<PlanName, Pla
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',
cfaUrl: 'https://tally.so/r/D4BlOE',
footnote: '',
learnMoreUrl: 'https://joplinapp.org/help/apps/joplin_server_business',
hostingType: PlanHostingType.Self,

View File

@@ -17,6 +17,12 @@ version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f26201604c87b1e01bd3d98f8d5d9a8fcbb815e8cedb41ffccbeb4bf593a35fe"
[[package]]
name = "adler2"
version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
[[package]]
name = "aho-corasick"
version = "0.7.15"
@@ -93,7 +99,7 @@ dependencies = [
"cc",
"cfg-if",
"libc",
"miniz_oxide",
"miniz_oxide 0.4.4",
"object",
"rustc-demangle",
]
@@ -113,12 +119,30 @@ version = "3.16.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "79296716171880943b8470b5f8d03aa55eb2e645a4874bdbb28adb49162e012c"
[[package]]
name = "byteorder"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
[[package]]
name = "bytes"
version = "1.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d71b6127be86fdcfddb610f7182ac57211d4b18a3e9c82eb2d17662f2227ad6a"
[[package]]
name = "cab"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "171228650e6721d5acc0868a462cd864f49ac5f64e4a42cde270406e64e404d2"
dependencies = [
"byteorder",
"flate2",
"lzxd",
"time",
]
[[package]]
name = "cc"
version = "1.0.96"
@@ -168,6 +192,24 @@ dependencies = [
"wasm-bindgen",
]
[[package]]
name = "crc32fast"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511"
dependencies = [
"cfg-if",
]
[[package]]
name = "deranged"
version = "0.5.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ececcb659e7ba858fb4f10388c250a7252eb0a27373f1a72b8748afdd248e587"
dependencies = [
"powerfmt",
]
[[package]]
name = "either"
version = "1.11.0"
@@ -204,6 +246,16 @@ dependencies = [
"once_cell",
]
[[package]]
name = "flate2"
version = "1.1.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b375d6465b98090a5f25b1c7703f3859783755aa9a80433b36e0379a3ec2f369"
dependencies = [
"crc32fast",
"miniz_oxide 0.8.9",
]
[[package]]
name = "getrandom"
version = "0.1.16"
@@ -270,6 +322,12 @@ version = "0.4.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "13dc2df351e3202783a1fe0d44375f7295ffb4049267b0f3018346dc122a1d94"
[[package]]
name = "lzxd"
version = "0.2.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7b29dffab797218e12e4df08ef5d15ab9efca2504038b1b32b9b32fc844b39c9"
[[package]]
name = "memchr"
version = "2.7.6"
@@ -302,6 +360,22 @@ dependencies = [
"autocfg",
]
[[package]]
name = "miniz_oxide"
version = "0.8.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316"
dependencies = [
"adler2",
"simd-adler32",
]
[[package]]
name = "num-conv"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9"
[[package]]
name = "num-traits"
version = "0.2.19"
@@ -455,6 +529,12 @@ version = "0.2.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bda66fc9667c18cb2758a2ac84d1167245054bcf85d5d1aaa6923f45801bdd02"
[[package]]
name = "powerfmt"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
[[package]]
name = "ppv-lite86"
version = "0.2.17"
@@ -553,6 +633,7 @@ version = "0.0.1"
dependencies = [
"askama",
"bytes",
"cab",
"color-eyre",
"console_error_panic_hook",
"encoding_rs",
@@ -652,6 +733,12 @@ dependencies = [
"lazy_static",
]
[[package]]
name = "simd-adler32"
version = "0.3.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2"
[[package]]
name = "siphasher"
version = "0.3.11"
@@ -710,6 +797,25 @@ dependencies = [
"once_cell",
]
[[package]]
name = "time"
version = "0.3.44"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "91e7d9e3bb61134e77bde20dd4825b97c010155709965fedf0f49bb138e52a9d"
dependencies = [
"deranged",
"num-conv",
"powerfmt",
"serde",
"time-core",
]
[[package]]
name = "time-core"
version = "0.1.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "40868e7c1d2f0b8d73e4a8c7f0ff63af4f6d19be117e90bd73eb1d62cf831c6b"
[[package]]
name = "tracing"
version = "0.1.40"

View File

@@ -4,7 +4,7 @@
"access": "public"
},
"description": "Used to import a OneNote archive into Joplin",
"version": "3.5.1",
"version": "3.6.0",
"license": "MPL-2.0-no-copyleft-exception",
"repository": "https://github.com/laurent22/joplin/tree/dev/packages/onenote-converter",
"main": "./renderer/pkg/renderer.js",

View File

@@ -28,10 +28,31 @@ function normalizeAndWriteFile(filePath, data) {
fs.writeFileSync(filePath, data);
}
function fileReader(path) {
const fd = fs.openSync(path);
const size = fs.fstatSync(fd).size;
return {
read: (position, length) => {
const data = Buffer.alloc(length);
const sizeRead = fs.readSync(fd, data, { length, position });
// Make data.size match the number of bytes read:
return data.subarray(0, sizeRead);
},
size: () => {
return size;
},
close: () => {
fs.closeSync(fd);
},
};
}
module.exports = {
mkdirSyncRecursive,
isDirectory,
readDir,
removePrefix,
normalizeAndWriteFile,
fileReader,
};

View File

@@ -1,4 +1,7 @@
use std::io::{Read, Seek};
pub type ApiResult<T> = std::result::Result<T, std::io::Error>;
pub trait FileHandle: Read + Seek {}
pub trait FileApiDriver: Send + Sync {
fn is_directory(&self, path: &str) -> ApiResult<bool>;
@@ -7,6 +10,7 @@ pub trait FileApiDriver: Send + Sync {
fn write_file(&self, path: &str, data: &[u8]) -> ApiResult<()>;
fn make_dir(&self, path: &str) -> ApiResult<()>;
fn exists(&self, path: &str) -> ApiResult<bool>;
fn open_file(&self, path: &str) -> ApiResult<Box<dyn FileHandle>>;
// These functions correspond to the similarly-named
// NodeJS path functions and should behave like the NodeJS

View File

@@ -1,6 +1,7 @@
pub mod api;
pub use api::ApiResult;
pub use api::FileApiDriver;
pub use api::FileHandle;
use lazy_static::lazy_static;
use std::sync::Arc;

View File

@@ -1,5 +1,6 @@
use super::ApiResult;
use super::FileApiDriver;
use super::FileHandle;
use std::fs;
use std::path;
use std::path::Path;
@@ -26,6 +27,10 @@ impl FileApiDriver for FileApiDriverImpl {
fs::read(path)
}
fn open_file(&self, path: &str) -> ApiResult<Box<dyn FileHandle>> {
Ok(Box::new(fs::File::open(path)?))
}
fn write_file(&self, path: &str, data: &[u8]) -> ApiResult<()> {
fs::write(path, data)
}
@@ -72,6 +77,8 @@ impl FileApiDriver for FileApiDriverImpl {
}
}
impl FileHandle for fs::File {}
#[cfg(test)]
mod test {
use crate::file_api::FileApiDriver;

View File

@@ -1,5 +1,7 @@
use super::ApiResult;
use super::FileApiDriver;
use super::FileHandle;
use std::io::{BufReader, Read, Seek, SeekFrom};
use wasm_bindgen::JsValue;
use wasm_bindgen::prelude::wasm_bindgen;
use web_sys::js_sys;
@@ -31,6 +33,27 @@ extern "C" {
#[wasm_bindgen(js_name = readDir, catch)]
fn read_dir_js(path: &str) -> std::result::Result<JsValue, JsValue>;
#[wasm_bindgen(js_name = fileReader, catch)]
fn open_file_handle(path: &str) -> std::result::Result<JsFileHandle, JsValue>;
}
#[wasm_bindgen]
extern "C" {
type JsFileHandle;
#[wasm_bindgen(structural, method, catch)]
fn read(
this: &JsFileHandle,
offset: usize,
size: usize,
) -> std::result::Result<Uint8Array, JsValue>;
#[wasm_bindgen(structural, method)]
fn size(this: &JsFileHandle) -> usize;
#[wasm_bindgen(structural, method, catch)]
fn close(this: &JsFileHandle) -> std::result::Result<(), JsValue>;
}
#[wasm_bindgen(module = "fs")]
@@ -97,6 +120,16 @@ impl FileApiDriver for FileApiDriverImpl {
}
}
fn open_file(&self, path: &str) -> ApiResult<Box<dyn FileHandle>> {
match open_file_handle(path) {
Ok(handle) => {
let file = BufReader::new(SeekableFileHandle { handle, offset: 0 });
Ok(Box::new(file))
}
Err(e) => Err(handle_error(e, &format!("opening file {}", path))),
}
}
fn write_file(&self, path: &str, data: &[u8]) -> ApiResult<()> {
if let Err(error) = write_file(path, data) {
Err(handle_error(error, &format!("writing file {}", path)))
@@ -138,3 +171,87 @@ impl FileApiDriver for FileApiDriverImpl {
join_path(path_1, path_2).unwrap().as_string().unwrap()
}
}
struct SeekableFileHandle {
handle: JsFileHandle,
offset: usize,
}
impl Read for SeekableFileHandle {
fn read(&mut self, out: &mut [u8]) -> std::io::Result<usize> {
let file_size = self.handle.size();
let bytes_remaining = if self.offset < file_size {
file_size - self.offset
} else {
0
};
let maximum_read_size = bytes_remaining.min(out.len());
match self.handle.read(self.offset, maximum_read_size) {
Ok(data) => {
let data = data.to_vec();
let size = data.len();
self.offset += size;
// Verify that handle.read respected the maximum length:
if size > out.len() {
return Err(std::io::Error::new(
std::io::ErrorKind::Other,
"Invariant violation: Size read must be less than or equal to the maximum_read_size.",
));
}
let (target_mem, padding) = out.split_at_mut(size);
target_mem.copy_from_slice(&data);
padding.fill(0);
Ok(size)
}
Err(error) => {
return Err(std::io::Error::new(
std::io::ErrorKind::Other,
format!("Read failed: {:?}.", error),
));
}
}
}
}
impl Seek for SeekableFileHandle {
fn seek(&mut self, pos: SeekFrom) -> std::io::Result<u64> {
match pos {
SeekFrom::Start(pos) => {
self.offset = pos as usize;
}
SeekFrom::Current(offset) => {
// Disallow seeking to a negative position
if offset < 0 && (-offset) as usize > self.offset {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
"Attempted to seek before the beginning of the file.",
));
}
self.offset = (self.offset as i64 + offset) as usize;
}
SeekFrom::End(offset) => {
self.offset = self.handle.size();
self.seek(SeekFrom::Current(offset))?;
}
}
Ok(self.offset as u64)
}
}
impl Drop for SeekableFileHandle {
fn drop(&mut self) {
if let Err(error) = self.handle.close() {
// Use web_sys directly -- log_warn! can't be used from within the parser-utils package:
let message: JsValue =
format!("OneNote converter: Failed to close file: Error: {error:?}").into();
web_sys::console::warn_1(&message);
}
}
}
impl FileHandle for BufReader<SeekableFileHandle> {}

View File

@@ -9,6 +9,7 @@ pub mod parse;
pub mod reader;
pub use errors::Result;
pub use file_api::FileHandle;
pub use file_api::fs_driver;
pub type Reader<'a, 'b> = &'b mut crate::reader::Reader<'a>;

View File

@@ -767,14 +767,9 @@ impl AttachmentInfo {
.into())
} else if self.data_ref.starts_with("<invfdo>") {
// "invalid"
log_warn!("Attempted to load an invalid {} file", self.extension);
Err(parser_error!(
ResolutionFailed,
"Unable to load invalid file reference: {} (ext: {})",
self.data_ref,
self.extension
)
.into())
log_warn!("Attempted to load an invalid {} file. Importing an empty file.", self.extension);
// Return empty data
Ok(FileBlob::default())
} else {
Err(parser_error!(
ResolutionFailed,

View File

@@ -76,10 +76,17 @@ impl Parser {
pub fn parse_section(&mut self, path: String) -> Result<Section> {
log!("Parsing section: {:?}", path);
let data = fs_driver().read_file(path.as_str())?;
self.parse_section_from_data(&data, &path)
}
/// Parse a OneNote section file from a byte array.
/// The [path] is used to provide debugging information and determine
/// the name of the section file.
pub fn parse_section_from_data(&mut self, data: &[u8], path: &str) -> Result<Section> {
let store = parse_onestore(&mut Reader::new(&data))?;
if store.get_type() != OneStoreType::Section {
return Err(ErrorKind::NotASectionFile { file: path }.into());
return Err(ErrorKind::NotASectionFile { file: String::from(path) }.into());
}
let filename = fs_driver()

View File

@@ -30,6 +30,7 @@ uuid = "1.1.2"
widestring = "1.0.2"
wasm-bindgen = "0.2"
lazy_static = "1.4"
cab = "0.6.0"
parser = { path = "../parser" }
parser-utils = { path = "../parser-utils" }

View File

@@ -1,9 +1,10 @@
use color_eyre::eyre::{Result, eyre};
pub use parser::Parser;
use std::panic;
use sanitize_filename::sanitize;
use std::{io::Read, panic};
use wasm_bindgen::{JsError, prelude::wasm_bindgen};
use parser_utils::{fs_driver, log};
use parser_utils::{FileHandle, fs_driver, log};
mod errors;
mod notebook;
@@ -34,8 +35,6 @@ fn _main(input_path: &str, output_dir: &str, base_path: &str) -> Result<()> {
}
pub fn convert(path: &str, output_dir: &str, base_path: &str) -> Result<()> {
let mut parser = Parser::new();
let extension: String = fs_driver().get_file_extension(path);
match extension.as_str() {
@@ -47,7 +46,7 @@ pub fn convert(path: &str, output_dir: &str, base_path: &str) -> Result<()> {
return Ok(());
}
let section = parser.parse_section(path.to_owned())?;
let section = Parser::new().parse_section(path.to_owned())?;
let section_output_dir = fs_driver().get_output_path(base_path, output_dir, path);
section::Renderer::new().render(&section, section_output_dir.to_owned())?;
@@ -56,7 +55,7 @@ pub fn convert(path: &str, output_dir: &str, base_path: &str) -> Result<()> {
let _name: String = fs_driver().get_file_name(path).expect("Missing file name");
log!("Parsing .onetoc2 file: {}", _name);
let notebook = parser.parse_notebook(path.to_owned())?;
let notebook = Parser::new().parse_notebook(path.to_owned())?;
let notebook_name = fs_driver()
.get_parent_dir(path)
@@ -71,8 +70,66 @@ pub fn convert(path: &str, output_dir: &str, base_path: &str) -> Result<()> {
notebook::Renderer::new().render(&notebook, &notebook_name, &notebook_output_dir)?;
}
".onepkg" => {
let file_data = fs_driver().open_file(path)?;
convert_onepkg(file_data, output_dir)?;
}
ext => return Err(eyre!("Invalid file extension: {}, file: {}", ext, path)),
}
Ok(())
}
fn convert_onepkg(file_data: Box<dyn FileHandle>, output_dir: &str) -> Result<()> {
// .onepkg files are cabinet files
let mut cabinet = cab::Cabinet::new(file_data)?;
let file_paths: Vec<String> = cabinet
.folder_entries()
.flat_map(|folder| folder.file_entries())
.map(|entry| String::from(entry.name()))
.collect();
let build_output_dir = |file_path_in_archive: &str| -> Result<(String, String)> {
let mut output_path = String::from(output_dir);
// Split on both "\"s and "/"s since CAB archives seem to use Windows-style paths,
// where both / and \ are valid path separators.
let is_path_separator = |c| c == '\\' || c == '/';
let path_segments: Vec<&str> = file_path_in_archive.split(is_path_separator).collect();
let path_segments_without_filename = &path_segments[0..path_segments.len() - 1];
for part in path_segments_without_filename {
output_path = fs_driver().join(&output_path, &sanitize(part));
fs_driver().make_dir(&output_path)?;
}
let file_name = path_segments.last().unwrap_or(&"");
Ok((output_path, sanitize(file_name)))
};
let mut parser = Parser::new();
for file_path in file_paths {
log!("File path {file_path}");
if !file_path.ends_with(".one") {
log!("Skipping non-section file {file_path}");
continue;
}
log!("Rendering {file_path}");
let data = {
let mut file_data = cabinet.read_file(&file_path)?;
let mut data = Vec::new();
file_data.read_to_end(&mut data)?;
data
};
let (output_path, file_name) = build_output_dir(&file_path)?;
let section = parser.parse_section_from_data(&data, &file_name)?;
section::Renderer::new().render(&section, output_path)?;
}
Ok(())
}

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