Compare commits

...

151 Commits

Author SHA1 Message Date
JYC333
89532cc503 refactor: address gemini code review 2026-03-09 12:20:36 +00:00
JYC333
0957421529 refactor: migrate label autocomplete 2026-03-09 10:42:42 +00:00
JYC333
e208bba7d6 refactor: limit ctrl+enter action only at when creating reation on relation map 2026-03-09 10:42:42 +00:00
JYC333
1e1ce020a9 refactor: fix cleanup to avoid DOM leaks 2026-03-09 10:42:42 +00:00
JYC333
a1898c4439 refactor: use ctrl+enter to confirm in relation creation at relation map page 2026-03-09 10:42:42 +00:00
JYC333
5dae3f2fb1 fix: relation definition is not included when create relation in relation map 2026-03-09 10:42:42 +00:00
JYC333
107ca9b503 refactor: clean up old autocomplete implementation 2026-03-09 10:42:42 +00:00
JYC333
55f6d13d0f refactor: migrate relation map 2026-03-09 10:42:42 +00:00
JYC333
32e5d51376 fix: dropdown menu not follow the input when attribute detail dialog height changed 2026-03-09 10:42:42 +00:00
JYC333
b1dd3aa9a5 refactor: use headless autocomplete, migrate attribute deatil 2026-03-09 10:42:41 +00:00
JYC333
89dfc0dbe4 refactor: add new autocomplete registry 2026-03-09 10:42:41 +00:00
JYC333
a3438e895a refactor: add plan and package 2026-03-09 10:42:41 +00:00
JYC333
0dcb8b3ff8 Translations update from Hosted Weblate (#8975) 2026-03-09 10:17:22 +00:00
JYC333
e4ddff01ca Update docs/README-sv.md
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
2026-03-09 10:13:43 +00:00
JYC333
015c1161d4 Update docs/README-sv.md
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
2026-03-09 10:11:43 +00:00
Robert Magnusson
ca0c6076c5 Translated using Weblate (Swedish)
Currently translated at 5.4% (21 of 387 strings)

Translation: Trilium Notes/Server
Translate-URL: https://hosted.weblate.org/projects/trilium/server/sv/
2026-03-09 05:47:59 +00:00
Robert Magnusson
80a02f88be Translated using Weblate (Swedish)
Currently translated at 1.3% (22 of 1676 strings)

Translation: Trilium Notes/Client
Translate-URL: https://hosted.weblate.org/projects/trilium/client/sv/
2026-03-09 05:47:58 +00:00
Robert Magnusson
430833bedb Translated using Weblate (Swedish)
Currently translated at 13.2% (21 of 158 strings)

Translation: Trilium Notes/Website
Translate-URL: https://hosted.weblate.org/projects/trilium/website/sv/
2026-03-09 05:47:57 +00:00
Hosted Weblate
dc80d83964 Update translation files
Updated by "Cleanup translation files" add-on in Weblate.

Translation: Trilium Notes/README
Translate-URL: https://hosted.weblate.org/projects/trilium/readme/
2026-03-09 05:47:56 +00:00
Elian Doran
5f7ade45f4 fix(deps): update dependency katex to v0.16.38 (#8969) 2026-03-09 07:47:35 +02:00
Elian Doran
8b36a7ab1e Spreadsheet experiment v0.5 (#8966) 2026-03-09 07:47:08 +02:00
Elian Doran
fd18276693 fix(deps): update dependency @preact/signals to v2.8.2 (#8968) 2026-03-09 07:46:47 +02:00
Elian Doran
0becfc16ba chore(deps): update pnpm to v10.31.0 (#8971) 2026-03-09 07:46:02 +02:00
renovate[bot]
d480d1f6ba chore(deps): update pnpm to v10.31.0 2026-03-09 01:36:51 +00:00
renovate[bot]
f5c9a71ba0 fix(deps): update dependency katex to v0.16.38 2026-03-09 01:35:41 +00:00
renovate[bot]
c177a8a464 fix(deps): update dependency @preact/signals to v2.8.2 2026-03-09 01:34:42 +00:00
Elian Doran
c826564c9e chore(spreadsheet): address requested changes 2026-03-08 23:25:47 +02:00
Elian Doran
ccb13fa6b9 fix(commons): typecheck 2026-03-08 23:19:23 +02:00
Elian Doran
69e374138f fix(spreadsheet): missing some CSS imports 2026-03-08 23:07:48 +02:00
Elian Doran
3156b2cb59 feat(spreadsheet): enable conditional formatting 2026-03-08 23:02:54 +02:00
Elian Doran
d6217ffed4 feat(spreadsheet): enable data validation 2026-03-08 22:59:41 +02:00
Elian Doran
fc90c6af9d feat(spreadsheet): enable sorting 2026-03-08 22:56:11 +02:00
Elian Doran
a1118419ec feat(spreadsheet): enable filtering 2026-03-08 22:53:04 +02:00
Elian Doran
8599785ee8 refactor(spreadsheet): use multiple modules 2026-03-08 22:39:43 +02:00
Elian Doran
99ba192a44 feat(spreadsheet): allow triggering find/replace from context menu 2026-03-08 22:35:08 +02:00
Elian Doran
b86d3587ac feat(spreadsheet): basic integration of find/replace 2026-03-08 22:24:03 +02:00
Elian Doran
b2a0baf56a fix(spreadsheet): jumping when editing in another split 2026-03-08 22:15:29 +02:00
Elian Doran
22f37817e5 fix(spreadsheet): fix The column width is less than 0 when switching tabs 2026-03-08 22:01:45 +02:00
Elian Doran
6b4fe03625 fix(spreadsheet): mitigate The column width is less than 0, need to adjust page width to make it great than 0 when changing an inactive tab 2026-03-08 21:57:26 +02:00
Elian Doran
f44b47ec23 fix(client): tabs still rendering in the background 2026-03-08 21:48:45 +02:00
Elian Doran
8d667e838a feat(spreadsheet): hide cell protection mechanism 2026-03-08 21:28:12 +02:00
Elian Doran
f32385de2e feat(spreadsheet): hide toolbars while in read-only 2026-03-08 21:24:24 +02:00
Elian Doran
90796fc4fa feat(spreadsheet): basic read-only support 2026-03-08 21:09:11 +02:00
Elian Doran
4960c49cb2 feat(spreadsheet): add note plugin 2026-03-08 20:39:07 +02:00
Elian Doran
b112e8b56b feat(spreadsheet): basic support for note revision using image 2026-03-08 20:30:24 +02:00
Elian Doran
83095130f6 feat(spreadsheet): basic rendering as HTML for share 2026-03-08 20:04:14 +02:00
Elian Doran
d005c0ef2d feat(spreadsheet): basic note list preview using SVG 2026-03-08 19:49:53 +02:00
Elian Doran
c135578626 fix(spreadsheet): not focusing on tab switch 2026-03-08 13:05:47 +02:00
Elian Doran
9a6e20029e fix(client): all tabs loaded in the background 2026-03-08 12:59:57 +02:00
Elian Doran
39bd4ccea1 Merge branch 'main' of https://github.com/TriliumNext/Trilium 2026-03-08 12:59:44 +02:00
Elian Doran
ea7aac2030 v0.102.1 (#8961) 2026-03-08 12:13:12 +02:00
Elian Doran
e7f98f08d0 Merge remote-tracking branch 'origin/main' into stable 2026-03-08 12:12:52 +02:00
Elian Doran
8ac9daa5d3 chore(release): prepare for v0.102.1 2026-03-08 10:43:59 +02:00
Elian Doran
0b506c6327 chore(pdfjs): bump pdfjs viewer version 2026-03-08 10:41:21 +02:00
Elian Doran
d2b62540ec fix(ci): migrate all the jank docker ci to use crane instead (#8869) 2026-03-08 10:37:49 +02:00
Elian Doran
64418c7fec docs(release): prepare for v0.102.1 2026-03-08 10:36:06 +02:00
Elian Doran
8c1a58e64f fix(pdf): cache buster not working in all circumstances 2026-03-08 10:29:57 +02:00
Adorian Doran
b27fd31c1f style/pdf viewer: fix some layout issues in toolbar 2026-03-08 10:25:05 +02:00
Elian Doran
f18a531924 fix(mindmap): crashing on auto-switch to dark theme (closes #8879) 2026-03-08 10:22:21 +02:00
Elian Doran
3cabb4b661 fix(pdf): not accessible on Nginx Proxy Manager with block common exploits (closes #8877) 2026-03-08 09:30:27 +02:00
Elian Doran
5c88b1c6b8 chore(server): add infrastructure for running Nginx Proxy Manager 2026-03-08 09:01:47 +02:00
Elian Doran
c2adc43780 chore(deps): update dependency @types/multer to v2.1.0 (#8921) 2026-03-07 23:17:45 +02:00
Elian Doran
7eaa5352ba Translations update from Hosted Weblate (#8956) 2026-03-07 23:17:15 +02:00
Patric Siesing
17e3e3187b Translated using Weblate (Swedish)
Currently translated at 4.6% (18 of 387 strings)

Translation: Trilium Notes/Server
Translate-URL: https://hosted.weblate.org/projects/trilium/server/sv/
2026-03-07 22:15:57 +01:00
Robert Magnusson
2ad7cd3a49 Translated using Weblate (Swedish)
Currently translated at 4.6% (18 of 387 strings)

Translation: Trilium Notes/Server
Translate-URL: https://hosted.weblate.org/projects/trilium/server/sv/
2026-03-07 22:15:56 +01:00
Patric Siesing
39aa8d61c2 Translated using Weblate (Swedish)
Currently translated at 11.3% (18 of 158 strings)

Translation: Trilium Notes/Website
Translate-URL: https://hosted.weblate.org/projects/trilium/website/sv/
2026-03-07 22:15:55 +01:00
Robert Magnusson
1a3ea977b7 Translated using Weblate (Swedish)
Currently translated at 11.3% (18 of 158 strings)

Translation: Trilium Notes/Website
Translate-URL: https://hosted.weblate.org/projects/trilium/website/sv/
2026-03-07 22:15:54 +01:00
Robert Magnusson
4cd8f9a1e6 Translated using Weblate (Swedish)
Currently translated at 1.0% (18 of 1676 strings)

Translation: Trilium Notes/Client
Translate-URL: https://hosted.weblate.org/projects/trilium/client/sv/
2026-03-07 22:15:53 +01:00
Hosted Weblate
87ce6d1231 Update translation files
Updated by "Cleanup translation files" add-on in Weblate.

Translation: Trilium Notes/README
Translate-URL: https://hosted.weblate.org/projects/trilium/readme/
2026-03-07 22:15:52 +01:00
Elian Doran
8fdbeacf77 fix(deps): update dependency katex to v0.16.37 (#8935) 2026-03-07 23:15:44 +02:00
Elian Doran
f4f775a1c9 chore(deps): update dependency @smithy/middleware-retry to v4.4.40 (#8945) 2026-03-07 23:13:13 +02:00
Elian Doran
fe1154cb2d chore(deps): update dependency @types/sanitize-html to v2.16.1 (#8946) 2026-03-07 23:12:53 +02:00
Elian Doran
638f479ff3 chore(deps): update dependency eslint to v10.0.3 (#8947) 2026-03-07 23:12:27 +02:00
Elian Doran
70436bdb04 fix(deps): update dependency react-i18next to v16.5.6 (#8949) 2026-03-07 23:12:05 +02:00
Elian Doran
575ecaae07 fix(deps): update dependency tabulator-tables to v6.4.0 (#8950) 2026-03-07 23:11:37 +02:00
Elian Doran
d277e6db94 chore(deps): update actions/upload-artifact action to v7 (#8951) 2026-03-07 08:29:42 +02:00
renovate[bot]
25efcd12d0 chore(deps): update actions/upload-artifact action to v7 2026-03-07 02:18:00 +00:00
renovate[bot]
10129321be fix(deps): update dependency tabulator-tables to v6.4.0 2026-03-07 02:17:54 +00:00
renovate[bot]
72710a8f6b chore(deps): update dependency @types/multer to v2.1.0 2026-03-07 02:17:10 +00:00
renovate[bot]
6a7c5c04d8 fix(deps): update dependency react-i18next to v16.5.6 2026-03-07 02:16:21 +00:00
renovate[bot]
5d89591dea chore(deps): update dependency eslint to v10.0.3 2026-03-07 02:14:21 +00:00
renovate[bot]
a88bf5a87b chore(deps): update dependency @types/sanitize-html to v2.16.1 2026-03-07 02:13:18 +00:00
renovate[bot]
bbe5d3506e chore(deps): update dependency @smithy/middleware-retry to v4.4.40 2026-03-07 02:12:12 +00:00
renovate[bot]
c2993d4e7d fix(deps): update dependency katex to v0.16.37 2026-03-06 21:42:06 +00:00
Elian Doran
17ba479182 chore(deps): update dependency @smithy/middleware-retry to v4.4.39 (#8906) 2026-03-06 19:01:41 +02:00
Elian Doran
a465014bbe fix(deps): update codemirror (#8885) 2026-03-06 19:01:13 +02:00
Elian Doran
5dfe253ef6 chore(deps): update imjasonh/setup-crane action to v0.5 (#8910) 2026-03-06 19:00:14 +02:00
Elian Doran
ae7ca6021f Translations update from Hosted Weblate (#8919) 2026-03-06 18:57:49 +02:00
noobhjy
c389697acd Translated using Weblate (Chinese (Simplified Han script))
Currently translated at 100.0% (1676 of 1676 strings)

Translation: Trilium Notes/Client
Translate-URL: https://hosted.weblate.org/projects/trilium/client/zh_Hans/
2026-03-06 16:50:15 +00:00
Aleksandr Reid
c13c3e0f4a Translated using Weblate (Russian)
Currently translated at 100.0% (1676 of 1676 strings)

Translation: Trilium Notes/Client
Translate-URL: https://hosted.weblate.org/projects/trilium/client/ru/
2026-03-06 16:50:14 +00:00
Ulices
82c042d045 Translated using Weblate (Spanish)
Currently translated at 100.0% (1676 of 1676 strings)

Translation: Trilium Notes/Client
Translate-URL: https://hosted.weblate.org/projects/trilium/client/es/
2026-03-06 16:50:14 +00:00
Aleksandr Reid
9145ba1690 Translated using Weblate (Russian)
Currently translated at 100.0% (387 of 387 strings)

Translation: Trilium Notes/Server
Translate-URL: https://hosted.weblate.org/projects/trilium/server/ru/
2026-03-06 16:50:13 +00:00
Marcel
d60653ee17 Translated using Weblate (German)
Currently translated at 100.0% (158 of 158 strings)

Translation: Trilium Notes/Website
Translate-URL: https://hosted.weblate.org/projects/trilium/website/de/
2026-03-06 16:50:12 +00:00
Marcel
dae8613b4e Translated using Weblate (German)
Currently translated at 100.0% (1676 of 1676 strings)

Translation: Trilium Notes/Client
Translate-URL: https://hosted.weblate.org/projects/trilium/client/de/
2026-03-06 16:50:12 +00:00
Aindriú Mac Giolla Eoin
2f8e2c40be Translated using Weblate (Irish)
Currently translated at 100.0% (1676 of 1676 strings)

Translation: Trilium Notes/Client
Translate-URL: https://hosted.weblate.org/projects/trilium/client/ga/
2026-03-06 16:50:11 +00:00
Francis C.
d85225a0dc Translated using Weblate (Chinese (Traditional Han script))
Currently translated at 100.0% (1676 of 1676 strings)

Translation: Trilium Notes/Client
Translate-URL: https://hosted.weblate.org/projects/trilium/client/zh_Hant/
2026-03-06 16:50:11 +00:00
green
0cb66df2b2 Translated using Weblate (Japanese)
Currently translated at 100.0% (1676 of 1676 strings)

Translation: Trilium Notes/Client
Translate-URL: https://hosted.weblate.org/projects/trilium/client/ja/
2026-03-06 16:50:10 +00:00
Aleksandr Reid
92e0578cb6 Translated using Weblate (Russian)
Currently translated at 100.0% (158 of 158 strings)

Translation: Trilium Notes/Website
Translate-URL: https://hosted.weblate.org/projects/trilium/website/ru/
2026-03-06 16:50:09 +00:00
Elian Doran
2eee06786e chore(deps): update dependency lint-staged to v16.3.2 (#8908) 2026-03-06 18:50:00 +02:00
Elian Doran
19053dcb3b fix(deps): update dependency mind-elixir to v5.9.2 (#8909) 2026-03-06 18:49:24 +02:00
JYC333
e10c30c59f fix(deps): update dependency i18next to v25.8.14 (#8922) 2026-03-06 14:12:55 +00:00
Elian Doran
c356159664 fix(deps): update dependency marked to v17.0.4 (#8923) 2026-03-06 15:45:12 +02:00
Elian Doran
579be68ca1 chore(deps): update dependency electron to v40.8.0 (#8924) 2026-03-06 15:28:24 +02:00
Elian Doran
a6326a682e chore(deps): update dependency @types/node to v24.12.0 (#8934) 2026-03-06 15:27:19 +02:00
renovate[bot]
4595a3a5dd fix(deps): update dependency i18next to v25.8.14 2026-03-06 12:42:27 +00:00
renovate[bot]
ee21185e64 chore(deps): update dependency electron to v40.8.0 2026-03-06 12:39:17 +00:00
Elian Doran
6d0676c37d chore(deps): update docker/login-action action to v4 (#8925) 2026-03-06 14:38:39 +02:00
Elian Doran
1d4768a581 chore(deps): update docker/setup-qemu-action action to v4 (#8926) 2026-03-06 14:38:14 +02:00
Elian Doran
d086bb7fcb chore(deps): update dependency multer to v2.1.1 [security] (#8929) 2026-03-06 14:37:33 +02:00
Elian Doran
2607c4a32e fix(deps): update dependency react-i18next to v16.5.5 (#8936) 2026-03-06 14:37:12 +02:00
Elian Doran
624333a2ef chore(deps): update dependency express-rate-limit to v8.3.0 (#8937) 2026-03-06 14:36:43 +02:00
Elian Doran
d4acb37f21 chore(deps): update dependency ejs to v5 (#8938) 2026-03-06 14:36:24 +02:00
Elian Doran
6c1a1e9812 chore(deps): update docker/build-push-action action to v7 (#8939) 2026-03-06 13:19:16 +02:00
Elian Doran
9a13641f9b chore(deps): update docker/metadata-action action to v6 (#8940) 2026-03-06 13:18:33 +02:00
renovate[bot]
699e0624c9 chore(deps): update docker/setup-qemu-action action to v4 2026-03-06 06:58:29 +00:00
renovate[bot]
47ceb0d4d2 chore(deps): update docker/metadata-action action to v6 2026-03-06 06:58:27 +00:00
renovate[bot]
15c42f4a09 chore(deps): update docker/login-action action to v4 2026-03-06 06:58:24 +00:00
renovate[bot]
bf8401bb26 chore(deps): update docker/build-push-action action to v7 2026-03-06 06:58:21 +00:00
renovate[bot]
f234433c63 chore(deps): update dependency ejs to v5 2026-03-06 06:58:18 +00:00
renovate[bot]
1b70101123 chore(deps): update imjasonh/setup-crane action to v0.5 2026-03-06 06:57:50 +00:00
renovate[bot]
d610c63c28 chore(deps): update dependency express-rate-limit to v8.3.0 2026-03-06 06:57:17 +00:00
renovate[bot]
5e820a407f chore(deps): update dependency @types/node to v24.12.0 2026-03-06 06:56:18 +00:00
renovate[bot]
62610979b7 fix(deps): update dependency react-i18next to v16.5.5 2026-03-06 06:55:50 +00:00
renovate[bot]
700e99e854 fix(deps): update dependency mind-elixir to v5.9.2 2026-03-06 06:55:19 +00:00
renovate[bot]
7767116b3d fix(deps): update dependency marked to v17.0.4 2026-03-06 06:54:40 +00:00
renovate[bot]
0206e8247b fix(deps): update codemirror 2026-03-06 06:52:48 +00:00
renovate[bot]
5476fe3df9 chore(deps): update dependency lint-staged to v16.3.2 2026-03-06 06:50:46 +00:00
renovate[bot]
d9a4581d37 chore(deps): update dependency @smithy/middleware-retry to v4.4.39 2026-03-06 06:49:46 +00:00
renovate[bot]
8d9c888481 chore(deps): update dependency multer to v2.1.1 [security] 2026-03-06 06:46:38 +00:00
Elian Doran
11e4b672d1 Fix CI test issues (#8932) 2026-03-06 08:43:51 +02:00
Elian Doran
bace3daadc Update apps/server/src/routes/session_parser.ts
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-03-06 08:43:31 +02:00
Elian Doran
dee5380e60 fix(ci): sequential tests ended up run in parallel 2026-03-06 08:20:17 +02:00
Elian Doran
bc6a6fd860 Revert "test(server): reset ws module"
This reverts commit 0212398815.
2026-03-05 23:44:24 +02:00
Elian Doran
e928337fe9 test(server): adjust timeout 2026-03-05 23:40:43 +02:00
Elian Doran
432f86ea4b Revert "test(server): switch to forks with 2 max workers"
This reverts commit 4ac22678df.
2026-03-05 23:37:28 +02:00
Elian Doran
5d2daecee0 test(server): switch to forks with 6 max workers 2026-03-05 23:35:15 +02:00
Elian Doran
7c8eb311af test(server): switch to forks with 3 max workers 2026-03-05 23:31:54 +02:00
Elian Doran
4ac22678df test(server): switch to forks with 2 max workers 2026-03-05 23:25:45 +02:00
Elian Doran
5057c02176 test(server): fix errors due to database already existing 2026-03-05 22:52:26 +02:00
Elian Doran
d301e56216 refactor(server): don't set up other timers on module init 2026-03-05 22:19:04 +02:00
Elian Doran
3c22ab8c9c refactor(server): don't set up session timer on module init 2026-03-05 22:17:19 +02:00
Elian Doran
0212398815 test(server): reset ws module 2026-03-05 22:14:34 +02:00
Elian Doran
db0c515bad test(server): fake timers not restored 2026-03-05 22:11:51 +02:00
Elian Doran
9b4f8c5003 feat(ci/client): HTML output 2026-03-05 22:07:11 +02:00
Elian Doran
85d8c4c8fa feat(ci/server): HTML output 2026-03-05 22:06:46 +02:00
Elian Doran
5afab6938a test(server): reduce max workers to 1 2026-03-05 21:54:30 +02:00
Elian Doran
a437169ad5 test(server): increase hook timeout 2026-03-05 21:20:12 +02:00
Elian Doran
f632d3aeb6 Merge remote-tracking branch 'origin/main' into fix/ci 2026-03-05 21:14:57 +02:00
Elian Doran
513fffcb1a ci(dev): escape test filter 2026-03-05 21:14:21 +02:00
Elian Doran
c80bb9657c fix(mindmap): crashing on auto-switch to dark theme 2026-03-05 19:25:07 +02:00
Elian Doran
0ca179f990 ci(test): quote command 2026-03-05 18:40:24 +02:00
80 changed files with 3631 additions and 1544 deletions

View File

@@ -0,0 +1,223 @@
# Migration Plan: `autocomplete.js` → `@algolia/autocomplete-js`
> Issue: https://github.com/TriliumNext/Trilium/issues/5134
>
> 目标:将旧的 `autocomplete.js@0.38.1`jQuery 插件)迁移到 `@algolia/autocomplete-js`(独立组件)
---
## 当前状态总览
### 两个库的架构差异
| | 旧 `autocomplete.js` | 新 `@algolia/autocomplete-js` |
|---|---|---|
| 模式 | jQuery 插件,**增强已有 `<input>`** | 独立组件,**传入容器 `<div>`,自己创建 `<input>`** |
| 初始化 | `$el.autocomplete(config, [datasets])` | `autocomplete({ container, getSources, ... })` 返回 `api` |
| 操作 | `$el.autocomplete("open"/"close"/"val")` | `api.setIsOpen()`, `api.setQuery()`, `api.refresh()` |
| 销毁 | `$el.autocomplete("destroy")` | `api.destroy()` |
| 事件 | jQuery 事件 `autocomplete:selected` | `onSelect` 回调、`onStateChange` 回调 |
| DOM | 增强已有 input添加 `.aa-input` 类 | 替换容器内容为自己的 DOM`.aa-Form``.aa-Panel` 等) |
### 关键迁移原则
1. **不使用 wrapper/适配层**,直接在各 service 中调用 `autocomplete()` API
2. 消费者代码需要适配:传入容器 `<div>` 而非 `<input>`,通过 API/回调读写值
3. 增量迁移:每个使用点独立迁移,逐一验证
### 涉及的功能区域
1. **属性名称自动补全**`attribute_autocomplete.ts``attribute_detail.ts``RelationMap.tsx`
2. **标签值自动补全**`attribute_autocomplete.ts``attribute_detail.ts`
3. **笔记搜索自动补全**`note_autocomplete.ts``NoteAutocomplete.tsx``attribute_detail.ts`
4. **关闭弹出窗口**`dialog.ts``entrypoints.ts``tab_manager.ts`
5. **CKEditor 提及** — 不使用 autocomplete.js**无需迁移**
---
## 迁移步骤
### Step 0: 安装新依赖 ✅ 完成
**文件变更:**
- `apps/client/package.json` — 添加 `@algolia/autocomplete-js@1.19.6`,暂时保留 `autocomplete.js`
**验证方式:**
- ✅ 新依赖安装成功
---
### Step 1: 迁移属性名称自动补全 ✅ 完成
**文件变更:**
- `apps/client/src/services/attribute_autocomplete.ts` — 将 `initAttributeNameAutocomplete()` 完全使用 **Headless API (`@algolia/autocomplete-core`)** 重写,移除遗留的 jQuery autocomplete 调用。
- `apps/client/src/widgets/attribute_widgets/attribute_detail.ts` — 维持原有 `<input>` 模型不变,仅需增加 `onValueChange` 处理回调。
- `apps/client/src/widgets/type_widgets/relation_map/RelationMap.tsx` — 维持原有回调逻辑,新旧无感替换。
- `apps/client/src/stylesheets/style.css` — 增加自定义 Headless 渲染面板样式 (`.aa-core-panel``.aa-core-list` 等)。
**架构说明:**
由于 Trilium 依赖同一页面同时运行多个 autocomplete 生命周期(边栏属性列表,底部编辑器等),原生 `@algolia/autocomplete-js` 会因为单例 DOM 冲突强行报错 "doesn't support multiple instances running at the same time"。
解决方案是退化使用纯状态机的 `@algolia/autocomplete-core`,自己进行 DOM 劫持与面板渲染。
- `requestAnimationFrame`:针对下拉层自动跟踪光标位置,适配面板的高频大小变化
- 事件阻断:拦截了选择时候的 `Enter` 返回键事件气泡,避免误触外层 Dialog 销毁。
**验证方式:**
- ✅ 打开一个笔记 → 点击属性面板弹出 "Label detail" → 输入属性名称时正常显示下拉自动补全框
- ✅ 放大/缩小/变形整个面板,下拉菜单粘连位置准确
- ✅ 键盘上下方向键可高亮,按 Enter 可选中当前项填充,且对话框不关闭
- ✅ 关系图 (Relation map) 创建关系时,关系名输入框的自动补全同样工作正常
---
### Step 2: 迁移标签值自动补全 ✅ 完成
**文件变更:**
- `apps/client/src/services/attribute_autocomplete.ts` — 移除旧有的 jQuery `$el.autocomplete` 初始化,整体复用封装的 `@algolia/autocomplete-core` Headless 架构流。在内部设计了一套针对 Label Name 值更变时的 `cachedAttributeName` 以及 `getItems` 数据惰性更新机制。
- `apps/client/src/widgets/attribute_widgets/attribute_detail.ts` — 取消监听不标准的 jQuery 强盗冒泡事件 `autocomplete:closed`,改为直接在配置中传入清晰的 `onValueChange` 回调函数。同时解决了所有输入遗留 Bug。
**说明与优化点:**
与 Step 1 类似,同样完全剔除了所有的残旧依赖与 jQuery 控制流,在此基础上还针对值类型的特异性做了几个高级改动:
1. **取消内存破坏型重建 (Fix Memory Leak)**:旧版本在每次触发聚焦 (Focus) 时都会发送摧毁指令强扫 DOM。新架构下只要容器保持存活就仅仅使用 `.refresh()` 接口来控制界面弹出与数据隐式获取。
2. **惰性与本地缓存 (Local Fast CACHE)**:如果关联的属性名 (Attribute Name) 没有被更改,再次打开提示面板时将以 0ms 的延迟抛出旧缓存 `cachedAttributeValues`。一旦属性名被修改,则重新发起服务端网络请求。
3. **彻底分离逻辑**:删除了文件中的 `still using old autocomplete.js` 遗留注释,此时 `attribute_autocomplete.ts` 文件内已经 100% 运行在崭新的 Autocomplete 体系上。
**验证方式:**
- ✅ 打开属性面板 → 点击或输入任意已有 Label 类型的 Name → 切换到值输入框 → 能瞬间弹出相应的旧值提示列表。
- ✅ 在旧值提示列表中用上下方向键选取并回车 → 能实现无缝填充并将更变保存回右侧详细侧边栏。
- ✅ 解决回车冲突:确认选择时系统发出的事件能干净落回所属宿主 DOM 且并不抢占外层组件快捷键。
---
### Step 3: 迁移笔记搜索自动补全核心
**文件变更:**
- `apps/client/src/services/note_autocomplete.ts``initNoteAutocomplete()` 改为直接调用 `autocomplete()`
**说明:**
这是迁移中最复杂的部分,`initNoteAutocomplete()` 包含:
- 复杂的 source 函数带防抖、IME 处理)
- 自定义 suggestion 模板(图标、路径高亮)
- 多种选择类型分发(笔记、外部链接、命令)
- `autocomplete("val", ...)` 等操作性 API 调用
- 附带的辅助按钮(清除、最近笔记、全文搜索、跳转按钮)
消费者通过自定义 jQuery 事件(`autocomplete:noteselected` 等)接收结果,需要保持这些事件或改为回调。
**验证方式:**
- 搜索栏 → 输入笔记名称 → 应能看到搜索结果
- 选择结果 → 应正确跳转到对应笔记
- 命令面板(`>` 前缀)正常工作
- 中文输入法不应中途触发搜索
- Shift+Enter 全文搜索、Ctrl+Enter 搜索笔记
---
### Step 4: 迁移辅助函数
**文件变更:**
- `apps/client/src/services/note_autocomplete.ts``clearText`, `setText`, `showRecentNotes` 等函数
**说明:**
这些函数使用旧库的操作 API`$el.autocomplete("val", value)` 等),需要改为新库的 `api.setQuery()` / `api.setIsOpen()` / `api.refresh()`
**验证方式:**
- 最近笔记按钮 → 下拉菜单正常打开
- 清除按钮 → 输入框被清空
- Shift+Enter → 触发全文搜索
---
### Step 5: 迁移 `NoteAutocomplete.tsx` (React/Preact 组件)
**文件变更:**
- `apps/client/src/widgets/react/NoteAutocomplete.tsx` — 传入容器 `<div>`,管理 `api` 生命周期
**验证方式:**
- 关系属性的目标笔记选择正常工作
- `noteId``text` props 的动态更新正确
---
### Step 6: 迁移"关闭弹窗"逻辑 + `attribute_detail.ts` 引用
**文件变更:**
- `apps/client/src/services/dialog.ts` — 替换 `$(".aa-input").autocomplete("close")`
- `apps/client/src/components/entrypoints.ts` — 替换 `$(".aa-input").autocomplete("close")`
- `apps/client/src/components/tab_manager.ts` — 替换 `$(".aa-input").autocomplete("close")`
- `apps/client/src/widgets/attribute_widgets/attribute_detail.ts` — 更新 `.algolia-autocomplete` 选择器
**说明:**
需要一个全局的"关闭所有 autocomplete"机制。方案:维护一个全局 `Set<AutocompleteApi>`,在各处调用时遍历关闭。可以放在 `note_autocomplete.ts` 中导出。
**验证方式:**
- autocomplete 弹窗打开时切换标签页 → 弹窗自动关闭
- autocomplete 弹窗打开时打开对话框 → 弹窗自动关闭
- 点击 autocomplete 下拉菜单时属性面板不应关闭
---
### Step 7: 更新 CSS 样式
**文件变更:**
- `apps/client/src/stylesheets/style.css`(第 895-961 行)
**说明:**
新库使用的 CSS 类名:
- `.aa-Autocomplete` — 容器
- `.aa-Form` — 搜索表单(含 input
- `.aa-Input` — 输入框
- `.aa-Panel` — 下拉面板
- `.aa-List` — 列表
- `.aa-Item` — 列表项
- `.aa-Item[aria-selected="true"]` — 选中项
**验证方式:**
- 下拉菜单样式正常(亮色/暗色模式)
- 选中项高亮正确
---
### Step 8: 更新类型声明
**文件变更:**
- `apps/client/src/types.d.ts` — 移除 `AutoCompleteConfig``AutoCompleteArg`、jQuery `.autocomplete()` 方法
**验证方式:**
- TypeScript 编译无错误
---
### Step 9: 移除旧库和 Polyfill
**文件变更:**
- `apps/client/package.json` — 移除 `"autocomplete.js": "0.38.1"`
- `apps/client/src/desktop.ts` — 移除 `import "autocomplete.js/index_jquery.js";`
- `apps/client/src/mobile.ts` — 移除 `import "autocomplete.js/index_jquery.js";`
- `apps/client/src/runtime.ts` — 移除 jQuery polyfill
- `apps/client/src/index.ts` — 移除 jQuery polyfill
**验证方式:**
- 完整回归测试
- 构建无错误
---
### Step 10: 更新 E2E 测试
**文件变更:**
- `apps/server-e2e/src/support/app.ts`
- `apps/server-e2e/src/layout/split_pane.spec.ts`
**验证方式:**
- E2E 测试全部通过
---
## 依赖关系图
```
Step 0 (安装新库) ✅
├── Step 1 (属性名称 autocomplete) ← 最简单,优先迁移
├── Step 2 (标签值 autocomplete)
├── Step 3 (笔记搜索 autocomplete 核心) ← 最复杂
│ ├── Step 4 (辅助函数)
│ └── Step 5 (React 组件)
├── Step 6 (关闭弹窗 + attribute_detail 引用)
└── Step 7 (CSS 样式)
└── Step 8 (类型声明)
└── Step 9 (移除旧库) ← 最后执行
└── Step 10 (E2E 测试)
```
## 风险点
1. **消费者代码需要改动**:新库要求传入容器而非 input消费者需要调整 HTML 模板和值的读写方式。
2. **自定义事件兼容性**:旧库通过 jQuery 事件与外部交互,新库使用回调,`attribute_detail.ts` 等消费者中的事件监听需要更新。
3. **IME 输入处理**:新库原生支持 `ignoreCompositionEvents` 选项,但需要验证行为是否与旧的手动处理一致。
4. **CSS 类名变化**:多处代码通过 `.aa-input``.algolia-autocomplete` 定位元素,需要统一更新为新的 `.aa-*` 类名。
5. **全局关闭机制**:旧代码通过 `$(".aa-input").autocomplete("close")` 关闭所有实例,新库需要手动维护实例注册表。

View File

@@ -40,11 +40,32 @@ jobs:
- name: Run the client-side tests
run: pnpm run --filter=client test
- name: Upload client test report
uses: actions/upload-artifact@v7
if: always()
with:
name: client-test-report
path: apps/client/test-output/vitest/html/
retention-days: 30
- name: Run the server-side tests
run: pnpm run --filter=server test
- name: Upload server test report
uses: actions/upload-artifact@v7
if: always()
with:
name: server-test-report
path: apps/server/test-output/vitest/html/
retention-days: 30
- name: Run CKEditor e2e tests
run: |
pnpm run --filter=ckeditor5-mermaid test
pnpm run --filter=ckeditor5-math test
- name: Run the rest of the tests
run: "pnpm run --filter=!client --filter=!server test"
run: pnpm run --filter=\!client --filter=\!server --filter=\!ckeditor5-mermaid --filter=\!ckeditor5-math test
build_docker:
name: Build Docker image
@@ -69,7 +90,7 @@ jobs:
- name: Trigger server build
run: pnpm run server:build
- uses: docker/setup-buildx-action@v3
- uses: docker/build-push-action@v6
- uses: docker/build-push-action@v7
with:
context: apps/server
cache-from: type=gha
@@ -106,7 +127,7 @@ jobs:
uses: docker/setup-buildx-action@v3
- name: Build and export to Docker
uses: docker/build-push-action@v6
uses: docker/build-push-action@v7
with:
context: apps/server
file: apps/server/${{ matrix.dockerfile }}

View File

@@ -59,7 +59,7 @@ jobs:
run: pnpm run server:build
- name: Build and export to Docker
uses: docker/build-push-action@v6
uses: docker/build-push-action@v7
with:
context: apps/server
file: apps/server/${{ matrix.dockerfile }}
@@ -164,7 +164,7 @@ jobs:
- name: Docker meta
id: meta
uses: docker/metadata-action@v5
uses: docker/metadata-action@v6
with:
images: ${{ env.GHCR_REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
@@ -175,13 +175,13 @@ jobs:
latest=false
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
uses: docker/setup-qemu-action@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to GHCR
uses: docker/login-action@v3
uses: docker/login-action@v4
with:
registry: ${{ env.GHCR_REGISTRY }}
username: ${{ github.actor }}
@@ -189,7 +189,7 @@ jobs:
- name: Build and push by digest
id: build
uses: docker/build-push-action@v6
uses: docker/build-push-action@v7
with:
context: apps/server
file: apps/server/${{ matrix.dockerfile }}
@@ -229,17 +229,17 @@ jobs:
run: echo "TEST_TAG=${TEST_TAG,,}" >> $GITHUB_ENV
- name: Set up crane
uses: imjasonh/setup-crane@v0.4
uses: imjasonh/setup-crane@v0.5
- name: Login to GHCR
uses: docker/login-action@v3
uses: docker/login-action@v4
with:
registry: ${{ env.GHCR_REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Login to DockerHub
uses: docker/login-action@v3
uses: docker/login-action@v4
with:
registry: ${{ env.DOCKERHUB_REGISTRY }}
username: ${{ secrets.DOCKERHUB_USERNAME }}
@@ -247,7 +247,7 @@ jobs:
- name: Docker meta
id: meta
uses: docker/metadata-action@v5
uses: docker/metadata-action@v6
with:
images: ${{ env.GHCR_REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |

View File

@@ -14,7 +14,7 @@
"keywords": [],
"author": "Elian Doran <contact@eliandoran.me>",
"license": "AGPL-3.0-only",
"packageManager": "pnpm@10.30.3",
"packageManager": "pnpm@10.31.0",
"devDependencies": {
"@redocly/cli": "2.20.2",
"archiver": "7.0.1",

View File

@@ -1,6 +1,6 @@
{
"name": "@triliumnext/client",
"version": "0.102.0",
"version": "0.102.1",
"description": "JQuery-based client for TriliumNext, used for both web and desktop (via Electron)",
"private": true,
"license": "AGPL-3.0-only",
@@ -16,6 +16,7 @@
"circular-deps": "dpdm -T src/**/*.ts --tree=false --warning=false --skip-dynamic-imports=circular"
},
"dependencies": {
"@algolia/autocomplete-js": "1.19.6",
"@excalidraw/excalidraw": "0.18.0",
"@fullcalendar/core": "6.1.20",
"@fullcalendar/daygrid": "6.1.20",
@@ -28,14 +29,20 @@
"@mermaid-js/layout-elk": "0.2.0",
"@mind-elixir/node-menu": "5.0.1",
"@popperjs/core": "2.11.8",
"@preact/signals": "2.8.1",
"@preact/signals": "2.8.2",
"@triliumnext/ckeditor5": "workspace:*",
"@triliumnext/codemirror": "workspace:*",
"@triliumnext/commons": "workspace:*",
"@triliumnext/highlightjs": "workspace:*",
"@triliumnext/share-theme": "workspace:*",
"@triliumnext/split.js": "workspace:*",
"@univerjs/preset-sheets-conditional-formatting": "0.16.1",
"@univerjs/preset-sheets-core": "0.16.1",
"@univerjs/preset-sheets-data-validation": "0.16.1",
"@univerjs/preset-sheets-filter": "0.16.1",
"@univerjs/preset-sheets-find-replace": "0.16.1",
"@univerjs/preset-sheets-note": "0.16.1",
"@univerjs/preset-sheets-sort": "0.16.1",
"@univerjs/presets": "0.16.1",
"@zumer/snapdom": "2.0.2",
"autocomplete.js": "0.38.1",
@@ -47,28 +54,28 @@
"draggabilly": "3.0.0",
"force-graph": "1.51.1",
"globals": "17.4.0",
"i18next": "25.8.13",
"i18next": "25.8.14",
"i18next-http-backend": "3.0.2",
"jquery": "4.0.0",
"jquery.fancytree": "2.38.5",
"jsplumb": "2.15.6",
"katex": "0.16.33",
"katex": "0.16.38",
"knockout": "3.5.1",
"leaflet": "1.9.4",
"leaflet-gpx": "2.2.0",
"mark.js": "8.11.1",
"marked": "17.0.3",
"marked": "17.0.4",
"mermaid": "11.12.3",
"mind-elixir": "5.9.1",
"mind-elixir": "5.9.2",
"normalize.css": "8.0.1",
"panzoom": "9.4.3",
"preact": "10.28.4",
"react-i18next": "16.5.4",
"react-i18next": "16.5.6",
"react-window": "2.2.7",
"reveal.js": "5.2.1",
"rrule": "2.8.1",
"svg-pan-zoom": "3.6.2",
"tabulator-tables": "6.3.1",
"tabulator-tables": "6.4.0",
"vanilla-js-wheel-zoom": "9.0.4"
},
"devDependencies": {

View File

@@ -1,114 +1,431 @@
import { createAutocomplete } from "@algolia/autocomplete-core";
import type { AutocompleteApi as CoreAutocompleteApi, BaseItem } from "@algolia/autocomplete-core";
import type { AttributeType } from "../entities/fattribute.js";
import server from "./server.js";
interface InitOptions {
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
interface NameItem extends BaseItem {
name: string;
}
interface InitAttributeNameOptions {
/** The <input> element where the user types */
$el: JQuery<HTMLElement>;
attributeType?: AttributeType | (() => AttributeType);
open: boolean;
nameCallback?: () => string;
/** Called when the user selects a value or the panel closes */
onValueChange?: (value: string) => void;
}
/**
* @param $el - element on which to init autocomplete
* @param attributeType - "relation" or "label" or callback providing one of those values as a type of autocompleted attributes
* @param open - should the autocomplete be opened after init?
*/
function initAttributeNameAutocomplete({ $el, attributeType, open }: InitOptions) {
if (!$el.hasClass("aa-input")) {
$el.autocomplete(
{
appendTo: document.querySelector("body"),
hint: false,
openOnFocus: true,
minLength: 0,
tabAutocomplete: false
},
[
{
displayKey: "name",
// disabling cache is important here because otherwise cache can stay intact when switching between attribute type which will lead to autocomplete displaying attribute names for incorrect attribute type
cache: false,
source: async (term, cb) => {
const type = typeof attributeType === "function" ? attributeType() : attributeType;
// ---------------------------------------------------------------------------
// Instance tracking
// ---------------------------------------------------------------------------
const names = await server.get<string[]>(`attribute-names/?type=${type}&query=${encodeURIComponent(term)}`);
const result = names.map((name) => ({ name }));
cb(result);
}
}
]
);
$el.on("autocomplete:opened", () => {
if ($el.attr("readonly")) {
$el.autocomplete("close");
}
});
}
if (open) {
$el.autocomplete("open");
}
interface ManagedInstance {
autocomplete: CoreAutocompleteApi<NameItem>;
panelEl: HTMLElement;
cleanup: () => void;
}
async function initLabelValueAutocomplete({ $el, open, nameCallback }: InitOptions) {
if ($el.hasClass("aa-input")) {
// we reinit every time because autocomplete seems to have a bug where it retains state from last
// open even though the value was reset
$el.autocomplete("destroy");
}
const instanceMap = new WeakMap<HTMLElement, ManagedInstance>();
let attributeName = "";
if (nameCallback) {
attributeName = nameCallback();
}
// ---------------------------------------------------------------------------
// Dropdown panel DOM helpers
// ---------------------------------------------------------------------------
if (attributeName.trim() === "") {
function createPanelEl(): HTMLElement {
const panel = document.createElement("div");
panel.className = "aa-core-panel";
panel.style.display = "none";
document.body.appendChild(panel);
return panel;
}
function renderItems(panelEl: HTMLElement, items: NameItem[], activeItemId: number | null, onSelect: (item: NameItem) => void): void {
panelEl.innerHTML = "";
if (items.length === 0) {
panelEl.style.display = "none";
return;
}
const attributeValues = (await server.get<string[]>(`attribute-values/${encodeURIComponent(attributeName)}`)).map((attribute) => ({ value: attribute }));
if (attributeValues.length === 0) {
return;
}
$el.autocomplete(
{
appendTo: document.querySelector("body"),
hint: false,
openOnFocus: false, // handled manually
minLength: 0,
tabAutocomplete: false
},
[
{
displayKey: "value",
cache: false,
source: async function (term, cb) {
term = term.toLowerCase();
const filtered = attributeValues.filter((attr) => attr.value.toLowerCase().includes(term));
cb(filtered);
}
}
]
);
$el.on("autocomplete:opened", () => {
if ($el.attr("readonly")) {
$el.autocomplete("close");
const list = document.createElement("ul");
list.className = "aa-core-list";
items.forEach((item, index) => {
const li = document.createElement("li");
li.className = "aa-core-item";
if (index === activeItemId) {
li.classList.add("aa-core-item--active");
}
li.textContent = item.name;
li.addEventListener("mousedown", (e) => {
e.preventDefault(); // prevent input blur
onSelect(item);
});
list.appendChild(li);
});
panelEl.appendChild(list);
}
function positionPanel(panelEl: HTMLElement, inputEl: HTMLElement): void {
const rect = inputEl.getBoundingClientRect();
const top = `${rect.bottom}px`;
const left = `${rect.left}px`;
const width = `${rect.width}px`;
panelEl.style.position = "fixed";
if (panelEl.style.top !== top) panelEl.style.top = top;
if (panelEl.style.left !== left) panelEl.style.left = left;
if (panelEl.style.width !== width) panelEl.style.width = width;
if (panelEl.style.display !== "block") panelEl.style.display = "block";
}
// ---------------------------------------------------------------------------
// Attribute name autocomplete — new (autocomplete-core, headless)
// ---------------------------------------------------------------------------
function initAttributeNameAutocomplete({ $el, attributeType, open, onValueChange }: InitAttributeNameOptions) {
const inputEl = $el[0] as HTMLInputElement;
// Already initialized — just open if requested
if (instanceMap.has(inputEl)) {
if (open) {
const inst = instanceMap.get(inputEl)!;
inst.autocomplete.setIsOpen(true);
inst.autocomplete.refresh();
}
return;
}
const panelEl = createPanelEl();
let isPanelOpen = false;
let hasActiveItem = false;
let rafId: number | null = null;
function startPositioning() {
if (rafId !== null) return;
const update = () => {
positionPanel(panelEl, inputEl);
rafId = requestAnimationFrame(update);
};
update();
}
function stopPositioning() {
if (rafId !== null) {
cancelAnimationFrame(rafId);
rafId = null;
}
}
const autocomplete = createAutocomplete<NameItem>({
openOnFocus: true,
defaultActiveItemId: 0,
shouldPanelOpen() {
return true;
},
getSources({ query }) {
return [
{
sourceId: "attribute-names",
getItems() {
const type = typeof attributeType === "function" ? attributeType() : attributeType;
return server
.get<string[]>(`attribute-names/?type=${type}&query=${encodeURIComponent(query)}`)
.then((names) => names.map((name) => ({ name })));
},
getItemInputValue({ item }) {
return item.name;
},
onSelect({ item }) {
inputEl.value = item.name;
autocomplete.setQuery(item.name);
autocomplete.setIsOpen(false);
onValueChange?.(item.name);
},
},
];
},
onStateChange({ state }) {
isPanelOpen = state.isOpen;
hasActiveItem = state.activeItemId !== null;
// Render items
const collections = state.collections;
const items = collections.length > 0 ? (collections[0].items as NameItem[]) : [];
const activeId = state.activeItemId ?? null;
if (state.isOpen && items.length > 0) {
renderItems(panelEl, items, activeId, (item) => {
inputEl.value = item.name;
autocomplete.setQuery(item.name);
autocomplete.setIsOpen(false);
onValueChange?.(item.name);
});
startPositioning();
} else {
panelEl.style.display = "none";
stopPositioning();
}
if (!state.isOpen) {
panelEl.style.display = "none";
stopPositioning();
}
},
});
// Wire up the input events
const handlers = autocomplete.getInputProps({ inputElement: inputEl });
const onInput = (e: Event) => {
handlers.onChange(e as any);
};
const onFocus = (e: Event) => {
handlers.onFocus(e as any);
};
const onBlur = () => {
// Delay to allow mousedown on panel items
setTimeout(() => {
autocomplete.setIsOpen(false);
panelEl.style.display = "none";
stopPositioning();
onValueChange?.(inputEl.value);
}, 50);
};
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === "Enter" && isPanelOpen && hasActiveItem) {
// Prevent the enter key from propagating to parent dialogs
// (which might interpret it as "submit" or "save and close")
e.stopPropagation();
// We shouldn't preventDefault here because we want handlers.onKeyDown
// to process it properly. OnSelect will correctly close the panel.
}
handlers.onKeyDown(e as any);
};
inputEl.addEventListener("input", onInput);
inputEl.addEventListener("focus", onFocus);
inputEl.addEventListener("blur", onBlur);
inputEl.addEventListener("keydown", onKeyDown);
const cleanup = () => {
inputEl.removeEventListener("input", onInput);
inputEl.removeEventListener("focus", onFocus);
inputEl.removeEventListener("blur", onBlur);
inputEl.removeEventListener("keydown", onKeyDown);
stopPositioning();
if (panelEl.parentElement) {
panelEl.parentElement.removeChild(panelEl);
}
};
instanceMap.set(inputEl, { autocomplete, panelEl, cleanup });
if (open) {
$el.autocomplete("open");
autocomplete.setIsOpen(true);
autocomplete.refresh();
startPositioning();
}
}
// ---------------------------------------------------------------------------
// Label value autocomplete (headless autocomplete-core)
// ---------------------------------------------------------------------------
interface LabelValueInitOptions {
$el: JQuery<HTMLElement>;
open: boolean;
nameCallback?: () => string;
onValueChange?: (value: string) => void;
}
function initLabelValueAutocomplete({ $el, open, nameCallback, onValueChange }: LabelValueInitOptions) {
const inputEl = $el[0] as HTMLInputElement;
if (instanceMap.has(inputEl)) {
if (open) {
const inst = instanceMap.get(inputEl)!;
inst.autocomplete.setIsOpen(true);
inst.autocomplete.refresh();
}
return;
}
const panelEl = createPanelEl();
let isPanelOpen = false;
let hasActiveItem = false;
let isSelecting = false;
let rafId: number | null = null;
function startPositioning() {
if (rafId !== null) return;
const update = () => {
positionPanel(panelEl, inputEl);
rafId = requestAnimationFrame(update);
};
update();
}
function stopPositioning() {
if (rafId !== null) {
cancelAnimationFrame(rafId);
rafId = null;
}
}
let cachedAttributeName = "";
let cachedAttributeValues: NameItem[] = [];
const handleSelect = (item: NameItem) => {
isSelecting = true;
inputEl.value = item.name;
inputEl.dispatchEvent(new Event("input", { bubbles: true }));
autocomplete.setQuery(item.name);
autocomplete.setIsOpen(false);
onValueChange?.(item.name);
isSelecting = false;
setTimeout(() => {
inputEl.dispatchEvent(new KeyboardEvent("keydown", {
key: "Enter",
code: "Enter",
keyCode: 13,
which: 13,
bubbles: true,
cancelable: true
}));
}, 0);
};
const autocomplete = createAutocomplete<NameItem>({
openOnFocus: true,
defaultActiveItemId: null,
shouldPanelOpen() {
return true;
},
getSources({ query }) {
return [
{
sourceId: "attribute-values",
async getItems() {
const attributeName = nameCallback ? nameCallback() : "";
if (!attributeName.trim()) {
return [];
}
if (attributeName !== cachedAttributeName || cachedAttributeValues.length === 0) {
cachedAttributeName = attributeName;
const values = await server.get<string[]>(`attribute-values/${encodeURIComponent(attributeName)}`);
cachedAttributeValues = values.map((name) => ({ name }));
}
const q = query.toLowerCase();
return cachedAttributeValues.filter((attr) => attr.name.toLowerCase().includes(q));
},
getItemInputValue({ item }) {
return item.name;
},
onSelect({ item }) {
handleSelect(item);
},
},
];
},
onStateChange({ state }) {
isPanelOpen = state.isOpen;
hasActiveItem = state.activeItemId !== null;
const collections = state.collections;
const items = collections.length > 0 ? (collections[0].items as NameItem[]) : [];
const activeId = state.activeItemId ?? null;
if (state.isOpen && items.length > 0) {
renderItems(panelEl, items, activeId, handleSelect);
startPositioning();
} else {
panelEl.style.display = "none";
stopPositioning();
}
if (!state.isOpen) {
panelEl.style.display = "none";
stopPositioning();
}
},
});
const handlers = autocomplete.getInputProps({ inputElement: inputEl });
const onInput = (e: Event) => {
if (!isSelecting) {
handlers.onChange(e as any);
}
};
const onFocus = (e: Event) => {
const attributeName = nameCallback ? nameCallback() : "";
if (attributeName !== cachedAttributeName) {
cachedAttributeName = "";
cachedAttributeValues = [];
}
handlers.onFocus(e as any);
};
const onBlur = () => {
setTimeout(() => {
autocomplete.setIsOpen(false);
panelEl.style.display = "none";
stopPositioning();
onValueChange?.(inputEl.value);
}, 50);
};
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === "Enter" && isPanelOpen && hasActiveItem) {
e.stopPropagation();
}
handlers.onKeyDown(e as any);
};
inputEl.addEventListener("input", onInput);
inputEl.addEventListener("focus", onFocus);
inputEl.addEventListener("blur", onBlur);
inputEl.addEventListener("keydown", onKeyDown);
const cleanup = () => {
inputEl.removeEventListener("input", onInput);
inputEl.removeEventListener("focus", onFocus);
inputEl.removeEventListener("blur", onBlur);
inputEl.removeEventListener("keydown", onKeyDown);
stopPositioning();
if (panelEl.parentElement) {
panelEl.parentElement.removeChild(panelEl);
}
};
instanceMap.set(inputEl, { autocomplete, panelEl, cleanup });
if (open) {
autocomplete.setIsOpen(true);
autocomplete.refresh();
startPositioning();
}
}
export function destroyAutocomplete($el: JQuery<HTMLElement> | HTMLElement) {
const inputEl = $el instanceof HTMLElement ? $el : $el[0] as HTMLInputElement;
const instance = instanceMap.get(inputEl);
if (instance) {
instance.cleanup();
instanceMap.delete(inputEl);
}
}
export default {
initAttributeNameAutocomplete,
initLabelValueAutocomplete
destroyAutocomplete,
initLabelValueAutocomplete,
};

View File

@@ -54,7 +54,7 @@ export async function getRenderedContent(this: {} | { ctx: string }, entity: FNo
await renderText(entity, $renderedContent, options);
} else if (type === "code") {
await renderCode(entity, $renderedContent);
} else if (["image", "canvas", "mindMap"].includes(type)) {
} else if (["image", "canvas", "mindMap", "spreadsheet"].includes(type)) {
renderImage(entity, $renderedContent, options);
} else if (!options.tooltip && ["file", "pdf", "audio", "video"].includes(type)) {
await renderFile(entity, type, $renderedContent);

View File

@@ -89,7 +89,7 @@ async function remove<T>(url: string, componentId?: string) {
return await call<T>("DELETE", url, componentId);
}
async function upload(url: string, fileToUpload: File, componentId?: string) {
async function upload(url: string, fileToUpload: File, componentId?: string, method = "PUT") {
const formData = new FormData();
formData.append("upload", fileToUpload);
@@ -99,7 +99,7 @@ async function upload(url: string, fileToUpload: File, componentId?: string) {
"trilium-component-id": componentId
} : undefined),
data: formData,
type: "PUT",
type: method,
timeout: 60 * 60 * 1000,
contentType: false, // NEEDED, DON'T REMOVE THIS
processData: false // NEEDED, DON'T REMOVE THIS

View File

@@ -960,6 +960,38 @@ table.promoted-attributes-in-tooltip th {
background-color: var(--active-item-background-color);
}
/* ===== @algolia/autocomplete-core (headless, custom panel) ===== */
.aa-core-panel {
z-index: 10000;
background-color: var(--main-background-color);
border: 1px solid var(--main-border-color);
border-top: none;
max-height: 500px;
overflow: auto;
padding: 0;
margin: 0;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
}
.aa-core-list {
list-style: none;
padding: 0;
margin: 0;
}
.aa-core-item {
cursor: pointer;
padding: 6px 16px;
margin: 0;
}
.aa-core-item:hover,
.aa-core-item--active {
color: var(--active-item-text-color);
background-color: var(--active-item-background-color);
}
.help-button {
float: inline-end;
background: none;

View File

@@ -1535,7 +1535,8 @@
"new-feature": "新建",
"collections": "集合",
"book": "集合",
"ai-chat": "AI聊天"
"ai-chat": "AI聊天",
"spreadsheet": "电子表格"
},
"protect_note": {
"toggle-on": "保护笔记",

View File

@@ -1488,20 +1488,21 @@
"mermaid-diagram": "Mermaid Diagramm",
"canvas": "Leinwand",
"web-view": "Webansicht",
"mind-map": "Mind Map",
"mind-map": "Mindmap",
"file": "Datei",
"image": "Bild",
"launcher": "Starter",
"doc": "Dokument",
"widget": "Widget",
"confirm-change": "Es is nicht empfehlenswert den Notiz-Typ zu ändern, wenn der Inhalt der Notiz nicht leer ist. Möchtest du dennoch fortfahren?",
"confirm-change": "Es ist nicht empfehlenswert den Notiz-Typ zu ändern, wenn der Inhalt der Notiz nicht leer ist. Möchtest du dennoch fortfahren?",
"geo-map": "Geo-Karte",
"beta-feature": "Beta",
"book": "Sammlung",
"ai-chat": "KI Chat",
"ai-chat": "KI-Chat",
"task-list": "Aufgabenliste",
"new-feature": "Neu",
"collections": "Sammlungen"
"collections": "Sammlungen",
"spreadsheet": "Tabelle"
},
"protect_note": {
"toggle-on": "Notiz schützen",

View File

@@ -1548,7 +1548,8 @@
"task-list": "Lista de tareas",
"book": "Colección",
"new-feature": "Nuevo",
"collections": "Colecciones"
"collections": "Colecciones",
"spreadsheet": "Hoja de cálculo"
},
"protect_note": {
"toggle-on": "Proteger la nota",
@@ -1650,7 +1651,8 @@
},
"search_result": {
"no_notes_found": "No se han encontrado notas para los parámetros de búsqueda dados.",
"search_not_executed": "La búsqueda aún no se ha ejecutado. Dé clic en el botón «Buscar» para ver los resultados."
"search_not_executed": "La búsqueda aún no se ha ejecutado.",
"search_now": "Buscar ahora"
},
"spacer": {
"configure_launchbar": "Configurar barra de lanzamiento"

View File

@@ -1571,7 +1571,8 @@
"ai-chat": "Comhrá AI",
"task-list": "Liosta Tascanna",
"new-feature": "Nua",
"collections": "Bailiúcháin"
"collections": "Bailiúcháin",
"spreadsheet": "Scarbhileog"
},
"protect_note": {
"toggle-on": "Cosain an nóta",

View File

@@ -600,7 +600,8 @@
"task-list": "タスクリスト",
"new-feature": "New",
"collections": "コレクション",
"ai-chat": "AI チャット"
"ai-chat": "AI チャット",
"spreadsheet": "スプレッドシート"
},
"edited_notes": {
"no_edited_notes_found": "この日の編集されたノートはまだありません...",

View File

@@ -257,7 +257,7 @@
"collapseExpand": "свернуть/развернуть узел",
"notSet": "не установлено",
"goBackForwards": "назад / вперед в истории",
"showJumpToNoteDialog": "показать <a class=\"external\" href=\"https://triliumnext.github.io/Docs/Wiki/note-navigation.html#jump-to-note\">окно \"Перейти к\"</a>",
"showJumpToNoteDialog": "Перейти к <a class=\"external\" href=\"https://triliumnext.github.io/Docs/Wiki/note-navigation.html#jump-to-note\">\"Перейти к\" окно</a>",
"scrollToActiveNote": "прокрутка к активной заметке",
"jumpToParentNote": "переход к родительской заметке",
"collapseWholeTree": "свернуть все дерево заметок",
@@ -471,7 +471,7 @@
"calendar_root": "отмечает заметку, которая должна использоваться в качестве корневой для заметок дня. Только одна должна быть отмечена как таковая.",
"archived": "заметки с этой меткой не будут отображаться в результатах поиска по умолчанию (а также в диалоговых окнах «Перейти к», «Добавить ссылку» и т. д.).",
"exclude_from_export": "заметки (с их поддеревьями) не будут включены ни в один экспорт заметок",
"run": "определяет, при каких событиях должен запускаться скрипт. Возможные значения:\n<ul>\n<li>frontendStartup — при запуске (или обновлении) фронтенда Trilium, но не на мобильном устройстве.</li>\n<li>mobileStartup — при запуске (или обновлении) фронтенда Trilium на мобильном устройстве.</li>\n<li>backendStartup — при запуске бэкенда Trilium.</li>\n<li>hourly — запускать каждый час. Для указания времени можно использовать дополнительную метку <code>runAtHour</code>.</li>\n<li>daily — запускать раз в день.</li>\n</ul>",
"run": "определяет, при каких событиях должен запускаться скрипт. Возможные значения:<ul>\n<li>frontendStartup — при запуске (или обновлении) фронтенда Trilium, но не на мобильном устройстве.</li>\n<li>mobileStartup — при запуске (или обновлении) фронтенда Trilium на мобильном устройстве.</li>\n<li>backendStartup — при запуске бэкенда Trilium.</li>\n<li>hourly — запускать каждый час. Для указания времени можно использовать дополнительную метку <code>runAtHour</code>.</li>\n<li>daily — запускать раз в день.</li></ul>",
"run_on_instance": "Определить, на каком экземпляре Trilium это должно выполняться. По умолчанию — для всех экземпляров.",
"run_at_hour": "В какой час это должно выполняться? Следует использовать вместе с <code>#run=hourly</code>. Можно задать несколько раз для большего количества запусков в течение дня.",
"disable_inclusion": "скрипты с этой меткой не будут включены в выполнение родительского скрипта.",
@@ -594,7 +594,8 @@
"display-week-numbers": "Отображать номера недель",
"hide-weekends": "Скрыть выходные",
"raster": "Растр",
"show-scale": "Показать масштаб"
"show-scale": "Показать масштаб",
"show-labels": "Показать названия маркеров"
},
"editorfeatures": {
"note_completion_enabled": "Включить автодополнение",
@@ -782,7 +783,13 @@
"shared-indicator-tooltip": "Эта заметка опубликована",
"shared-indicator-tooltip-with-url": "Эта заметка доступно публично по адресу: {{- url}}",
"subtree-hidden-moved-description-other": "В дереве, к которому относится эта заметка, скрыты дочерние заметки.",
"subtree-hidden-moved-description-collection": "Эта коллекция скрывает свои дочерние заметки в дереве."
"subtree-hidden-moved-description-collection": "Эта коллекция скрывает свои дочерние заметки в дереве.",
"clone-indicator-tooltip": "У этой заметки {{- count}} родителей: {{- parents}}",
"clone-indicator-tooltip-single": "Эта заметка клонирована (1 дополнительный родитель: {{- parent}})",
"subtree-hidden-moved-title": "Добавлено в {{title}}",
"subtree-hidden-tooltip_one": "{{count}} дочерняя заметка скрыта",
"subtree-hidden-tooltip_few": "Скрыто {{count}} дочерних заметок",
"subtree-hidden-tooltip_many": "Скрыто {{count}} дочерних заметок"
},
"quick-search": {
"no-results": "Результаты не найдены",
@@ -826,7 +833,9 @@
"mind-map": "Mind Map",
"geo-map": "Географическая карта",
"task-list": "Список задач",
"confirm-change": "Не рекомендуется менять тип заметки, если её содержимое не пустое. Вы всё равно хотите продолжить?"
"confirm-change": "Не рекомендуется менять тип заметки, если её содержимое не пустое. Вы всё равно хотите продолжить?",
"ai-chat": "Чат с ИИ",
"spreadsheet": "Электронная таблица"
},
"tree-context-menu": {
"open-in-popup": "Быстрое редактирование",
@@ -1153,7 +1162,8 @@
"search_note_saved": "Заметка с настройкой поиска сохранена в {{- notePathTitle}}",
"unknown_search_option": "Неизвестный параметр поиска {{searchOptionName}}",
"actions_executed": "Действия выполнены.",
"view_options": "Просмотреть опции:"
"view_options": "Просмотреть опции:",
"option": "опция"
},
"ancestor": {
"depth_label": "глубина",
@@ -1403,7 +1413,8 @@
"type_text_to_filter": "Введите текст для фильтрации сочетаний клавиш...",
"reload_app": "Перезагрузить приложение, чтобы применить изменения",
"confirm_reset": "Вы действительно хотите сбросить все сочетания клавиш до значений по умолчанию?",
"set_all_to_default": "Установить все сочетания клавиш по умолчанию"
"set_all_to_default": "Установить все сочетания клавиш по умолчанию",
"no_results": "Не найдено ярлыков, соответствующих '{{filter}}'"
},
"sync_2": {
"timeout_unit": "миллисекунд",
@@ -1713,7 +1724,8 @@
"delete_this_note": "Удалить эту заметку",
"insert_child_note": "Вставить дочернюю заметку",
"note_revisions": "История изменений",
"content_language_switcher": "Язык содержимого: {{language}}"
"content_language_switcher": "Язык содержимого: {{language}}",
"backlinks": "Ссылки"
},
"svg_export_button": {
"button_title": "Экспортировать диаграмму как SVG"
@@ -1790,7 +1802,8 @@
},
"search_result": {
"no_notes_found": "По заданным параметрам поиска заметки не найдены.",
"search_not_executed": "Поиск ещё не выполнен. Нажмите кнопку «Поиск» выше, чтобы увидеть результаты."
"search_not_executed": "Поиск ещё не выполнен.",
"search_now": "Искать сейчас"
},
"empty": {
"search_placeholder": "поиск заметки по ее названию",
@@ -1988,10 +2001,12 @@
"print_report_collection_content_few": "{{count}} заметки в коллекции не удалось распечатать, поскольку они не поддерживаются или защищены.",
"print_report_collection_content_many": "{{count}} заметок в коллекции не удалось распечатать, поскольку они не поддерживаются или защищены.",
"print_report_collection_details_button": "Подробнее",
"print_report_collection_details_ignored_notes": "Пропущенные заметки"
"print_report_collection_details_ignored_notes": "Пропущенные заметки",
"print_report_error_title": "Не удалось напечатать",
"print_report_stack_trace": "Трассировка стека"
},
"book": {
"no_children_help": "В этой коллекции нет дочерних заметок, поэтому отображать нечего. Подробности см. в <a href=\"https://triliumnext.github.io/Docs/Wiki/book-note.html\">wiki</a>.",
"no_children_help": "В этой коллекции нет дочерних заметок, поэтому отображать нечего.",
"drag_locked_title": "Защищено от изменения",
"drag_locked_message": "Перетаскивание не допускается, так как коллекция защищена от редактирования."
},
@@ -2007,7 +2022,9 @@
"rendering_error": "Невозможно отобразить содержимое из-за ошибки."
},
"pagination": {
"total_notes": "{{count}} заметок"
"total_notes": "{{count}} заметок",
"prev_page": "Предыдущая страница",
"next_page": "Следующая страница"
},
"status_bar": {
"attributes_one": "{{count}} атрибут",
@@ -2137,5 +2154,49 @@
},
"platform_indicator": {
"available_on": "Доступно для {{platform}}"
},
"render": {
"setup_title": "Отобразить настраиваемый HTML или Preact JSX в этой заметке",
"setup_create_sample_preact": "Создать образец заметки с помощью Preact",
"setup_create_sample_html": "Создать образец заметки с помощью HTML",
"setup_sample_created": "Образец заметки был создан в качестве дочерней записи.",
"disabled_description": "Эти заметки для рендера поступают из внешнего источника. Чтобы защитить вас от вредоносного содержимого, они не включены по умолчанию. Убедитесь, что вы доверяете источнику до его включения.",
"disabled_button_enable": "Включить заметки для рендера"
},
"web_view_setup": {
"title": "Создайте живой просмотр веб-страницы прямо в Trilium",
"url_placeholder": "Введите или вставьте адрес сайта, например https://triliumnotes.org",
"create_button": "Создать веб-просмотр",
"invalid_url_title": "Неверный адрес",
"invalid_url_message": "Введите корректный веб-адрес, например https://triliumnotes.org.",
"disabled_description": "Этот веб-просмотр был импортирован из внешнего источника. Чтобы защитить вас от фишинга или вредоносного контента, он не загружается автоматически. Вы можете включить его, если доверяете источнику.",
"disabled_button_enable": "Включить просмотр веб-страниц"
},
"active_content_badges": {
"type_icon_pack": "Набор иконок",
"type_backend_script": "Бэкенд скрипт",
"type_frontend_script": "Фронтенд скрипт",
"type_widget": "Виджет",
"type_app_css": "Пользовательский CSS",
"type_render_note": "Заметка для рендера",
"type_web_view": "Просмотр веб-страницы",
"type_app_theme": "Пользовательская тема",
"toggle_tooltip_enable_tooltip": "Нажмите, чтобы включить этот {{type}}.",
"toggle_tooltip_disable_tooltip": "Нажмите, чтобы выключить этот {{type}}.",
"menu_docs": "Открытая документация",
"menu_execute_now": "Выполнить скрипт сейчас",
"menu_run": "Выполнять автоматически",
"menu_run_disabled": "Вручную",
"menu_run_backend_startup": "При запуске бэкенда",
"menu_run_hourly": "Ежечасно",
"menu_run_daily": "Ежедневно",
"menu_run_frontend_startup": "Когда запускается интерфейс ПК",
"menu_run_mobile_startup": "При запуске мобильного интерфейса",
"menu_change_to_widget": "Изменить виджет",
"menu_change_to_frontend_script": "Перейти к фронтенд скрипту",
"menu_theme_base": "Базовая тема"
},
"setup_form": {
"more_info": "Узнать больше"
}
}

View File

@@ -3,6 +3,38 @@
"title": "Om Trilium Notes",
"homepage": "Hemsida:",
"app_version": "App version:",
"db_version": "DB version:"
"db_version": "DB version:",
"sync_version": "Sync version:",
"build_date": "Bygg datum:",
"build_revision": "Bygg version:",
"data_directory": "Data sökväg:"
},
"toast": {
"critical-error": {
"title": "Kritiskt fel",
"message": "Ett kritiskt fel har inträffat som förhindrar klientprogrammet från att starta:\n\n{{message}}\n\nDetta beror troligen på att ett skript har misslyckats på ett oväntat sätt. Försök att starta programmet i felsäkert läge och åtgärda problemet."
},
"widget-error": {
"title": "Misslyckades att starta widget",
"message-custom": "Anpassad widget från anteckning med ID \"{{id}}\", med rubrik \"{{title}}\" kunde inte startas på grund av:\n\n{{message}}",
"message-unknown": "Okänd widget kunde inte startas på grund av:\n\n{{message}}"
},
"bundle-error": {
"title": "Misslyckades att starta ett anpassat skript",
"message": "Skript kunde inte startas på grund av:\n\n{{message}}"
},
"widget-list-error": {
"title": "Misslyckades att hämta widget-listan från servern"
},
"widget-render-error": {
"title": "Misslyckades att renderera en anpassad React-widget"
},
"widget-missing-parent": "Anpassad widget saknar '{{property}}', som måste vara definierad.\n\nOm skriptet är avsett att köras utan gränssnitt, använd '#run-frontendStartup' istället.",
"open-script-note": "Öppna skriptanteckning",
"scripting-error": "Fel i anpassat skript: {{title}}"
},
"add_link": {
"add_link": "Infoga länk",
"help_on_links": "Hjälp om länkar"
}
}

View File

@@ -1496,7 +1496,8 @@
"task-list": "任務列表",
"new-feature": "新增",
"collections": "集合",
"ai-chat": "AI 聊天"
"ai-chat": "AI 聊天",
"spreadsheet": "試算表"
},
"protect_note": {
"toggle-on": "保護筆記",

View File

@@ -40,6 +40,19 @@ export default function NoteDetail() {
const widgetRequestId = useRef(0);
const hasFixedTree = note && noteContext?.hoistedNoteId === "_lbMobileRoot" && isMobile() && note.noteId.startsWith("_lbMobile");
// Defer loading for tabs that haven't been active yet (e.g. on app refresh).
const [ hasTabBeenActive, setHasTabBeenActive ] = useState(() => noteContext?.isActive() ?? false);
useEffect(() => {
if (!hasTabBeenActive && noteContext?.isActive()) {
setHasTabBeenActive(true);
}
}, [ noteContext, hasTabBeenActive ]);
useTriliumEvent("activeNoteChanged", ({ ntxId: eventNtxId }) => {
if (eventNtxId === ntxId && !hasTabBeenActive) {
setHasTabBeenActive(true);
}
});
const props: TypeWidgetProps = {
note: note!,
viewScope,
@@ -49,7 +62,7 @@ export default function NoteDetail() {
};
useEffect(() => {
if (!type) return;
if (!type || !hasTabBeenActive) return;
const requestId = ++widgetRequestId.current;
if (!noteTypesToRender[type]) {
@@ -68,7 +81,7 @@ export default function NoteDetail() {
} else {
setActiveNoteType(type);
}
}, [ note, viewScope, type, noteTypesToRender ]);
}, [ note, viewScope, type, noteTypesToRender, hasTabBeenActive ]);
// Detect note type changes.
useTriliumEvent("entitiesReloaded", async ({ loadResults }) => {
@@ -247,9 +260,8 @@ function NoteDetailWrapper({ Element, type, isVisible, isFullHeight, props }: {
useEffect(() => {
if (isVisible) {
setCachedProps(props);
} else {
// Do nothing, keep the old props.
}
// When not visible, keep the old props to avoid re-rendering in the background.
}, [ props, isVisible ]);
const typeMapping = TYPE_MAPPINGS[type];
@@ -260,7 +272,7 @@ function NoteDetailWrapper({ Element, type, isVisible, isFullHeight, props }: {
height: isFullHeight ? "100%" : ""
}}
>
{ <Element {...cachedProps} /> }
<Element {...cachedProps} />
</div>
);
}

View File

@@ -378,7 +378,8 @@ export default class AttributeDetailWidget extends NoteContextAwareWidget {
attributeAutocompleteService.initAttributeNameAutocomplete({
$el: this.$inputName,
attributeType: () => (["relation", "relation-definition"].includes(this.attrType || "") ? "relation" : "label"),
open: true
open: true,
onValueChange: () => this.userEditedAttribute(),
});
});
@@ -391,12 +392,12 @@ export default class AttributeDetailWidget extends NoteContextAwareWidget {
}
});
this.$inputValue.on("change", () => this.userEditedAttribute());
this.$inputValue.on("autocomplete:closed", () => this.userEditedAttribute());
this.$inputValue.on("focus", () => {
attributeAutocompleteService.initLabelValueAutocomplete({
$el: this.$inputValue,
open: true,
nameCallback: () => String(this.$inputName.val())
nameCallback: () => String(this.$inputName.val()),
onValueChange: () => this.userEditedAttribute(),
});
});

View File

@@ -24,6 +24,7 @@ export interface PromptDialogOptions {
shown?: PromptShownDialogCallback;
callback?: (value: string | null) => void;
readOnly?: boolean;
submitWithCtrlEnter?: boolean;
}
export default function PromptDialog() {
@@ -69,7 +70,7 @@ export default function PromptDialog() {
submitValue.current = null;
opts.current = undefined;
}}
footer={<Button text={t("prompt.ok")} keyboardShortcut="Enter" kind="primary" />}
footer={<Button text={t("prompt.ok")} keyboardShortcut={opts.current?.submitWithCtrlEnter ? "ctrl+return" : "Enter"} kind="primary" />}
show={shown}
stackable
>
@@ -78,6 +79,13 @@ export default function PromptDialog() {
inputRef={answerRef}
currentValue={value} onChange={setValue}
readOnly={opts.current?.readOnly}
onKeyDown={(e: KeyboardEvent) => {
if (opts.current?.submitWithCtrlEnter && (e.ctrlKey || e.metaKey) && e.key === "Enter") {
e.preventDefault();
submitValue.current = answerRef.current?.value || value;
setShown(false);
}
}}
/>
</FormGroup>
</Modal>

View File

@@ -272,7 +272,8 @@ function RevisionContent({ noteContent, revisionItem, fullRevision, showDiff }:
return <FilePreview fullRevision={fullRevision} revisionItem={revisionItem} />;
case "canvas":
case "mindMap":
case "mermaid": {
case "mermaid":
case "spreadsheet": {
const encodedTitle = encodeURIComponent(revisionItem.title);
return <img
src={`api/revisions/${revisionItem.revisionId}/image/${encodedTitle}?${Math.random()}`}

View File

@@ -143,7 +143,7 @@ export const TYPE_MAPPINGS: Record<ExtendedNoteType, NoteTypeMapping> = {
isFullHeight: true
},
spreadsheet: {
view: () => import("./type_widgets/Spreadsheet"),
view: () => import("./type_widgets/spreadsheet/Spreadsheet"),
className: "note-detail-spreadsheet",
printable: true,
isFullHeight: true

View File

@@ -98,6 +98,7 @@ export interface SavedData {
mime: string;
content: string;
position: number;
encoding?: "base64";
}[];
}

View File

@@ -75,7 +75,7 @@ export function NoteContextMenu({ note, noteContext, itemsAtStart, itemsNearNote
const noteType = useNoteProperty(note, "type") ?? "";
const [viewType] = useNoteLabel(note, "viewType");
const canBeConvertedToAttachment = note?.isEligibleForConversionToAttachment();
const isSearchable = ["text", "code", "book", "mindMap", "doc"].includes(noteType);
const isSearchable = ["text", "code", "book", "mindMap", "doc", "spreadsheet"].includes(noteType);
const isInOptionsOrHelp = note?.noteId.startsWith("_options") || note?.noteId.startsWith("_help");
const isExportableToImage = ["mermaid", "mindMap"].includes(noteType);
const isContentAvailable = note.isContentAvailable();

View File

@@ -189,7 +189,7 @@ function SwitchSplitOrientationButton({ note, isReadOnly, isDefaultViewMode }: N
export function ToggleReadOnlyButton({ note, isDefaultViewMode }: NoteActionsCustomInnerProps) {
const [ isReadOnly, setReadOnly ] = useNoteLabelBoolean(note, "readOnly");
const isSavedSqlite = note.isTriliumSqlite() && !note.isHiddenCompletely();
const isEnabled = ([ "mermaid", "mindMap", "canvas" ].includes(note.type) || isSavedSqlite)
const isEnabled = ([ "mermaid", "mindMap", "canvas", "spreadsheet" ].includes(note.type) || isSavedSqlite)
&& note.isContentAvailable() && isDefaultViewMode;
return isEnabled && <NoteAction

View File

@@ -6,7 +6,7 @@ import "./MindMap.css";
import nodeMenu from "@mind-elixir/node-menu";
import { DISPLAYABLE_LOCALE_IDS } from "@triliumnext/commons";
import { snapdom } from "@zumer/snapdom";
import { default as VanillaMindElixir,MindElixirData, MindElixirInstance, Operation, Options, THEME as LIGHT_THEME, DARK_THEME } from "mind-elixir";
import { DARK_THEME, default as VanillaMindElixir, MindElixirData, MindElixirInstance, Operation, Options, THEME as LIGHT_THEME } from "mind-elixir";
import { HTMLAttributes, RefObject } from "preact";
import { useCallback, useEffect, useRef } from "preact/hooks";
@@ -154,6 +154,7 @@ function MindElixir({ containerRef: externalContainerRef, containerProps, apiRef
const apiRef = useRef<MindElixirInstance>(null);
const [ locale ] = useTriliumOption("locale");
const colorScheme = useColorScheme();
const defaultColorScheme = useRef(colorScheme);
function reinitialize() {
if (!containerRef.current) return;
@@ -162,7 +163,7 @@ function MindElixir({ containerRef: externalContainerRef, containerProps, apiRef
el: containerRef.current,
locale: LOCALE_MAPPINGS[locale as DISPLAYABLE_LOCALE_IDS] ?? undefined,
editable,
theme: LIGHT_THEME
theme: defaultColorScheme.current === "dark" ? DARK_THEME : LIGHT_THEME
});
if (editable) {
@@ -188,7 +189,11 @@ function MindElixir({ containerRef: externalContainerRef, containerProps, apiRef
if (!apiRef.current) return;
const newTheme = colorScheme === "dark" ? DARK_THEME : LIGHT_THEME;
if (apiRef.current.theme === newTheme) return; // Avoid unnecessary theme changes, which can be expensive to render.
apiRef.current.changeTheme(newTheme);
try {
apiRef.current.changeTheme(newTheme);
} catch (e) {
console.warn("Failed to change mind map theme:", e);
}
}, [ colorScheme ]);
useEffect(() => {

View File

@@ -1,125 +0,0 @@
import "@univerjs/preset-sheets-core/lib/index.css";
import "./Spreadsheet.css";
import { UniverSheetsCorePreset } from '@univerjs/preset-sheets-core';
import UniverPresetSheetsCoreEnUS from '@univerjs/preset-sheets-core/locales/en-US';
import { CommandType, createUniver, FUniver, IDisposable, IWorkbookData, LocaleType, mergeLocales } from '@univerjs/presets';
import { MutableRef, useEffect, useRef } from "preact/hooks";
import NoteContext from "../../components/note_context";
import FNote from "../../entities/fnote";
import { useColorScheme, useEditorSpacedUpdate } from "../react/hooks";
import { TypeWidgetProps } from "./type_widget";
interface PersistedData {
version: number;
workbook: Parameters<FUniver["createWorkbook"]>[0];
}
export default function Spreadsheet({ note, noteContext }: TypeWidgetProps) {
const containerRef = useRef<HTMLDivElement>(null);
const apiRef = useRef<FUniver>();
useInitializeSpreadsheet(containerRef, apiRef);
useDarkMode(apiRef);
usePersistence(note, noteContext, apiRef);
return <div ref={containerRef} className="spreadsheet" />;
}
function useInitializeSpreadsheet(containerRef: MutableRef<HTMLDivElement | null>, apiRef: MutableRef<FUniver | undefined>) {
useEffect(() => {
if (!containerRef.current) return;
const { univerAPI } = createUniver({
locale: LocaleType.EN_US,
locales: {
[LocaleType.EN_US]: mergeLocales(
UniverPresetSheetsCoreEnUS
),
},
presets: [
UniverSheetsCorePreset({
container: containerRef.current,
})
]
});
apiRef.current = univerAPI;
return () => univerAPI.dispose();
}, [ apiRef, containerRef ]);
}
function useDarkMode(apiRef: MutableRef<FUniver | undefined>) {
const colorScheme = useColorScheme();
// React to dark mode.
useEffect(() => {
const univerAPI = apiRef.current;
if (!univerAPI) return;
univerAPI.toggleDarkMode(colorScheme === 'dark');
}, [ colorScheme, apiRef ]);
}
function usePersistence(note: FNote, noteContext: NoteContext | null | undefined, apiRef: MutableRef<FUniver | undefined>) {
const changeListener = useRef<IDisposable>(null);
const spacedUpdate = useEditorSpacedUpdate({
noteType: "spreadsheet",
note,
noteContext,
getData() {
const univerAPI = apiRef.current;
if (!univerAPI) return undefined;
const workbook = univerAPI.getActiveWorkbook();
if (!workbook) return undefined;
const content = {
version: 1,
workbook: workbook.save()
};
return {
content: JSON.stringify(content)
};
},
onContentChange(newContent) {
const univerAPI = apiRef.current;
if (!univerAPI) return undefined;
// Dispose the existing workbook.
const existingWorkbook = univerAPI.getActiveWorkbook();
if (existingWorkbook) {
univerAPI.disposeUnit(existingWorkbook.getId());
}
let workbookData: Partial<IWorkbookData> = {};
if (newContent) {
try {
const parsedContent = JSON.parse(newContent) as unknown;
if (parsedContent && typeof parsedContent === "object" && "workbook" in parsedContent) {
const persistedData = parsedContent as PersistedData;
workbookData = persistedData.workbook;
}
} catch (e) {
console.error("Failed to parse spreadsheet content", e);
}
}
const workbook = univerAPI.createWorkbook(workbookData);
if (changeListener.current) {
changeListener.current.dispose();
}
changeListener.current = workbook.onCommandExecuted(command => {
if (command.type !== CommandType.MUTATION) return;
spacedUpdate.scheduleUpdate();
});
},
});
useEffect(() => {
return () => {
if (changeListener.current) {
changeListener.current.dispose();
changeListener.current = null;
}
};
}, []);
}

View File

@@ -184,7 +184,7 @@ export default function PdfPreview({ note, blob, componentId, noteContext }: {
<PdfViewer
iframeRef={iframeRef}
tabIndex={300}
pdfUrl={`../../api/notes/${note.noteId}/open`}
pdfUrl={new URL(`${window.glob.baseApiUrl}notes/${note.noteId}/open`, window.location.href).pathname}
onLoad={() => {
const win = iframeRef.current?.contentWindow;
if (win) {

View File

@@ -1,7 +1,8 @@
import type { HTMLAttributes, RefObject } from "preact";
import { useCallback, useEffect, useRef } from "preact/hooks";
import Inter from "./../../../fonts/Inter/Inter-VariableFont_opsz,wght.ttf";
import { useSyncedRef, useTriliumOption, useTriliumOptionBool } from "../../react/hooks";
import Inter from "./../../../fonts/Inter/Inter-VariableFont_opsz,wght.ttf";
interface FontDefinition {
name: string;
@@ -10,11 +11,11 @@ interface FontDefinition {
const FONTS: FontDefinition[] = [
{name: "Inter", url: Inter},
]
];
interface PdfViewerProps extends Pick<HTMLAttributes<HTMLIFrameElement>, "tabIndex"> {
iframeRef?: RefObject<HTMLIFrameElement>;
/** Note: URLs are relative to /pdfjs/web. */
/** Note: URLs are relative to /pdfjs/web, ideally use absolute paths (but without domain name) to avoid issues with some proxies. */
pdfUrl: string;
onLoad?(): void;
/**
@@ -37,7 +38,7 @@ export default function PdfViewer({ iframeRef: externalIframeRef, pdfUrl, onLoad
ref={iframeRef}
class="pdf-preview"
style={{width: "100%", height: "100%"}}
src={`pdfjs/web/viewer.html?file=${pdfUrl}&lang=${locale}&sidebar=${newLayout ? "0" : "1"}&editable=${editable ? "1" : "0"}`}
src={`pdfjs/web/viewer.html?v=${glob.triliumVersion}&file=${pdfUrl}&lang=${locale}&sidebar=${newLayout ? "0" : "1"}&editable=${editable ? "1" : "0"}`}
onLoad={() => {
injectStyles();
onLoad?.();
@@ -63,7 +64,7 @@ function useStyleInjection(iframeRef: RefObject<HTMLIFrameElement>) {
const fontStyles = doc.createElement("style");
fontStyles.textContent = FONTS.map(injectFont).join("\n");
doc.head.appendChild(fontStyles);
}, [ iframeRef ]);
// React to changes.
@@ -107,4 +108,4 @@ function injectFont(font: FontDefinition) {
src: url('${font.url}');
}
`;
}
}

View File

@@ -416,6 +416,7 @@ function useRelationCreation({ mapApiRef, jsPlumbApiRef }: { mapApiRef: RefObjec
if (!originalEvent || !mapApiRef.current) return;
const name = await dialog.prompt({
submitWithCtrlEnter: true,
message: t("relation_map.specify_new_relation_name"),
shown: ({ $answer }) => {
if (!$answer) {

View File

@@ -0,0 +1,120 @@
import "./Spreadsheet.css";
import "@univerjs/preset-sheets-core/lib/index.css";
import "@univerjs/preset-sheets-sort/lib/index.css";
import "@univerjs/preset-sheets-conditional-formatting/lib/index.css";
import "@univerjs/preset-sheets-find-replace/lib/index.css";
import "@univerjs/preset-sheets-note/lib/index.css";
import "@univerjs/preset-sheets-filter/lib/index.css";
import "@univerjs/preset-sheets-data-validation/lib/index.css";
import { UniverSheetsConditionalFormattingPreset } from '@univerjs/preset-sheets-conditional-formatting';
import UniverPresetSheetsConditionalFormattingEnUS from '@univerjs/preset-sheets-conditional-formatting/locales/en-US';
import { UniverSheetsCorePreset } from '@univerjs/preset-sheets-core';
import sheetsCoreEnUS from '@univerjs/preset-sheets-core/locales/en-US';
import { UniverSheetsDataValidationPreset } from '@univerjs/preset-sheets-data-validation';
import UniverPresetSheetsDataValidationEnUS from '@univerjs/preset-sheets-data-validation/locales/en-US';
import { UniverSheetsFilterPreset } from '@univerjs/preset-sheets-filter';
import UniverPresetSheetsFilterEnUS from '@univerjs/preset-sheets-filter/locales/en-US';
import { UniverSheetsFindReplacePreset } from '@univerjs/preset-sheets-find-replace';
import sheetsFindReplaceEnUS from '@univerjs/preset-sheets-find-replace/locales/en-US';
import { UniverSheetsNotePreset } from '@univerjs/preset-sheets-note';
import sheetsNoteEnUS from '@univerjs/preset-sheets-note/locales/en-US';
import { UniverSheetsSortPreset } from '@univerjs/preset-sheets-sort';
import UniverPresetSheetsSortEnUS from '@univerjs/preset-sheets-sort/locales/en-US';
import { createUniver, FUniver, LocaleType, mergeLocales } from '@univerjs/presets';
import { MutableRef, useEffect, useRef } from "preact/hooks";
import { useColorScheme, useNoteLabelBoolean, useTriliumEvent } from "../../react/hooks";
import { TypeWidgetProps } from "../type_widget";
import usePersistence from "./persistence";
export default function Spreadsheet(props: TypeWidgetProps) {
const [ readOnly ] = useNoteLabelBoolean(props.note, "readOnly");
// Use readOnly as key to force full remount (and data reload) when it changes.
return <SpreadsheetEditor key={String(readOnly)} {...props} readOnly={readOnly} />;
}
function SpreadsheetEditor({ note, noteContext, readOnly }: TypeWidgetProps & { readOnly: boolean }) {
const containerRef = useRef<HTMLDivElement>(null);
const apiRef = useRef<FUniver>();
useInitializeSpreadsheet(containerRef, apiRef, readOnly);
useDarkMode(apiRef);
usePersistence(note, noteContext, apiRef, containerRef, readOnly);
useSearchIntegration(apiRef);
// Focus the spreadsheet when the note is focused.
useTriliumEvent("focusOnDetail", () => {
const focusable = containerRef.current?.querySelector('[data-u-comp="editor"]');
if (focusable instanceof HTMLElement) {
focusable.focus();
}
});
return <div ref={containerRef} className="spreadsheet" />;
}
function useInitializeSpreadsheet(containerRef: MutableRef<HTMLDivElement | null>, apiRef: MutableRef<FUniver | undefined>, readOnly: boolean) {
useEffect(() => {
if (!containerRef.current) return;
const { univerAPI } = createUniver({
locale: LocaleType.EN_US,
locales: {
[LocaleType.EN_US]: mergeLocales(
sheetsCoreEnUS,
sheetsFindReplaceEnUS,
sheetsNoteEnUS,
UniverPresetSheetsFilterEnUS,
UniverPresetSheetsSortEnUS,
UniverPresetSheetsDataValidationEnUS,
UniverPresetSheetsConditionalFormattingEnUS,
),
},
presets: [
UniverSheetsCorePreset({
container: containerRef.current,
toolbar: !readOnly,
contextMenu: !readOnly,
formulaBar: !readOnly,
footer: readOnly ? false : undefined,
menu: {
"sheet.contextMenu.permission": { hidden: true },
"sheet-permission.operation.openPanel": { hidden: true },
"sheet.command.add-range-protection-from-toolbar": { hidden: true },
},
}),
UniverSheetsFindReplacePreset(),
UniverSheetsNotePreset(),
UniverSheetsFilterPreset(),
UniverSheetsSortPreset(),
UniverSheetsDataValidationPreset(),
UniverSheetsConditionalFormattingPreset()
]
});
apiRef.current = univerAPI;
return () => univerAPI.dispose();
}, [ apiRef, containerRef, readOnly ]);
}
function useDarkMode(apiRef: MutableRef<FUniver | undefined>) {
const colorScheme = useColorScheme();
// React to dark mode.
useEffect(() => {
const univerAPI = apiRef.current;
if (!univerAPI) return;
univerAPI.toggleDarkMode(colorScheme === 'dark');
}, [ colorScheme, apiRef ]);
}
function useSearchIntegration(apiRef: MutableRef<FUniver | undefined>) {
useTriliumEvent("findInText", () => {
const univerAPI = apiRef.current;
if (!univerAPI) return;
// Open find/replace panel and populate the search term.
univerAPI.executeCommand("ui.operation.open-find-dialog");
});
}

View File

@@ -0,0 +1,194 @@
import { CommandType, FUniver, IDisposable, IWorkbookData } from "@univerjs/presets";
import { MutableRef, useEffect, useRef } from "preact/hooks";
import NoteContext from "../../../components/note_context";
import FNote from "../../../entities/fnote";
import { SavedData, useEditorSpacedUpdate } from "../../react/hooks";
interface PersistedData {
version: number;
workbook: Parameters<FUniver["createWorkbook"]>[0];
}
interface SpreadsheetViewState {
activeSheetId?: string;
cursorRow?: number;
cursorCol?: number;
scrollRow?: number;
scrollCol?: number;
}
export default function usePersistence(note: FNote, noteContext: NoteContext | null | undefined, apiRef: MutableRef<FUniver | undefined>, containerRef: MutableRef<HTMLDivElement | null>, readOnly: boolean) {
const changeListener = useRef<IDisposable>(null);
const pendingContent = useRef<string | null>(null);
function saveViewState(univerAPI: FUniver): SpreadsheetViewState {
const state: SpreadsheetViewState = {};
try {
const workbook = univerAPI.getActiveWorkbook();
if (!workbook) return state;
const activeSheet = workbook.getActiveSheet();
state.activeSheetId = activeSheet?.getSheetId();
const currentCell = activeSheet?.getSelection()?.getCurrentCell();
if (currentCell) {
state.cursorRow = currentCell.actualRow;
state.cursorCol = currentCell.actualColumn;
}
const scrollState = activeSheet?.getScrollState?.();
if (scrollState) {
state.scrollRow = scrollState.sheetViewStartRow;
state.scrollCol = scrollState.sheetViewStartColumn;
}
} catch {
// Ignore errors when reading state from a workbook being disposed.
}
return state;
}
function restoreViewState(workbook: ReturnType<FUniver["createWorkbook"]>, state: SpreadsheetViewState) {
try {
if (state.activeSheetId) {
const targetSheet = workbook.getSheetBySheetId(state.activeSheetId);
if (targetSheet) {
workbook.setActiveSheet(targetSheet);
}
}
if (state.cursorRow !== undefined && state.cursorCol !== undefined) {
workbook.getActiveSheet().getRange(state.cursorRow, state.cursorCol).activate();
}
if (state.scrollRow !== undefined && state.scrollCol !== undefined) {
workbook.getActiveSheet().scrollToCell(state.scrollRow, state.scrollCol);
}
} catch {
// Ignore errors when restoring state (e.g. sheet no longer exists).
}
}
function applyContent(univerAPI: FUniver, newContent: string) {
const viewState = saveViewState(univerAPI);
// Dispose the existing workbook.
const existingWorkbook = univerAPI.getActiveWorkbook();
if (existingWorkbook) {
univerAPI.disposeUnit(existingWorkbook.getId());
}
let workbookData: Partial<IWorkbookData> = {};
if (newContent) {
try {
const parsedContent = JSON.parse(newContent) as unknown;
if (parsedContent && typeof parsedContent === "object" && "workbook" in parsedContent) {
const persistedData = parsedContent as PersistedData;
workbookData = persistedData.workbook;
}
} catch (e) {
console.error("Failed to parse spreadsheet content", e);
}
}
const workbook = univerAPI.createWorkbook(workbookData);
if (readOnly) {
workbook.disableSelection();
const permission = workbook.getPermission();
permission.setWorkbookEditPermission(workbook.getId(), false);
permission.setPermissionDialogVisible(false);
}
restoreViewState(workbook, viewState);
if (changeListener.current) {
changeListener.current.dispose();
}
changeListener.current = workbook.onCommandExecuted(command => {
if (command.type !== CommandType.MUTATION) return;
spacedUpdate.scheduleUpdate();
});
}
function isContainerVisible() {
const el = containerRef.current;
if (!el) return false;
return el.offsetWidth > 0 && el.offsetHeight > 0;
}
const spacedUpdate = useEditorSpacedUpdate({
noteType: "spreadsheet",
note,
noteContext,
async getData() {
const univerAPI = apiRef.current;
if (!univerAPI) return undefined;
const workbook = univerAPI.getActiveWorkbook();
if (!workbook) return undefined;
const content = {
version: 1,
workbook: workbook.save()
};
const attachments: SavedData["attachments"] = [];
const canvasEl = containerRef.current?.querySelector<HTMLCanvasElement>("canvas[id]");
if (canvasEl) {
const dataUrl = canvasEl.toDataURL("image/png");
const base64 = dataUrl.split(",")[1];
attachments.push({
role: "image",
title: "spreadsheet-export.png",
mime: "image/png",
content: base64,
position: 0,
encoding: "base64"
});
}
return {
content: JSON.stringify(content),
attachments
};
},
onContentChange(newContent) {
const univerAPI = apiRef.current;
if (!univerAPI) return undefined;
// Defer content application if the container is hidden (zero size),
// since the spreadsheet library cannot calculate layout in that state.
if (!isContainerVisible()) {
pendingContent.current = newContent;
return;
}
pendingContent.current = null;
applyContent(univerAPI, newContent);
},
});
// Apply pending content once the container becomes visible (non-zero size).
useEffect(() => {
if (!containerRef.current) return;
const observer = new ResizeObserver(() => {
if (pendingContent.current === null || !isContainerVisible()) return;
const univerAPI = apiRef.current;
if (!univerAPI) return;
const content = pendingContent.current;
pendingContent.current = null;
applyContent(univerAPI, content);
});
observer.observe(containerRef.current);
return () => observer.disconnect();
// eslint-disable-next-line react-hooks/exhaustive-deps -- intentionally stable: applyContent/isContainerVisible use refs
}, [ containerRef ]);
useEffect(() => {
return () => {
if (changeListener.current) {
changeListener.current.dispose();
changeListener.current = null;
}
};
}, []);
}

View File

@@ -120,7 +120,11 @@ export default defineConfig(() => ({
environment: "happy-dom",
setupFiles: [
"./src/test/setup.ts"
]
],
reporters: [
"verbose",
["html", { outputFile: "./test-output/vitest/html/index.html" }]
],
},
commonjsOptions: {
transformMixedEsModules: true,

View File

@@ -1,6 +1,6 @@
{
"name": "@triliumnext/desktop",
"version": "0.102.0",
"version": "0.102.1",
"description": "Build your personal knowledge base with Trilium Notes",
"private": true,
"main": "src/main.ts",
@@ -35,7 +35,7 @@
"@triliumnext/commons": "workspace:*",
"@triliumnext/server": "workspace:*",
"copy-webpack-plugin": "14.0.0",
"electron": "40.6.1",
"electron": "40.8.0",
"@electron-forge/cli": "7.11.1",
"@electron-forge/maker-deb": "7.11.1",
"@electron-forge/maker-dmg": "7.11.1",

View File

@@ -1,6 +1,6 @@
{
"name": "@triliumnext/edit-docs",
"version": "0.102.0",
"version": "0.102.1",
"private": true,
"description": "Desktop version of Trilium which imports the demo database (presented to new users at start-up) or the user guide and other documentation and saves the modifications for committing.",
"dependencies": {
@@ -12,7 +12,7 @@
"@triliumnext/desktop": "workspace:*",
"@types/fs-extra": "11.0.4",
"copy-webpack-plugin": "14.0.0",
"electron": "40.6.1",
"electron": "40.8.0",
"fs-extra": "11.3.4"
},
"scripts": {

View File

@@ -0,0 +1,51 @@
# Nginx Proxy Manager (for testing reverse proxy setups)
## Quick start
1. Start Trilium on the host (default port 8080):
```bash
pnpm run server:start
```
2. Start Nginx Proxy Manager:
```bash
docker compose up -d
```
3. Open the NPM admin panel at **http://localhost:8081** and log in with:
- Email: `admin@example.com`
- Password: `changeme`
(You'll be asked to change these on first login.)
4. Add a proxy host:
- **Domain Names**: `localhost`
- **Scheme**: `http`
- **Forward Hostname / IP**: `host.docker.internal`
- **Forward Port**: `8080`
- Enable **Websockets Support** (required for Trilium sync)
5. Access Trilium through NPM at **http://localhost:8090**.
## With a subpath
To test Trilium behind a subpath (e.g. `/trilium/`), add a **Custom Nginx Configuration** in NPM under the **Advanced** tab of the proxy host:
```nginx
location /trilium/ {
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_pass http://host.docker.internal:8080/;
proxy_cookie_path / /trilium/;
proxy_read_timeout 90;
}
```
## Cleanup
```bash
docker compose down -v
```

View File

@@ -0,0 +1,19 @@
services:
nginx-proxy-manager:
image: "jc21/nginx-proxy-manager:latest"
restart: unless-stopped
ports:
# Public HTTP port
- "8090:80"
# Admin panel
- "8081:81"
volumes:
- npm_data:/data
- npm_letsencrypt:/etc/letsencrypt
# Use host network mode so NPM can reach Trilium on the host.
extra_hosts:
- "host.docker.internal:host-gateway"
volumes:
npm_data:
npm_letsencrypt:

View File

@@ -1,6 +1,6 @@
{
"name": "@triliumnext/server",
"version": "0.102.0",
"version": "0.102.1",
"description": "The server-side component of TriliumNext, which exposes the client via the web, allows for sync and provides a REST API for both internal and external use.",
"private": true,
"main": "./src/main.ts",
@@ -55,9 +55,9 @@
"@types/html": "1.0.4",
"@types/ini": "4.1.1",
"@types/mime-types": "3.0.1",
"@types/multer": "2.0.0",
"@types/multer": "2.1.0",
"@types/safe-compare": "1.1.2",
"@types/sanitize-html": "2.16.0",
"@types/sanitize-html": "2.16.1",
"@types/sax": "1.2.7",
"@types/serve-favicon": "2.5.7",
"@types/serve-static": "2.2.0",
@@ -81,15 +81,15 @@
"csrf-csrf": "3.2.2",
"debounce": "3.0.0",
"debug": "4.4.3",
"ejs": "4.0.1",
"electron": "40.6.1",
"ejs": "5.0.1",
"electron": "40.8.0",
"electron-debug": "4.1.0",
"electron-window-state": "5.0.3",
"escape-html": "1.0.3",
"express": "5.2.1",
"express-http-proxy": "2.1.2",
"express-openid-connect": "2.19.4",
"express-rate-limit": "8.2.1",
"express-rate-limit": "8.3.0",
"express-session": "1.19.0",
"file-uri-to-path": "2.0.0",
"fs-extra": "11.3.4",
@@ -98,7 +98,7 @@
"html2plaintext": "2.1.4",
"http-proxy-agent": "7.0.2",
"https-proxy-agent": "7.0.6",
"i18next": "25.8.13",
"i18next": "25.8.14",
"i18next-fs-backend": "2.6.1",
"image-type": "6.0.0",
"ini": "6.0.0",
@@ -106,9 +106,9 @@
"is-svg": "6.1.0",
"jimp": "1.6.0",
"lorem-ipsum": "2.0.8",
"marked": "17.0.3",
"marked": "17.0.4",
"mime-types": "3.0.2",
"multer": "2.1.0",
"multer": "2.1.1",
"normalize-strings": "1.1.1",
"rand-token": "1.0.1",
"safe-compare": "1.1.4",

View File

@@ -86,8 +86,9 @@ export default async function buildApp() {
app.use(`/robots.txt`, express.static(path.join(publicAssetsDir, "robots.txt")));
app.use(`/icon.png`, express.static(path.join(publicAssetsDir, "icon.png")));
const sessionParser = (await import("./routes/session_parser.js")).default;
const { default: sessionParser, startSessionCleanup } = await import("./routes/session_parser.js");
app.use(sessionParser);
startSessionCleanup();
app.use(favicon(path.join(assetsDir, isDev ? "icon-dev.ico" : "icon.ico")));
if (openID.isOpenIDEnabled())
@@ -98,16 +99,16 @@ export default async function buildApp() {
custom.register(app);
error_handlers.register(app);
// triggers sync timer
await import("./services/sync.js");
const { startSyncTimer } = await import("./services/sync.js");
startSyncTimer();
// triggers backup timer
await import("./services/backup.js");
// trigger consistency checks timer
await import("./services/consistency_checks.js");
const { startConsistencyChecks } = await import("./services/consistency_checks.js");
startConsistencyChecks();
await import("./services/scheduler.js");
const { startScheduler } = await import("./services/scheduler.js");
startScheduler();
startScheduledCleanup();

View File

@@ -156,7 +156,8 @@
"go-to-next-note-title": "К следующей заметке",
"open-today-journal-note-title": "Открыть сегодняшнюю заметку в журнале",
"zen-mode": "Режим \"Дзен\"",
"command-palette": "Открыть панель команд"
"command-palette": "Открыть панель команд",
"tab-switcher-title": "Переключатель вкладок"
},
"tray": {
"bookmarks": "Закладки",
@@ -313,7 +314,7 @@
"title": "Настройка",
"heading": "Настройка Trilium",
"new-document": "Я новый пользователь и хочу создать новый документ Trilium для своих заметок",
"sync-from-desktop": "У меня уже есть приложение ПК, и я хочу настроить синхронизацию с ним",
"sync-from-desktop": "У меня уже есть настольное приложение, и я хочу настроить синхронизацию с ним",
"sync-from-server": "У меня уже есть сервер, и я хочу настроить синхронизацию с ним",
"init-in-progress": "Идет инициализация документа",
"redirecting": "Вскоре вы будете перенаправлены на страницу приложения."
@@ -397,8 +398,8 @@
"clipped-from": "Эта заметка изначально была вырезана из {{- url}}"
},
"setup_sync-from-desktop": {
"heading": "Синхронизация с приложения ПК",
"description": "Эту настройку необходимо инициировать из приложения для ПК:",
"heading": "Синхронизация с настольной версией",
"description": "Это настройку нужно выполнить с помощью настольной версии:",
"step1": "Откройте приложение Trilium Notes на ПК.",
"step2": "В меню Trilium выберите «Параметры».",
"step3": "Нажмите на категорию «Синхронизация».",

View File

@@ -2,6 +2,24 @@
"keyboard_actions": {
"back-in-note-history": "Gå till föregående anteckning i historiken",
"forward-in-note-history": "Gå till nästa anteckning i historiken",
"open-jump-to-note-dialog": "Öppna \"Hoppa till anteckning\" dialog"
"open-jump-to-note-dialog": "Öppna \"Hoppa till anteckning\" dialog",
"open-command-palette": "Öppna kommandomenyn",
"quick-search": "Öppna snabbsökning",
"search-in-subtree": "Sök anteckningar nedåt i anteckningshierarkin",
"expand-subtree": "Expandera hierarkin under denna anteckning",
"collapse-tree": "Stänger anteckningshierarkin",
"collapse-subtree": "Stänger hierarkin under aktuell anteckning",
"sort-child-notes": "Sortera underordnade anteckningar",
"creating-and-moving-notes": "Skapa och flytta anteckningar",
"create-note-after": "Skapa ny anteckning efter aktiv anteckning",
"create-note-into": "Skapa ny anteckning underordnad aktiv anteckning",
"create-note-into-inbox": "Skapa en anteckning i inboxen (om angiven) eller som daganteckning",
"delete-note": "Radera anteckning",
"move-note-up": "Flytta anteckning uppåt",
"move-note-down": "Flytta anteckning nedåt",
"scroll-to-active-note": "Bläddra i anteckningshierarkin till aktiv anteckning",
"move-note-up-in-hierarchy": "Flytta anteckning uppåt i hierarkin",
"move-note-down-in-hierarchy": "Flytta anteckning neråt i hierarkin",
"edit-note-title": "Hoppa från träd till anteckning och redigera titel"
}
}

View File

@@ -23,7 +23,7 @@ function returnImageInt(image: BNote | BRevision | null, res: Response) {
if (!image) {
res.set("Content-Type", "image/png");
return res.send(fs.readFileSync(`${RESOURCE_DIR}/db/image-deleted.png`));
} else if (!["image", "canvas", "mermaid", "mindMap"].includes(image.type)) {
} else if (!["image", "canvas", "mermaid", "mindMap", "spreadsheet"].includes(image.type)) {
return res.sendStatus(400);
}
@@ -33,6 +33,8 @@ function returnImageInt(image: BNote | BRevision | null, res: Response) {
renderSvgAttachment(image, res, "mermaid-export.svg");
} else if (image.type === "mindMap") {
renderSvgAttachment(image, res, "mindmap-export.svg");
} else if (image.type === "spreadsheet") {
renderPngAttachment(image, res, "spreadsheet-export.png");
} else {
res.set("Content-Type", image.mime);
res.set("Cache-Control", "no-cache, no-store, must-revalidate");
@@ -60,6 +62,18 @@ export function renderSvgAttachment(image: BNote | BRevision, res: Response, att
res.send(svg);
}
export function renderPngAttachment(image: BNote | BRevision, res: Response, attachmentName: string) {
const attachment = image.getAttachmentByTitle(attachmentName);
if (attachment) {
res.set("Content-Type", "image/png");
res.set("Cache-Control", "no-cache, no-store, must-revalidate");
res.send(attachment.getContent());
} else {
res.sendStatus(404);
}
}
function returnAttachedImage(req: Request<{ attachmentId: string }>, res: Response) {
const attachment = becca.getAttachment(req.params.attachmentId);

View File

@@ -2,7 +2,7 @@ import { dayjs } from "@triliumnext/commons";
import type { Application } from "express";
import { SessionData } from "express-session";
import supertest, { type Response } from "supertest";
import { beforeAll, describe, expect, it, vi } from "vitest";
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
import cls from "../services/cls.js";
import { type SQLiteSessionStore } from "./session_parser.js";
@@ -20,6 +20,10 @@ describe("Login Route test", () => {
({ sessionStore, CLEAN_UP_INTERVAL } = (await import("./session_parser.js")));
});
afterAll(() => {
vi.useRealTimers();
});
it("should return the login page, when using a GET request", async () => {
// RegExp for login page specific string in HTML

View File

@@ -113,11 +113,13 @@ const sessionParser: express.RequestHandler = session({
store: sessionStore
});
setInterval(() => {
// Clean up expired sesions.
const now = Date.now();
const result = sql.execute(/*sql*/`DELETE FROM sessions WHERE expires < ?`, now);
console.log("Cleaning up expired sessions: ", result.changes);
}, CLEAN_UP_INTERVAL);
export function startSessionCleanup() {
setInterval(() => {
// Clean up expired sessions.
const now = Date.now();
const result = sql.execute(/*sql*/`DELETE FROM sessions WHERE expires < ?`, now);
console.log("Cleaning up expired sessions: ", result.changes);
}, CLEAN_UP_INTERVAL);
}
export default sessionParser;

View File

@@ -71,6 +71,27 @@ function getAttributeNames(type: string, nameLike: string) {
[type, `%${nameLike}%`]
);
// Also include attribute definitions (e.g. 'relation:*' or 'label:*') which are saved as type='label'
if (type === "relation" || type === "label") {
const prefix = `${type}:`;
const defNames = sql.getColumn<string>(
/*sql*/`SELECT DISTINCT name
FROM attributes
WHERE isDeleted = 0
AND type = 'label'
AND name LIKE ?`,
[`${prefix}%${nameLike}%`]
);
for (const dn of defNames) {
if (dn.startsWith(prefix)) {
const stripped = dn.substring(prefix.length);
if (!names.includes(stripped)) {
names.push(stripped);
}
}
}
}
for (const attr of BUILTIN_ATTRIBUTES) {
if (attr.type === type && attr.name.toLowerCase().includes(nameLike) && !names.includes(attr.name)) {
names.push(attr.name);

View File

@@ -953,12 +953,14 @@ function runEntityChangesChecks() {
consistencyChecks.findEntityChangeIssues();
}
sqlInit.dbReady.then(() => {
setInterval(cls.wrap(runPeriodicChecks), 60 * 60 * 1000);
export function startConsistencyChecks() {
sqlInit.dbReady.then(() => {
setInterval(cls.wrap(runPeriodicChecks), 60 * 60 * 1000);
// kickoff checks soon after startup (to not block the initial load)
setTimeout(cls.wrap(runPeriodicChecks), 4 * 1000);
});
// kickoff checks soon after startup (to not block the initial load)
setTimeout(cls.wrap(runPeriodicChecks), 4 * 1000);
});
}
export default {
runOnDemandChecks,

View File

@@ -24,6 +24,7 @@ async function testImport(fileName: string, mimetype: string) {
const rootNote = becca.getNote("root");
if (!rootNote) {
reject("Missing root note.");
return;
}
const importedNote = single.importSingleFile(

View File

@@ -772,16 +772,20 @@ function updateNoteData(noteId: string, content: string, attachments: Attachment
if (attachments?.length > 0) {
const existingAttachmentsByTitle = toMap(note.getAttachments(), "title");
for (const { attachmentId, role, mime, title, position, content } of attachments) {
for (const { attachmentId, role, mime, title, position, content, encoding } of attachments) {
const decodedContent = encoding === "base64" && typeof content === "string"
? Buffer.from(content, "base64")
: content;
const existingAttachment = existingAttachmentsByTitle.get(title);
if (attachmentId || !existingAttachment) {
note.saveAttachment({ attachmentId, role, mime, title, content, position });
note.saveAttachment({ attachmentId, role, mime, title, content: decodedContent, position });
} else {
existingAttachment.role = role;
existingAttachment.mime = mime;
existingAttachment.position = position;
if (content) {
existingAttachment.setContent(content, { forceSave: true });
if (decodedContent) {
existingAttachment.setContent(decodedContent, { forceSave: true });
}
}
}

View File

@@ -35,39 +35,41 @@ function runNotesWithLabel(runAttrValue: string) {
}
}
// If the database is already initialized, we need to check the hidden subtree. Otherwise, hidden subtree
// is also checked before importing the demo.zip, so no need to do it again.
if (sqlInit.isDbInitialized()) {
console.log("Checking hidden subtree.");
sqlInit.dbReady.then(() => cls.init(() => hiddenSubtreeService.checkHiddenSubtree()));
}
// Periodic checks.
sqlInit.dbReady.then(() => {
if (!process.env.TRILIUM_SAFE_MODE) {
setTimeout(
cls.wrap(() => runNotesWithLabel("backendStartup")),
10 * 1000
);
setInterval(
cls.wrap(() => runNotesWithLabel("hourly")),
3600 * 1000
);
setInterval(
cls.wrap(() => runNotesWithLabel("daily")),
24 * 3600 * 1000
);
setInterval(
cls.wrap(() => hiddenSubtreeService.checkHiddenSubtree()),
7 * 3600 * 1000
);
export function startScheduler() {
// If the database is already initialized, we need to check the hidden subtree. Otherwise, hidden subtree
// is also checked before importing the demo.zip, so no need to do it again.
if (sqlInit.isDbInitialized()) {
console.log("Checking hidden subtree.");
sqlInit.dbReady.then(() => cls.init(() => hiddenSubtreeService.checkHiddenSubtree()));
}
setInterval(() => checkProtectedSessionExpiration(), 30000);
});
// Periodic checks.
sqlInit.dbReady.then(() => {
if (!process.env.TRILIUM_SAFE_MODE) {
setTimeout(
cls.wrap(() => runNotesWithLabel("backendStartup")),
10 * 1000
);
setInterval(
cls.wrap(() => runNotesWithLabel("hourly")),
3600 * 1000
);
setInterval(
cls.wrap(() => runNotesWithLabel("daily")),
24 * 3600 * 1000
);
setInterval(
cls.wrap(() => hiddenSubtreeService.checkHiddenSubtree()),
7 * 3600 * 1000
);
}
setInterval(() => checkProtectedSessionExpiration(), 30000);
});
}
function checkProtectedSessionExpiration() {
const protectedSessionTimeout = options.getOptionInt("protectedSessionTimeout");

View File

@@ -50,7 +50,7 @@ async function initDbConnection() {
await migrationService.migrateIfNecessary();
sql.execute('CREATE TEMP TABLE "param_list" (`paramId` TEXT NOT NULL PRIMARY KEY)');
sql.execute('CREATE TEMP TABLE IF NOT EXISTS "param_list" (`paramId` TEXT NOT NULL PRIMARY KEY)');
sql.execute(`
CREATE TABLE IF NOT EXISTS "user_data"

View File

@@ -446,15 +446,17 @@ function getOutstandingPullCount() {
return outstandingPullCount;
}
becca_loader.beccaLoaded.then(() => {
setInterval(cls.wrap(sync), 60000);
export function startSyncTimer() {
becca_loader.beccaLoaded.then(() => {
setInterval(cls.wrap(sync), 60000);
// kickoff initial sync immediately, but should happen after initial consistency checks
setTimeout(cls.wrap(sync), 5000);
// kickoff initial sync immediately, but should happen after initial consistency checks
setTimeout(cls.wrap(sync), 5000);
// called just so ws.setLastSyncedPush() is called
getLastSyncedPush();
});
// called just so ws.setLastSyncedPush() is called
getLastSyncedPush();
});
}
export default {
sync,

View File

@@ -1,4 +1,5 @@
import { sanitizeUrl } from "@braintree/sanitize-url";
import { renderSpreadsheetToHtml } from "@triliumnext/commons";
import { highlightAuto } from "@triliumnext/highlightjs";
import ejs from "ejs";
import escapeHtml from "escape-html";
@@ -286,6 +287,8 @@ export function getContent(note: SNote | BNote) {
result.isEmpty = true;
} else if (note.type === "webView") {
renderWebView(note, result);
} else if (note.type === "spreadsheet") {
renderSpreadsheet(result);
} else {
result.content = `<p>${t("content_renderer.note-cannot-be-displayed")}</p>`;
}
@@ -487,6 +490,14 @@ function renderFile(note: SNote | BNote, result: Result) {
}
}
function renderSpreadsheet(result: Result) {
if (typeof result.content !== "string" || !result.content?.trim()) {
result.isEmpty = true;
} else {
result.content = renderSpreadsheetToHtml(result.content);
}
}
function renderWebView(note: SNote | BNote, result: Result) {
const url = note.getLabelValue("webViewSrc");
if (!url) return;

View File

@@ -1,6 +1,6 @@
import type { Application, NextFunction,Request, Response } from "express";
import supertest from "supertest";
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { safeExtractMessageAndStackFromError } from "../services/utils.js";
@@ -23,6 +23,10 @@ describe("Share API test", () => {
});
});
afterAll(() => {
vi.useRealTimers();
});
beforeEach(() => {
cannotSetHeadersCount = 0;
});

View File

@@ -19,16 +19,18 @@ export default defineConfig(() => ({
exclude: [
"spec/build-checks/**",
],
hookTimeout: 20000,
hookTimeout: 20_000,
testTimeout: 40_000,
reporters: [
"verbose"
"verbose",
["html", { outputFile: "./test-output/vitest/html/index.html" }]
],
coverage: {
reportsDirectory: './test-output/vitest/coverage',
provider: 'v8' as const,
reporter: [ "text", "html" ]
},
pool: "vmForks",
maxWorkers: 3
pool: "forks",
maxWorkers: 6
},
}));

View File

@@ -13,7 +13,7 @@
"postinstall": "wxt prepare"
},
"keywords": [],
"packageManager": "pnpm@10.30.3",
"packageManager": "pnpm@10.31.0",
"devDependencies": {
"@wxt-dev/auto-icons": "1.1.1",
"wxt": "0.20.18"

View File

@@ -9,16 +9,16 @@
"preview": "pnpm build && vite preview"
},
"dependencies": {
"i18next": "25.8.13",
"i18next": "25.8.14",
"i18next-http-backend": "3.0.2",
"preact": "10.28.4",
"preact-iso": "2.11.1",
"preact-render-to-string": "6.6.6",
"react-i18next": "16.5.4"
"react-i18next": "16.5.6"
},
"devDependencies": {
"@preact/preset-vite": "2.10.3",
"eslint": "10.0.2",
"eslint": "10.0.3",
"eslint-config-preact": "2.0.0",
"typescript": "5.9.3",
"user-agent-data-types": "0.4.2",

View File

@@ -43,7 +43,7 @@
"code_title": "Code Notizen",
"canvas_title": "Leinwand",
"mermaid_title": "Mermaid Diagramm",
"mindmap_title": "Mind Map",
"mindmap_title": "Mindmap",
"text_description": "Die Notizen werden mit einem visuellen Editor (WYSIWYG) bearbeitet, der Tabellen, Bilder, mathematische Ausdrücke und Code-Blöcke mit Syntaxhervorhebung unterstützt. Formatieren Sie den Text schnell mit einer Markdown-ähnlichen Syntax oder mit Slash-Befehlen.",
"code_description": "Große Quellcode- oder Skriptdateien werden mit einem speziellen Editor bearbeitet, der Syntaxhervorhebung für viele Programmiersprachen und diverse Farbschemata bietet.",
"title": "Verschiedene Darstellungsformen für Ihre Informationen",

View File

@@ -12,7 +12,7 @@
"get_started": "Начало работы",
"github": "GitHub",
"dockerhub": "Docker Hub",
"screenshot_alt": "Скриншот приложения Trilium Notes для ПК"
"screenshot_alt": "Скриншот приложения Trilium Notes для настольного приложения"
},
"organization_benefits": {
"title": "Структура",
@@ -202,6 +202,7 @@
"title": "Ресурсы",
"icon_packs": "Наборы иконок",
"download": "Скачать",
"website": "Сайт"
"website": "Сайт",
"icon_packs_intro": "Расширьте выбор значков для заметок, используя набор иконок. Подробнее о наборах иконок смотрите в <DocumentationLink>официальной документации</DocumentationLink>."
}
}

View File

@@ -2,6 +2,30 @@
"get-started": {
"title": "Kom igång",
"desktop_title": "Ladda ner skrivbordsprogrammet (v{{version}})",
"architecture": "Arkitektur:"
"architecture": "Arkitektur:",
"older_releases": "Se äldre versioner",
"server_title": "Skapa en server för åtkomst på flera enheter"
},
"hero_section": {
"title": "Organisera dina tankar. Skapa din personliga kunskapsbank.",
"subtitle": "Trilium är en lösning med öppen källkod som möjliggör anteckningar och organisering av en personlig kunskapsbank. Använd den lokalt på ditt skrivbord, eller synka till en självhostad server för att komma åt dina anteckningar överallt.",
"screenshot_alt": "Skärmdump av Trilium Notes skrivbordsapplikation",
"get_started": "Kom igång",
"github": "GitHub",
"dockerhub": "Docker Hub"
},
"organization_benefits": {
"title": "Organisation",
"note_structure_title": "Anteckningsstruktur",
"note_structure_description": "Anteckningar kan sorteras hierarkiskt. Det behövs inga mappar eftersom varje anteckning kan innehålla underordnade anteckningar. En enskild anteckning kan placeras på flera ställen samtidigt i hierarkin.",
"attributes_title": "Hantera etiketter och relationer",
"attributes_description": "Använd relationer mellan anteckningar eller lägg till etiketter för enkel kategorisering. Använd framhävda attribut för att ange strukturerad information som sedan kan visas i tabeller och tavlor.",
"hoisting_title": "Arbetsyta och fokusområde",
"hoisting_description": "Separera enkelt privata- och jobbanteckningar genom att gruppera dem på en arbetsyta, vilket fokuserar anteckningshierarkin att enbart visa en viss grupp av anteckningar."
},
"productivity_benefits": {
"title": "Produktivitet och säkerhet",
"revisions_title": "Anteckningshistorik",
"revisions_content": "Anteckningar sparas regelbundet i bakgrunden och versioner kan användas för att söka- eller ångra oavsiktliga ändringar. En version kan också skapas manuellt."
}
}

45
docs/README-sv.md vendored
View File

@@ -37,40 +37,39 @@ antecknings app med fokus på att bygga en stor personlig kunskapsbas.
## Ladda ner
- [Latest release](https://github.com/TriliumNext/Trilium/releases/latest)
stabil version, rekommenderas för dom flesta användare.
- [Nightly build](https://github.com/TriliumNext/Trilium/releases/tag/nightly)
unstable development version, updated daily with the latest features and
fixes.
- [Nightly build](https://github.com/TriliumNext/Trilium/releases/tag/nightly) -
ostabil utvecklings version, uppdaterad dagligen med de senaste funktionerna
och fixarna.
## 📚 Documentation
## 📚 Dokumentation
**Visit our comprehensive documentation at
**Läs mer i vår omfattande dokumentation
[docs.triliumnotes.org](https://docs.triliumnotes.org/)**
Our documentation is available in multiple formats:
- **Online Documentation**: Browse the full documentation at
r dokumentation är tillgänglig i flera format:
- **Webb dokumentation**: Läs hela dokumentationen på
[docs.triliumnotes.org](https://docs.triliumnotes.org/)
- **In-App Help**: Press `F1` within Trilium to access the same documentation
directly in the application
- **GitHub**: Navigate through the [User Guide](./User%20Guide/User%20Guide/) in
this repository
- **I-appen-hjälp**: Tryck `F1` i Trilium för att läsa samma dokumentation inuti
programmet
- **GitHub**: Läs [användarhandboken](./User%20Guide/User%20Guide/) i denna repo
### Quick Links
- [Getting Started Guide](https://docs.triliumnotes.org/)
- [Installation Instructions](https://docs.triliumnotes.org/user-guide/setup)
### Snabblänkar
- [Snabbstartsguide](https://docs.triliumnotes.org/)
- [Installationsanvisning](https://docs.triliumnotes.org/user-guide/setup)
- [Docker
Setup](https://docs.triliumnotes.org/user-guide/setup/server/installation/docker)
- [Upgrading
- [Uppdaterar
TriliumNext](https://docs.triliumnotes.org/user-guide/setup/upgrading)
- [Basic Concepts and
Features](https://docs.triliumnotes.org/user-guide/concepts/notes)
- [Patterns of Personal Knowledge
Base](https://docs.triliumnotes.org/user-guide/misc/patterns-of-personal-knowledge)
- [Grundläggande koncept och
funktioner](https://docs.triliumnotes.org/user-guide/concepts/notes)
- [Modeller av personlig
kunskapsbas](https://docs.triliumnotes.org/user-guide/misc/patterns-of-personal-knowledge)
## 🎁 Features
## 🎁 Funktioner
* Notes can be arranged into arbitrarily deep tree. Single note can be placed
into multiple places in the tree (see
[cloning](https://docs.triliumnotes.org/user-guide/concepts/notes/cloning))
* Anteckningar kan sorteras som en trädstruktur. En enskild anteckning kan
placeras på fler än en plats i trädet (se
[kloning](https://docs.triliumnotes.org/user-guide/concepts/notes/cloning))
* Rich WYSIWYG note editor including e.g. tables, images and
[math](https://docs.triliumnotes.org/user-guide/note-types/text) with markdown
[autoformat](https://docs.triliumnotes.org/user-guide/note-types/text/markdown-formatting)

View File

@@ -1,6 +1,6 @@
{
"formatVersion": 2,
"appVersion": "0.101.3",
"appVersion": "0.102.0",
"files": [
{
"isClone": false,
@@ -61,6 +61,32 @@
"attachments": [],
"dirFileName": "Release Notes",
"children": [
{
"isClone": false,
"noteId": "4FTGCuCiG7s7",
"notePath": [
"hD3V4hiu2VW4",
"4FTGCuCiG7s7"
],
"title": "v0.102.1",
"notePosition": 10,
"prefix": null,
"isExpanded": false,
"type": "text",
"mime": "text/html",
"attributes": [
{
"type": "relation",
"name": "template",
"value": "wyurrlcDl416",
"isInheritable": false,
"position": 60
}
],
"format": "markdown",
"dataFileName": "v0.102.1.md",
"attachments": []
},
{
"isClone": false,
"noteId": "d582eD4RY4OM",
@@ -69,7 +95,7 @@
"d582eD4RY4OM"
],
"title": "v0.102.0",
"notePosition": 10,
"notePosition": 20,
"prefix": null,
"isExpanded": false,
"type": "text",
@@ -95,7 +121,7 @@
"IlBzLeN3MJhw"
],
"title": "v0.101.3",
"notePosition": 20,
"notePosition": 30,
"prefix": null,
"isExpanded": false,
"type": "text",
@@ -121,7 +147,7 @@
"vcBthaXcwAm6"
],
"title": "v0.101.2",
"notePosition": 30,
"notePosition": 40,
"prefix": null,
"isExpanded": false,
"type": "text",
@@ -147,7 +173,7 @@
"AgUcrU9nFXuW"
],
"title": "v0.101.1",
"notePosition": 40,
"notePosition": 50,
"prefix": null,
"isExpanded": false,
"type": "text",
@@ -173,7 +199,7 @@
"uYwlZ594eyJu"
],
"title": "v0.101.0",
"notePosition": 50,
"notePosition": 60,
"prefix": null,
"isExpanded": false,
"type": "text",
@@ -199,7 +225,7 @@
"iPGKEk7pwJXK"
],
"title": "v0.100.0",
"notePosition": 60,
"notePosition": 70,
"prefix": null,
"isExpanded": false,
"type": "text",
@@ -225,7 +251,7 @@
"7HKMTjmopLcM"
],
"title": "v0.99.5",
"notePosition": 70,
"notePosition": 80,
"prefix": null,
"isExpanded": false,
"type": "text",
@@ -251,7 +277,7 @@
"RMBaNYPsRpIr"
],
"title": "v0.99.4",
"notePosition": 80,
"notePosition": 90,
"prefix": null,
"isExpanded": false,
"type": "text",
@@ -277,7 +303,7 @@
"yuroLztFfpu5"
],
"title": "v0.99.3",
"notePosition": 90,
"notePosition": 100,
"prefix": null,
"isExpanded": false,
"type": "text",
@@ -303,7 +329,7 @@
"z207sehwMJ6C"
],
"title": "v0.99.2",
"notePosition": 100,
"notePosition": 110,
"prefix": null,
"isExpanded": false,
"type": "text",
@@ -329,7 +355,7 @@
"WGQsXq2jNyTi"
],
"title": "v0.99.1",
"notePosition": 110,
"notePosition": 120,
"prefix": null,
"isExpanded": false,
"type": "text",
@@ -355,7 +381,7 @@
"cyw2Yue9vXf3"
],
"title": "v0.99.0",
"notePosition": 120,
"notePosition": 130,
"prefix": null,
"isExpanded": false,
"type": "text",
@@ -381,7 +407,7 @@
"QOJwjruOUr4k"
],
"title": "v0.98.1",
"notePosition": 130,
"notePosition": 140,
"prefix": null,
"isExpanded": false,
"type": "text",
@@ -407,7 +433,7 @@
"PLUoryywi0BC"
],
"title": "v0.98.0",
"notePosition": 140,
"notePosition": 150,
"prefix": null,
"isExpanded": false,
"type": "text",
@@ -433,7 +459,7 @@
"lvOuiWsLDv8F"
],
"title": "v0.97.2",
"notePosition": 150,
"notePosition": 160,
"prefix": null,
"isExpanded": false,
"type": "text",
@@ -459,7 +485,7 @@
"OtFZ6Nd9vM3n"
],
"title": "v0.97.1",
"notePosition": 160,
"notePosition": 170,
"prefix": null,
"isExpanded": false,
"type": "text",
@@ -485,7 +511,7 @@
"SJZ5PwfzHSQ1"
],
"title": "v0.97.0",
"notePosition": 170,
"notePosition": 180,
"prefix": null,
"isExpanded": false,
"type": "text",
@@ -511,7 +537,7 @@
"mYXFde3LuNR7"
],
"title": "v0.96.0",
"notePosition": 180,
"notePosition": 190,
"prefix": null,
"isExpanded": false,
"type": "text",
@@ -537,7 +563,7 @@
"jthwbL0FdaeU"
],
"title": "v0.95.0",
"notePosition": 190,
"notePosition": 200,
"prefix": null,
"isExpanded": false,
"type": "text",
@@ -563,7 +589,7 @@
"7HGYsJbLuhnv"
],
"title": "v0.94.1",
"notePosition": 200,
"notePosition": 210,
"prefix": null,
"isExpanded": false,
"type": "text",
@@ -589,7 +615,7 @@
"Neq53ujRGBqv"
],
"title": "v0.94.0",
"notePosition": 210,
"notePosition": 220,
"prefix": null,
"isExpanded": false,
"type": "text",
@@ -615,7 +641,7 @@
"VN3xnce1vLkX"
],
"title": "v0.93.0",
"notePosition": 220,
"notePosition": 230,
"prefix": null,
"isExpanded": false,
"type": "text",
@@ -633,7 +659,7 @@
"WRaBfQqPr6qo"
],
"title": "v0.92.7",
"notePosition": 230,
"notePosition": 240,
"prefix": null,
"isExpanded": false,
"type": "text",
@@ -659,7 +685,7 @@
"a2rwfKNmUFU1"
],
"title": "v0.92.6",
"notePosition": 240,
"notePosition": 250,
"prefix": null,
"isExpanded": false,
"type": "text",
@@ -677,7 +703,7 @@
"fEJ8qErr0BKL"
],
"title": "v0.92.5-beta",
"notePosition": 250,
"notePosition": 260,
"prefix": null,
"isExpanded": false,
"type": "text",
@@ -695,7 +721,7 @@
"kkkZQQGSXjwy"
],
"title": "v0.92.4",
"notePosition": 260,
"notePosition": 270,
"prefix": null,
"isExpanded": false,
"type": "text",
@@ -713,7 +739,7 @@
"vAroNixiezaH"
],
"title": "v0.92.3-beta",
"notePosition": 270,
"notePosition": 280,
"prefix": null,
"isExpanded": false,
"type": "text",
@@ -731,7 +757,7 @@
"mHEq1wxAKNZd"
],
"title": "v0.92.2-beta",
"notePosition": 280,
"notePosition": 290,
"prefix": null,
"isExpanded": false,
"type": "text",
@@ -749,7 +775,7 @@
"IykjoAmBpc61"
],
"title": "v0.92.1-beta",
"notePosition": 290,
"notePosition": 300,
"prefix": null,
"isExpanded": false,
"type": "text",
@@ -767,7 +793,7 @@
"dq2AJ9vSBX4Y"
],
"title": "v0.92.0-beta",
"notePosition": 300,
"notePosition": 310,
"prefix": null,
"isExpanded": false,
"type": "text",
@@ -785,7 +811,7 @@
"3a8aMe4jz4yM"
],
"title": "v0.91.6",
"notePosition": 310,
"notePosition": 320,
"prefix": null,
"isExpanded": false,
"type": "text",
@@ -803,7 +829,7 @@
"8djQjkiDGESe"
],
"title": "v0.91.5",
"notePosition": 320,
"notePosition": 330,
"prefix": null,
"isExpanded": false,
"type": "text",
@@ -821,7 +847,7 @@
"OylxVoVJqNmr"
],
"title": "v0.91.4-beta",
"notePosition": 330,
"notePosition": 340,
"prefix": null,
"isExpanded": false,
"type": "text",
@@ -839,7 +865,7 @@
"tANGQDvnyhrj"
],
"title": "v0.91.3-beta",
"notePosition": 340,
"notePosition": 350,
"prefix": null,
"isExpanded": false,
"type": "text",
@@ -857,7 +883,7 @@
"hMoBfwSoj1SC"
],
"title": "v0.91.2-beta",
"notePosition": 350,
"notePosition": 360,
"prefix": null,
"isExpanded": false,
"type": "text",
@@ -875,7 +901,7 @@
"a2XMSKROCl9z"
],
"title": "v0.91.1-beta",
"notePosition": 360,
"notePosition": 370,
"prefix": null,
"isExpanded": false,
"type": "text",
@@ -893,7 +919,7 @@
"yqXFvWbLkuMD"
],
"title": "v0.90.12",
"notePosition": 370,
"notePosition": 380,
"prefix": null,
"isExpanded": false,
"type": "text",
@@ -911,7 +937,7 @@
"veS7pg311yJP"
],
"title": "v0.90.11-beta",
"notePosition": 380,
"notePosition": 390,
"prefix": null,
"isExpanded": false,
"type": "text",
@@ -929,7 +955,7 @@
"sq5W9TQxRqMq"
],
"title": "v0.90.10-beta",
"notePosition": 390,
"notePosition": 400,
"prefix": null,
"isExpanded": false,
"type": "text",
@@ -947,7 +973,7 @@
"yFEGVCUM9tPx"
],
"title": "v0.90.9-beta",
"notePosition": 400,
"notePosition": 410,
"prefix": null,
"isExpanded": false,
"type": "text",
@@ -965,7 +991,7 @@
"o4wAGqOQuJtV"
],
"title": "v0.90.8",
"notePosition": 410,
"notePosition": 420,
"prefix": null,
"isExpanded": false,
"type": "text",
@@ -998,7 +1024,7 @@
"i4A5g9iOg9I0"
],
"title": "v0.90.7-beta",
"notePosition": 420,
"notePosition": 430,
"prefix": null,
"isExpanded": false,
"type": "text",
@@ -1016,7 +1042,7 @@
"ThNf2GaKgXUs"
],
"title": "v0.90.6-beta",
"notePosition": 430,
"notePosition": 440,
"prefix": null,
"isExpanded": false,
"type": "text",
@@ -1034,7 +1060,7 @@
"G4PAi554kQUr"
],
"title": "v0.90.5-beta",
"notePosition": 440,
"notePosition": 450,
"prefix": null,
"isExpanded": false,
"type": "text",
@@ -1061,7 +1087,7 @@
"zATRobGRCmBn"
],
"title": "v0.90.4",
"notePosition": 450,
"notePosition": 460,
"prefix": null,
"isExpanded": false,
"type": "text",
@@ -1079,7 +1105,7 @@
"sCDLf8IKn3Iz"
],
"title": "v0.90.3",
"notePosition": 460,
"notePosition": 470,
"prefix": null,
"isExpanded": false,
"type": "text",
@@ -1097,7 +1123,7 @@
"VqqyBu4AuTjC"
],
"title": "v0.90.2-beta",
"notePosition": 470,
"notePosition": 480,
"prefix": null,
"isExpanded": false,
"type": "text",
@@ -1115,7 +1141,7 @@
"RX3Nl7wInLsA"
],
"title": "v0.90.1-beta",
"notePosition": 480,
"notePosition": 490,
"prefix": null,
"isExpanded": false,
"type": "text",
@@ -1133,7 +1159,7 @@
"GyueACukPWjk"
],
"title": "v0.90.0-beta",
"notePosition": 490,
"notePosition": 500,
"prefix": null,
"isExpanded": false,
"type": "text",
@@ -1151,7 +1177,7 @@
"kzjHexDTTeVB"
],
"title": "v0.48",
"notePosition": 500,
"notePosition": 510,
"prefix": null,
"isExpanded": false,
"type": "text",
@@ -1218,7 +1244,7 @@
"wyurrlcDl416"
],
"title": "Release Template",
"notePosition": 510,
"notePosition": 520,
"prefix": null,
"isExpanded": false,
"type": "text",

View File

@@ -0,0 +1,22 @@
# v0.102.1
> [!NOTE]
> If you enjoyed this release, consider showing a token of appreciation by:
>
> * Pressing the “Star” button on [GitHub](https://github.com/TriliumNext/Trilium) (top-right).
> * Considering a one-time or recurrent donation to the [lead developer](https://github.com/eliandoran) via [GitHub Sponsors](https://github.com/sponsors/eliandoran) or [PayPal](https://paypal.me/eliandoran).
> * If you are interested in an [official mobile application](https://oss.issuehunt.io/r/TriliumNext/Trilium/issues/7447)  ([#7447](https://github.com/TriliumNext/Trilium/issues/7447)) or [multi-user support](https://oss.issuehunt.io/r/TriliumNext/Trilium/issues/4956) ([#4956](https://github.com/TriliumNext/Trilium/issues/4956)), consider offering financial support via IssueHunt (see links).
> [!IMPORTANT]
> This is a hotfix of v0.102.0, addressing some blocking issues. For more information about the previous major version, see [v0.102.0 changelog](https://github.com/TriliumNext/Trilium/releases/tag/v0.102.0).
## 🐞 Bugfixes
* [Mind Map feature breaks rendering in v0.102.0](https://github.com/TriliumNext/Trilium/issues/8879)
* Fixes for the PDF viewer:
* [PDF view is '403 Forbidden' on Nginx Proxy Manager](https://github.com/TriliumNext/Trilium/issues/8877)
* [PDF: address some layout issues](https://github.com/TriliumNext/Trilium/commit/8712e7dd160564f9a923a88bf5871e63c79d40f0) by @adoriandoran
* Cache not properly invalidated across versions.
## 🛠️ Technical updates
* [Rework Docker infrastructure to use crane](https://github.com/TriliumNext/Trilium/pull/8869) by @perfectra1n

View File

@@ -1,6 +1,6 @@
{
"name": "@triliumnext/source",
"version": "0.102.0",
"version": "0.102.1",
"description": "Build your personal knowledge base with Trilium Notes",
"directories": {
"doc": "docs"
@@ -50,7 +50,7 @@
"@triliumnext/server": "workspace:*",
"@types/express": "5.0.6",
"@types/js-yaml": "4.0.9",
"@types/node": "24.11.0",
"@types/node": "24.12.0",
"@vitest/browser-webdriverio": "4.0.18",
"@vitest/coverage-v8": "4.0.18",
"@vitest/ui": "4.0.18",
@@ -58,7 +58,7 @@
"cross-env": "10.1.0",
"dpdm": "4.0.1",
"esbuild": "0.27.3",
"eslint": "10.0.2",
"eslint": "10.0.3",
"eslint-config-preact": "2.0.0",
"eslint-config-prettier": "10.1.8",
"eslint-plugin-playwright": "2.9.0",
@@ -93,7 +93,7 @@
"url": "https://github.com/TriliumNext/Trilium/issues"
},
"homepage": "https://triliumnotes.org",
"packageManager": "pnpm@10.30.3",
"packageManager": "pnpm@10.31.0",
"pnpm": {
"patchedDependencies": {
"@ckeditor/ckeditor5-mention": "patches/@ckeditor__ckeditor5-mention.patch",

View File

@@ -29,10 +29,10 @@
"@vitest/browser": "4.0.18",
"@vitest/coverage-istanbul": "4.0.18",
"ckeditor5": "47.4.0",
"eslint": "10.0.2",
"eslint": "10.0.3",
"eslint-config-ckeditor5": ">=9.1.0",
"http-server": "14.1.1",
"lint-staged": "16.3.1",
"lint-staged": "16.3.2",
"stylelint": "17.4.0",
"stylelint-config-ckeditor5": ">=9.1.0",
"ts-node": "10.9.2",

View File

@@ -30,10 +30,10 @@
"@vitest/browser": "4.0.18",
"@vitest/coverage-istanbul": "4.0.18",
"ckeditor5": "47.4.0",
"eslint": "10.0.2",
"eslint": "10.0.3",
"eslint-config-ckeditor5": ">=9.1.0",
"http-server": "14.1.1",
"lint-staged": "16.3.1",
"lint-staged": "16.3.2",
"stylelint": "17.4.0",
"stylelint-config-ckeditor5": ">=9.1.0",
"ts-node": "10.9.2",

View File

@@ -32,10 +32,10 @@
"@vitest/browser": "4.0.18",
"@vitest/coverage-istanbul": "4.0.18",
"ckeditor5": "47.4.0",
"eslint": "10.0.2",
"eslint": "10.0.3",
"eslint-config-ckeditor5": ">=9.1.0",
"http-server": "14.1.1",
"lint-staged": "16.3.1",
"lint-staged": "16.3.2",
"stylelint": "17.4.0",
"stylelint-config-ckeditor5": ">=9.1.0",
"ts-node": "10.9.2",

View File

@@ -32,10 +32,10 @@
"@vitest/browser": "4.0.18",
"@vitest/coverage-istanbul": "4.0.18",
"ckeditor5": "47.4.0",
"eslint": "10.0.2",
"eslint": "10.0.3",
"eslint-config-ckeditor5": ">=9.1.0",
"http-server": "14.1.1",
"lint-staged": "16.3.1",
"lint-staged": "16.3.2",
"stylelint": "17.4.0",
"stylelint-config-ckeditor5": ">=9.1.0",
"ts-node": "10.9.2",

View File

@@ -32,10 +32,10 @@
"@vitest/browser": "4.0.18",
"@vitest/coverage-istanbul": "4.0.18",
"ckeditor5": "47.4.0",
"eslint": "10.0.2",
"eslint": "10.0.3",
"eslint-config-ckeditor5": ">=9.1.0",
"http-server": "14.1.1",
"lint-staged": "16.3.1",
"lint-staged": "16.3.2",
"stylelint": "17.4.0",
"stylelint-config-ckeditor5": ">=9.1.0",
"ts-node": "10.9.2",

View File

@@ -16,7 +16,7 @@
"ckeditor5-premium-features": "47.4.0"
},
"devDependencies": {
"@smithy/middleware-retry": "4.4.37",
"@smithy/middleware-retry": "4.4.40",
"@types/jquery": "4.0.0"
}
}

View File

@@ -8,7 +8,7 @@
"@codemirror/commands": "6.10.2",
"@codemirror/lang-css": "6.3.1",
"@codemirror/lang-html": "6.4.11",
"@codemirror/lang-javascript": "6.2.4",
"@codemirror/lang-javascript": "6.2.5",
"@codemirror/lang-json": "6.0.2",
"@codemirror/lang-markdown": "6.5.0",
"@codemirror/lang-php": "6.0.2",
@@ -16,7 +16,7 @@
"@codemirror/lang-xml": "6.1.0",
"@codemirror/legacy-modes": "6.5.2",
"@codemirror/search": "6.6.0",
"@codemirror/view": "6.39.15",
"@codemirror/view": "6.39.16",
"@fsegurai/codemirror-theme-abcdef": "6.2.3",
"@fsegurai/codemirror-theme-abyss": "6.2.3",
"@fsegurai/codemirror-theme-android-studio": "6.2.3",

View File

@@ -1,6 +1,6 @@
{
"name": "@triliumnext/commons",
"version": "0.102.0",
"version": "0.102.1",
"description": "Shared library between the clients (e.g. browser, Electron) and the server, mostly for type definitions and utility methods.",
"private": true,
"type": "module",

View File

@@ -15,3 +15,4 @@ export * from "./lib/dayjs.js";
export * from "./lib/notes.js";
export * from "./lib/week_utils.js";
export { default as BUILTIN_ATTRIBUTES } from "./lib/builtin_attributes.js";
export * from "./lib/spreadsheet/render_to_html.js";

View File

@@ -17,6 +17,8 @@ export interface AttachmentRow {
deleteId?: string;
contentLength?: number;
content?: Buffer | string;
/** If set to `"base64"`, the `content` string will be decoded from base64 to binary before storage. */
encoding?: "base64";
}
export interface RevisionRow {

View File

@@ -0,0 +1,421 @@
import { describe, expect, it } from "vitest";
import { renderSpreadsheetToHtml } from "./render_to_html.js";
describe("renderSpreadsheetToHtml", () => {
it("renders a basic spreadsheet with values and styles", () => {
const input = JSON.stringify({
version: 1,
workbook: {
id: "test",
sheetOrder: ["sheet1"],
name: "",
appVersion: "0.16.1",
locale: "zhCN",
styles: {
boldStyle: { bl: 1 }
},
sheets: {
sheet1: {
id: "sheet1",
name: "Sheet1",
hidden: 0,
rowCount: 1000,
columnCount: 20,
defaultColumnWidth: 88,
defaultRowHeight: 24,
mergeData: [],
cellData: {
"1": {
"1": { v: "lol", t: 1 }
},
"3": {
"0": { v: "wut", t: 1 },
"2": { s: "boldStyle", v: "Bold string", t: 1 }
}
},
rowData: {},
columnData: {},
showGridlines: 1
}
}
}
});
const html = renderSpreadsheetToHtml(input);
// Should contain a table.
expect(html).toContain("<table");
expect(html).toContain("</table>");
// Should contain cell values.
expect(html).toContain("lol");
expect(html).toContain("wut");
expect(html).toContain("Bold string");
// Bold cell should have font-weight:bold.
expect(html).toContain("font-weight:bold");
// Should not render sheet header for single sheet.
expect(html).not.toContain("<h3>");
});
it("renders multiple visible sheets with headers", () => {
const input = JSON.stringify({
version: 1,
workbook: {
sheetOrder: ["s1", "s2"],
styles: {},
sheets: {
s1: {
id: "s1",
name: "Data",
hidden: 0,
rowCount: 10,
columnCount: 5,
mergeData: [],
cellData: { "0": { "0": { v: "A1" } } },
rowData: {},
columnData: {}
},
s2: {
id: "s2",
name: "Summary",
hidden: 0,
rowCount: 10,
columnCount: 5,
mergeData: [],
cellData: { "0": { "0": { v: "B1" } } },
rowData: {},
columnData: {}
}
}
}
});
const html = renderSpreadsheetToHtml(input);
expect(html).toContain("<h3>Data</h3>");
expect(html).toContain("<h3>Summary</h3>");
expect(html).toContain("A1");
expect(html).toContain("B1");
});
it("skips hidden sheets", () => {
const input = JSON.stringify({
version: 1,
workbook: {
sheetOrder: ["s1", "s2"],
styles: {},
sheets: {
s1: {
id: "s1",
name: "Visible",
hidden: 0,
rowCount: 10,
columnCount: 5,
mergeData: [],
cellData: { "0": { "0": { v: "shown" } } },
rowData: {},
columnData: {}
},
s2: {
id: "s2",
name: "Hidden",
hidden: 1,
rowCount: 10,
columnCount: 5,
mergeData: [],
cellData: { "0": { "0": { v: "secret" } } },
rowData: {},
columnData: {}
}
}
}
});
const html = renderSpreadsheetToHtml(input);
expect(html).toContain("shown");
expect(html).not.toContain("secret");
// Single visible sheet, no header.
expect(html).not.toContain("<h3>");
});
it("handles merged cells", () => {
const input = JSON.stringify({
version: 1,
workbook: {
sheetOrder: ["s1"],
styles: {},
sheets: {
s1: {
id: "s1",
name: "Sheet1",
hidden: 0,
rowCount: 10,
columnCount: 5,
mergeData: [
{ startRow: 0, endRow: 1, startColumn: 0, endColumn: 1 }
],
cellData: {
"0": { "0": { v: "merged" } }
},
rowData: {},
columnData: {}
}
}
}
});
const html = renderSpreadsheetToHtml(input);
expect(html).toContain('rowspan="2"');
expect(html).toContain('colspan="2"');
expect(html).toContain("merged");
});
it("escapes HTML in cell values", () => {
const input = JSON.stringify({
version: 1,
workbook: {
sheetOrder: ["s1"],
styles: {},
sheets: {
s1: {
id: "s1",
name: "Sheet1",
hidden: 0,
rowCount: 10,
columnCount: 5,
mergeData: [],
cellData: {
"0": { "0": { v: "<script>alert('xss')</script>" } }
},
rowData: {},
columnData: {}
}
}
}
});
const html = renderSpreadsheetToHtml(input);
expect(html).not.toContain("<script>");
expect(html).toContain("&lt;script&gt;");
});
it("handles invalid JSON gracefully", () => {
const html = renderSpreadsheetToHtml("not json");
expect(html).toContain("Unable to parse");
});
it("handles empty workbook", () => {
const input = JSON.stringify({
version: 1,
workbook: {
sheetOrder: ["s1"],
styles: {},
sheets: {
s1: {
id: "s1",
name: "Sheet1",
hidden: 0,
rowCount: 10,
columnCount: 5,
mergeData: [],
cellData: {},
rowData: {},
columnData: {}
}
}
}
});
const html = renderSpreadsheetToHtml(input);
expect(html).toContain("Empty sheet");
});
it("renders boolean values", () => {
const input = JSON.stringify({
version: 1,
workbook: {
sheetOrder: ["s1"],
styles: {},
sheets: {
s1: {
id: "s1",
name: "Sheet1",
hidden: 0,
rowCount: 10,
columnCount: 5,
mergeData: [],
cellData: {
"0": {
"0": { v: true, t: 3 },
"1": { v: false, t: 3 }
}
},
rowData: {},
columnData: {}
}
}
}
});
const html = renderSpreadsheetToHtml(input);
expect(html).toContain("TRUE");
expect(html).toContain("FALSE");
});
it("applies inline styles for colors, alignment, and borders", () => {
const input = JSON.stringify({
version: 1,
workbook: {
sheetOrder: ["s1"],
styles: {},
sheets: {
s1: {
id: "s1",
name: "Sheet1",
hidden: 0,
rowCount: 10,
columnCount: 5,
mergeData: [],
cellData: {
"0": {
"0": {
v: "styled",
s: {
bg: { rgb: "#FF0000" },
cl: { rgb: "#FFFFFF" },
ht: 2,
bd: {
b: { s: 1, cl: { rgb: "#000000" } }
}
}
}
}
},
rowData: {},
columnData: {}
}
}
}
});
const html = renderSpreadsheetToHtml(input);
expect(html).toContain("background-color:#FF0000");
expect(html).toContain("color:#FFFFFF");
expect(html).toContain("text-align:center");
expect(html).toContain("border-bottom:");
});
it("sanitizes CSS injection in color values", () => {
const input = JSON.stringify({
version: 1,
workbook: {
sheetOrder: ["s1"],
styles: {},
sheets: {
s1: {
id: "s1",
name: "Sheet1",
hidden: 0,
rowCount: 10,
columnCount: 5,
mergeData: [],
cellData: {
"0": {
"0": {
v: "test",
s: {
bg: { rgb: "red;background:url(//evil.com/steal)" },
cl: { rgb: "#FFF;color:expression(alert(1))" }
}
}
}
},
rowData: {},
columnData: {}
}
}
}
});
const html = renderSpreadsheetToHtml(input);
expect(html).not.toContain("evil.com");
expect(html).not.toContain("expression");
expect(html).toContain("transparent");
});
it("sanitizes CSS injection in font-family", () => {
const input = JSON.stringify({
version: 1,
workbook: {
sheetOrder: ["s1"],
styles: {},
sheets: {
s1: {
id: "s1",
name: "Sheet1",
hidden: 0,
rowCount: 10,
columnCount: 5,
mergeData: [],
cellData: {
"0": {
"0": {
v: "test",
s: {
ff: "Arial;}</style><script>alert(1)</script>"
}
}
}
},
rowData: {},
columnData: {}
}
}
}
});
const html = renderSpreadsheetToHtml(input);
expect(html).not.toContain("<script>");
expect(html).not.toContain("</style>");
expect(html).toContain("font-family:Arial");
});
it("sanitizes CSS injection in border colors", () => {
const input = JSON.stringify({
version: 1,
workbook: {
sheetOrder: ["s1"],
styles: {},
sheets: {
s1: {
id: "s1",
name: "Sheet1",
hidden: 0,
rowCount: 10,
columnCount: 5,
mergeData: [],
cellData: {
"0": {
"0": {
v: "test",
s: {
bd: {
b: { s: 1, cl: { rgb: "#000;background:url(//evil.com)" } }
}
}
}
}
},
rowData: {},
columnData: {}
}
}
}
});
const html = renderSpreadsheetToHtml(input);
expect(html).not.toContain("evil.com");
expect(html).toContain("transparent");
});
});

View File

@@ -0,0 +1,451 @@
/**
* Converts a UniversJS workbook JSON structure into a static HTML table representation.
* This is used for rendering spreadsheets in shared notes and exports.
*
* Only the subset of UniversJS types needed for rendering is defined here,
* to avoid depending on @univerjs/core.
*/
// #region UniversJS type subset
interface PersistedData {
version: number;
workbook: IWorkbookData;
}
interface IWorkbookData {
sheetOrder: string[];
styles?: Record<string, IStyleData | null>;
sheets: Record<string, IWorksheetData>;
}
interface IWorksheetData {
id: string;
name: string;
hidden?: number;
rowCount: number;
columnCount: number;
defaultColumnWidth?: number;
defaultRowHeight?: number;
mergeData?: IRange[];
cellData: CellMatrix;
rowData?: Record<number, IRowData>;
columnData?: Record<number, IColumnData>;
showGridlines?: number;
}
type CellMatrix = Record<number, Record<number, ICellData>>;
interface ICellData {
v?: string | number | boolean | null;
t?: number | null;
s?: IStyleData | string | null;
}
interface IStyleData {
bl?: number;
it?: number;
ul?: ITextDecoration;
st?: ITextDecoration;
fs?: number;
ff?: string | null;
bg?: IColorStyle | null;
cl?: IColorStyle | null;
ht?: number | null;
vt?: number | null;
bd?: IBorderData | null;
}
interface ITextDecoration {
s?: number;
}
interface IColorStyle {
rgb?: string | null;
}
interface IBorderData {
t?: IBorderStyleData | null;
r?: IBorderStyleData | null;
b?: IBorderStyleData | null;
l?: IBorderStyleData | null;
}
interface IBorderStyleData {
s?: number;
cl?: IColorStyle;
}
interface IRange {
startRow: number;
endRow: number;
startColumn: number;
endColumn: number;
}
interface IRowData {
h?: number;
hd?: number;
}
interface IColumnData {
w?: number;
hd?: number;
}
// Alignment enums (from UniversJS)
const enum HorizontalAlign {
LEFT = 1,
CENTER = 2,
RIGHT = 3
}
const enum VerticalAlign {
TOP = 1,
MIDDLE = 2,
BOTTOM = 3
}
// Border style enum
const enum BorderStyle {
THIN = 1,
MEDIUM = 6,
THICK = 9,
DASHED = 3,
DOTTED = 4
}
// #endregion
/**
* Parses the raw JSON content of a spreadsheet note and renders it as HTML.
* Returns an HTML string containing one `<table>` per visible sheet.
*/
export function renderSpreadsheetToHtml(jsonContent: string): string {
let data: PersistedData;
try {
data = JSON.parse(jsonContent);
} catch {
return "<p>Unable to parse spreadsheet data.</p>";
}
if (!data?.workbook?.sheets) {
return "<p>Empty spreadsheet.</p>";
}
const { workbook } = data;
const sheetIds = workbook.sheetOrder ?? Object.keys(workbook.sheets);
const visibleSheets = sheetIds
.map((id) => workbook.sheets[id])
.filter((s) => s && !s.hidden);
if (visibleSheets.length === 0) {
return "<p>Empty spreadsheet.</p>";
}
const parts: string[] = [];
for (const sheet of visibleSheets) {
if (visibleSheets.length > 1) {
parts.push(`<h3>${escapeHtml(sheet.name)}</h3>`);
}
parts.push(renderSheet(sheet, workbook.styles ?? {}));
}
return parts.join("\n");
}
function renderSheet(sheet: IWorksheetData, styles: Record<string, IStyleData | null>): string {
const { cellData, mergeData = [], columnData = {}, rowData = {} } = sheet;
// Determine the actual bounds (only cells with data).
const bounds = computeBounds(cellData, mergeData);
if (!bounds) {
return "<p>Empty sheet.</p>";
}
const { minRow, maxRow, minCol, maxCol } = bounds;
// Build a set of cells that are hidden by merges (non-origin cells).
const mergeMap = buildMergeMap(mergeData, minRow, maxRow, minCol, maxCol);
const lines: string[] = [];
lines.push('<table class="spreadsheet-table">');
// Colgroup for column widths.
const defaultWidth = sheet.defaultColumnWidth ?? 88;
lines.push("<colgroup>");
for (let col = minCol; col <= maxCol; col++) {
const colMeta = columnData[col];
if (colMeta?.hd) continue;
const width = isFiniteNumber(colMeta?.w) ? colMeta.w : defaultWidth;
lines.push(`<col style="width:${width}px">`);
}
lines.push("</colgroup>");
const defaultHeight = sheet.defaultRowHeight ?? 24;
for (let row = minRow; row <= maxRow; row++) {
const rowMeta = rowData[row];
if (rowMeta?.hd) continue;
const height = isFiniteNumber(rowMeta?.h) ? rowMeta.h : defaultHeight;
lines.push(`<tr style="height:${height}px">`);
for (let col = minCol; col <= maxCol; col++) {
if (columnData[col]?.hd) continue;
const mergeInfo = mergeMap.get(cellKey(row, col));
if (mergeInfo === "hidden") continue;
const cell = cellData[row]?.[col];
const cellStyle = resolveCellStyle(cell, styles);
const cssText = buildCssText(cellStyle);
const value = formatCellValue(cell);
const attrs: string[] = [];
if (cssText) attrs.push(`style="${cssText}"`);
if (mergeInfo) {
if (mergeInfo.rowSpan > 1) attrs.push(`rowspan="${mergeInfo.rowSpan}"`);
if (mergeInfo.colSpan > 1) attrs.push(`colspan="${mergeInfo.colSpan}"`);
}
lines.push(`<td${attrs.length ? " " + attrs.join(" ") : ""}>${value}</td>`);
}
lines.push("</tr>");
}
lines.push("</table>");
return lines.join("\n");
}
// #region Bounds computation
interface Bounds {
minRow: number;
maxRow: number;
minCol: number;
maxCol: number;
}
function computeBounds(cellData: CellMatrix, mergeData: IRange[]): Bounds | null {
let minRow = Infinity;
let maxRow = -Infinity;
let minCol = Infinity;
let maxCol = -Infinity;
for (const rowStr of Object.keys(cellData)) {
const row = Number(rowStr);
const cols = cellData[row];
for (const colStr of Object.keys(cols)) {
const col = Number(colStr);
if (minRow > row) minRow = row;
if (maxRow < row) maxRow = row;
if (minCol > col) minCol = col;
if (maxCol < col) maxCol = col;
}
}
// Extend bounds to cover merged ranges.
for (const range of mergeData) {
if (minRow > range.startRow) minRow = range.startRow;
if (maxRow < range.endRow) maxRow = range.endRow;
if (minCol > range.startColumn) minCol = range.startColumn;
if (maxCol < range.endColumn) maxCol = range.endColumn;
}
if (minRow > maxRow) return null;
return { minRow, maxRow, minCol, maxCol };
}
// #endregion
// #region Merge handling
interface MergeOrigin {
rowSpan: number;
colSpan: number;
}
type MergeInfo = MergeOrigin | "hidden";
function cellKey(row: number, col: number): string {
return `${row},${col}`;
}
function buildMergeMap(mergeData: IRange[], minRow: number, maxRow: number, minCol: number, maxCol: number): Map<string, MergeInfo> {
const map = new Map<string, MergeInfo>();
for (const range of mergeData) {
const startRow = Math.max(range.startRow, minRow);
const endRow = Math.min(range.endRow, maxRow);
const startCol = Math.max(range.startColumn, minCol);
const endCol = Math.min(range.endColumn, maxCol);
map.set(cellKey(range.startRow, range.startColumn), {
rowSpan: endRow - startRow + 1,
colSpan: endCol - startCol + 1
});
for (let r = startRow; r <= endRow; r++) {
for (let c = startCol; c <= endCol; c++) {
if (r === range.startRow && c === range.startColumn) continue;
map.set(cellKey(r, c), "hidden");
}
}
}
return map;
}
// #endregion
// #region Style resolution
function resolveCellStyle(cell: ICellData | undefined, styles: Record<string, IStyleData | null>): IStyleData | null {
if (!cell?.s) return null;
if (typeof cell.s === "string") {
return styles[cell.s] ?? null;
}
return cell.s;
}
function buildCssText(style: IStyleData | null): string {
if (!style) return "";
const parts: string[] = [];
if (style.bl) parts.push("font-weight:bold");
if (style.it) parts.push("font-style:italic");
if (style.ul?.s) parts.push("text-decoration:underline");
if (style.st?.s) {
// Combine with underline if both are set.
const existing = parts.findIndex((p) => p.startsWith("text-decoration:"));
if (existing >= 0) {
parts[existing] = "text-decoration:underline line-through";
} else {
parts.push("text-decoration:line-through");
}
}
if (style.fs && isFiniteNumber(style.fs)) parts.push(`font-size:${style.fs}pt`);
if (style.ff) parts.push(`font-family:${sanitizeCssValue(style.ff)}`);
if (style.bg?.rgb) parts.push(`background-color:${sanitizeCssColor(style.bg.rgb)}`);
if (style.cl?.rgb) parts.push(`color:${sanitizeCssColor(style.cl.rgb)}`);
if (style.ht != null) {
const align = horizontalAlignToCss(style.ht);
if (align) parts.push(`text-align:${align}`);
}
if (style.vt != null) {
const valign = verticalAlignToCss(style.vt);
if (valign) parts.push(`vertical-align:${valign}`);
}
if (style.bd) {
appendBorderCss(parts, "border-top", style.bd.t);
appendBorderCss(parts, "border-right", style.bd.r);
appendBorderCss(parts, "border-bottom", style.bd.b);
appendBorderCss(parts, "border-left", style.bd.l);
}
return parts.join(";");
}
function horizontalAlignToCss(align: number): string | null {
switch (align) {
case HorizontalAlign.LEFT: return "left";
case HorizontalAlign.CENTER: return "center";
case HorizontalAlign.RIGHT: return "right";
default: return null;
}
}
function verticalAlignToCss(align: number): string | null {
switch (align) {
case VerticalAlign.TOP: return "top";
case VerticalAlign.MIDDLE: return "middle";
case VerticalAlign.BOTTOM: return "bottom";
default: return null;
}
}
function appendBorderCss(parts: string[], property: string, border: IBorderStyleData | null | undefined): void {
if (!border) return;
const width = borderStyleToWidth(border.s);
const color = sanitizeCssColor(border.cl?.rgb ?? "#000");
const style = borderStyleToCss(border.s);
parts.push(`${property}:${width} ${style} ${color}`);
}
function borderStyleToWidth(style: number | undefined): string {
switch (style) {
case BorderStyle.MEDIUM: return "2px";
case BorderStyle.THICK: return "3px";
default: return "1px";
}
}
function borderStyleToCss(style: number | undefined): string {
switch (style) {
case BorderStyle.DASHED: return "dashed";
case BorderStyle.DOTTED: return "dotted";
default: return "solid";
}
}
/** Checks that a value is a finite number (guards against stringified payloads from JSON). */
function isFiniteNumber(v: unknown): v is number {
return typeof v === "number" && Number.isFinite(v);
}
/**
* Sanitizes an arbitrary string for use as a CSS value by removing characters
* that could break out of a property (semicolons, braces, angle brackets, etc.).
*/
function sanitizeCssValue(value: string): string {
return value.replace(/[;<>{}\\/()'"]/g, "");
}
/**
* Validates a CSS color string. Accepts hex colors (#rgb, #rrggbb, #rrggbbaa),
* named colors (letters only), and rgb()/rgba()/hsl()/hsla() functional notation
* with safe characters. Returns "transparent" for anything that doesn't match.
*/
function sanitizeCssColor(value: string): string {
const trimmed = value.trim();
// Hex colors
if (/^#[0-9a-fA-F]{3,8}$/.test(trimmed)) return trimmed;
// Named colors (letters only, reasonable length)
if (/^[a-zA-Z]{1,30}$/.test(trimmed)) return trimmed;
// Functional notation: rgb(), rgba(), hsl(), hsla() — allow digits, commas, dots, spaces, %
if (/^(?:rgb|hsl)a?\([0-9.,\s%]+\)$/.test(trimmed)) return trimmed;
return "transparent";
}
// #endregion
// #region Value formatting
function formatCellValue(cell: ICellData | undefined): string {
if (!cell || cell.v == null) return "";
if (typeof cell.v === "boolean") {
return cell.v ? "TRUE" : "FALSE";
}
return escapeHtml(String(cell.v));
}
function escapeHtml(text: string): string {
return text
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
;
}
// #endregion

View File

@@ -1,6 +1,6 @@
{
"name": "@triliumnext/pdfjs-viewer",
"version": "0.102.0",
"version": "0.102.1",
"private": true,
"scripts": {
"build": "tsx scripts/build.ts",

View File

@@ -71,13 +71,17 @@ function patchCacheBuster(htmlFilePath: string) {
const version = packageJson.version;
console.log(`Versioned URLs: ${version}.`)
let html = readFileSync(htmlFilePath, "utf-8");
html = html.replace(
`<link rel="stylesheet" href="custom.css" />`,
`<link rel="stylesheet" href="custom.css?v=${version}" />`);
html = html.replace(
`<script src="custom.mjs" type="module"></script>`,
`<script src="custom.mjs?v=${version}" type="module"></script>`
);
for (const file of [ "viewer.css", "custom.css" ]) {
html = html.replace(
`<link rel="stylesheet" href="${file}" />`,
`<link rel="stylesheet" href="${file}?v=${version}" />`);
}
for (const file of [ "viewer.mjs", "custom.mjs" ]) {
html = html.replace(
`<script src="${file}" type="module"></script>`,
`<script src="${file}?v=${version}" type="module"></script>`
);
}
writeFileSync(htmlFilePath, html);
}

View File

@@ -25,7 +25,7 @@
"license": "Apache-2.0",
"dependencies": {
"fuse.js": "7.1.0",
"katex": "0.16.33",
"katex": "0.16.38",
"mermaid": "11.12.3"
},
"devDependencies": {
@@ -35,7 +35,7 @@
"@typescript-eslint/parser": "8.56.1",
"dotenv": "17.3.1",
"esbuild": "0.27.3",
"eslint": "10.0.2",
"eslint": "10.0.3",
"highlight.js": "11.11.1",
"typescript": "5.9.3"
}

2178
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff