Compare commits

..

201 Commits

Author SHA1 Message Date
Elian Doran
9350c43e5b chore(core): port bulk actions route 2026-02-09 19:49:07 +02:00
Elian Doran
0fae11d54c chore(core): port bulk actions service 2026-02-09 19:46:34 +02:00
Elian Doran
1ed3999639 chore(core): port recent changes route 2026-02-09 19:43:53 +02:00
Elian Doran
7d30771f05 chore(core): port relation map route 2026-02-09 19:41:31 +02:00
Elian Doran
08f1d44d90 chore(core): port revisions route 2026-02-09 19:38:24 +02:00
Elian Doran
969860c344 chore(core): port attribute route 2026-02-09 19:32:46 +02:00
Elian Doran
ed905c9d64 chore(core): integrate builtin_attributes 2026-02-09 19:29:59 +02:00
Elian Doran
c5518b64b7 chore(core): integrate attribute_formatter 2026-02-09 19:24:06 +02:00
Elian Doran
a7b2b631c5 feat(standalone): add warning about stability 2026-02-09 18:59:44 +02:00
Elian Doran
dcfc1119eb chore(core): port sql route 2026-02-09 18:38:51 +02:00
Elian Doran
88add55ebc chore(standalone): wrap routes in a transaction 2026-02-09 18:35:29 +02:00
Elian Doran
ad41a58904 chore(standalone): use CLS with per-request context isolation 2026-02-09 18:20:14 +02:00
Elian Doran
49ce312ab2 chore(standalone): use a simpler CLS mechanism considering lack of multi-threading 2026-02-09 18:16:15 +02:00
Elian Doran
223d69206c fix(standalone): missing context menu cover 2026-02-09 18:00:11 +02:00
Elian Doran
d68ada1026 fix(standalone): translations not working in prod 2026-02-08 22:38:28 +02:00
Elian Doran
e0a23f6b63 fix(bootstrap): background effects are enabled 2026-02-08 21:30:19 +02:00
Elian Doran
bd147ea72e Merge remote-tracking branch 'origin/main' into standalone 2026-02-08 21:14:12 +02:00
Elian Doran
4494aed1cf chore(standalone): use async for init 2026-01-30 15:55:20 +02:00
Elian Doran
788eaad61c fix(standalone): wrong server translation path in production 2026-01-30 15:49:32 +02:00
Elian Doran
0cfd6bae0e refactor(standalone): use different mechanism for importing local server worker 2026-01-30 15:24:53 +02:00
Elian Doran
82c435b916 chore(ci): deploy app on workflow change 2026-01-30 07:55:21 +02:00
Elian Doran
bc5b9708c7 Merge remote-tracking branch 'origin/main' into standalone 2026-01-30 07:51:36 +02:00
Elian Doran
7e87e6f832 chore(ci): deploy app on standalone branch 2026-01-30 07:48:11 +02:00
Elian Doran
e5a7a32439 chore(core): port cloning route 2026-01-29 22:20:54 +02:00
Elian Doran
e9214d84b7 chore(core): port stats route 2026-01-29 21:51:47 +02:00
Elian Doran
da7a61a8b6 Merge remote-tracking branch 'origin/main' into HEAD
; Conflicts:
;	apps/client/src/index.ts
;	apps/client/src/widgets/sql_table_schemas.tsx
;	apps/server/package.json
;	apps/server/src/app.ts
;	apps/server/src/becca/entities/bnote.ts
;	apps/server/src/services/import/single.ts
;	apps/server/src/services/import/zip.ts
;	apps/server/src/services/note-interface.ts
;	apps/server/src/services/notes.ts
;	apps/server/src/services/tree.ts
;	apps/server/src/services/utils.ts
;	apps/server/src/share/shaca/entities/snote.ts
;	pnpm-lock.yaml
;	scripts/update-nightly-version.ts
;	scripts/update-version.ts
2026-01-29 21:47:06 +02:00
Elian Doran
458e858b24 fix(standalone): error due to SQL returning bigint 2026-01-17 20:01:46 +02:00
Elian Doran
ec84e72b4c Lightweight/browser api (#8287) 2026-01-14 18:30:05 +02:00
Elian Doran
64a8c3b005 chore(client-standalone): address requested changes 2026-01-14 18:27:53 +02:00
Elian Doran
0b5cf2e6c8 Merge remote-tracking branch 'origin/standalone' into lightweight/browser_api 2026-01-14 18:04:54 +02:00
Elian Doran
7ed4e1c284 Lightweight/decouple server api (#8284) 2026-01-14 18:01:54 +02:00
Elian Doran
9dd7616f7d chore(client-standalone): address requested changes 2026-01-14 18:00:10 +02:00
Elian Doran
ab29caff7b fix(client-standalone): CK premium features not working 2026-01-14 17:48:29 +02:00
Elian Doran
7633e3d48e chore(client-standalone): address requested changes 2026-01-14 17:41:24 +02:00
Elian Doran
411fdf3114 chore(client-standalone): disable WS error notification 2026-01-14 17:33:57 +02:00
Elian Doran
5c52917459 fix(client-standalone): webmanifest icon path not correct 2026-01-14 17:31:06 +02:00
Elian Doran
51753ad82a chore(ci): run tests on standalone branch as well 2026-01-12 21:51:26 +02:00
Elian Doran
7e00634f3d chore(deps): align package lock 2026-01-12 21:44:25 +02:00
Elian Doran
daf41804d4 chore(core): address requested changes 2026-01-12 21:43:57 +02:00
Elian Doran
43d087f886 chore(deps): update lock file 2026-01-12 21:32:06 +02:00
Elian Doran
503a6e520d Merge remote-tracking branch 'origin/main' into lightweight/decouple_server_api 2026-01-12 21:31:32 +02:00
Elian Doran
52610a7410 fix(client-standalone): missing manifest 2026-01-12 21:06:00 +02:00
Elian Doran
c7edb71fed fix(client-standalone): missing favicon 2026-01-12 21:05:21 +02:00
Elian Doran
83db37ed31 fix(server): app-info not showing data dir 2026-01-12 21:03:55 +02:00
Elian Doran
0d1c8ae01e fix(server): login not working due to bad import to i18n 2026-01-12 20:55:32 +02:00
Elian Doran
92f71e100f chore(core): integrate app_info route 2026-01-12 20:54:18 +02:00
Elian Doran
659573b864 fix(client-standalone): update version to match 2026-01-12 20:50:12 +02:00
Elian Doran
e1c798561b fix(client-standalone): user guide not working 2026-01-12 20:46:08 +02:00
Elian Doran
0c52b56e02 chore(core): integrate branches service and route 2026-01-12 19:25:45 +02:00
Elian Doran
f9731d9cfc chore(text): re-enable emojis 2026-01-12 19:00:35 +02:00
Elian Doran
7547371ba0 feat(client-standalone): proper integration of server-side locale 2026-01-12 18:44:48 +02:00
Elian Doran
84e1d45d2a fix(client-standalone): print not working 2026-01-11 23:05:27 +02:00
Elian Doran
364c9cda27 chore(client-standalone): reduce verbosity in logs for requests 2026-01-11 23:05:26 +02:00
Elian Doran
af944c29a8 feat(client-standalone): support more globals 2026-01-11 23:04:53 +02:00
Elian Doran
45577f1585 feat(client-standalone): better device detection 2026-01-11 23:04:53 +02:00
Elian Doran
1648c67467 feat(client-standalone): initialize server-side translations 2026-01-11 22:46:47 +02:00
Elian Doran
882793e794 chore(client-standalone): basic support for mobile 2026-01-11 18:29:47 +02:00
Elian Doran
4a4a7d79c2 chore(client-standalone): integrate faster preact 2026-01-11 17:52:56 +02:00
Elian Doran
a955eb80da chore(client-standalone): integrate main client script 2026-01-11 17:34:25 +02:00
Elian Doran
cd64a1ee18 chore(client-standalone): fix noscript 2026-01-11 17:31:15 +02:00
Elian Doran
9894d4256c chore(deps): update lock 2026-01-11 17:31:07 +02:00
Elian Doran
3b5f1dabd6 Merge remote-tracking branch 'origin/lightweight/decouple_server_api' into lightweight/browser_api 2026-01-11 17:21:37 +02:00
Elian Doran
750fa2e647 Merge remote-tracking branch 'origin/main' into lightweight/decouple_server_api 2026-01-11 17:15:35 +02:00
Elian Doran
4f0021e44e Merge remote-tracking branch 'origin/main' into lightweight/browser_api
; Conflicts:
;	apps/client/src/widgets/layout/StatusBar.tsx
2026-01-07 19:41:51 +02:00
Elian Doran
2546e4c0dc fix(client): server worker in client 2026-01-07 18:29:00 +02:00
Elian Doran
eac5dbb210 chore(client-standalone): async-proxy missing in prod 2026-01-07 17:58:12 +02:00
Elian Doran
8b6da981f7 chore(client-standalone): try to use plain header file 2026-01-07 17:49:25 +02:00
Elian Doran
7433ca069f chore(client-standalone): wrong file name to CORS 2026-01-07 17:35:37 +02:00
Elian Doran
128049b672 chore(core): integrate icon usage API 2026-01-07 17:33:44 +02:00
Elian Doran
0eb3cb1118 feat(client-standalone): proper startup without requiring refresh 2026-01-07 17:19:52 +02:00
Elian Doran
8fc28716a7 feat(client-standalone): set up CORS for Cloudflare Pages 2026-01-07 17:14:31 +02:00
Elian Doran
af346f455a fix(client-standalone): version check was broken 2026-01-07 16:53:37 +02:00
Elian Doran
3e5a6c1e51 chore(client-standalone): fake two more routes 2026-01-07 16:43:17 +02:00
Elian Doran
9e3b4435cd fix(client): request to recent changes for undefined note 2026-01-07 16:43:11 +02:00
Elian Doran
3a793a3549 chore(client-standalone): fake two more routes 2026-01-07 16:41:19 +02:00
Elian Doran
4f139552f4 chore(core): integrate recent-notes 2026-01-07 16:41:08 +02:00
Elian Doran
13f25e9fed chore(client-standalone): integrate note map backlink count 2026-01-07 16:36:49 +02:00
Elian Doran
91db73703b chore(client-standalone): add two dummy routes 2026-01-07 16:32:29 +02:00
Elian Doran
d690985b58 fix(client): SQL schemas loaded even when not needed 2026-01-07 16:27:48 +02:00
Elian Doran
b5bcf73531 chore(client-standalone): bring back window.global 2026-01-07 16:21:35 +02:00
Elian Doran
2e905c8292 fix(deps): lock file out of sync 2026-01-07 16:06:15 +02:00
Elian Doran
4374c92032 feat(ci): add deployment script for standalone client 2026-01-07 16:04:04 +02:00
Elian Doran
edde0d0f90 fix(client-standalone): get it to start in prod 2026-01-07 15:50:34 +02:00
Elian Doran
32c39384ff fix(client-standalone): missing entry point for sw, local-bridge, local-server-worker 2026-01-07 15:20:59 +02:00
Elian Doran
807ab4be8c fix(client-standalone): build missing .wasm 2026-01-07 15:16:38 +02:00
Elian Doran
4da20f4829 fix(client-standalone): some assets could not be loaded 2026-01-07 15:11:01 +02:00
Elian Doran
cb5b491633 fix(client-standalone): get client scripts to run 2026-01-07 14:42:02 +02:00
Elian Doran
e76c33c37a chore(client-standalone): relocate index file to root 2026-01-07 14:34:41 +02:00
Elian Doran
89fc89603e chore(client-standalone): set up live reload for assets 2026-01-07 14:30:29 +02:00
Elian Doran
c0bf294457 chore(client-standalone): basic integration for assets 2026-01-07 14:29:23 +02:00
Elian Doran
24e076cacf chore(client-standalone): integrate new files from client 2026-01-07 14:22:04 +02:00
Elian Doran
1e381b13ca chore(client-standalone): create empty project 2026-01-07 14:14:52 +02:00
Elian Doran
f83121ce1d chore(core): integrate attachments route 2026-01-07 13:48:59 +02:00
Elian Doran
b32480f1d3 feat(client/lightweight): basic WS support 2026-01-07 13:42:42 +02:00
Elian Doran
d4468bd97b feat(client/lightweight): basic OPFS support for persistence 2026-01-07 13:27:17 +02:00
Elian Doran
e8711d7cd5 fix(client/lightweight): not handling returning backend entities 2026-01-07 13:04:24 +02:00
Elian Doran
35f4d2aaad chore(client/lightweight): improve route error handling 2026-01-07 12:55:58 +02:00
Elian Doran
b1f3fe5345 fix(client/lightweight): saving not working 2026-01-07 12:53:07 +02:00
Elian Doran
9f1b0ac449 fix(client/lightweight): saved statements causing issues 2026-01-07 12:41:08 +02:00
Elian Doran
a84e804fc3 fix(client/lightweight): CLS not available in routes 2026-01-07 12:37:29 +02:00
Elian Doran
3371a31c70 fix(client/lightweight): crypto hash not working 2026-01-07 12:32:45 +02:00
Elian Doran
724af8e103 fix(client/lightweight): statements with parameters not working 2026-01-07 12:21:27 +02:00
Elian Doran
c5803a2650 fix(client/lightweight): missing pluck implementation 2026-01-07 12:16:09 +02:00
Elian Doran
baf18835be fix(client/lightweight): SQL nested transactions not supported 2026-01-07 12:14:30 +02:00
Elian Doran
3d1c93e58c fix(client/lightweight): note content not rendering 2026-01-07 12:07:49 +02:00
Elian Doran
ab0800a9f3 chore(core): integrate notes route 2026-01-07 12:00:38 +02:00
Elian Doran
dd58eac4b0 fix(client/lightweight): boxicons not loading 2026-01-07 11:50:25 +02:00
Elian Doran
c6d1457ad7 refactor(client/lightweight): bootstrap route as part of the new router 2026-01-07 11:48:22 +02:00
Elian Doran
f05fda871c chore(core): integrate icon_packs service 2026-01-07 11:45:40 +02:00
Elian Doran
22590596da feat(core): shared router between lightweight and server 2026-01-07 11:37:50 +02:00
Elian Doran
8274f9a220 feat(client): lightweight router implementation 2026-01-07 11:30:52 +02:00
Elian Doran
b19bf62d7e chore(client): bypass autocomplete count for now 2026-01-07 11:26:31 +02:00
Elian Doran
7b436bdf70 chore(client): vite not reloading core module 2026-01-07 11:24:04 +02:00
Elian Doran
a1c4a17d64 chore(core): integrate keyboard actions route 2026-01-07 11:23:46 +02:00
Elian Doran
7966cfd09c chore(client/lightweight): wait for becca to load before processing requests 2026-01-07 11:13:25 +02:00
Elian Doran
0fe299250e chore(client/lightweight): tree route import not seen 2026-01-07 11:07:23 +02:00
Elian Doran
adfe490480 chore(core): fix import 2026-01-07 10:43:02 +02:00
Elian Doran
872ab0864b chore(client/lightweight): handle routes properly 2026-01-06 23:19:41 +02:00
Elian Doran
6633b4233d chore(client/lightweight): initialize database earlier 2026-01-06 23:05:40 +02:00
Elian Doran
a2d873d16f chore(client/lightweight): port tree integration 2026-01-06 22:59:18 +02:00
Elian Doran
a6f52fff3e fix(client/lightweight): raw SQL queries not working 2026-01-06 22:20:53 +02:00
Elian Doran
7832f20c89 feat(client/lightweight): import demo database 2026-01-06 21:45:02 +02:00
Elian Doran
405db7cedb chore(client/lightweight): fix errors in SQL provider & implement crypto provider 2026-01-06 21:05:53 +02:00
Elian Doran
ccf4df8e86 chore(client/lightweight): basic SQL implementation 2026-01-06 20:59:52 +02:00
Elian Doran
1beda05e6c chore(client/lightweight): basic CLS implementation 2026-01-06 20:59:49 +02:00
Elian Doran
18a3d9d71a fix(client/lightweight): TypeScript not processed 2026-01-06 20:39:11 +02:00
Elian Doran
25dc9201bf feat(client/lightweight): improve error handling 2026-01-06 20:27:35 +02:00
Elian Doran
b60501dd3f chore(core) integrate options route 2026-01-06 20:12:03 +02:00
Elian Doran
cbd2fc3966 chore(client/lightweight): fix asset and API base path 2026-01-06 19:41:31 +02:00
Elian Doran
9bce12a85b Merge remote-tracking branch 'origin/lightweight/decouple_server_api' into lightweight/browser_api 2026-01-06 19:33:35 +02:00
Elian Doran
8523c369e1 fix(server): imports preventing start-up 2026-01-06 19:15:53 +02:00
Elian Doran
7c16aeca4a chore(core): crash due to dbReady before CLS init 2026-01-06 16:30:45 +02:00
Elian Doran
8399600e79 chore(core): address some missing methods in utils 2026-01-06 16:29:30 +02:00
Elian Doran
edac58f3fa chore(core): integrate revisions 2026-01-06 16:24:14 +02:00
Elian Doran
51d0d848c5 chore(core): no-op request service 2026-01-06 16:22:47 +02:00
Elian Doran
1edab8e8da chore(core): no-op image service 2026-01-06 16:21:42 +02:00
Elian Doran
e1e294914a chore(core): no-op search 2026-01-06 16:20:10 +02:00
Elian Doran
4668fdc15c chore(core): no-op sqlInit 2026-01-06 16:18:06 +02:00
Elian Doran
f1e0d5558c chore(core): integrate erase 2026-01-06 16:16:54 +02:00
Elian Doran
c94c54c641 chore(core): integrate task_context with ws no-op 2026-01-06 16:09:21 +02:00
Elian Doran
18416eb89a chore(core): no op script 2026-01-06 16:07:30 +02:00
Elian Doran
263c9028e2 chore(core): integrate hidden_subtree 2026-01-06 16:05:16 +02:00
Elian Doran
0b528e9937 chore(core): integrate handlers 2026-01-06 15:57:36 +02:00
Elian Doran
e905c1ec11 chore(core): integrate cloning service 2026-01-06 15:52:37 +02:00
Elian Doran
ecb27fe9f7 chore(core): integrate tree service 2026-01-06 15:48:48 +02:00
Elian Doran
a8f6db4b20 chore(core): fix some imports 2026-01-06 15:45:07 +02:00
Elian Doran
78262e55ec chore(core): integrate escape/unescape & toMap 2026-01-06 15:43:36 +02:00
Elian Doran
c6197e520d chore(core): integrate some more utils 2026-01-06 15:41:34 +02:00
Elian Doran
299c06c1a6 chore(core): fix inaccessible NoteParams 2026-01-06 15:33:48 +02:00
Elian Doran
674593b38c chore(core): integrate html_sanitizer 2026-01-06 15:31:22 +02:00
Elian Doran
f5535657ad chore(core): port note_types 2026-01-06 15:17:10 +02:00
Elian Doran
de4d07e904 chore(core): get rid of note_interface 2026-01-06 15:15:12 +02:00
Elian Doran
5508b505c8 chore(core): port notes service partially 2026-01-06 15:14:08 +02:00
Elian Doran
8cdfc108ba fix(core): wrong imports to src 2026-01-06 13:52:57 +02:00
Elian Doran
6a0f6fab83 fix(core): server not starting due to crypto not initialized 2026-01-06 13:50:22 +02:00
Elian Doran
ad3be73e1b chore(core): integrate note_set 2026-01-06 13:45:53 +02:00
Elian Doran
64b212b93e chore(core): integrate entity_changes 2026-01-06 13:42:29 +02:00
Elian Doran
60cb8d950e chore(core): integrate promoted_attribute_definition_parser 2026-01-06 13:30:21 +02:00
Elian Doran
61f6f94295 chore(core): integrate sanitize_attribute_name 2026-01-06 13:26:19 +02:00
Elian Doran
ebe7276f40 chore(core): fix some use of logs 2026-01-06 13:21:39 +02:00
Elian Doran
26d299aa44 chore(core): integrate options, options_init & keyboard_actions 2026-01-06 13:20:42 +02:00
Elian Doran
bd45c32251 chore(core): integrate utils partially 2026-01-06 13:06:14 +02:00
Elian Doran
321558a01f chore(core): fix minor type issue 2026-01-06 12:43:45 +02:00
Elian Doran
f5a77477aa chore(core): fix missing CLS method 2026-01-06 12:42:35 +02:00
Elian Doran
20c90d1296 chore(server): fix incompatibility with Uint8Array 2026-01-06 12:40:43 +02:00
Elian Doran
bbfef0315f chore(core): fix incompatibility with Uint8Array 2026-01-06 12:34:16 +02:00
Elian Doran
321fcf34f2 chore(core): fix references to getHoistedNoteId 2026-01-06 12:29:13 +02:00
Elian Doran
b9a59fe0c4 chore(core): fixs some imports to protected_session 2026-01-06 12:27:00 +02:00
Elian Doran
01f3c32d92 refactor(server): remove Blob interface in favor of BlobRow 2026-01-06 12:24:09 +02:00
Elian Doran
05b9e2ec2a chore(core): fix references to core 2026-01-06 12:20:01 +02:00
Elian Doran
c8d3b091fd chore(commons): fix Node reference 2026-01-06 12:19:42 +02:00
Elian Doran
d717a89163 chore(core): fix references to Buffer 2026-01-06 12:16:38 +02:00
Elian Doran
8149460547 chore(commons): fix issues with Buffer 2026-01-06 12:07:16 +02:00
Elian Doran
b7ad76827a chore(server): various references to core 2026-01-06 12:05:52 +02:00
Elian Doran
e19e9b3830 chore(core): fix references to blob-service 2026-01-06 12:01:18 +02:00
Elian Doran
40b07c3e8a chore(core): fix references to becca-interface 2026-01-06 11:59:45 +02:00
Elian Doran
a15b84b4e5 chore(core): fix type error in getFlatText 2026-01-06 11:56:52 +02:00
Elian Doran
544c52931c chore(server): fix references to abstract becca entity 2026-01-06 11:55:10 +02:00
Elian Doran
9391159413 chore(server): fix references to becca service 2026-01-06 11:52:25 +02:00
Elian Doran
f9e22a9ba9 chore(server): fix references to becca loader 2026-01-06 11:49:22 +02:00
Elian Doran
f88ac5dfae chore(server): fix imports to becca entities 2026-01-06 11:46:15 +02:00
Elian Doran
3459d2906e chore(server): fix imports to validation error 2026-01-06 11:41:06 +02:00
Elian Doran
4506b717d5 chore(server): fix imports to becca 2026-01-06 11:38:25 +02:00
Elian Doran
af8744ef2a chore(core): integrate errors 2026-01-06 11:31:13 +02:00
Elian Doran
320d8e3b45 chore(core): partially integrate becca 2026-01-06 11:23:52 +02:00
Elian Doran
c20da77f83 chore(core): integrate events service 2026-01-06 11:10:09 +02:00
Elian Doran
14e2e85da7 chore(core): integrate date_utils 2026-01-06 11:03:33 +02:00
Elian Doran
c7f0d541c2 fix(server): blob errors out 2026-01-06 10:41:50 +02:00
Elian Doran
5d474150da feat(client/lightweight): integrate SQLite 2026-01-05 20:00:00 +02:00
Elian Doran
d3941752f1 chore(client/lightweight): disable caching for now 2026-01-05 19:10:25 +02:00
Elian Doran
56b305b1de fix(client/lightweight): html aggressively cached 2026-01-05 18:50:09 +02:00
Elian Doran
bde472d649 feat(client/standalone): basic service worker attempt 2026-01-05 18:35:14 +02:00
Elian Doran
c1548b0f54 chore(server): integrate data_encryption, and protected_session 2026-01-05 17:47:25 +02:00
Elian Doran
6f04738629 chore(core): add documentation for SQL 2026-01-05 16:07:17 +02:00
Elian Doran
f79af7b045 fix(server): request content empty due to CLS 2026-01-05 16:01:27 +02:00
Elian Doran
527f502083 fix(server): requests failing due to cls namespacing issue 2026-01-05 15:58:24 +02:00
Elian Doran
d61e2c6f2c chore(server): get DB to be loaded 2026-01-05 15:52:31 +02:00
Elian Doran
ea31d2f446 chore(core): basic integration of SQL + CLS + log 2026-01-05 15:45:45 +02:00
Elian Doran
62803a1817 chore(server): set up dependency to trilium-core 2026-01-05 14:42:32 +02:00
Elian Doran
a67464b4a0 refactor(server): decouple bettersqlite3 from sql service 2026-01-05 14:03:03 +02:00
Elian Doran
00e7482968 chore(core): create empty package 2026-01-05 12:26:13 +02:00
1002 changed files with 69928 additions and 39825 deletions

View File

@@ -1,6 +1,6 @@
root = true
[*.{js,cjs,ts,tsx,css}]
[*.{js,ts,tsx,css}]
charset = utf-8
end_of_line = lf
indent_size = 4

View File

@@ -85,7 +85,6 @@ runs:
APPLE_ID: ${{ env.APPLE_ID }}
APPLE_ID_PASSWORD: ${{ env.APPLE_ID_PASSWORD }}
WINDOWS_SIGN_EXECUTABLE: ${{ env.WINDOWS_SIGN_EXECUTABLE }}
WINDOWS_SIGN_ERROR_LOG: ${{ env.WINDOWS_SIGN_ERROR_LOG }}
TRILIUM_ARTIFACT_NAME_HINT: TriliumNotes-${{ github.ref_name }}-${{ inputs.os }}-${{ inputs.arch }}
TARGET_ARCH: ${{ inputs.arch }}
run: pnpm run --filter desktop electron-forge:make --arch=${{ inputs.arch }} --platform=${{ inputs.forge_platform }}

View File

@@ -8,7 +8,7 @@ inputs:
runs:
using: composite
steps:
- uses: pnpm/action-setup@v5
- uses: pnpm/action-setup@v4
- name: Set up node & dependencies
uses: actions/setup-node@v6
with:

View File

@@ -69,7 +69,7 @@ runs:
# Post github action comment
- name: Post comment
uses: marocchino/sticky-pull-request-comment@v3
uses: marocchino/sticky-pull-request-comment@v2
if: ${{ steps.bundleSize.outputs.hasDifferences == 'true' }} # post only in case of changes
with:
number: ${{ github.event.pull_request.number }}

67
.github/workflows/deploy-app.yml vendored Normal file
View File

@@ -0,0 +1,67 @@
name: Deploy Standalone App
on:
# Trigger on push to main branch
push:
branches:
- standalone
# Only run when app files change
paths:
- 'apps/client/**'
- 'apps/client-standalone/**'
- 'packages/trilium-core/**'
- '.github/workflows/deploy-app.yml'
# Allow manual triggering from Actions tab
workflow_dispatch:
# Run on pull requests for preview deployments
pull_request:
paths:
- 'apps/client/**'
- 'apps/client-standalone/**'
- 'packages/trilium-core/**'
- '.github/workflows/deploy-app.yml'
jobs:
build-and-deploy:
name: Build and Deploy App
runs-on: ubuntu-latest
timeout-minutes: 10
# Required permissions for deployment
permissions:
contents: read
deployments: write
pull-requests: write # For PR preview comments
id-token: write # For OIDC authentication (if needed)
steps:
- name: Checkout Repository
uses: actions/checkout@v6
- name: Setup pnpm
uses: pnpm/action-setup@v4
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: '24'
cache: 'pnpm'
- name: Install Dependencies
run: pnpm install --frozen-lockfile
- name: Trigger build of app
run: pnpm --filter=client-standalone build
- name: Deploy
uses: ./.github/actions/deploy-to-cloudflare-pages
if: github.repository == vars.REPO_MAIN
with:
project_name: "trilium-app"
comment_body: "🖥️ App preview is ready"
production_url: "https://app.triliumnotes.org"
deploy_dir: "apps/client-standalone/dist"
cloudflare_api_token: ${{ secrets.CLOUDFLARE_API_TOKEN }}
cloudflare_account_id: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
github_token: ${{ secrets.GITHUB_TOKEN }}

View File

@@ -45,7 +45,7 @@ jobs:
uses: actions/checkout@v6
- name: Setup pnpm
uses: pnpm/action-setup@v5
uses: pnpm/action-setup@v4
- name: Setup Node.js
uses: actions/setup-node@v6

View File

@@ -1,9 +1,9 @@
name: Dev
on:
push:
branches: [ main ]
branches: [ main, standalone ]
pull_request:
branches: [ main ]
branches: [ main, standalone ]
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
@@ -26,7 +26,7 @@ jobs:
- name: Checkout the repository
uses: actions/checkout@v6
- uses: pnpm/action-setup@v5
- uses: pnpm/action-setup@v4
- name: Set up node & dependencies
uses: actions/setup-node@v6
with:
@@ -37,35 +37,8 @@ jobs:
- name: Typecheck
run: pnpm typecheck
- name: Run the client-side tests
run: pnpm run --filter=client test
- name: Upload client test report
uses: actions/upload-artifact@v7
if: always()
with:
name: client-test-report
path: apps/client/test-output/vitest/html/
retention-days: 30
- name: Run the server-side tests
run: pnpm run --filter=server test
- name: Upload server test report
uses: actions/upload-artifact@v7
if: always()
with:
name: server-test-report
path: apps/server/test-output/vitest/html/
retention-days: 30
- name: Run CKEditor e2e tests
run: |
pnpm run --filter=ckeditor5-mermaid test
pnpm run --filter=ckeditor5-math test
- name: Run the rest of the tests
run: pnpm run --filter=\!client --filter=\!server --filter=\!ckeditor5-mermaid --filter=\!ckeditor5-math test
- name: Run the unit tests
run: pnpm run test:all
build_docker:
name: Build Docker image
@@ -74,7 +47,7 @@ jobs:
- test_dev
steps:
- uses: actions/checkout@v6
- uses: pnpm/action-setup@v5
- uses: pnpm/action-setup@v4
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Update build info
@@ -89,8 +62,8 @@ jobs:
key: ${{ secrets.RELATIVE_CI_CLIENT_KEY }}
- name: Trigger server build
run: pnpm run server:build
- uses: docker/setup-buildx-action@v4
- uses: docker/build-push-action@v7
- uses: docker/setup-buildx-action@v3
- uses: docker/build-push-action@v6
with:
context: apps/server
cache-from: type=gha
@@ -109,7 +82,7 @@ jobs:
- name: Checkout the repository
uses: actions/checkout@v6
- uses: pnpm/action-setup@v5
- uses: pnpm/action-setup@v4
- name: Install dependencies
run: pnpm install --frozen-lockfile
@@ -124,10 +97,10 @@ jobs:
run: echo "TEST_TAG=${TEST_TAG,,}" >> $GITHUB_ENV
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
uses: docker/setup-buildx-action@v3
- name: Build and export to Docker
uses: docker/build-push-action@v7
uses: docker/build-push-action@v6
with:
context: apps/server
file: apps/server/${{ matrix.dockerfile }}

View File

@@ -18,7 +18,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: pnpm/action-setup@v5
- uses: pnpm/action-setup@v4
- name: Set up node & dependencies
uses: actions/setup-node@v6
with:

View File

@@ -40,9 +40,9 @@ jobs:
run: echo "TEST_TAG=${TEST_TAG,,}" >> $GITHUB_ENV
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
uses: docker/setup-buildx-action@v3
- uses: pnpm/action-setup@v5
- uses: pnpm/action-setup@v4
- name: Set up node & dependencies
uses: actions/setup-node@v6
with:
@@ -59,7 +59,7 @@ jobs:
run: pnpm run server:build
- name: Build and export to Docker
uses: docker/build-push-action@v7
uses: docker/build-push-action@v6
with:
context: apps/server
file: apps/server/${{ matrix.dockerfile }}
@@ -86,12 +86,12 @@ jobs:
- name: Upload Playwright trace
if: failure()
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@v6
with:
name: Playwright trace (${{ matrix.dockerfile }})
path: test-output/playwright/output
- uses: actions/upload-artifact@v7
- uses: actions/upload-artifact@v6
if: ${{ !cancelled() }}
with:
name: Playwright report (${{ matrix.dockerfile }})
@@ -142,7 +142,7 @@ jobs:
- name: Checkout repository
uses: actions/checkout@v6
- uses: pnpm/action-setup@v5
- uses: pnpm/action-setup@v4
- name: Set up node & dependencies
uses: actions/setup-node@v6
with:
@@ -164,9 +164,11 @@ jobs:
- name: Docker meta
id: meta
uses: docker/metadata-action@v6
uses: docker/metadata-action@v5
with:
images: ${{ env.GHCR_REGISTRY }}/${{ env.IMAGE_NAME }}
images: |
${{ env.GHCR_REGISTRY }}/${{ env.IMAGE_NAME }}
${{ env.DOCKERHUB_REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=ref,event=branch
type=ref,event=tag
@@ -175,21 +177,28 @@ jobs:
latest=false
- name: Set up QEMU
uses: docker/setup-qemu-action@v4
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
uses: docker/setup-buildx-action@v3
- name: Login to GHCR
uses: docker/login-action@v4
uses: docker/login-action@v3
with:
registry: ${{ env.GHCR_REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Login to DockerHub
uses: docker/login-action@v3
with:
registry: ${{ env.DOCKERHUB_REGISTRY }}
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Build and push by digest
id: build
uses: docker/build-push-action@v7
uses: docker/build-push-action@v6
with:
context: apps/server
file: apps/server/${{ matrix.dockerfile }}
@@ -204,7 +213,7 @@ jobs:
touch "/tmp/digests/${digest#sha256:}"
- name: Upload digest
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@v6
with:
name: digests-${{ env.PLATFORM_PAIR }}-${{ matrix.dockerfile }}
path: /tmp/digests/*
@@ -218,7 +227,7 @@ jobs:
- build
steps:
- name: Download digests
uses: actions/download-artifact@v8
uses: actions/download-artifact@v7
with:
path: /tmp/digests
pattern: digests-*
@@ -228,86 +237,75 @@ jobs:
- name: Set TEST_TAG to lowercase
run: echo "TEST_TAG=${TEST_TAG,,}" >> $GITHUB_ENV
- name: Set up crane
uses: imjasonh/setup-crane@v0.5
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Docker meta
id: meta
uses: docker/metadata-action@v5
with:
images: |
${{ env.GHCR_REGISTRY }}/${{ env.IMAGE_NAME }}
${{ env.DOCKERHUB_REGISTRY }}/${{ env.IMAGE_NAME }}
flavor: |
latest=false
- name: Login to GHCR
uses: docker/login-action@v4
uses: docker/login-action@v3
with:
registry: ${{ env.GHCR_REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Login to DockerHub
uses: docker/login-action@v4
uses: docker/login-action@v3
with:
registry: ${{ env.DOCKERHUB_REGISTRY }}
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Docker meta
id: meta
uses: docker/metadata-action@v6
with:
images: ${{ env.GHCR_REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=ref,event=branch
type=ref,event=tag
type=sha
flavor: |
latest=false
- name: Verify digests exist on GHCR
- name: Create manifest list and push
working-directory: /tmp/digests
run: |
echo "Verifying all digests are available on GHCR..."
for DIGEST_FILE in *; do
DIGEST="sha256:${DIGEST_FILE}"
echo -n " ${DIGEST}: "
crane manifest "${{ env.GHCR_REGISTRY }}/${{ env.IMAGE_NAME }}@${DIGEST}" > /dev/null
echo "OK"
done
# Extract the branch or tag name from the ref
REF_NAME=$(echo "${GITHUB_REF}" | sed 's/refs\/heads\///' | sed 's/refs\/tags\///')
- name: Create and push multi-arch manifest
working-directory: /tmp/digests
run: |
GHCR_IMAGE="${{ env.GHCR_REGISTRY }}/${{ env.IMAGE_NAME }}"
DOCKERHUB_IMAGE="${{ env.DOCKERHUB_REGISTRY }}/${{ env.IMAGE_NAME }}"
# Create and push the manifest list with both the branch/tag name and the commit SHA
docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
-t ${{ env.GHCR_REGISTRY }}/${{ env.IMAGE_NAME }}:${REF_NAME} \
$(printf '${{ env.GHCR_REGISTRY }}/${{ env.IMAGE_NAME }}@sha256:%s ' *)
# Build -m flags for crane index append from digest files
MANIFEST_ARGS=""
for d in *; do
MANIFEST_ARGS="${MANIFEST_ARGS} -m ${GHCR_IMAGE}@sha256:${d}"
done
docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
-t ${{ env.DOCKERHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${REF_NAME} \
$(printf '${{ env.DOCKERHUB_REGISTRY }}/${{ env.IMAGE_NAME }}@sha256:%s ' *)
# Create multi-arch manifest for each tag from metadata, plus copy to DockerHub
while IFS= read -r TAG; do
echo "Creating manifest: ${TAG}"
crane index append ${MANIFEST_ARGS} -t "${TAG}"
SUFFIX="${TAG#*:}"
echo "Copying to DockerHub: ${DOCKERHUB_IMAGE}:${SUFFIX}"
crane copy "${TAG}" "${DOCKERHUB_IMAGE}:${SUFFIX}"
done <<< "${{ steps.meta.outputs.tags }}"
# For stable releases (tags without hyphens), also create stable + latest
REF_NAME="${GITHUB_REF#refs/tags/}"
# If the ref is a tag, also tag the image as stable as this is part of a 'release'
# and only go in the `if` if there is NOT a `-` in the tag's name, due to tagging of `-alpha`, `-beta`, etc...
if [[ "${GITHUB_REF}" == refs/tags/* && ! "${REF_NAME}" =~ - ]]; then
echo "Creating stable tags..."
crane index append ${MANIFEST_ARGS} -t "${GHCR_IMAGE}:stable"
crane copy "${GHCR_IMAGE}:stable" "${DOCKERHUB_IMAGE}:stable"
# First create stable tags
docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
-t ${{ env.GHCR_REGISTRY }}/${{ env.IMAGE_NAME }}:stable \
$(printf '${{ env.GHCR_REGISTRY }}/${{ env.IMAGE_NAME }}@sha256:%s ' *)
docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
-t ${{ env.DOCKERHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:stable \
$(printf '${{ env.DOCKERHUB_REGISTRY }}/${{ env.IMAGE_NAME }}@sha256:%s ' *)
# Small delay to ensure stable tag is fully propagated
sleep 5
# Now update latest tags
docker buildx imagetools create \
-t ${{ env.GHCR_REGISTRY }}/${{ env.IMAGE_NAME }}:latest \
${{ env.GHCR_REGISTRY }}/${{ env.IMAGE_NAME }}:stable
docker buildx imagetools create \
-t ${{ env.DOCKERHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:latest \
${{ env.DOCKERHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:stable
echo "Creating latest tags..."
crane copy "${GHCR_IMAGE}:stable" "${GHCR_IMAGE}:latest"
crane copy "${GHCR_IMAGE}:latest" "${DOCKERHUB_IMAGE}:latest"
fi
- name: Inspect manifests
- name: Inspect image
run: |
REF_NAME="${GITHUB_REF#refs/heads/}"
REF_NAME="${REF_NAME#refs/tags/}"
echo "=== GHCR ==="
crane manifest "${{ env.GHCR_REGISTRY }}/${{ env.IMAGE_NAME }}:${REF_NAME}"
echo ""
echo "=== DockerHub ==="
crane manifest "${{ env.DOCKERHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${REF_NAME}"
docker buildx imagetools inspect ${{ env.GHCR_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.meta.outputs.version }}
docker buildx imagetools inspect ${{ env.DOCKERHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.meta.outputs.version }}

View File

@@ -61,7 +61,7 @@ jobs:
runs-on: ${{ matrix.os.image }}
steps:
- uses: actions/checkout@v6
- uses: pnpm/action-setup@v5
- uses: pnpm/action-setup@v4
- name: Set up node & dependencies
uses: actions/setup-node@v6
with:
@@ -87,11 +87,10 @@ jobs:
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_ID_PASSWORD: ${{ secrets.APPLE_ID_PASSWORD }}
WINDOWS_SIGN_EXECUTABLE: ${{ vars.WINDOWS_SIGN_EXECUTABLE }}
WINDOWS_SIGN_ERROR_LOG: ${{ vars.WINDOWS_SIGN_ERROR_LOG }}
GPG_SIGNING_KEY: ${{ secrets.GPG_SIGN_KEY }}
- name: Publish release
uses: softprops/action-gh-release@v2.6.1
uses: softprops/action-gh-release@v2.5.0
if: ${{ github.event_name != 'pull_request' }}
with:
make_latest: false
@@ -103,7 +102,7 @@ jobs:
name: Nightly Build
- name: Publish artifacts
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@v6
if: ${{ github.event_name == 'pull_request' }}
with:
name: TriliumNotes ${{ matrix.os.name }} ${{ matrix.arch }}
@@ -132,7 +131,7 @@ jobs:
arch: ${{ matrix.arch }}
- name: Publish release
uses: softprops/action-gh-release@v2.6.1
uses: softprops/action-gh-release@v2.5.0
if: ${{ github.event_name != 'pull_request' }}
with:
make_latest: false

View File

@@ -38,7 +38,7 @@ jobs:
filter: tree:0
fetch-depth: 0
- uses: pnpm/action-setup@v5
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v6
with:
node-version: 24
@@ -77,7 +77,7 @@ jobs:
- name: Upload test report
if: failure()
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@v6
with:
name: e2e report ${{ matrix.arch }}
path: apps/server-e2e/test-output

View File

@@ -17,22 +17,10 @@ jobs:
steps:
- uses: actions/checkout@v6
- uses: pnpm/action-setup@v5
- name: Set up node & dependencies
uses: actions/setup-node@v6
with:
node-version: 24
cache: 'pnpm'
- name: Install dependencies
run: pnpm install --filter source --frozen-lockfile --ignore-scripts
- name: Check version consistency
run: pnpm tsx ${{ github.workspace }}/scripts/check-version-consistency.ts ${{ github.ref_name }}
make-electron:
name: Make Electron
needs:
- sanity-check
strategy:
fail-fast: false
matrix:
@@ -66,7 +54,7 @@ jobs:
runs-on: ${{ matrix.os.image }}
steps:
- uses: actions/checkout@v6
- uses: pnpm/action-setup@v5
- uses: pnpm/action-setup@v4
- name: Set up node & dependencies
uses: actions/setup-node@v6
with:
@@ -90,19 +78,16 @@ jobs:
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_ID_PASSWORD: ${{ secrets.APPLE_ID_PASSWORD }}
WINDOWS_SIGN_EXECUTABLE: ${{ vars.WINDOWS_SIGN_EXECUTABLE }}
WINDOWS_SIGN_ERROR_LOG: ${{ vars.WINDOWS_SIGN_ERROR_LOG }}
GPG_SIGNING_KEY: ${{ secrets.GPG_SIGN_KEY }}
- name: Upload the artifact
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@v6
with:
name: release-desktop-${{ matrix.os.name }}-${{ matrix.arch }}
path: apps/desktop/upload/*.*
build_server:
name: Build Linux Server
needs:
- sanity-check
strategy:
fail-fast: false
matrix:
@@ -123,7 +108,7 @@ jobs:
arch: ${{ matrix.arch }}
- name: Upload the artifact
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@v6
with:
name: release-server-linux-${{ matrix.arch }}
path: upload/*.*
@@ -143,14 +128,14 @@ jobs:
docs/Release Notes
- name: Download all artifacts
uses: actions/download-artifact@v8
uses: actions/download-artifact@v7
with:
merge-multiple: true
pattern: release-*
path: upload
- name: Publish stable release
uses: softprops/action-gh-release@v2.6.1
uses: softprops/action-gh-release@v2.5.0
with:
draft: false
body_path: docs/Release Notes/Release Notes/${{ github.ref_name }}.md

View File

@@ -32,7 +32,7 @@ jobs:
steps:
- uses: actions/checkout@v6
- uses: pnpm/action-setup@v5
- uses: pnpm/action-setup@v4
- name: Set up node & dependencies
uses: actions/setup-node@v6
with:
@@ -48,7 +48,7 @@ jobs:
pnpm --filter web-clipper zip:firefox
- name: Upload build artifacts
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@v6
if: ${{ !startsWith(github.ref, 'refs/tags/web-clipper-v') }}
with:
name: web-clipper-extension
@@ -58,7 +58,7 @@ jobs:
compression-level: 0
- name: Release web clipper extension
uses: softprops/action-gh-release@v2.6.1
uses: softprops/action-gh-release@v2.5.0
if: ${{ startsWith(github.ref, 'refs/tags/web-clipper-v') }}
with:
draft: false

View File

@@ -26,7 +26,7 @@ jobs:
steps:
- uses: actions/checkout@v6
- uses: pnpm/action-setup@v5
- uses: pnpm/action-setup@v4
- name: Set up node & dependencies
uses: actions/setup-node@v6
with:

1
.gitignore vendored
View File

@@ -46,6 +46,7 @@ upload
/.direnv
/result
.svelte-kit
# docs
site/

2
.nvmrc
View File

@@ -1 +1 @@
24.14.0
24.13.0

57
.vscode/launch.json vendored
View File

@@ -1,57 +0,0 @@
{
"version": "0.2.0",
"configurations": [
{
"name": "Launch client (Chrome)",
"request": "launch",
"type": "chrome",
"url": "http://localhost:8080",
"webRoot": "${workspaceFolder}/apps/client"
},
{
"name": "Launch server",
"type": "node",
"request": "launch",
"program": "${workspaceFolder}/apps/server/src/main.ts",
"runtimeExecutable": "${workspaceFolder}/node_modules/.bin/tsx",
"env": {
"NODE_ENV": "development",
"TRILIUM_ENV": "dev",
"TRILIUM_DATA_DIR": "${input:trilium_data_dir}",
"TRILIUM_RESOURCE_DIR": "${workspaceFolder}/apps/server/src"
},
"autoAttachChildProcesses": true,
"cwd": "${workspaceFolder}",
"console": "integratedTerminal",
"internalConsoleOptions": "neverOpen",
"skipFiles": ["<node_internals>/**", "${workspaceFolder}/node_modules/**"]
},
{
"name": "Launch Vitest with current test file",
"type": "node",
"request": "launch",
"autoAttachChildProcesses": true,
"program": "${workspaceFolder}/node_modules/vitest/vitest.mjs",
"args": ["run", "${relativeFile}"],
"smartStep": true,
"console": "integratedTerminal",
"skipFiles": ["<node_internals>/**", "**/node_modules/**"],
"cwd": "${workspaceFolder}"
}
],
"compounds": [
{
"name": "Launch client (Chrome) and server",
"configurations": ["Launch server","Launch client (Chrome)"],
"stopAll": true
}
],
"inputs": [
{
"id": "trilium_data_dir",
"type": "promptString",
"description": "Select Trilum Notes data directory",
"default": "${workspaceFolder}/apps/server/data"
}
]
}

View File

@@ -10,5 +10,4 @@ Description above is a general rule and may be altered on case by case basis.
## Reporting a Vulnerability
* For low severity vulnerabilities, they can be reported as GitHub issues.
* For severe vulnerabilities, please report it using [GitHub Security Advisories](https://github.com/TriliumNext/Trilium/security/advisories).
You can report low severity vulnerabilities as GitHub issues, more severe vulnerabilities should be reported to the email [contact@eliandoran.me](mailto:contact@eliandoran.me)

View File

@@ -1,28 +1,22 @@
{
"name": "build-docs",
"version": "1.0.0",
"description": "Build documentation from Trilium notes",
"description": "",
"main": "src/main.ts",
"bin": {
"trilium-build-docs": "dist/cli.js"
},
"scripts": {
"start": "tsx .",
"cli": "tsx src/cli.ts",
"build": "tsx scripts/build.ts"
"start": "tsx ."
},
"keywords": [],
"author": "Elian Doran <contact@eliandoran.me>",
"license": "AGPL-3.0-only",
"packageManager": "pnpm@10.32.1",
"packageManager": "pnpm@10.29.1",
"devDependencies": {
"@redocly/cli": "2.24.0",
"@redocly/cli": "2.15.1",
"archiver": "7.0.1",
"fs-extra": "11.3.4",
"js-yaml": "4.1.1",
"fs-extra": "11.3.3",
"react": "19.2.4",
"react-dom": "19.2.4",
"typedoc": "0.28.17",
"typedoc": "0.28.16",
"typedoc-plugin-missing-exports": "4.1.2"
}
}

View File

@@ -1,23 +0,0 @@
import BuildHelper from "../../../scripts/build-utils";
const build = new BuildHelper("apps/build-docs");
async function main() {
// Build the CLI and other TypeScript files
await build.buildBackend([
"src/cli.ts",
"src/main.ts",
"src/build-docs.ts",
"src/swagger.ts",
"src/script-api.ts",
"src/context.ts"
]);
// Copy HTML template
build.copy("src/index.html", "index.html");
// Copy node modules dependencies if needed
build.copyNodeModules([ "better-sqlite3", "bindings", "file-uri-to-path" ]);
}
main();

View File

@@ -13,12 +13,8 @@
* Make sure to keep in line with backend's `script_context.ts`.
*/
export type {
default as AbstractBeccaEntity
} from "../../server/src/becca/entities/abstract_becca_entity.js";
export type {
default as BAttachment
} from "../../server/src/becca/entities/battachment.js";
export type { default as AbstractBeccaEntity } from "../../server/src/becca/entities/abstract_becca_entity.js";
export type { default as BAttachment } from "../../server/src/becca/entities/battachment.js";
export type { default as BAttribute } from "../../server/src/becca/entities/battribute.js";
export type { default as BBranch } from "../../server/src/becca/entities/bbranch.js";
export type { default as BEtapiToken } from "../../server/src/becca/entities/betapi_token.js";
@@ -35,7 +31,6 @@ export type { Api };
const fakeNote = new BNote();
/**
* The `api` global variable allows access to the backend script API,
* which is documented in {@link Api}.
* The `api` global variable allows access to the backend script API, which is documented in {@link Api}.
*/
export const api: Api = new BackendScriptApi(fakeNote, {});

View File

@@ -1,90 +1,19 @@
process.env.TRILIUM_INTEGRATION_TEST = "memory-no-store";
// Only set TRILIUM_RESOURCE_DIR if not already set (e.g., by Nix wrapper)
if (!process.env.TRILIUM_RESOURCE_DIR) {
process.env.TRILIUM_RESOURCE_DIR = "../server/src";
}
process.env.TRILIUM_RESOURCE_DIR = "../server/src";
process.env.NODE_ENV = "development";
import cls from "@triliumnext/server/src/services/cls.js";
import archiver from "archiver";
import { execSync } from "child_process";
import { WriteStream } from "fs";
import { dirname, join, resolve } from "path";
import * as fs from "fs/promises";
import * as fsExtra from "fs-extra";
import yaml from "js-yaml";
import { dirname, join, resolve } from "path";
import archiver from "archiver";
import { WriteStream } from "fs";
import { execSync } from "child_process";
import BuildContext from "./context.js";
interface NoteMapping {
rootNoteId: string;
path: string;
format: "markdown" | "html" | "share";
ignoredFiles?: string[];
exportOnly?: boolean;
}
interface Config {
baseUrl: string;
noteMappings: NoteMapping[];
}
const DOCS_ROOT = "../../../docs";
const OUTPUT_DIR = "../../site";
// Load configuration from edit-docs-config.yaml
async function loadConfig(configPath?: string): Promise<Config | null> {
const pathsToTry = configPath
? [resolve(configPath)]
: [
join(process.cwd(), "edit-docs-config.yaml"),
join(__dirname, "../../../edit-docs-config.yaml")
];
for (const path of pathsToTry) {
try {
const configContent = await fs.readFile(path, "utf-8");
const config = yaml.load(configContent) as Config;
// Resolve all paths relative to the config file's directory
const CONFIG_DIR = dirname(path);
config.noteMappings = config.noteMappings.map((mapping) => ({
...mapping,
path: resolve(CONFIG_DIR, mapping.path)
}));
return config;
} catch (error) {
if (error.code !== "ENOENT") {
throw error; // rethrow unexpected errors
}
}
}
return null; // No config file found
}
async function exportDocs(
noteId: string,
format: "markdown" | "html" | "share",
outputPath: string,
ignoredFiles?: string[]
) {
const zipFilePath = `output-${noteId}.zip`;
try {
const { exportToZipFile } = (await import("@triliumnext/server/src/services/export/zip.js"))
.default;
await exportToZipFile(noteId, format, zipFilePath, {});
const ignoredSet = ignoredFiles ? new Set(ignoredFiles) : undefined;
await extractZip(zipFilePath, outputPath, ignoredSet);
} finally {
if (await fsExtra.exists(zipFilePath)) {
await fsExtra.rm(zipFilePath);
}
}
}
async function importAndExportDocs(sourcePath: string, outputSubDir: string) {
const note = await importData(sourcePath);
@@ -92,18 +21,15 @@ async function importAndExportDocs(sourcePath: string, outputSubDir: string) {
const zipName = outputSubDir || "user-guide";
const zipFilePath = `output-${zipName}.zip`;
try {
const { exportToZip } = (await import("@triliumnext/server/src/services/export/zip.js"))
.default;
const { exportToZip } = (await import("@triliumnext/server/src/services/export/zip.js")).default;
const branch = note.getParentBranches()[0];
const taskContext = new (await import("@triliumnext/server/src/services/task_context.js"))
.default(
"no-progress-reporting",
"export",
null
);
const taskContext = new (await import("@triliumnext/server/src/services/task_context.js")).default(
"no-progress-reporting",
"export",
null
);
const fileOutputStream = fsExtra.createWriteStream(zipFilePath);
await exportToZip(taskContext, branch, "share", fileOutputStream);
const { waitForStreamToFinish } = await import("@triliumnext/server/src/services/utils.js");
await waitForStreamToFinish(fileOutputStream);
// Output to root directory if outputSubDir is empty, otherwise to subdirectory
@@ -116,7 +42,7 @@ async function importAndExportDocs(sourcePath: string, outputSubDir: string) {
}
}
async function buildDocsInner(config?: Config) {
async function buildDocsInner() {
const i18n = await import("@triliumnext/server/src/services/i18n.js");
await i18n.initializeTranslations();
@@ -127,49 +53,18 @@ async function buildDocsInner(config?: Config) {
const beccaLoader = await import("../../server/src/becca/becca_loader.js");
await beccaLoader.beccaLoaded;
if (config) {
// Config-based build (reads from edit-docs-config.yaml)
console.log("Building documentation from config file...");
// Build User Guide
console.log("Building User Guide...");
await importAndExportDocs(join(__dirname, DOCS_ROOT, "User Guide"), "user-guide");
// Import all non-export-only mappings
for (const mapping of config.noteMappings) {
if (!mapping.exportOnly) {
console.log(`Importing from ${mapping.path}...`);
await importData(mapping.path);
}
}
// Build Developer Guide
console.log("Building Developer Guide...");
await importAndExportDocs(join(__dirname, DOCS_ROOT, "Developer Guide"), "developer-guide");
// Export all mappings
for (const mapping of config.noteMappings) {
if (mapping.exportOnly) {
console.log(`Exporting ${mapping.format} to ${mapping.path}...`);
await exportDocs(
mapping.rootNoteId,
mapping.format,
mapping.path,
mapping.ignoredFiles
);
}
}
} else {
// Legacy hardcoded build (for backward compatibility)
console.log("Building User Guide...");
await importAndExportDocs(join(__dirname, DOCS_ROOT, "User Guide"), "user-guide");
console.log("Building Developer Guide...");
await importAndExportDocs(
join(__dirname, DOCS_ROOT, "Developer Guide"),
"developer-guide"
);
// Copy favicon.
await fs.copyFile("../../apps/website/src/assets/favicon.ico",
join(OUTPUT_DIR, "favicon.ico"));
await fs.copyFile("../../apps/website/src/assets/favicon.ico",
join(OUTPUT_DIR, "user-guide", "favicon.ico"));
await fs.copyFile("../../apps/website/src/assets/favicon.ico",
join(OUTPUT_DIR, "developer-guide", "favicon.ico"));
}
// Copy favicon.
await fs.copyFile("../../apps/website/src/assets/favicon.ico", join(OUTPUT_DIR, "favicon.ico"));
await fs.copyFile("../../apps/website/src/assets/favicon.ico", join(OUTPUT_DIR, "user-guide", "favicon.ico"));
await fs.copyFile("../../apps/website/src/assets/favicon.ico", join(OUTPUT_DIR, "developer-guide", "favicon.ico"));
console.log("Documentation built successfully!");
}
@@ -196,13 +91,12 @@ async function createImportZip(path: string) {
zlib: { level: 0 }
});
console.log("Archive path is ", resolve(path));
console.log("Archive path is ", resolve(path))
archive.directory(path, "/");
const outputStream = fsExtra.createWriteStream(inputFile);
archive.pipe(outputStream);
archive.finalize();
const { waitForStreamToFinish } = await import("@triliumnext/server/src/services/utils.js");
await waitForStreamToFinish(outputStream);
try {
@@ -212,15 +106,15 @@ async function createImportZip(path: string) {
}
}
function waitForStreamToFinish(stream: WriteStream) {
return new Promise<void>((res, rej) => {
stream.on("finish", () => res());
stream.on("error", (err) => rej(err));
});
}
export async function extractZip(
zipFilePath: string,
outputPath: string,
ignoredFiles?: Set<string>
) {
const { readZipFile, readContent } = (await import(
"@triliumnext/server/src/services/import/zip.js"
));
export async function extractZip(zipFilePath: string, outputPath: string, ignoredFiles?: Set<string>) {
const { readZipFile, readContent } = (await import("@triliumnext/server/src/services/import/zip.js"));
await readZipFile(await fs.readFile(zipFilePath), async (zip, entry) => {
// We ignore directories since they can appear out of order anyway.
if (!entry.fileName.endsWith("/") && !ignoredFiles?.has(entry.fileName)) {
@@ -235,27 +129,6 @@ export async function extractZip(
});
}
export async function buildDocsFromConfig(configPath?: string, gitRootDir?: string) {
const config = await loadConfig(configPath);
if (gitRootDir) {
// Build the share theme if we have a gitRootDir (for Trilium project)
execSync(`pnpm run --filter share-theme build`, {
stdio: "inherit",
cwd: gitRootDir
});
}
// Trigger the actual build.
await new Promise((res, rej) => {
cls.init(() => {
buildDocsInner(config ?? undefined)
.catch(rej)
.then(res);
});
});
}
export default async function buildDocs({ gitRootDir }: BuildContext) {
// Build the share theme.
execSync(`pnpm run --filter share-theme build`, {

View File

@@ -1,89 +0,0 @@
#!/usr/bin/env node
import packageJson from "../package.json" with { type: "json" };
import { buildDocsFromConfig } from "./build-docs.js";
// Parse command-line arguments
function parseArgs() {
const args = process.argv.slice(2);
let configPath: string | undefined;
let showHelp = false;
let showVersion = false;
for (let i = 0; i < args.length; i++) {
if (args[i] === "--config" || args[i] === "-c") {
configPath = args[i + 1];
if (!configPath) {
console.error("Error: --config/-c requires a path argument");
process.exit(1);
}
i++; // Skip the next argument as it's the value
} else if (args[i] === "--help" || args[i] === "-h") {
showHelp = true;
} else if (args[i] === "--version" || args[i] === "-v") {
showVersion = true;
}
}
return { configPath, showHelp, showVersion };
}
function getVersion(): string {
return packageJson.version;
}
function printHelp() {
const version = getVersion();
console.log(`
Usage: trilium-build-docs [options]
Options:
-c, --config <path> Path to the configuration file
(default: edit-docs-config.yaml in current directory)
-h, --help Display this help message
-v, --version Display version information
Description:
Builds documentation from Trilium note structure and exports to various formats.
Configuration file should be in YAML format with the following structure:
baseUrl: "https://example.com"
noteMappings:
- rootNoteId: "noteId123"
path: "docs"
format: "markdown"
- rootNoteId: "noteId456"
path: "public/docs"
format: "share"
exportOnly: true
Version: ${version}
`);
}
function printVersion() {
const version = getVersion();
console.log(version);
}
async function main() {
const { configPath, showHelp, showVersion } = parseArgs();
if (showHelp) {
printHelp();
process.exit(0);
} else if (showVersion) {
printVersion();
process.exit(0);
}
try {
await buildDocsFromConfig(configPath);
process.exit(0);
} catch (error) {
console.error("Error building documentation:", error);
process.exit(1);
}
}
main();

View File

@@ -13,19 +13,16 @@
* Make sure to keep in line with frontend's `script_context.ts`.
*/
export type { default as BasicWidget } from "../../client/src/widgets/basic_widget.js";
export type { default as FAttachment } from "../../client/src/entities/fattachment.js";
export type { default as FAttribute } from "../../client/src/entities/fattribute.js";
export type { default as FBranch } from "../../client/src/entities/fbranch.js";
export type { default as FNote } from "../../client/src/entities/fnote.js";
export type { Api } from "../../client/src/services/frontend_script_api.js";
export type { default as BasicWidget } from "../../client/src/widgets/basic_widget.js";
export type {
default as NoteContextAwareWidget
} from "../../client/src/widgets/note_context_aware_widget.js";
export type { default as NoteContextAwareWidget } from "../../client/src/widgets/note_context_aware_widget.js";
export type { default as RightPanelWidget } from "../../client/src/widgets/right_panel_widget.js";
import FrontendScriptApi, { type Api } from "../../client/src/services/frontend_script_api.js";
// @ts-expect-error - FrontendScriptApi is not directly exportable as Api without this simulation.
//@ts-expect-error
export const api: Api = new FrontendScriptApi();

View File

@@ -1,10 +1,9 @@
import { cpSync, existsSync, mkdirSync, rmSync } from "fs";
import { join } from "path";
import buildDocs from "./build-docs";
import BuildContext from "./context";
import buildScriptApi from "./script-api";
import buildSwagger from "./swagger";
import { cpSync, existsSync, mkdirSync, rmSync } from "fs";
import buildDocs from "./build-docs";
import buildScriptApi from "./script-api";
const context: BuildContext = {
gitRootDir: join(__dirname, "../../../"),

View File

@@ -1,7 +1,6 @@
import { execSync } from "child_process";
import { join } from "path";
import BuildContext from "./context";
import { join } from "path";
export default function buildScriptApi({ baseDir, gitRootDir }: BuildContext) {
// Generate types

View File

@@ -1,8 +1,7 @@
import BuildContext from "./context";
import { join } from "path";
import { execSync } from "child_process";
import { mkdirSync } from "fs";
import { join } from "path";
import BuildContext from "./context";
interface BuildInfo {
specPath: string;
@@ -28,9 +27,6 @@ export default function buildSwagger({ baseDir, gitRootDir }: BuildContext) {
const absSpecPath = join(gitRootDir, specPath);
const targetDir = join(baseDir, outDir);
mkdirSync(targetDir, { recursive: true });
execSync(
`pnpm redocly build-docs ${absSpecPath} -o ${targetDir}/index.html`,
{ stdio: "inherit" }
);
execSync(`pnpm redocly build-docs ${absSpecPath} -o ${targetDir}/index.html`, { stdio: "inherit" });
}
}

View File

@@ -1,8 +1,6 @@
{
"extends": "../../tsconfig.base.json",
"include": [
"scripts/**/*.ts"
],
"include": [],
"references": [
{
"path": "../server"

View File

@@ -4,7 +4,6 @@
"entryPoints": [
"src/backend_script_entrypoint.ts"
],
"tsconfig": "tsconfig.app.json",
"plugin": [
"typedoc-plugin-missing-exports"
]

View File

@@ -4,7 +4,6 @@
"entryPoints": [
"src/frontend_script_entrypoint.ts"
],
"tsconfig": "tsconfig.app.json",
"plugin": [
"typedoc-plugin-missing-exports"
]

View File

@@ -0,0 +1,4 @@
# The development license key for premium CKEditor features.
# Note: This key must only be used for the Trilium Notes project.
VITE_CKEDITOR_KEY=eyJhbGciOiJFUzI1NiJ9.eyJleHAiOjE3ODcyNzA0MDAsImp0aSI6IjkyMWE1MWNlLTliNDMtNGRlMC1iOTQwLTc5ZjM2MDBkYjg1NyIsImRpc3RyaWJ1dGlvbkNoYW5uZWwiOiJ0cmlsaXVtIiwiZmVhdHVyZXMiOlsiVFJJTElVTSJdLCJ2YyI6ImU4YzRhMjBkIn0.hny77p-U4-jTkoqbwPytrEar5ylGCWBN7Ez3SlB8i6_mJCBIeCSTOlVQk_JMiOEq3AGykUMHzWXzjdMFwgniOw
VITE_CKEDITOR_ENABLE_INSPECTOR=false

View File

@@ -0,0 +1 @@
VITE_CKEDITOR_ENABLE_INSPECTOR=false

View File

@@ -0,0 +1,86 @@
{
"name": "@triliumnext/client-standalone",
"version": "0.101.3",
"description": "Standalone client for TriliumNext with SQLite WASM backend",
"private": true,
"license": "AGPL-3.0-only",
"scripts": {
"build": "cross-env NODE_OPTIONS=--max-old-space-size=4096 vite build",
"dev": "vite dev",
"test": "vitest",
"start-prod": "pnpm build && pnpm http-server dist -p 8888",
"coverage": "vitest --coverage"
},
"dependencies": {
"@excalidraw/excalidraw": "0.18.0",
"@fullcalendar/core": "6.1.20",
"@fullcalendar/daygrid": "6.1.20",
"@fullcalendar/interaction": "6.1.20",
"@fullcalendar/list": "6.1.20",
"@fullcalendar/multimonth": "6.1.20",
"@fullcalendar/timegrid": "6.1.20",
"@maplibre/maplibre-gl-leaflet": "0.1.3",
"@mermaid-js/layout-elk": "0.2.0",
"@mind-elixir/node-menu": "5.0.1",
"@popperjs/core": "2.11.8",
"@preact/signals": "2.5.1",
"@sqlite.org/sqlite-wasm": "3.51.1-build2",
"@triliumnext/ckeditor5": "workspace:*",
"@triliumnext/codemirror": "workspace:*",
"@triliumnext/commons": "workspace:*",
"@triliumnext/core": "workspace:*",
"@triliumnext/highlightjs": "workspace:*",
"@triliumnext/share-theme": "workspace:*",
"@triliumnext/split.js": "workspace:*",
"@zumer/snapdom": "2.0.1",
"autocomplete.js": "0.38.1",
"bootstrap": "5.3.8",
"boxicons": "2.1.4",
"clsx": "2.1.1",
"color": "5.0.3",
"debounce": "3.0.0",
"draggabilly": "3.0.0",
"force-graph": "1.51.0",
"globals": "17.0.0",
"i18next": "25.7.3",
"i18next-http-backend": "3.0.2",
"jquery": "3.7.1",
"jquery.fancytree": "2.38.5",
"js-sha1": "0.7.0",
"js-sha512": "0.9.0",
"jsplumb": "2.15.6",
"katex": "0.16.27",
"knockout": "3.5.1",
"leaflet": "1.9.4",
"leaflet-gpx": "2.2.0",
"mark.js": "8.11.1",
"marked": "17.0.1",
"mermaid": "11.12.2",
"mind-elixir": "5.4.0",
"normalize.css": "8.0.1",
"panzoom": "9.4.3",
"preact": "10.28.2",
"react-i18next": "16.5.1",
"react-window": "2.2.3",
"reveal.js": "5.2.1",
"svg-pan-zoom": "3.6.2",
"tabulator-tables": "6.3.1",
"vanilla-js-wheel-zoom": "9.0.4"
},
"devDependencies": {
"@ckeditor/ckeditor5-inspector": "5.0.0",
"@preact/preset-vite": "2.10.2",
"@types/bootstrap": "5.2.10",
"@types/jquery": "3.5.33",
"@types/leaflet": "1.9.21",
"@types/leaflet-gpx": "1.3.8",
"@types/mark.js": "8.11.12",
"@types/reveal.js": "5.2.2",
"@types/tabulator-tables": "6.3.1",
"copy-webpack-plugin": "13.0.1",
"cross-env": "7.0.3",
"happy-dom": "20.0.11",
"script-loader": "0.7.2",
"vite-plugin-static-copy": "3.1.4"
}
}

View File

@@ -0,0 +1,3 @@
/*
Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Embedder-Policy: require-corp

Binary file not shown.

After

Width:  |  Height:  |  Size: 112 KiB

View File

@@ -0,0 +1,20 @@
{
"name": "Trilium Notes",
"short_name": "Trilium",
"description": "Trilium Notes is a hierarchical note taking application with focus on building large personal knowledge bases.",
"theme_color": "#333333",
"background_color": "#1F1F1F",
"display": "standalone",
"scope": "/",
"start_url": "/",
"display_override": [
"window-controls-overlay"
],
"icons": [
{
"src": "assets/icon.png",
"sizes": "512x512",
"type": "image/png"
}
]
}

View File

@@ -0,0 +1,2 @@
// Re-export desktop from client
export * from "../../client/src/desktop";

View File

@@ -0,0 +1,31 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<link rel="shortcut icon" href="favicon.ico">
<meta name="mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, viewport-fit=cover" />
<link rel="manifest" crossorigin="use-credentials" href="manifest.webmanifest">
<title>Trilium Notes</title>
</head>
<body id="trilium-app">
<noscript>Trilium requires JavaScript to be enabled.</noscript>
<div id="context-menu-cover"></div>
<div class="dropdown-menu dropdown-menu-sm" id="context-menu-container" style="display: none"></div>
<!-- Required for match the PWA's top bar color with the theme -->
<!-- This works even when the user directly changes --root-background in CSS -->
<div id="background-color-tracker" style="position: absolute; visibility: hidden; color: var(--root-background); transition: color 1ms;"></div>
<!-- Bootstrap (request server for required information) -->
<script src="./main.ts" type="module"></script>
<!-- Required for correct loading of scripts in Electron -->
<script>
if (typeof module === 'object') {window.module = module; module = undefined;}
</script>
</body>
</html>

View File

@@ -0,0 +1,254 @@
/**
* Browser-compatible router that mimics Express routing patterns.
* Supports path parameters (e.g., /api/notes/:noteId) and query strings.
*/
import { getContext, routes } from "@triliumnext/core";
export interface BrowserRequest {
method: string;
url: string;
path: string;
params: Record<string, string>;
query: Record<string, string | undefined>;
body?: unknown;
}
export interface BrowserResponse {
status: number;
headers: Record<string, string>;
body: ArrayBuffer | null;
}
export type RouteHandler = (req: BrowserRequest) => unknown | Promise<unknown>;
interface Route {
method: string;
pattern: RegExp;
paramNames: string[];
handler: RouteHandler;
}
const encoder = new TextEncoder();
/**
* Convert an Express-style path pattern to a RegExp.
* Supports :param syntax for path parameters.
*
* Examples:
* /api/notes/:noteId -> /^\/api\/notes\/([^\/]+)$/
* /api/notes/:noteId/revisions -> /^\/api\/notes\/([^\/]+)\/revisions$/
*/
function pathToRegex(path: string): { pattern: RegExp; paramNames: string[] } {
const paramNames: string[] = [];
// Escape special regex characters except for :param patterns
const regexPattern = path
.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') // Escape special chars
.replace(/:([a-zA-Z_][a-zA-Z0-9_]*)/g, (_, paramName) => {
paramNames.push(paramName);
return '([^/]+)';
});
return {
pattern: new RegExp(`^${regexPattern}$`),
paramNames
};
}
/**
* Parse query string into an object.
*/
function parseQuery(search: string): Record<string, string | undefined> {
const query: Record<string, string | undefined> = {};
if (!search || search === '?') return query;
const params = new URLSearchParams(search);
for (const [key, value] of params) {
query[key] = value;
}
return query;
}
/**
* Convert a result to a JSON response.
*/
function jsonResponse(obj: unknown, status = 200, extraHeaders: Record<string, string> = {}): BrowserResponse {
const parsedObj = routes.convertEntitiesToPojo(obj);
const body = encoder.encode(JSON.stringify(parsedObj)).buffer as ArrayBuffer;
return {
status,
headers: { "content-type": "application/json; charset=utf-8", ...extraHeaders },
body
};
}
/**
* Convert a string to a text response.
*/
function textResponse(text: string, status = 200, extraHeaders: Record<string, string> = {}): BrowserResponse {
const body = encoder.encode(text).buffer as ArrayBuffer;
return {
status,
headers: { "content-type": "text/plain; charset=utf-8", ...extraHeaders },
body
};
}
/**
* Browser router class that handles route registration and dispatching.
*/
export class BrowserRouter {
private routes: Route[] = [];
/**
* Register a route handler.
*/
register(method: string, path: string, handler: RouteHandler): void {
const { pattern, paramNames } = pathToRegex(path);
this.routes.push({
method: method.toUpperCase(),
pattern,
paramNames,
handler
});
}
/**
* Convenience methods for common HTTP methods.
*/
get(path: string, handler: RouteHandler): void {
this.register('GET', path, handler);
}
post(path: string, handler: RouteHandler): void {
this.register('POST', path, handler);
}
put(path: string, handler: RouteHandler): void {
this.register('PUT', path, handler);
}
patch(path: string, handler: RouteHandler): void {
this.register('PATCH', path, handler);
}
delete(path: string, handler: RouteHandler): void {
this.register('DELETE', path, handler);
}
/**
* Dispatch a request to the appropriate handler.
*/
async dispatch(method: string, urlString: string, body?: unknown, headers?: Record<string, string>): Promise<BrowserResponse> {
const url = new URL(urlString);
const path = url.pathname;
const query = parseQuery(url.search);
const upperMethod = method.toUpperCase();
// Parse JSON body if it's an ArrayBuffer and content-type suggests JSON
let parsedBody = body;
if (body instanceof ArrayBuffer && headers) {
const contentType = headers['content-type'] || headers['Content-Type'] || '';
if (contentType.includes('application/json')) {
try {
const text = new TextDecoder().decode(body);
if (text.trim()) {
parsedBody = JSON.parse(text);
}
} catch (e) {
console.warn('[Router] Failed to parse JSON body:', e);
// Keep original body if JSON parsing fails
parsedBody = body;
}
}
}
// Find matching route
for (const route of this.routes) {
if (route.method !== upperMethod) continue;
const match = path.match(route.pattern);
if (!match) continue;
// Extract path parameters
const params: Record<string, string> = {};
for (let i = 0; i < route.paramNames.length; i++) {
params[route.paramNames[i]] = decodeURIComponent(match[i + 1]);
}
const request: BrowserRequest = {
method: upperMethod,
url: urlString,
path,
params,
query,
body: parsedBody
};
try {
const result = await getContext().init(async () => await route.handler(request));
return this.formatResult(result);
} catch (error) {
return this.formatError(error, `Error handling ${method} ${path}`);
}
}
// No route matched
return textResponse(`Not found: ${method} ${path}`, 404);
}
/**
* Format a handler result into a response.
* Follows the same patterns as the server's apiResultHandler.
*/
private formatResult(result: unknown): BrowserResponse {
// Handle [statusCode, response] format
if (Array.isArray(result) && result.length > 0 && Number.isInteger(result[0])) {
const [statusCode, response] = result;
return jsonResponse(response, statusCode);
}
// Handle undefined (no content) - 204 should have no body
if (result === undefined) {
return {
status: 204,
headers: {},
body: null
};
}
// Default: JSON response with 200
return jsonResponse(result, 200);
}
/**
* Format an error into a response.
*/
private formatError(error: unknown, context: string): BrowserResponse {
console.error('[Router] Handler error:', context, error);
// Check for known error types
if (error && typeof error === 'object') {
const err = error as { constructor?: { name?: string }; message?: string };
if (err.constructor?.name === 'NotFoundError') {
return jsonResponse({ message: err.message || 'Not found' }, 404);
}
if (err.constructor?.name === 'ValidationError') {
return jsonResponse({ message: err.message || 'Validation error' }, 400);
}
}
// Generic error
const message = error instanceof Error ? error.message : String(error);
return jsonResponse({ message }, 500);
}
}
/**
* Create a new router instance.
*/
export function createRouter(): BrowserRouter {
return new BrowserRouter();
}

View File

@@ -0,0 +1,101 @@
/**
* Browser route definitions.
* This integrates with the shared route builder from @triliumnext/core.
*/
import { BootstrapDefinition } from '@triliumnext/commons';
import { getSharedBootstrapItems, getSql, routes } from '@triliumnext/core';
import packageJson from '../../package.json' with { type: 'json' };
import { type BrowserRequest,BrowserRouter } from './browser_router';
type HttpMethod = 'get' | 'post' | 'put' | 'patch' | 'delete';
/**
* Wraps a core route handler to work with the BrowserRouter.
* Core handlers expect an Express-like request object with params, query, and body.
*/
function wrapHandler(handler: (req: any) => unknown, transactional: boolean) {
return (req: BrowserRequest) => {
// Create an Express-like request object
const expressLikeReq = {
params: req.params,
query: req.query,
body: req.body
};
if (transactional) {
return getSql().transactional(() => handler(expressLikeReq));
}
return handler(expressLikeReq);
};
}
/**
* Creates an apiRoute function compatible with buildSharedApiRoutes.
* This bridges the core's route registration to the BrowserRouter.
*/
function createApiRoute(router: BrowserRouter) {
return (method: HttpMethod, path: string, handler: (req: any) => unknown) => {
router.register(method, path, wrapHandler(handler, true));
};
}
/**
* Register all API routes on the browser router using the shared builder.
*
* @param router - The browser router instance
*/
export function registerRoutes(router: BrowserRouter): void {
const apiRoute = createApiRoute(router);
routes.buildSharedApiRoutes(apiRoute);
apiRoute('get', '/bootstrap', bootstrapRoute);
// Dummy routes for compatibility.
apiRoute("get", "/api/script/widgets", () => []);
apiRoute("get", "/api/script/startup", () => []);
apiRoute("get", "/api/system-checks", () => ({ isCpuArchMismatch: false }));
apiRoute("get", "/api/search/:searchString", () => []);
apiRoute("get", "/api/search-templates", () => []);
apiRoute("get", "/api/autocomplete", () => []);
}
function bootstrapRoute() {
const assetPath = ".";
return {
...getSharedBootstrapItems(assetPath),
appPath: assetPath,
device: false, // Let the client detect device type.
csrfToken: "dummy-csrf-token",
themeCssUrl: false,
themeUseNextAsBase: "next",
triliumVersion: packageJson.version,
baseApiUrl: "../api/",
headingStyle: "plain",
layoutOrientation: "vertical",
platform: "web",
isDev: import.meta.env.DEV,
isMainWindow: true,
isElectron: false,
isStandalone: true,
hasNativeTitleBar: false,
hasBackgroundEffects: false,
// TODO: Fill properly
currentLocale: { id: "en", name: "English", rtl: false },
isRtl: false,
instanceName: null,
appCssNoteIds: [],
TRILIUM_SAFE_MODE: false
} satisfies BootstrapDefinition;
}
/**
* Create and configure a router with all routes registered.
*/
export function createConfiguredRouter(): BrowserRouter {
const router = new BrowserRouter();
registerRoutes(router);
return router;
}

View File

@@ -0,0 +1,77 @@
import { ExecutionContext } from "@triliumnext/core";
/**
* Browser execution context implementation.
*
* Handles per-request context isolation with support for fire-and-forget async operations
* using a context stack and grace-period cleanup to allow unawaited promises to complete.
*/
export default class BrowserExecutionContext implements ExecutionContext {
private contextStack: Map<string, any>[] = [];
private cleanupTimers = new WeakMap<Map<string, any>, ReturnType<typeof setTimeout>>();
private readonly CLEANUP_GRACE_PERIOD = 1000; // 1 second for fire-and-forget operations
private getCurrentContext(): Map<string, any> {
if (this.contextStack.length === 0) {
throw new Error("ExecutionContext not initialized");
}
return this.contextStack[this.contextStack.length - 1];
}
get<T = any>(key: string): T {
return this.getCurrentContext().get(key);
}
set(key: string, value: any): void {
this.getCurrentContext().set(key, value);
}
reset(): void {
this.contextStack = [];
}
init<T>(callback: () => T): T {
const context = new Map<string, any>();
this.contextStack.push(context);
// Cancel any pending cleanup timer for this context
const existingTimer = this.cleanupTimers.get(context);
if (existingTimer) {
clearTimeout(existingTimer);
this.cleanupTimers.delete(context);
}
try {
const result = callback();
// If the result is a Promise
if (result && typeof result === 'object' && 'then' in result && 'catch' in result) {
const promise = result as unknown as Promise<any>;
return promise.finally(() => {
this.scheduleContextCleanup(context);
}) as T;
} else {
// For synchronous results, schedule delayed cleanup to allow fire-and-forget operations
this.scheduleContextCleanup(context);
return result;
}
} catch (error) {
// Always clean up on error with grace period
this.scheduleContextCleanup(context);
throw error;
}
}
private scheduleContextCleanup(context: Map<string, any>): void {
const timer = setTimeout(() => {
// Remove from stack if still present
const index = this.contextStack.indexOf(context);
if (index !== -1) {
this.contextStack.splice(index, 1);
}
this.cleanupTimers.delete(context);
}, this.CLEANUP_GRACE_PERIOD);
this.cleanupTimers.set(context, timer);
}
}

View File

@@ -0,0 +1,145 @@
import type { CryptoProvider } from "@triliumnext/core";
import { sha1 } from "js-sha1";
import { sha512 } from "js-sha512";
interface Cipher {
update(data: Uint8Array): Uint8Array;
final(): Uint8Array;
}
const CHARS = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
/**
* Crypto provider for browser environments using the Web Crypto API.
*/
export default class BrowserCryptoProvider implements CryptoProvider {
createHash(algorithm: "sha1" | "sha512", content: string | Uint8Array): Uint8Array {
const data = typeof content === "string" ? content :
new TextDecoder().decode(content);
const hexHash = algorithm === "sha1" ? sha1(data) : sha512(data);
// Convert hex string to Uint8Array
const bytes = new Uint8Array(hexHash.length / 2);
for (let i = 0; i < hexHash.length; i += 2) {
bytes[i / 2] = parseInt(hexHash.substr(i, 2), 16);
}
return bytes;
}
createCipheriv(algorithm: "aes-128-cbc", key: Uint8Array, iv: Uint8Array): Cipher {
// Web Crypto API doesn't support streaming cipher like Node.js
// We need to implement a wrapper that collects data and encrypts on final()
return new WebCryptoCipher(algorithm, key, iv, "encrypt");
}
createDecipheriv(algorithm: "aes-128-cbc", key: Uint8Array, iv: Uint8Array): Cipher {
return new WebCryptoCipher(algorithm, key, iv, "decrypt");
}
randomBytes(size: number): Uint8Array {
const bytes = new Uint8Array(size);
crypto.getRandomValues(bytes);
return bytes;
}
randomString(length: number): string {
const bytes = this.randomBytes(length);
let result = "";
for (let i = 0; i < length; i++) {
result += CHARS[bytes[i] % CHARS.length];
}
return result;
}
}
/**
* A cipher implementation that wraps Web Crypto API.
* Note: This buffers all data until final() is called, which differs from
* Node.js's streaming cipher behavior.
*/
class WebCryptoCipher implements Cipher {
private chunks: Uint8Array[] = [];
private algorithm: string;
private key: Uint8Array;
private iv: Uint8Array;
private mode: "encrypt" | "decrypt";
private finalized = false;
constructor(
algorithm: "aes-128-cbc",
key: Uint8Array,
iv: Uint8Array,
mode: "encrypt" | "decrypt"
) {
this.algorithm = algorithm;
this.key = key;
this.iv = iv;
this.mode = mode;
}
update(data: Uint8Array): Uint8Array {
if (this.finalized) {
throw new Error("Cipher has already been finalized");
}
// Buffer the data - Web Crypto doesn't support streaming
this.chunks.push(data);
// Return empty array since we process everything in final()
return new Uint8Array(0);
}
final(): Uint8Array {
if (this.finalized) {
throw new Error("Cipher has already been finalized");
}
this.finalized = true;
// Web Crypto API is async, but we need sync behavior
// This is a fundamental limitation that requires architectural changes
// For now, throw an error directing users to use async methods
throw new Error(
"Synchronous cipher finalization not available in browser. " +
"The Web Crypto API is async-only. Use finalizeAsync() instead."
);
}
/**
* Async version that actually performs the encryption/decryption.
*/
async finalizeAsync(): Promise<Uint8Array> {
if (this.finalized) {
throw new Error("Cipher has already been finalized");
}
this.finalized = true;
// Concatenate all chunks
const totalLength = this.chunks.reduce((sum, chunk) => sum + chunk.length, 0);
const data = new Uint8Array(totalLength);
let offset = 0;
for (const chunk of this.chunks) {
data.set(chunk, offset);
offset += chunk.length;
}
// Copy key and iv to ensure they're plain ArrayBuffer-backed
const keyBuffer = new Uint8Array(this.key);
const ivBuffer = new Uint8Array(this.iv);
// Import the key
const cryptoKey = await crypto.subtle.importKey(
"raw",
keyBuffer,
{ name: "AES-CBC" },
false,
[this.mode]
);
// Perform encryption/decryption
const result = this.mode === "encrypt"
? await crypto.subtle.encrypt({ name: "AES-CBC", iv: ivBuffer }, cryptoKey, data)
: await crypto.subtle.decrypt({ name: "AES-CBC", iv: ivBuffer }, cryptoKey, data);
return new Uint8Array(result);
}
}

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,90 @@
import type { WebSocketMessage } from "@triliumnext/commons";
import type { MessagingProvider, MessageHandler } from "@triliumnext/core";
/**
* Messaging provider for browser Worker environments.
*
* This provider uses the Worker's postMessage API to communicate
* with the main thread. It's designed to be used inside a Web Worker
* that runs the core services.
*
* Message flow:
* - Outbound (worker → main): Uses self.postMessage() with type: "WS_MESSAGE"
* - Inbound (main → worker): Listens to onmessage for type: "WS_MESSAGE"
*/
export default class WorkerMessagingProvider implements MessagingProvider {
private messageHandlers: MessageHandler[] = [];
private isDisposed = false;
constructor() {
// Listen for incoming messages from the main thread
self.addEventListener("message", this.handleIncomingMessage);
}
private handleIncomingMessage = (event: MessageEvent) => {
if (this.isDisposed) return;
const { type, message } = event.data || {};
if (type === "WS_MESSAGE" && message) {
// Dispatch to all registered handlers
for (const handler of this.messageHandlers) {
try {
handler(message as WebSocketMessage);
} catch (e) {
console.error("[WorkerMessagingProvider] Error in message handler:", e);
}
}
}
};
/**
* Send a message to all clients (in this case, the main thread).
* The main thread is responsible for further distribution if needed.
*/
sendMessageToAllClients(message: WebSocketMessage): void {
if (this.isDisposed) {
console.warn("[WorkerMessagingProvider] Cannot send message - provider is disposed");
return;
}
try {
self.postMessage({
type: "WS_MESSAGE",
message
});
} catch (e) {
console.error("[WorkerMessagingProvider] Error sending message:", e);
}
}
/**
* Subscribe to incoming messages from the main thread.
*/
onMessage(handler: MessageHandler): () => void {
this.messageHandlers.push(handler);
return () => {
this.messageHandlers = this.messageHandlers.filter(h => h !== handler);
};
}
/**
* Get the number of connected "clients".
* In worker context, there's always exactly 1 client (the main thread).
*/
getClientCount(): number {
return this.isDisposed ? 0 : 1;
}
/**
* Clean up resources.
*/
dispose(): void {
if (this.isDisposed) return;
this.isDisposed = true;
self.removeEventListener("message", this.handleIncomingMessage);
this.messageHandlers = [];
}
}

View File

@@ -0,0 +1,618 @@
import { type BindableValue, default as sqlite3InitModule } from "@sqlite.org/sqlite-wasm";
import type { DatabaseProvider, RunResult, Statement, Transaction } from "@triliumnext/core";
import demoDbSql from "./db.sql?raw";
// Type definitions for SQLite WASM (the library doesn't export these directly)
type Sqlite3Module = Awaited<ReturnType<typeof sqlite3InitModule>>;
type Sqlite3Database = InstanceType<Sqlite3Module["oo1"]["DB"]>;
type Sqlite3PreparedStatement = ReturnType<Sqlite3Database["prepare"]>;
/**
* Wraps an SQLite WASM PreparedStatement to match the Statement interface
* expected by trilium-core.
*/
class WasmStatement implements Statement {
private isRawMode = false;
private isPluckMode = false;
private isFinalized = false;
constructor(
private stmt: Sqlite3PreparedStatement,
private db: Sqlite3Database,
private sqlite3: Sqlite3Module
) {}
run(...params: unknown[]): RunResult {
if (this.isFinalized) {
throw new Error("Cannot call run() on finalized statement");
}
this.bindParams(params);
try {
// Use step() and then reset instead of stepFinalize()
// This allows the statement to be reused
this.stmt.step();
const changes = this.db.changes();
// Get the last insert row ID using the C API
const lastInsertRowid = this.db.pointer ? this.sqlite3.capi.sqlite3_last_insert_rowid(this.db.pointer) : 0;
this.stmt.reset();
return {
changes,
lastInsertRowid: typeof lastInsertRowid === "bigint" ? Number(lastInsertRowid) : lastInsertRowid
};
} catch (e) {
// Reset on error to allow reuse
this.stmt.reset();
throw e;
}
}
get(params: unknown): unknown {
if (this.isFinalized) {
throw new Error("Cannot call get() on finalized statement");
}
this.bindParams(Array.isArray(params) ? params : params !== undefined ? [params] : []);
try {
if (this.stmt.step()) {
if (this.isPluckMode) {
// In pluck mode, return only the first column value
const row = this.stmt.get([]);
return Array.isArray(row) && row.length > 0 ? row[0] : undefined;
}
return this.isRawMode ? this.stmt.get([]) : this.stmt.get({});
}
return undefined;
} finally {
this.stmt.reset();
}
}
all(...params: unknown[]): unknown[] {
if (this.isFinalized) {
throw new Error("Cannot call all() on finalized statement");
}
this.bindParams(params);
const results: unknown[] = [];
try {
while (this.stmt.step()) {
if (this.isPluckMode) {
// In pluck mode, return only the first column value for each row
const row = this.stmt.get([]);
if (Array.isArray(row) && row.length > 0) {
results.push(row[0]);
}
} else {
results.push(this.isRawMode ? this.stmt.get([]) : this.stmt.get({}));
}
}
return results;
} finally {
this.stmt.reset();
}
}
iterate(...params: unknown[]): IterableIterator<unknown> {
if (this.isFinalized) {
throw new Error("Cannot call iterate() on finalized statement");
}
this.bindParams(params);
const stmt = this.stmt;
const isRaw = this.isRawMode;
const isPluck = this.isPluckMode;
return {
[Symbol.iterator]() {
return this;
},
next(): IteratorResult<unknown> {
if (stmt.step()) {
if (isPluck) {
const row = stmt.get([]);
const value = Array.isArray(row) && row.length > 0 ? row[0] : undefined;
return { value, done: false };
}
return { value: isRaw ? stmt.get([]) : stmt.get({}), done: false };
}
stmt.reset();
return { value: undefined, done: true };
}
};
}
raw(toggleState?: boolean): this {
// In raw mode, rows are returned as arrays instead of objects
// If toggleState is undefined, enable raw mode (better-sqlite3 behavior)
this.isRawMode = toggleState !== undefined ? toggleState : true;
return this;
}
pluck(toggleState?: boolean): this {
// In pluck mode, only the first column of each row is returned
// If toggleState is undefined, enable pluck mode (better-sqlite3 behavior)
this.isPluckMode = toggleState !== undefined ? toggleState : true;
return this;
}
private bindParams(params: unknown[]): void {
this.stmt.clearBindings();
if (params.length === 0) {
return;
}
// Handle single object with named parameters
if (params.length === 1 && typeof params[0] === "object" && params[0] !== null && !Array.isArray(params[0])) {
const inputBindings = params[0] as { [paramName: string]: BindableValue };
// SQLite WASM expects parameter names to include the prefix (@ : or $)
// better-sqlite3 automatically maps unprefixed names to @name
// We need to add the @ prefix for compatibility
const bindings: { [paramName: string]: BindableValue } = {};
for (const [key, value] of Object.entries(inputBindings)) {
// If the key already has a prefix, use it as-is
if (key.startsWith('@') || key.startsWith(':') || key.startsWith('$')) {
bindings[key] = value;
} else {
// Add @ prefix to match better-sqlite3 behavior
bindings[`@${key}`] = value;
}
}
this.stmt.bind(bindings);
} else {
// Handle positional parameters - flatten and cast to BindableValue[]
const flatParams = params.flat() as BindableValue[];
if (flatParams.length > 0) {
this.stmt.bind(flatParams);
}
}
}
finalize(): void {
if (!this.isFinalized) {
try {
this.stmt.finalize();
} catch (e) {
console.warn("Error finalizing SQLite statement:", e);
} finally {
this.isFinalized = true;
}
}
}
}
/**
* SQLite database provider for browser environments using SQLite WASM.
*
* This provider wraps the official @sqlite.org/sqlite-wasm package to provide
* a DatabaseProvider implementation compatible with trilium-core.
*
* @example
* ```typescript
* const provider = new BrowserSqlProvider();
* await provider.initWasm(); // Initialize SQLite WASM module
* provider.loadFromMemory(); // Open an in-memory database
* // or
* provider.loadFromBuffer(existingDbBuffer); // Load from existing data
* ```
*/
export default class BrowserSqlProvider implements DatabaseProvider {
private db?: Sqlite3Database;
private sqlite3?: Sqlite3Module;
private _inTransaction = false;
private initPromise?: Promise<void>;
private initError?: Error;
private statementCache: Map<string, WasmStatement> = new Map();
// OPFS state tracking
private opfsDbPath?: string;
/**
* Get the SQLite WASM module version info.
* Returns undefined if the module hasn't been initialized yet.
*/
get version(): { libVersion: string; sourceId: string } | undefined {
return this.sqlite3?.version;
}
/**
* Initialize the SQLite WASM module.
* This must be called before using any database operations.
* Safe to call multiple times - subsequent calls return the same promise.
*
* @returns A promise that resolves when the module is initialized
* @throws Error if initialization fails
*/
async initWasm(): Promise<void> {
// Return existing promise if already initializing/initialized
if (this.initPromise) {
return this.initPromise;
}
// Fail fast if we already tried and failed
if (this.initError) {
throw this.initError;
}
this.initPromise = this.doInitWasm();
return this.initPromise;
}
private async doInitWasm(): Promise<void> {
try {
console.log("[BrowserSqlProvider] Initializing SQLite WASM...");
const startTime = performance.now();
this.sqlite3 = await sqlite3InitModule({
print: console.log,
printErr: console.error,
});
const initTime = performance.now() - startTime;
console.log(
`[BrowserSqlProvider] SQLite WASM initialized in ${initTime.toFixed(2)}ms:`,
this.sqlite3.version.libVersion
);
} catch (e) {
this.initError = e instanceof Error ? e : new Error(String(e));
console.error("[BrowserSqlProvider] SQLite WASM initialization failed:", this.initError);
throw this.initError;
}
}
/**
* Check if the SQLite WASM module has been initialized.
*/
get isInitialized(): boolean {
return this.sqlite3 !== undefined;
}
// ==================== OPFS Support ====================
/**
* Check if the OPFS VFS is available.
* This requires:
* - Running in a Worker context
* - Browser support for OPFS APIs
* - COOP/COEP headers sent by the server (for SharedArrayBuffer)
*
* @returns true if OPFS VFS is available for use
*/
isOpfsAvailable(): boolean {
this.ensureSqlite3();
// SQLite WASM automatically installs the OPFS VFS if the environment supports it
// We can check for its presence via sqlite3_vfs_find or the OpfsDb class
return this.sqlite3!.oo1.OpfsDb !== undefined;
}
/**
* Load or create a database stored in OPFS for persistent storage.
* The database will persist across browser sessions.
*
* Requires COOP/COEP headers to be set by the server:
* - Cross-Origin-Opener-Policy: same-origin
* - Cross-Origin-Embedder-Policy: require-corp
*
* @param path - The path for the database file in OPFS (e.g., "/trilium.db")
* Paths without a leading slash are treated as relative to OPFS root.
* Leading directories are created automatically.
* @param options - Additional options
* @throws Error if OPFS VFS is not available
*
* @example
* ```typescript
* const provider = new BrowserSqlProvider();
* await provider.initWasm();
* if (provider.isOpfsAvailable()) {
* provider.loadFromOpfs("/my-database.db");
* } else {
* console.warn("OPFS not available, using in-memory database");
* provider.loadFromMemory();
* }
* ```
*/
loadFromOpfs(path: string, options: { createIfNotExists?: boolean } = {}): void {
this.ensureSqlite3();
if (!this.isOpfsAvailable()) {
throw new Error(
"OPFS VFS is not available. This requires:\n" +
"1. Running in a Worker context\n" +
"2. Browser support for OPFS (Chrome 102+, Firefox 111+, Safari 17+)\n" +
"3. COOP/COEP headers from the server:\n" +
" Cross-Origin-Opener-Policy: same-origin\n" +
" Cross-Origin-Embedder-Policy: require-corp"
);
}
console.log(`[BrowserSqlProvider] Loading database from OPFS: ${path}`);
const startTime = performance.now();
try {
// OpfsDb automatically creates directories in the path
// Mode 'c' = create if not exists
const mode = options.createIfNotExists !== false ? 'c' : '';
this.db = new this.sqlite3!.oo1.OpfsDb(path, mode);
this.opfsDbPath = path;
// Configure the database for OPFS
// Note: WAL mode requires exclusive locking in OPFS environment
this.db.exec("PRAGMA journal_mode = DELETE");
this.db.exec("PRAGMA synchronous = NORMAL");
const loadTime = performance.now() - startTime;
console.log(`[BrowserSqlProvider] OPFS database loaded in ${loadTime.toFixed(2)}ms`);
} catch (e) {
const error = e instanceof Error ? e : new Error(String(e));
console.error(`[BrowserSqlProvider] Failed to load OPFS database: ${error.message}`);
throw error;
}
}
/**
* Check if the currently open database is stored in OPFS.
*/
get isUsingOpfs(): boolean {
return this.opfsDbPath !== undefined;
}
/**
* Get the OPFS path of the currently open database.
* Returns undefined if not using OPFS.
*/
get currentOpfsPath(): string | undefined {
return this.opfsDbPath;
}
/**
* Check if the database has been initialized with a schema.
* This is a simple sanity check that looks for the existence of core tables.
*
* @returns true if the database appears to be initialized
*/
isDbInitialized(): boolean {
this.ensureDb();
// Check if the 'notes' table exists (a core table that must exist in an initialized DB)
const tableExists = this.db!.selectValue(
"SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'notes'"
);
return tableExists !== undefined;
}
// ==================== End OPFS Support ====================
loadFromFile(_path: string, _isReadOnly: boolean): void {
// Browser environment doesn't have direct file system access.
// Use OPFS for persistent storage.
throw new Error(
"loadFromFile is not supported in browser environment. " +
"Use loadFromMemory() for temporary databases, loadFromBuffer() to load from data, " +
"or loadFromOpfs() for persistent storage."
);
}
/**
* Create an empty in-memory database.
* Data will be lost when the page is closed.
*
* For persistent storage, use loadFromOpfs() instead.
* To load demo data, call initializeDemoDatabase() after this.
*/
loadFromMemory(): void {
this.ensureSqlite3();
console.log("[BrowserSqlProvider] Creating in-memory database...");
const startTime = performance.now();
this.db = new this.sqlite3!.oo1.DB(":memory:", "c");
this.opfsDbPath = undefined; // Not using OPFS
this.db.exec("PRAGMA journal_mode = WAL");
// Initialize with demo data for in-memory databases
// (since they won't persist anyway)
this.initializeDemoDatabase();
const loadTime = performance.now() - startTime;
console.log(`[BrowserSqlProvider] In-memory database created in ${loadTime.toFixed(2)}ms`);
}
/**
* Initialize the database with demo/starter data.
* This should only be called once when creating a new database.
*
* For OPFS databases, this is called automatically only if the database
* doesn't already exist.
*/
initializeDemoDatabase(): void {
this.ensureDb();
console.log("[BrowserSqlProvider] Initializing database with demo data...");
const startTime = performance.now();
this.db!.exec(demoDbSql);
const loadTime = performance.now() - startTime;
console.log(`[BrowserSqlProvider] Demo data loaded in ${loadTime.toFixed(2)}ms`);
}
loadFromBuffer(buffer: Uint8Array): void {
this.ensureSqlite3();
// SQLite WASM can deserialize a database from a byte array
const p = this.sqlite3!.wasm.allocFromTypedArray(buffer);
try {
this.db = new this.sqlite3!.oo1.DB({ filename: ":memory:", flags: "c" });
this.opfsDbPath = undefined; // Not using OPFS
const rc = this.sqlite3!.capi.sqlite3_deserialize(
this.db.pointer!,
"main",
p,
buffer.byteLength,
buffer.byteLength,
this.sqlite3!.capi.SQLITE_DESERIALIZE_FREEONCLOSE |
this.sqlite3!.capi.SQLITE_DESERIALIZE_RESIZEABLE
);
if (rc !== 0) {
throw new Error(`Failed to deserialize database: ${rc}`);
}
} catch (e) {
this.sqlite3!.wasm.dealloc(p);
throw e;
}
}
backup(_destinationFile: string): void {
// In browser, we can serialize the database to a byte array
// For actual file backup, we'd need to use File System Access API or download
throw new Error(
"backup to file is not supported in browser environment. " +
"Use serialize() to get the database as a Uint8Array instead."
);
}
/**
* Serialize the database to a byte array.
* This can be used to save the database to IndexedDB, download it, etc.
*/
serialize(): Uint8Array {
this.ensureDb();
// Use the convenience wrapper which handles all the memory management
return this.sqlite3!.capi.sqlite3_js_db_export(this.db!);
}
prepare(query: string): Statement {
this.ensureDb();
// Check if we already have this statement cached
if (this.statementCache.has(query)) {
return this.statementCache.get(query)!;
}
// Create new statement and cache it
const stmt = this.db!.prepare(query);
const wasmStatement = new WasmStatement(stmt, this.db!, this.sqlite3!);
this.statementCache.set(query, wasmStatement);
return wasmStatement;
}
transaction<T>(func: (statement: Statement) => T): Transaction {
this.ensureDb();
const self = this;
let savepointCounter = 0;
// Helper function to execute within a transaction
const executeTransaction = (beginStatement: string, ...args: unknown[]): T => {
// If we're already in a transaction, use SAVEPOINTs for nesting
// This mimics better-sqlite3's behavior
if (self._inTransaction) {
const savepointName = `sp_${++savepointCounter}_${Date.now()}`;
self.db!.exec(`SAVEPOINT ${savepointName}`);
try {
const result = func.apply(null, args as [Statement]);
self.db!.exec(`RELEASE SAVEPOINT ${savepointName}`);
return result;
} catch (e) {
self.db!.exec(`ROLLBACK TO SAVEPOINT ${savepointName}`);
throw e;
}
}
// Not in a transaction, start a new one
self._inTransaction = true;
self.db!.exec(beginStatement);
try {
const result = func.apply(null, args as [Statement]);
self.db!.exec("COMMIT");
return result;
} catch (e) {
self.db!.exec("ROLLBACK");
throw e;
} finally {
self._inTransaction = false;
}
};
// Create the transaction function that acts like better-sqlite3's Transaction interface
// In better-sqlite3, the transaction function is callable and has .deferred(), .immediate(), etc.
const transactionWrapper = Object.assign(
// Default call executes with BEGIN (same as immediate)
(...args: unknown[]): T => executeTransaction("BEGIN", ...args),
{
// Deferred transaction - locks acquired on first data access
deferred: (...args: unknown[]): T => executeTransaction("BEGIN DEFERRED", ...args),
// Immediate transaction - acquires write lock immediately
immediate: (...args: unknown[]): T => executeTransaction("BEGIN IMMEDIATE", ...args),
// Exclusive transaction - exclusive lock
exclusive: (...args: unknown[]): T => executeTransaction("BEGIN EXCLUSIVE", ...args),
// Default is same as calling directly
default: (...args: unknown[]): T => executeTransaction("BEGIN", ...args)
}
);
return transactionWrapper as unknown as Transaction;
}
get inTransaction(): boolean {
return this._inTransaction;
}
exec(query: string): void {
this.ensureDb();
this.db!.exec(query);
}
close(): void {
// Clean up all cached statements first
for (const statement of this.statementCache.values()) {
try {
statement.finalize();
} catch (e) {
// Ignore errors during cleanup
console.warn("Error finalizing statement during cleanup:", e);
}
}
this.statementCache.clear();
if (this.db) {
this.db.close();
this.db = undefined;
}
// Reset OPFS state
this.opfsDbPath = undefined;
}
/**
* Get the number of rows changed by the last INSERT, UPDATE, or DELETE statement.
*/
changes(): number {
this.ensureDb();
return this.db!.changes();
}
/**
* Check if the database is currently open.
*/
isOpen(): boolean {
return this.db !== undefined && this.db.isOpen();
}
private ensureSqlite3(): void {
if (!this.sqlite3) {
throw new Error(
"SQLite WASM module not initialized. Call initialize() first with the sqlite3 module."
);
}
}
private ensureDb(): void {
this.ensureSqlite3();
if (!this.db) {
throw new Error("Database not opened. Call loadFromMemory(), loadFromBuffer(), or loadFromOpfs() first.");
}
}
}

View File

@@ -0,0 +1,16 @@
import { LOCALE_IDS } from "@triliumnext/commons";
import type i18next from "i18next";
import I18NextHttpBackend from "i18next-http-backend";
export default async function translationProvider(i18nextInstance: typeof i18next, locale: LOCALE_IDS) {
await i18nextInstance.use(I18NextHttpBackend).init({
lng: locale,
fallbackLng: "en",
ns: "server",
backend: {
loadPath: `${import.meta.resolve("../server-assets/translations")}/{{lng}}/{{ns}}.json`
},
returnEmptyString: false,
debug: true
});
}

View File

@@ -0,0 +1,88 @@
import LocalServerWorker from "./local-server-worker?worker";
let localWorker: Worker | null = null;
const pending = new Map();
export function startLocalServerWorker() {
if (localWorker) return localWorker;
localWorker = new LocalServerWorker();
// Handle worker errors during initialization
localWorker.onerror = (event) => {
console.error("[LocalBridge] Worker error:", event);
// Reject all pending requests
for (const [, resolver] of pending) {
resolver.reject(new Error(`Worker error: ${event.message}`));
}
pending.clear();
};
localWorker.onmessage = (event) => {
const msg = event.data;
// Handle worker error reports
if (msg?.type === "WORKER_ERROR") {
console.error("[LocalBridge] Worker reported error:", msg.error);
// Reject all pending requests with the error
for (const [, resolver] of pending) {
resolver.reject(new Error(msg.error?.message || "Unknown worker error"));
}
pending.clear();
return;
}
if (!msg || msg.type !== "LOCAL_RESPONSE") return;
const { id, response, error } = msg;
const resolver = pending.get(id);
if (!resolver) return;
pending.delete(id);
if (error) resolver.reject(new Error(error));
else resolver.resolve(response);
};
return localWorker;
}
export function attachServiceWorkerBridge() {
navigator.serviceWorker.addEventListener("message", async (event) => {
const msg = event.data;
if (!msg || msg.type !== "LOCAL_FETCH") return;
const port = event.ports && event.ports[0];
if (!port) return;
try {
startLocalServerWorker();
const id = msg.id;
const req = msg.request;
const response = await new Promise((resolve, reject) => {
pending.set(id, { resolve, reject });
// Transfer body to worker for efficiency (if present)
localWorker.postMessage({
type: "LOCAL_REQUEST",
id,
request: req
}, req.body ? [req.body] : []);
});
port.postMessage({
type: "LOCAL_FETCH_RESPONSE",
id,
response
}, response.body ? [response.body] : []);
} catch (e) {
port.postMessage({
type: "LOCAL_FETCH_RESPONSE",
id: msg.id,
response: {
status: 500,
headers: { "content-type": "text/plain; charset=utf-8" },
body: new TextEncoder().encode(String(e?.message || e)).buffer
}
});
}
});
}

View File

@@ -0,0 +1,207 @@
import { BrowserRouter } from './lightweight/browser_router';
import { createConfiguredRouter } from './lightweight/browser_routes';
import BrowserExecutionContext from './lightweight/cls_provider';
import BrowserCryptoProvider from './lightweight/crypto_provider';
import WorkerMessagingProvider from './lightweight/messaging_provider';
import BrowserSqlProvider from './lightweight/sql_provider';
import translationProvider from './lightweight/translation_provider';
// Global error handlers - MUST be set up before any async imports
self.onerror = (message, source, lineno, colno, error) => {
console.error("[Worker] Uncaught error:", message, source, lineno, colno, error);
// Try to notify the main thread about the error
try {
self.postMessage({
type: "WORKER_ERROR",
error: {
message: String(message),
source,
lineno,
colno,
stack: error?.stack
}
});
} catch (e) {
// Can't even post message, just log
console.error("[Worker] Failed to report error:", e);
}
return false; // Don't suppress the error
};
self.onunhandledrejection = (event) => {
console.error("[Worker] Unhandled rejection:", event.reason);
try {
self.postMessage({
type: "WORKER_ERROR",
error: {
message: String(event.reason?.message || event.reason),
stack: event.reason?.stack
}
});
} catch (e) {
console.error("[Worker] Failed to report rejection:", e);
}
};
console.log("[Worker] Error handlers installed");
// Shared SQL provider instance
const sqlProvider = new BrowserSqlProvider();
// Messaging provider for worker-to-main-thread communication
const messagingProvider = new WorkerMessagingProvider();
// Core module, router, and initialization state
let coreModule: typeof import("@triliumnext/core") | null = null;
let router: BrowserRouter | null = null;
let initPromise: Promise<void> | null = null;
let initError: Error | null = null;
/**
* Initialize SQLite WASM and load the core module.
* This happens once at worker startup.
*/
async function initialize(): Promise<void> {
if (initPromise) {
return initPromise; // Already initializing
}
if (initError) {
throw initError; // Failed before, don't retry
}
initPromise = (async () => {
try {
console.log("[Worker] Initializing SQLite WASM...");
await sqlProvider.initWasm();
// Try to use OPFS for persistent storage
if (sqlProvider.isOpfsAvailable()) {
console.log("[Worker] OPFS available, loading persistent database...");
sqlProvider.loadFromOpfs("/trilium.db");
// Check if database is initialized (schema exists)
if (!sqlProvider.isDbInitialized()) {
console.log("[Worker] Database not initialized, loading demo data...");
sqlProvider.initializeDemoDatabase();
console.log("[Worker] Demo data loaded");
} else {
console.log("[Worker] Existing initialized database loaded");
}
} else {
// Fall back to in-memory database (non-persistent)
console.warn("[Worker] OPFS not available, using in-memory database (data will not persist)");
console.warn("[Worker] To enable persistence, ensure COOP/COEP headers are set by the server");
sqlProvider.loadFromMemory();
}
console.log("[Worker] Database loaded");
console.log("[Worker] Loading @triliumnext/core...");
coreModule = await import("@triliumnext/core");
await coreModule.initializeCore({
executionContext: new BrowserExecutionContext(),
crypto: new BrowserCryptoProvider(),
messaging: messagingProvider,
translations: translationProvider,
dbConfig: {
provider: sqlProvider,
isReadOnly: false,
onTransactionCommit: () => {
// No-op for now
},
onTransactionRollback: () => {
// No-op for now
}
}
});
console.log("[Worker] Supported routes", Object.keys(coreModule.routes));
// Create and configure the router
router = createConfiguredRouter();
console.log("[Worker] Router configured");
console.log("[Worker] Initializing becca...");
await coreModule.becca_loader.beccaLoaded;
console.log("[Worker] Initialization complete");
} catch (error) {
initError = error instanceof Error ? error : new Error(String(error));
console.error("[Worker] Initialization failed:", initError);
throw initError;
}
})();
return initPromise;
}
/**
* Ensure the worker is initialized before processing requests.
* Returns the router if initialization was successful.
*/
async function ensureInitialized() {
await initialize();
if (!router) {
throw new Error("Router not initialized");
}
return router;
}
interface LocalRequest {
method: string;
url: string;
body?: unknown;
headers?: Record<string, string>;
}
// Main dispatch
async function dispatch(request: LocalRequest) {
const url = new URL(request.url);
console.log("[Worker] Dispatch:", url.pathname);
// Ensure initialization is complete and get the router
const appRouter = await ensureInitialized();
// Dispatch to the router
return appRouter.dispatch(request.method, request.url, request.body, request.headers);
}
// Start initialization immediately when the worker loads
console.log("[Worker] Starting initialization...");
initialize().catch(err => {
console.error("[Worker] Initialization failed:", err);
// Post error to main thread
self.postMessage({
type: "WORKER_ERROR",
error: {
message: String(err?.message || err),
stack: err?.stack
}
});
});
self.onmessage = async (event) => {
const msg = event.data;
if (!msg || msg.type !== "LOCAL_REQUEST") return;
const { id, request } = msg;
try {
const response = await dispatch(request);
// Transfer body back (if any) - use options object for proper typing
(self as unknown as Worker).postMessage({
type: "LOCAL_RESPONSE",
id,
response
}, { transfer: response.body ? [response.body] : [] });
} catch (e) {
console.error("[Worker] Dispatch error:", e);
(self as unknown as Worker).postMessage({
type: "LOCAL_RESPONSE",
id,
error: String((e as Error)?.message || e)
});
}
};

View File

@@ -0,0 +1,84 @@
import { attachServiceWorkerBridge, startLocalServerWorker } from "./local-bridge.js";
async function waitForServiceWorkerControl(): Promise<void> {
if (!("serviceWorker" in navigator)) {
throw new Error("Service Worker not supported in this browser");
}
// If already controlling, we're good
if (navigator.serviceWorker.controller) {
console.log("[Bootstrap] Service worker already controlling");
return;
}
console.log("[Bootstrap] Waiting for service worker to take control...");
// Register service worker
await navigator.serviceWorker.register("./sw.js", { scope: "/" });
// Wait for it to be ready (installed + activated)
await navigator.serviceWorker.ready;
// Check if we're now controlling
if (navigator.serviceWorker.controller) {
console.log("[Bootstrap] Service worker now controlling");
return;
}
// If not controlling yet, we need to reload the page for SW to take control
// This is standard PWA behavior on first install
console.log("[Bootstrap] Service worker installed but not controlling yet - reloading page");
// Wait a tiny bit for SW to fully activate
await new Promise(resolve => setTimeout(resolve, 100));
// Reload to let SW take control
window.location.reload();
// Throw to stop execution (page will reload)
throw new Error("Reloading for service worker activation");
}
async function bootstrap() {
/* fixes https://github.com/webpack/webpack/issues/10035 */
window.global = globalThis;
try {
// 1) Start local worker ASAP (so /bootstrap is fast)
startLocalServerWorker();
// 2) Bridge SW -> local worker
attachServiceWorkerBridge();
// 3) Wait for service worker to control the page (may reload on first install)
await waitForServiceWorkerControl();
await loadScripts();
} catch (err) {
// If error is from reload, it will stop here (page reloads)
// Otherwise, show error to user
if (err instanceof Error && err.message.includes("Reloading")) {
// Page is reloading, do nothing
return;
}
console.error("[Bootstrap] Fatal error:", err);
document.body.innerHTML = `
<div style="padding: 40px; max-width: 600px; margin: 0 auto; font-family: system-ui, sans-serif;">
<h1 style="color: #d32f2f;">Failed to Initialize</h1>
<p>The application failed to start. Please check the browser console for details.</p>
<pre style="background: #f5f5f5; padding: 16px; border-radius: 4px; overflow: auto;">${err instanceof Error ? err.message : String(err)}</pre>
<button onclick="location.reload()" style="padding: 12px 24px; background: #1976d2; color: white; border: none; border-radius: 4px; cursor: pointer; font-size: 16px;">
Reload Page
</button>
</div>
`;
document.body.style.display = "block";
}
}
async function loadScripts() {
await import("../../client/src/index.js");
}
bootstrap();

View File

@@ -0,0 +1,185 @@
// public/sw.js
const VERSION = "localserver-v1.4";
const STATIC_CACHE = `static-${VERSION}`;
// Check if running in dev mode (passed via URL parameter)
const isDev = true;
if (isDev) {
console.log('[Service Worker] Running in DEV mode - caching disabled');
}
// Adjust these to your routes:
const LOCAL_FIRST_PREFIXES = [
"/bootstrap",
"/api/",
"/sync/",
"/search/"
];
// Optional: basic precache list (keep small; you can expand later)
const PRECACHE_URLS = [
// "/",
// "/index.html",
// "/manifest.webmanifest",
// "/favicon.ico",
];
self.addEventListener("install", (event) => {
event.waitUntil((async () => {
// Skip precaching in dev mode
if (!isDev) {
const cache = await caches.open(STATIC_CACHE);
await cache.addAll(PRECACHE_URLS);
}
self.skipWaiting();
})());
});
self.addEventListener("activate", (event) => {
event.waitUntil((async () => {
// Cleanup old caches
const keys = await caches.keys();
await Promise.all(keys.map((k) => (k === STATIC_CACHE ? Promise.resolve() : caches.delete(k))));
await self.clients.claim();
})());
});
function isLocalFirst(url) {
return LOCAL_FIRST_PREFIXES.some((p) => url.pathname.startsWith(p));
}
async function cacheFirst(request) {
// In dev mode, always bypass cache
if (isDev) {
return fetch(request);
}
const cache = await caches.open(STATIC_CACHE);
const cached = await cache.match(request);
if (cached) return cached;
const fresh = await fetch(request);
// Cache only successful GETs
if (request.method === "GET" && fresh.ok) cache.put(request, fresh.clone());
return fresh;
}
async function networkFirst(request) {
// In dev mode, always bypass cache
if (isDev) {
return fetch(request);
}
const cache = await caches.open(STATIC_CACHE);
try {
const fresh = await fetch(request);
// Cache only successful GETs
if (request.method === "GET" && fresh.ok) cache.put(request, fresh.clone());
return fresh;
} catch (error) {
// Fallback to cache if network fails
const cached = await cache.match(request);
if (cached) return cached;
throw error;
}
}
async function forwardToClientLocalServer(request, clientId) {
// Find a client to handle the request (prefer the initiating client if available)
let client = clientId ? await self.clients.get(clientId) : null;
if (!client) {
const all = await self.clients.matchAll({ type: "window", includeUncontrolled: true });
client = all[0] || null;
}
// If no page is available, fall back to network
if (!client) return fetch(request);
const reqUrl = request.url;
const headersObj = {};
for (const [k, v] of request.headers.entries()) headersObj[k] = v;
const body = (request.method === "GET" || request.method === "HEAD")
? null
: await request.arrayBuffer();
const id = crypto.randomUUID();
const channel = new MessageChannel();
const responsePromise = new Promise((resolve, reject) => {
const timeout = setTimeout(() => {
reject(new Error("Local server timeout"));
}, 30_000);
channel.port1.onmessage = (event) => {
clearTimeout(timeout);
resolve(event.data);
};
channel.port1.onmessageerror = () => {
clearTimeout(timeout);
reject(new Error("Local server message error"));
};
});
// Send to the client with a reply port
client.postMessage({
type: "LOCAL_FETCH",
id,
request: {
url: reqUrl,
method: request.method,
headers: headersObj,
body // ArrayBuffer or null
}
}, [channel.port2]);
const localResp = await responsePromise;
if (!localResp || localResp.type !== "LOCAL_FETCH_RESPONSE" || localResp.id !== id) {
// Protocol mismatch; fall back
return fetch(request);
}
// localResp.response: { status, headers, body }
const { status, headers, body: respBody } = localResp.response;
const respHeaders = new Headers();
if (headers) {
for (const [k, v] of Object.entries(headers)) respHeaders.set(k, String(v));
}
return new Response(respBody ? respBody : null, {
status: status || 200,
headers: respHeaders
});
}
self.addEventListener("fetch", (event) => {
const url = new URL(event.request.url);
// Only handle same-origin
if (url.origin !== self.location.origin) return;
// HTML files: network-first to ensure updates are reflected immediately
if (event.request.mode === "navigate" || url.pathname.endsWith(".html")) {
event.respondWith(networkFirst(event.request));
return;
}
// Static assets: cache-first for performance
if (event.request.method === "GET" && !isLocalFirst(url)) {
event.respondWith(cacheFirst(event.request));
return;
}
// API-ish: local-first via bridge
if (isLocalFirst(url)) {
event.respondWith(forwardToClientLocalServer(event.request, event.clientId));
return;
}
// Default
event.respondWith(fetch(event.request));
});

View File

@@ -0,0 +1,31 @@
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_APP_TITLE: string
}
interface ImportMeta {
readonly env: ImportMetaEnv
}
interface Window {
glob: {
assetPath: string;
themeCssUrl?: string;
themeUseNextAsBase?: string;
iconPackCss: string;
device: string;
headingStyle: string;
layoutOrientation: string;
platform: string;
isElectron: boolean;
hasNativeTitleBar: boolean;
hasBackgroundEffects: boolean;
currentLocale: {
id: string;
rtl: boolean;
};
activeDialog: any;
};
global: typeof globalThis;
}

View File

@@ -0,0 +1,26 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"lib": [
"ES2022",
"dom",
"dom.iterable"
],
"skipLibCheck": true,
"types": [
"vite/client"
],
"jsx": "react-jsx",
"jsxImportSource": "preact"
},
"include": [
"src/**/*",
"../client/src/**/*"
],
"exclude": [
"src/**/*.spec.ts",
"src/**/*.test.ts",
"../client/src/**/*.spec.ts",
"../client/src/**/*.test.ts"
]
}

View File

@@ -0,0 +1,7 @@
{
"extends": "../../tsconfig.base.json",
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.spec.json" }
]
}

View File

@@ -0,0 +1,18 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"lib": [
"ES2022",
"dom",
"dom.iterable"
],
"types": [
"vitest/globals",
"happy-dom"
]
},
"include": [
"src/**/*.spec.ts",
"src/**/*.test.ts"
]
}

View File

@@ -0,0 +1,188 @@
import prefresh from '@prefresh/vite';
import { join } from 'path';
import { defineConfig } from 'vite';
import { viteStaticCopy } from 'vite-plugin-static-copy';
const clientAssets = ["assets", "stylesheets", "fonts", "translations"];
const isDev = process.env.NODE_ENV === "development";
// Watch client files and trigger reload in development
const clientWatchPlugin = () => ({
name: 'client-watch',
configureServer(server: any) {
if (isDev) {
// Watch client source files (adjusted for new root)
server.watcher.add('../../client/src/**/*');
server.watcher.on('change', (file: string) => {
if (file.includes('../../client/src/')) {
server.ws.send({
type: 'full-reload'
});
}
});
}
}
});
// Always copy SQLite WASM files so they're available to the module
const sqliteWasmPlugin = viteStaticCopy({
targets: [
{
src: "../../../node_modules/@sqlite.org/sqlite-wasm/sqlite-wasm/jswasm/sqlite3.wasm",
dest: "assets"
},
{
src: "../../../node_modules/@sqlite.org/sqlite-wasm/sqlite-wasm/jswasm/sqlite3-opfs-async-proxy.js",
dest: "assets"
}
]
});
let plugins: any = [
sqliteWasmPlugin, // Always include SQLite WASM files
viteStaticCopy({
targets: clientAssets.map((asset) => ({
src: `../../client/src/${asset}/*`,
dest: asset
})),
// Enable watching in development
...(isDev && {
watch: {
reloadPageOnChange: true
}
})
}),
viteStaticCopy({
targets: [
{
src: "../../server/src/assets/*",
dest: "server-assets"
}
]
}),
// Watch client files for changes in development
...(isDev ? [
prefresh(),
clientWatchPlugin()
] : [])
];
if (!isDev) {
plugins = [
...plugins,
viteStaticCopy({
structured: true,
targets: [
{
src: "../../../node_modules/@excalidraw/excalidraw/dist/prod/fonts/*",
dest: "",
}
]
})
]
}
export default defineConfig(() => ({
root: join(__dirname, 'src'), // Set src as root so index.html is served from /
envDir: __dirname, // Load .env files from client-standalone directory, not src/
cacheDir: '../../../node_modules/.vite/apps/client-standalone',
base: "",
plugins,
esbuild: {
jsx: 'automatic',
jsxImportSource: 'preact',
jsxDev: isDev
},
css: {
transformer: 'lightningcss',
devSourcemap: isDev
},
publicDir: join(__dirname, 'public'),
resolve: {
alias: [
{
find: "react",
replacement: "preact/compat"
},
{
find: "react-dom",
replacement: "preact/compat"
},
{
find: "@client",
replacement: join(__dirname, "../client/src")
}
],
dedupe: [
"react",
"react-dom",
"preact",
"preact/compat",
"preact/hooks"
]
},
server: {
watch: {
// Watch workspace packages
ignored: ['!**/node_modules/@triliumnext/**'],
// Also watch client assets for live reload
usePolling: false,
interval: 100,
binaryInterval: 300
},
// Watch additional directories for changes
fs: {
allow: [
// Allow access to workspace root
'../../../',
// Explicitly allow client directory
'../../client/src/'
]
},
headers: {
// Required for SharedArrayBuffer which is needed by SQLite WASM OPFS VFS
// See: https://sqlite.org/wasm/doc/trunk/persistence.md#coop-coep
"Cross-Origin-Opener-Policy": "same-origin",
"Cross-Origin-Embedder-Policy": "require-corp"
}
},
optimizeDeps: {
exclude: ['@sqlite.org/sqlite-wasm', '@triliumnext/core']
},
worker: {
format: "es" as const
},
commonjsOptions: {
transformMixedEsModules: true,
},
build: {
target: "esnext",
outDir: join(__dirname, 'dist'),
emptyOutDir: true,
rollupOptions: {
input: {
main: join(__dirname, 'src', 'index.html'),
sw: join(__dirname, 'src', 'sw.ts'),
'local-bridge': join(__dirname, 'src', 'local-bridge.ts'),
},
output: {
entryFileNames: (chunkInfo) => {
// Service worker and other workers should be at root level
if (chunkInfo.name === 'sw') {
return '[name].js';
}
return 'src/[name].js';
},
chunkFileNames: "src/[name].js",
assetFileNames: "src/[name].[ext]"
}
}
},
test: {
environment: "happy-dom"
},
define: {
"process.env.IS_PREACT": JSON.stringify("true"),
}
}));

View File

@@ -1,6 +1,6 @@
{
"name": "@triliumnext/client",
"version": "0.102.1",
"version": "0.101.3",
"description": "JQuery-based client for TriliumNext, used for both web and desktop (via Electron)",
"private": true,
"license": "AGPL-3.0-only",
@@ -22,28 +22,19 @@
"@fullcalendar/interaction": "6.1.20",
"@fullcalendar/list": "6.1.20",
"@fullcalendar/multimonth": "6.1.20",
"@fullcalendar/rrule": "6.1.20",
"@fullcalendar/timegrid": "6.1.20",
"@maplibre/maplibre-gl-leaflet": "0.1.3",
"@mermaid-js/layout-elk": "0.2.1",
"@mermaid-js/layout-elk": "0.2.0",
"@mind-elixir/node-menu": "5.0.1",
"@popperjs/core": "2.11.8",
"@preact/signals": "2.8.2",
"@preact/signals": "2.6.2",
"@triliumnext/ckeditor5": "workspace:*",
"@triliumnext/codemirror": "workspace:*",
"@triliumnext/commons": "workspace:*",
"@triliumnext/highlightjs": "workspace:*",
"@triliumnext/share-theme": "workspace:*",
"@triliumnext/split.js": "workspace:*",
"@univerjs/preset-sheets-conditional-formatting": "0.18.0",
"@univerjs/preset-sheets-core": "0.18.0",
"@univerjs/preset-sheets-data-validation": "0.18.0",
"@univerjs/preset-sheets-filter": "0.18.0",
"@univerjs/preset-sheets-find-replace": "0.18.0",
"@univerjs/preset-sheets-note": "0.18.0",
"@univerjs/preset-sheets-sort": "0.18.0",
"@univerjs/presets": "0.18.0",
"@zumer/snapdom": "2.5.0",
"@zumer/snapdom": "2.0.2",
"autocomplete.js": "0.38.1",
"bootstrap": "5.3.8",
"boxicons": "2.1.4",
@@ -51,44 +42,45 @@
"color": "5.0.3",
"debounce": "3.0.0",
"draggabilly": "3.0.0",
"force-graph": "1.51.2",
"globals": "17.4.0",
"i18next": "25.8.18",
"force-graph": "1.51.1",
"globals": "17.3.0",
"i18next": "25.8.0",
"i18next-http-backend": "3.0.2",
"jquery": "4.0.0",
"jquery.fancytree": "2.38.5",
"jsplumb": "2.15.6",
"katex": "0.16.39",
"katex": "0.16.28",
"knockout": "3.5.1",
"leaflet": "1.9.4",
"leaflet-gpx": "2.2.0",
"mark.js": "8.11.1",
"marked": "17.0.4",
"mermaid": "11.13.0",
"mind-elixir": "5.9.3",
"marked": "17.0.1",
"mermaid": "11.12.2",
"mind-elixir": "5.8.0",
"normalize.css": "8.0.1",
"panzoom": "9.4.3",
"preact": "10.29.0",
"react-i18next": "16.5.8",
"react-window": "2.2.7",
"reveal.js": "6.0.0",
"rrule": "2.8.1",
"preact": "10.28.3",
"react-i18next": "16.5.4",
"react-window": "2.2.6",
"reveal.js": "5.2.1",
"svg-pan-zoom": "3.6.2",
"tabulator-tables": "6.4.0",
"tabulator-tables": "6.3.1",
"vanilla-js-wheel-zoom": "9.0.4"
},
"devDependencies": {
"@ckeditor/ckeditor5-inspector": "5.0.0",
"@prefresh/vite": "2.4.12",
"@prefresh/vite": "2.4.11",
"@types/bootstrap": "5.2.10",
"@types/jquery": "4.0.0",
"@types/jquery": "3.5.33",
"@types/leaflet": "1.9.21",
"@types/leaflet-gpx": "1.3.8",
"@types/mark.js": "8.11.12",
"@types/reveal.js": "5.2.2",
"@types/tabulator-tables": "6.3.1",
"copy-webpack-plugin": "14.0.0",
"happy-dom": "20.8.4",
"lightningcss": "1.32.0",
"copy-webpack-plugin": "13.0.1",
"happy-dom": "20.5.0",
"lightningcss": "1.31.1",
"script-loader": "0.7.2",
"vite-plugin-static-copy": "3.3.0"
"vite-plugin-static-copy": "3.2.0"
}
}
}

View File

@@ -101,6 +101,8 @@ export type CommandMappings = {
showRevisions: CommandData & {
noteId?: string | null;
};
showLlmChat: CommandData;
createAiChat: CommandData;
showOptions: CommandData & {
section: string;
};

View File

@@ -381,10 +381,6 @@ class NoteContext extends Component implements EventListener<"entitiesReloaded">
// Collections must always display a note list, even if no children.
if (note.type === "book") {
if (note.isProtected && !protectedSessionHolder.isProtectedSessionAvailable()) {
return false;
}
const viewType = note.getLabelValue("viewType") ?? "grid";
if (!["list", "grid"].includes(viewType)) {
return true;

View File

@@ -1,8 +1,10 @@
import dateNoteService from "../services/date_notes.js";
import froca from "../services/froca.js";
import noteCreateService from "../services/note_create.js";
import openService from "../services/open.js";
import options from "../services/options.js";
import protectedSessionService from "../services/protected_session.js";
import toastService from "../services/toast.js";
import treeService from "../services/tree.js";
import utils, { openInReusableSplit } from "../services/utils.js";
import appContext, { type CommandListenerData } from "./app_context.js";
@@ -246,4 +248,34 @@ export default class RootCommandExecutor extends Component {
}
}
async createAiChatCommand() {
try {
// Create a new AI Chat note at the root level
const rootNoteId = "root";
const result = await noteCreateService.createNote(rootNoteId, {
title: "New AI Chat",
type: "aiChat",
content: JSON.stringify({
messages: [],
title: "New AI Chat"
})
});
if (!result.note) {
toastService.showError("Failed to create AI Chat note");
return;
}
await appContext.tabManager.openTabWithNoteWithHoisting(result.note.noteId, {
activate: true
});
toastService.showMessage("Created new AI Chat note");
}
catch (e) {
console.error("Error creating AI Chat note:", e);
toastService.showError(`Failed to create AI Chat note: ${(e as Error).message}`);
}
}
}

View File

@@ -18,7 +18,7 @@ const RELATION = "relation";
* end user. Those types should be used only for checking against, they are
* not for direct use.
*/
export type NoteType = "file" | "image" | "search" | "noteMap" | "launcher" | "doc" | "contentWidget" | "text" | "relationMap" | "render" | "canvas" | "mermaid" | "book" | "webView" | "code" | "mindMap" | "spreadsheet";
export type NoteType = "file" | "image" | "search" | "noteMap" | "launcher" | "doc" | "contentWidget" | "text" | "relationMap" | "render" | "canvas" | "mermaid" | "book" | "webView" | "code" | "mindMap" | "aiChat";
export interface NotePathRecord {
isArchived: boolean;
@@ -700,15 +700,6 @@ export default class FNote {
return this.hasAttribute(LABEL, name);
}
/**
* Returns `true` if the note has a label with the given name (same as {@link hasOwnedLabel}), or it has a label with the `disabled:` prefix (for example due to a safe import).
* @param name the name of the label to look for.
* @returns `true` if the label exists, or its version with the `disabled:` prefix.
*/
hasLabelOrDisabled(name: string) {
return this.hasLabel(name) || this.hasLabel(`disabled:${name}`);
}
/**
* @param name - label name
* @returns true if label exists (including inherited) and does not have "false" value.

View File

@@ -36,10 +36,37 @@ async function setupGlob() {
window.global = globalThis; /* fixes https://github.com/webpack/webpack/issues/10035 */
window.glob = {
...json,
activeDialog: null
activeDialog: null,
device: json.device || getDevice()
};
}
function getDevice() {
// Respect user's manual override via URL.
const urlParams = new URLSearchParams(window.location.search);
if (urlParams.has("print")) {
return "print";
} else if (urlParams.has("desktop")) {
return "desktop";
} else if (urlParams.has("mobile")) {
return "mobile";
}
const deviceCookie = document.cookie.split("; ").find(row => row.startsWith("trilium-device="))?.split("=")[1];
if (deviceCookie === "desktop" || deviceCookie === "mobile") return deviceCookie;
return isMobile() ? "mobile" : "desktop";
}
// https://stackoverflow.com/a/73731646/944162
function isMobile() {
const mQ = matchMedia?.("(pointer:coarse)");
if (mQ?.media === "(pointer:coarse)") return !!mQ.matches;
if ("orientation" in window) return true;
const userAgentsRegEx = /\b(Android|iPhone|iPad|iPod|Windows Phone|BlackBerry|webOS|IEMobile)\b/i;
return userAgentsRegEx.test(navigator.userAgent);
}
async function loadBootstrapCss() {
// We have to selectively import Bootstrap CSS based on text direction.
if (glob.isRtl) {

View File

@@ -30,6 +30,7 @@ import SpacerWidget from "../widgets/launch_bar/SpacerWidget.jsx";
import InlineTitle from "../widgets/layout/InlineTitle.jsx";
import NoteBadges from "../widgets/layout/NoteBadges.jsx";
import NoteTitleActions from "../widgets/layout/NoteTitleActions.jsx";
import StandaloneWarningBar from "../widgets/layout/StandaloneWarningBar.jsx";
import StatusBar from "../widgets/layout/StatusBar.jsx";
import NoteIconWidget from "../widgets/note_icon.jsx";
import NoteTitleWidget from "../widgets/note_title.jsx";
@@ -186,6 +187,7 @@ export default class DesktopLayout {
)
)
.optChild(launcherPaneIsHorizontal && isNewLayout, <StatusBar />)
.optChild(glob.isStandalone, <StandaloneWarningBar />)
.child(<CloseZenModeButton />)
// Desktop-specific dialogs.

View File

@@ -13,6 +13,7 @@ import LauncherContainer from "../widgets/launch_bar/LauncherContainer.jsx";
import InlineTitle from "../widgets/layout/InlineTitle.jsx";
import NoteBadges from "../widgets/layout/NoteBadges.jsx";
import NoteTitleActions from "../widgets/layout/NoteTitleActions.jsx";
import StandaloneWarningBar from "../widgets/layout/StandaloneWarningBar";
import MobileDetailMenu from "../widgets/mobile_widgets/mobile_detail_menu.js";
import ScreenContainer from "../widgets/mobile_widgets/screen_container.js";
import SidebarContainer from "../widgets/mobile_widgets/sidebar_container.js";
@@ -23,7 +24,8 @@ import NoteTreeWidget from "../widgets/note_tree.js";
import NoteWrapperWidget from "../widgets/note_wrapper.js";
import NoteDetail from "../widgets/NoteDetail.jsx";
import QuickSearchWidget from "../widgets/quick_search.js";
import ScrollPadding from "../widgets/scroll_padding";
import { useNoteContext } from "../widgets/react/hooks.jsx";
import FilePropertiesTab from "../widgets/ribbon/FilePropertiesTab.jsx";
import SearchResult from "../widgets/search_result.jsx";
import MobileEditorToolbar from "../widgets/type_widgets/text/mobile_editor_toolbar.jsx";
import { applyModals } from "./layout_commons.js";
@@ -55,6 +57,7 @@ export default class MobileLayout {
.child(
new SplitNoteContainer(() =>
new NoteWrapperWidget()
.optChild(glob.isStandalone, <StandaloneWarningBar />)
.child(
new FlexContainer("row")
.class("title-row note-split-title")
@@ -75,7 +78,7 @@ export default class MobileLayout {
.child(<NoteDetail />)
.child(<NoteList media="screen" />)
.child(<SearchResult />)
.child(<ScrollPadding />)
.child(<FilePropertiesWrapper />)
)
.child(<MobileEditorToolbar />)
.child(new FindWidget())
@@ -99,3 +102,13 @@ export default class MobileLayout {
return rootContainer;
}
}
function FilePropertiesWrapper() {
const { note, ntxId } = useNoteContext();
return (
<div>
{note?.type === "file" && <FilePropertiesTab note={note} ntxId={ntxId} />}
</div>
);
}

View File

@@ -3,8 +3,6 @@ import options from "../services/options.js";
import zoomService from "../components/zoom.js";
import contextMenu, { type MenuItem } from "./context_menu.js";
import { t } from "../services/i18n.js";
import server from "../services/server.js";
import * as clipboardExt from "../services/clipboard_ext.js";
import type { BrowserWindow } from "electron";
import type { CommandNames, AppContext } from "../components/app_context.js";
@@ -62,33 +60,6 @@ function setupContextMenu() {
uiIcon: "bx bx-copy",
handler: () => webContents.copy()
});
items.push({
enabled: hasText,
title: t("electron_context_menu.copy-as-markdown"),
uiIcon: "bx bx-copy-alt",
handler: async () => {
const selection = window.getSelection();
if (!selection || !selection.rangeCount) return '';
const range = selection.getRangeAt(0);
const div = document.createElement('div');
div.appendChild(range.cloneContents());
const htmlContent = div.innerHTML;
if (htmlContent) {
try {
const { markdownContent } = await server.post<{ markdownContent: string }>(
"other/to-markdown",
{ htmlContent }
);
await clipboardExt.copyTextWithToast(markdownContent);
} catch (error) {
console.error("Failed to copy as markdown:", error);
}
}
}
});
}
if (!["", "javascript:", "about:blank#blocked"].includes(params.linkURL) && params.mediaType === "none") {

View File

@@ -18,10 +18,6 @@ export type PrintReport = {
} | {
type: "collection";
ignoredNoteIds: string[];
} | {
type: "error";
message: string;
stack?: string;
};
async function main() {

View File

@@ -168,49 +168,6 @@ function isAffecting(attrRow: AttributeRow, affectedNote: FNote | null | undefin
return false;
}
/**
* Toggles whether a dangerous attribute is enabled or not. When an attribute is disabled, its name is prefixed with `disabled:`.
*
* Note that this work for non-dangerous attributes as well.
*
* If there are multiple attributes with the same name, all of them will be toggled at the same time.
*
* @param note the note whose attribute to change.
* @param type the type of dangerous attribute (label or relation).
* @param name the name of the dangerous attribute.
* @param willEnable whether to enable or disable the attribute.
* @returns a promise that will resolve when the request to the server completes.
*/
async function toggleDangerousAttribute(note: FNote, type: "label" | "relation", name: string, willEnable: boolean) {
const attrs = [
...note.getOwnedAttributes(type, name),
...note.getOwnedAttributes(type, `disabled:${name}`)
];
for (const attr of attrs) {
const baseName = getNameWithoutDangerousPrefix(attr.name);
const newName = willEnable ? baseName : `disabled:${baseName}`;
if (newName === attr.name) continue;
// We are adding and removing afterwards to avoid a flicker (because for a moment there would be no active content attribute anymore) because the operations are done in sequence and not atomically.
if (attr.type === "label") {
await setLabel(note.noteId, newName, attr.value);
} else {
await setRelation(note.noteId, newName, attr.value);
}
await removeAttributeById(note.noteId, attr.attributeId);
}
}
/**
* Returns the name of an attribute without the `disabled:` prefix, or the same name if it's not disabled.
* @param name the name of an attribute.
* @returns the name without the `disabled:` prefix.
*/
function getNameWithoutDangerousPrefix(name: string) {
return name.startsWith("disabled:") ? name.substring(9) : name;
}
export default {
addLabel,
setLabel,
@@ -220,7 +177,5 @@ export default {
removeAttributeById,
removeOwnedLabelByName,
removeOwnedRelationByName,
isAffecting,
toggleDangerousAttribute,
getNameWithoutDangerousPrefix
isAffecting
};

View File

@@ -2,6 +2,7 @@ import { h, VNode } from "preact";
import BasicWidget, { ReactWrappedWidget } from "../widgets/basic_widget.js";
import RightPanelWidget from "../widgets/right_panel_widget.js";
import froca from "./froca.js";
import type { Entity } from "./frontend_script_api.js";
import { WidgetDefinitionWithType } from "./frontend_script_api_preact.js";
import { t } from "./i18n.js";
@@ -37,18 +38,15 @@ async function getAndExecuteBundle(noteId: string, originEntity = null, script =
export type ParentName = "left-pane" | "center-pane" | "note-detail-pane" | "right-pane";
export async function executeBundleWithoutErrorHandling(bundle: Bundle, originEntity?: Entity | null, $container?: JQuery<HTMLElement>) {
const apiContext = await ScriptContext(bundle.noteId, bundle.allNoteIds, originEntity, $container);
return await function () {
return eval(`const apiContext = this; (async function() { ${bundle.script}\r\n})()`);
}.call(apiContext);
}
export async function executeBundle(bundle: Bundle, originEntity?: Entity | null, $container?: JQuery<HTMLElement>) {
const apiContext = await ScriptContext(bundle.noteId, bundle.allNoteIds, originEntity, $container);
try {
return await executeBundleWithoutErrorHandling(bundle, originEntity, $container);
} catch (e: unknown) {
showErrorForScriptNote(bundle.noteId, t("toast.bundle-error.message", { message: getErrorMessage(e) }));
return await function () {
return eval(`const apiContext = this; (async function() { ${bundle.script}\r\n})()`);
}.call(apiContext);
} catch (e: any) {
showErrorForScriptNote(bundle.noteId, t("toast.bundle-error.message", { message: e.message }));
logError("Widget initialization failed: ", e);
}
}

View File

@@ -1,14 +1,12 @@
import "./content_renderer.css";
import { normalizeMimeTypeForCKEditor } from "@triliumnext/commons";
import { h, render } from "preact";
import WheelZoom from 'vanilla-js-wheel-zoom';
import FAttachment from "../entities/fattachment.js";
import FNote from "../entities/fnote.js";
import imageContextMenuService from "../menus/image_context_menu.js";
import { t } from "../services/i18n.js";
import { renderReactWidget, renderReactWidgetAtElement } from "../widgets/react/react_utils";
import renderText from "./content_renderer_text.js";
import renderDoc from "./doc_renderer.js";
import { loadElkIfNeeded, postprocessMermaidSvg } from "./mermaid.js";
@@ -17,7 +15,7 @@ import protectedSessionService from "./protected_session.js";
import protectedSessionHolder from "./protected_session_holder.js";
import renderService from "./render.js";
import { applySingleBlockSyntaxHighlight } from "./syntax_highlight.js";
import utils, { getErrorMessage } from "./utils.js";
import utils from "./utils.js";
let idCounter = 1;
@@ -55,19 +53,16 @@ export async function getRenderedContent(this: {} | { ctx: string }, entity: FNo
await renderText(entity, $renderedContent, options);
} else if (type === "code") {
await renderCode(entity, $renderedContent);
} else if (["image", "canvas", "mindMap", "spreadsheet"].includes(type)) {
} else if (["image", "canvas", "mindMap"].includes(type)) {
renderImage(entity, $renderedContent, options);
} else if (!options.tooltip && ["file", "pdf", "audio", "video"].includes(type)) {
await renderFile(entity, type, $renderedContent);
renderFile(entity, type, $renderedContent);
} else if (type === "mermaid") {
await renderMermaid(entity, $renderedContent);
} else if (type === "render" && entity instanceof FNote) {
const $content = $("<div>");
await renderService.render(entity, $content, (e) => {
const $error = $("<div>").addClass("admonition caution").text(typeof e === "string" ? e : getErrorMessage(e));
$content.empty().append($error);
});
await renderService.render(entity, $content);
$renderedContent.append($content);
} else if (type === "doc" && "noteId" in entity) {
@@ -181,7 +176,7 @@ function renderImage(entity: FNote | FAttachment, $renderedContent: JQuery<HTMLE
imageContextMenuService.setupContextMenu($img);
}
async function renderFile(entity: FNote | FAttachment, type: string, $renderedContent: JQuery<HTMLElement>) {
function renderFile(entity: FNote | FAttachment, type: string, $renderedContent: JQuery<HTMLElement>) {
let entityType, entityId;
if (entity instanceof FNote) {
@@ -194,17 +189,13 @@ async function renderFile(entity: FNote | FAttachment, type: string, $renderedCo
throw new Error(`Can't recognize entity type of '${entity}'`);
}
const $content = $('<div style="display: flex; flex-direction: column; height: 100%; justify-content: end;">');
const $content = $('<div style="display: flex; flex-direction: column; height: 100%;">');
if (type === "pdf") {
const url = `../../api/${entityType}/${entityId}/open`;
const $viewer = $(`<div style="height: 100%">`);
const PdfViewer = (await import("../widgets/type_widgets/file/PdfViewer")).default;
render(h(PdfViewer, {pdfUrl: url, editable: false}), $viewer.get(0)!);
$content.append($viewer);
const $pdfPreview = $('<iframe class="pdf-preview" style="width: 100%; flex-grow: 100;"></iframe>');
$pdfPreview.attr("src", openService.getUrlForDownload(`pdfjs/web/viewer.html?file=../../api/${entityType}/${entityId}/open`));
$content.append($pdfPreview);
} else if (type === "audio") {
const $audioPreview = $("<audio controls></audio>")
.attr("src", openService.getUrlForDownload(`api/${entityType}/${entityId}/open-partial`))
@@ -213,16 +204,15 @@ async function renderFile(entity: FNote | FAttachment, type: string, $renderedCo
$content.append($audioPreview);
} else if (type === "video") {
const url = openService.getUrlForDownload(`api/${entityType}/${entityId}/open-partial`);
const mime = entity.mime;
const $videoPreview = $("<video controls></video>")
.attr("src", openService.getUrlForDownload(`api/${entityType}/${entityId}/open-partial`))
.attr("type", entity.mime)
.css("width", "100%");
const VideoPreviewContent = (await import("../widgets/type_widgets/file/Video")).VideoPreviewContent;
const $viewer = renderReactWidget(null, h(VideoPreviewContent, { url, mime }));
$content.append($viewer);
$content.append($videoPreview);
}
if (entityType === "notes" && "noteId" in entity && type !== "video") {
if (entityType === "notes" && "noteId" in entity) {
// TODO: we should make this available also for attachments, but there's a problem with "Open externally" support
// in attachment list
const $downloadButton = $(`

View File

@@ -120,6 +120,7 @@ async function renderChildrenList($renderedContent: JQuery<HTMLElement>, note: F
return;
}
$renderedContent.css("padding", "10px");
$renderedContent.addClass("text-with-ellipsis");
// just load the first 10 child notes

View File

@@ -1,5 +1,4 @@
import { dayjs } from "@triliumnext/commons";
import type { FNoteRow } from "../entities/fnote.js";
import froca from "./froca.js";
import server from "./server.js";
@@ -15,13 +14,8 @@ async function getTodayNote() {
return await getDayNote(dayjs().format("YYYY-MM-DD"));
}
async function getDayNote(date: string, calendarRootId?: string) {
let url = `special-notes/days/${date}`;
if (calendarRootId) {
url += `?calendarRootId=${calendarRootId}`;
}
const note = await server.get<FNoteRow>(url, "date-note");
async function getDayNote(date: string) {
const note = await server.get<FNoteRow>(`special-notes/days/${date}`, "date-note");
await ws.waitForMaxKnownEntityChangeId();

View File

@@ -5,7 +5,7 @@ import { formatCodeBlocks } from "./syntax_highlight.js";
export default function renderDoc(note: FNote) {
return new Promise<JQuery<HTMLElement>>((resolve) => {
let docName = note.getLabelValue("docName");
const docName = note.getLabelValue("docName");
const $content = $("<div>");
if (docName) {
@@ -16,7 +16,7 @@ export default function renderDoc(note: FNote) {
if (status === "error") {
const fallbackUrl = getUrl(docName, "en");
$content.load(fallbackUrl, async () => {
await processContent(fallbackUrl, $content)
await processContent(fallbackUrl, $content);
resolve($content);
});
return;
@@ -37,9 +37,9 @@ async function processContent(url: string, $content: JQuery<HTMLElement>) {
const dir = url.substring(0, url.lastIndexOf("/"));
// Images are relative to the docnote but that will not work when rendered in the application since the path breaks.
$content.find("img").each((i, el) => {
$content.find("img").each((_i, el) => {
const $img = $(el);
$img.attr("src", dir + "/" + $img.attr("src"));
$img.attr("src", `${dir}/${$img.attr("src")}`);
});
formatCodeBlocks($content);
@@ -51,7 +51,17 @@ async function processContent(url: string, $content: JQuery<HTMLElement>) {
function getUrl(docNameValue: string, language: string) {
// Cannot have spaces in the URL due to how JQuery.load works.
docNameValue = docNameValue.replaceAll(" ", "%20");
const basePath = window.glob.isDev ? window.glob.assetPath + "/.." : window.glob.assetPath;
return `${basePath}/doc_notes/${language}/${docNameValue}.html`;
// The user guide is available only in English, so make sure we are requesting correctly since 404s in standalone client are treated differently.
if (docNameValue.includes("User%20Guide")) language = "en";
return `${getBasePath()}/doc_notes/${language}/${docNameValue}.html`;
}
function getBasePath() {
if (window.glob.isStandalone) {
return `server-assets`;
}
if (window.glob.isDev) {
return `${window.glob.assetPath }/..`;
}
return window.glob.assetPath;
}

View File

@@ -110,12 +110,7 @@ function processNoteChange(loadResults: LoadResults, ec: EntityChange) {
}
}
// Only register as a content change if the protection status didn't change.
// When isProtected changes, the blobId change is a side effect of re-encryption,
// not a content edit. Registering it as content would cause the tree's content-only
// filter to incorrectly skip the note update (since both changes share the same
// componentId).
if (ec.componentId && note.isProtected === (ec.entity as FNoteRow).isProtected) {
if (ec.componentId) {
loadResults.addNoteContent(note.noteId, ec.componentId);
}
}

View File

@@ -24,8 +24,7 @@ export async function initLocale() {
backend: {
loadPath: `${window.glob.assetPath}/translations/{{lng}}/{{ns}}.json`
},
returnEmptyString: false,
showSupportNotice: false
returnEmptyString: false
});
await setDayjsLocale(locale);

View File

@@ -1,5 +1,4 @@
import { NoteType } from "@triliumnext/commons";
import FNote from "../entities/fnote";
import { ViewTypeOptions } from "../widgets/collections/interface";
@@ -19,7 +18,7 @@ export const byNoteType: Record<Exclude<NoteType, "book">, string | null> = {
search: null,
text: null,
webView: null,
spreadsheet: null
aiChat: null
};
export const byBookType: Record<ViewTypeOptions, string | null> = {
@@ -40,6 +39,6 @@ export function getHelpUrlForNote(note: FNote | null | undefined) {
} else if (note?.hasLabel("textSnippet")) {
return "pwc194wlRzcH";
} else if (note && note.type === "book") {
return byBookType[note.getAttributeValue("label", "viewType") as ViewTypeOptions ?? ""];
return byBookType[note.getAttributeValue("label", "viewType") as ViewTypeOptions ?? ""]
}
}

View File

@@ -12,7 +12,7 @@ const SELECTED_NOTE_PATH_KEY = "data-note-path";
const SELECTED_EXTERNAL_LINK_KEY = "data-external-link";
// To prevent search lag when there are a large number of notes, set a delay based on the number of notes to avoid jitter.
const notesCount = await server.get<number>(`autocomplete/notesCount`);
const notesCount = 10000; // TODO: Replace with dynamic count from becca once available.
let debounceTimeoutId: ReturnType<typeof setTimeout>;
function getSearchDelay(notesCount: number): number {

View File

@@ -1,17 +1,15 @@
import type { CKTextEditor } from "@triliumnext/ckeditor5";
import { AttributeRow } from "@triliumnext/commons";
import appContext from "../components/app_context.js";
import type FBranch from "../entities/fbranch.js";
import type FNote from "../entities/fnote.js";
import type { ChooseNoteTypeResponse } from "../widgets/dialogs/note_type_chooser.js";
import froca from "./froca.js";
import { t } from "./i18n.js";
import protectedSessionHolder from "./protected_session_holder.js";
import server from "./server.js";
import toastService from "./toast.js";
import treeService from "./tree.js";
import ws from "./ws.js";
import froca from "./froca.js";
import treeService from "./tree.js";
import toastService from "./toast.js";
import { t } from "./i18n.js";
import type FNote from "../entities/fnote.js";
import type FBranch from "../entities/fbranch.js";
import type { ChooseNoteTypeResponse } from "../widgets/dialogs/note_type_chooser.js";
import type { CKTextEditor } from "@triliumnext/ckeditor5";
export interface CreateNoteOpts {
isProtected?: boolean;
@@ -26,8 +24,6 @@ export interface CreateNoteOpts {
target?: string;
targetBranchId?: string;
textEditor?: CKTextEditor;
/** Attributes to be set on the note. These are set atomically on note creation, so entity changes are not sent for attributes defined here. */
attributes?: Omit<AttributeRow, "noteId" | "attributeId">[];
}
interface Response {
@@ -41,7 +37,7 @@ interface DuplicateResponse {
note: FNote;
}
async function createNote(parentNotePath: string | undefined, options: CreateNoteOpts = {}, componentId?: string) {
async function createNote(parentNotePath: string | undefined, options: CreateNoteOpts = {}) {
options = Object.assign(
{
activate: true,
@@ -67,15 +63,22 @@ async function createNote(parentNotePath: string | undefined, options: CreateNot
const parentNoteId = treeService.getNoteIdFromUrl(parentNotePath);
if (options.type === "mermaid" && !options.content && !options.templateNoteId) {
options.content = `graph TD;
A-->B;
A-->C;
B-->D;
C-->D;`;
}
const { note, branch } = await server.post<Response>(`notes/${parentNoteId}/children?target=${options.target}&targetBranchId=${options.targetBranchId || ""}`, {
title: options.title,
content: options.content || "",
isProtected: options.isProtected,
type: options.type,
mime: options.mime,
templateNoteId: options.templateNoteId,
attributes: options.attributes
}, componentId);
templateNoteId: options.templateNoteId
});
if (options.saveSelection) {
// we remove the selection only after it was saved to server to make sure we don't lose anything
@@ -137,8 +140,9 @@ function parseSelectedHtml(selectedHtml: string) {
const content = selectedHtml.replace(dom[0].outerHTML, "");
return [title, content];
} else {
return [null, selectedHtml];
}
return [null, selectedHtml];
}
async function duplicateSubtree(noteId: string, parentNotePath: string) {

View File

@@ -1,20 +1,20 @@
import appContext from "../components/app_context.js";
import type FNote from "../entities/fnote.js";
import treeService from "./tree.js";
import linkService from "./link.js";
import froca from "./froca.js";
import utils from "./utils.js";
import attributeRenderer from "./attribute_renderer.js";
import contentRenderer from "./content_renderer.js";
import froca from "./froca.js";
import appContext from "../components/app_context.js";
import type FNote from "../entities/fnote.js";
import { t } from "./i18n.js";
import linkService from "./link.js";
import treeService from "./tree.js";
import utils from "./utils.js";
// Track all elements that open tooltips
let openTooltipElements: JQuery<HTMLElement>[] = [];
let dismissTimer: ReturnType<typeof setTimeout>;
function setupGlobalTooltip() {
$(document).on("pointerenter", "a:not(.no-tooltip-preview)", mouseEnterHandler);
$(document).on("pointerenter", "[data-href]:not(.no-tooltip-preview)", mouseEnterHandler);
$(document).on("mouseenter", "a:not(.no-tooltip-preview)", mouseEnterHandler);
$(document).on("mouseenter", "[data-href]:not(.no-tooltip-preview)", mouseEnterHandler);
// close any note tooltip after click, this fixes the problem that sometimes tooltips remained on the screen
$(document).on("click", (e) => {
@@ -37,12 +37,10 @@ function dismissAllTooltips() {
}
function setupElementTooltip($el: JQuery<HTMLElement>) {
$el.on("pointerenter", mouseEnterHandler);
$el.on("mouseenter", mouseEnterHandler);
}
async function mouseEnterHandler<T>(this: HTMLElement, e: JQuery.TriggeredEvent<T, undefined, T, T>) {
if (e.pointerType !== "mouse") return;
async function mouseEnterHandler(this: HTMLElement) {
const $link = $(this);
if ($link.hasClass("no-tooltip-preview") || $link.hasClass("disabled")) {
@@ -93,7 +91,7 @@ async function mouseEnterHandler<T>(this: HTMLElement, e: JQuery.TriggeredEvent<
}
const html = `<div class="note-tooltip-content">${content}</div>`;
const tooltipClass = `tooltip-${ Math.floor(Math.random() * 999_999_999)}`;
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
@@ -226,7 +224,7 @@ function renderFootnoteOrAnchor($link: JQuery<HTMLElement>, url: string) {
}
let footnoteContent = $targetContent.html();
footnoteContent = `<div class="ck-content">${footnoteContent}</div>`;
footnoteContent = `<div class="ck-content">${footnoteContent}</div>`
return footnoteContent || "";
}

View File

@@ -1,9 +1,9 @@
import type { NoteType } from "../entities/fnote.js";
import type { MenuCommandItem, MenuItem, MenuItemBadge, MenuSeparatorItem } from "../menus/context_menu.js";
import type { TreeCommandNames } from "../menus/tree_context_menu.js";
import froca from "./froca.js";
import { t } from "./i18n.js";
import froca from "./froca.js";
import server from "./server.js";
import type { MenuCommandItem, MenuItem, MenuItemBadge, MenuSeparatorItem } from "../menus/context_menu.js";
import type { NoteType } from "../entities/fnote.js";
import type { TreeCommandNames } from "../menus/tree_context_menu.js";
export interface NoteTypeMapping {
type: NoteType;
@@ -26,7 +26,6 @@ export const NOTE_TYPES: NoteTypeMapping[] = [
// The default note type (always the first item)
{ type: "text", mime: "text/html", title: t("note_types.text"), icon: "bx-note" },
{ type: "spreadsheet", mime: "application/json", title: t("note_types.spreadsheet"), icon: "bx-table", isBeta: true },
// Text notes group
{ type: "book", mime: "", title: t("note_types.book"), icon: "bx-book" },
@@ -54,6 +53,7 @@ export const NOTE_TYPES: NoteTypeMapping[] = [
{ type: "file", title: t("note_types.file"), reserved: true },
{ type: "image", title: t("note_types.image"), reserved: true },
{ type: "launcher", mime: "", title: t("note_types.launcher"), reserved: true },
{ type: "aiChat", mime: "application/json", title: t("note_types.ai-chat"), reserved: true }
];
/** The maximum age in days for a template to be marked with the "New" badge */
@@ -97,9 +97,9 @@ function getBlankNoteTypes(command?: TreeCommandNames): MenuItem<TreeCommandName
title: nt.title,
command,
type: nt.type,
uiIcon: `bx ${nt.icon}`,
uiIcon: "bx " + nt.icon,
badges: []
};
}
if (nt.isNew) {
menuItem.badges?.push(NEW_BADGE);
@@ -131,7 +131,7 @@ async function getUserTemplates(command?: TreeCommandNames) {
const item: MenuItem<TreeCommandNames> = {
title: templateNote.title,
uiIcon: templateNote.getIcon(),
command,
command: command,
type: templateNote.type,
templateNoteId: templateNote.noteId
};
@@ -160,7 +160,7 @@ async function getBuiltInTemplates(title: string | null, command: TreeCommandNam
const items: MenuItem<TreeCommandNames>[] = [];
if (title) {
items.push({
title,
title: title,
kind: "header"
});
} else {
@@ -176,7 +176,7 @@ async function getBuiltInTemplates(title: string | null, command: TreeCommandNam
const item: MenuItem<TreeCommandNames> = {
title: templateNote.title,
uiIcon: templateNote.getIcon(),
command,
command: command,
type: templateNote.type,
templateNoteId: templateNote.noteId
};
@@ -194,7 +194,7 @@ async function isNewTemplate(templateNoteId) {
if (rootCreationDate === undefined) {
// Retrieve the root note creation date
try {
const rootNoteInfo: any = await server.get("notes/root");
let rootNoteInfo: any = await server.get("notes/root");
if ("dateCreated" in rootNoteInfo) {
rootCreationDate = new Date(rootNoteInfo.dateCreated);
}
@@ -209,7 +209,7 @@ async function isNewTemplate(templateNoteId) {
if (creationDate === undefined) {
// The creation date isn't available in the cache, try to retrieve it from the server
try {
const noteInfo: any = await server.get(`notes/${templateNoteId}`);
const noteInfo: any = await server.get("notes/" + templateNoteId);
if ("dateCreated" in noteInfo) {
creationDate = new Date(noteInfo.dateCreated);
creationDateCache.set(templateNoteId, creationDate);
@@ -231,8 +231,9 @@ async function isNewTemplate(templateNoteId) {
const age = (new Date().getTime() - creationDate.getTime()) / DAY_LENGTH;
// Return true if the template is at most NEW_TEMPLATE_MAX_AGE days old
return (age <= NEW_TEMPLATE_MAX_AGE);
} else {
return false;
}
return false;
}
export default {

View File

@@ -1,14 +1,4 @@
export type LabelType = "text" | "textarea" | "number" | "boolean" | "date" | "datetime" | "time" | "url" | "color";
type Multiplicity = "single" | "multi";
export interface DefinitionObject {
isPromoted?: boolean;
labelType?: LabelType;
multiplicity?: Multiplicity;
numberPrecision?: number;
promotedAlias?: string;
inverseRelation?: string;
}
import { DefinitionObject, LabelType, Multiplicity } from "@triliumnext/commons";
function parse(value: string) {
const tokens = value.split(",").map((t) => t.trim());
@@ -17,7 +7,7 @@ function parse(value: string) {
for (const token of tokens) {
if (token === "promoted") {
defObj.isPromoted = true;
} else if (["text", "textarea", "number", "boolean", "date", "datetime", "time", "url", "color"].includes(token)) {
} else if (["text", "number", "boolean", "date", "datetime", "time", "url", "color"].includes(token)) {
defObj.labelType = token as LabelType;
} else if (["single", "multi"].includes(token)) {
defObj.multiplicity = token as Multiplicity;

View File

@@ -0,0 +1,54 @@
import { h, VNode } from "preact";
import type FNote from "../entities/fnote.js";
import { renderReactWidgetAtElement } from "../widgets/react/react_utils.jsx";
import bundleService, { type Bundle } from "./bundle.js";
import froca from "./froca.js";
import server from "./server.js";
async function render(note: FNote, $el: JQuery<HTMLElement>) {
const relations = note.getRelations("renderNote");
const renderNoteIds = relations.map((rel) => rel.value).filter((noteId) => noteId);
$el.empty().toggle(renderNoteIds.length > 0);
for (const renderNoteId of renderNoteIds) {
const bundle = await server.post<Bundle>(`script/bundle/${renderNoteId}`);
const $scriptContainer = $("<div>");
$el.append($scriptContainer);
$scriptContainer.append(bundle.html);
// async so that scripts cannot block trilium execution
bundleService.executeBundle(bundle, note, $scriptContainer).then(result => {
// Render JSX
if (bundle.html === "") {
renderIfJsx(bundle, result, $el);
}
});
}
return renderNoteIds.length > 0;
}
async function renderIfJsx(bundle: Bundle, result: unknown, $el: JQuery<HTMLElement>) {
// Ensure the root script note is actually a JSX.
const rootScriptNoteId = await froca.getNote(bundle.noteId);
if (rootScriptNoteId?.mime !== "text/jsx") return;
// Ensure the output is a valid el.
if (typeof result !== "function") return;
// Obtain the parent component.
const closestComponent = glob.getComponentByEl($el.closest(".component")[0]);
if (!closestComponent) return;
// Render the element.
const el = h(result as () => VNode, {});
renderReactWidgetAtElement(closestComponent, el, $el[0]);
}
export default {
render
};

View File

@@ -1,86 +0,0 @@
import { Component, h, VNode } from "preact";
import type FNote from "../entities/fnote.js";
import { renderReactWidgetAtElement } from "../widgets/react/react_utils.jsx";
import { type Bundle, executeBundleWithoutErrorHandling } from "./bundle.js";
import froca from "./froca.js";
import server from "./server.js";
type ErrorHandler = (e: unknown) => void;
async function render(note: FNote, $el: JQuery<HTMLElement>, onError?: ErrorHandler) {
const relations = note.getRelations("renderNote");
const renderNoteIds = relations.map((rel) => rel.value).filter((noteId) => noteId);
$el.empty().toggle(renderNoteIds.length > 0);
try {
for (const renderNoteId of renderNoteIds) {
const bundle = await server.postWithSilentInternalServerError<Bundle>(`script/bundle/${renderNoteId}`);
const $scriptContainer = $("<div>");
$el.append($scriptContainer);
$scriptContainer.append(bundle.html);
// async so that scripts cannot block trilium execution
executeBundleWithoutErrorHandling(bundle, note, $scriptContainer)
.catch(onError)
.then(result => {
// Render JSX
if (bundle.html === "") {
renderIfJsx(bundle, result, $el, onError).catch(onError);
}
});
}
return renderNoteIds.length > 0;
} catch (e) {
if (typeof e === "string" && e.startsWith("{") && e.endsWith("}")) {
try {
onError?.(JSON.parse(e));
} catch (e) {
onError?.(e);
}
} else {
onError?.(e);
}
}
}
async function renderIfJsx(bundle: Bundle, result: unknown, $el: JQuery<HTMLElement>, onError?: ErrorHandler) {
// Ensure the root script note is actually a JSX.
const rootScriptNoteId = await froca.getNote(bundle.noteId);
if (rootScriptNoteId?.mime !== "text/jsx") return;
// Ensure the output is a valid el.
if (typeof result !== "function") return;
// Obtain the parent component.
const closestComponent = glob.getComponentByEl($el.closest(".component")[0]);
if (!closestComponent) return;
// Render the element.
const UserErrorBoundary = class UserErrorBoundary extends Component {
constructor(props: object) {
super(props);
this.state = { error: null };
}
componentDidCatch(error: unknown) {
onError?.(error);
this.setState({ error });
}
render() {
if ("error" in this.state && this.state?.error) return null;
return this.props.children;
}
};
const el = h(UserErrorBoundary, {}, h(result as () => VNode, {}));
renderReactWidgetAtElement(closestComponent, el, $el[0]);
}
export default {
render
};

View File

@@ -73,10 +73,6 @@ async function post<T>(url: string, data?: unknown, componentId?: string) {
return await call<T>("POST", url, componentId, { data });
}
async function postWithSilentInternalServerError<T>(url: string, data?: unknown, componentId?: string) {
return await call<T>("POST", url, componentId, { data, silentInternalServerError: true });
}
async function put<T>(url: string, data?: unknown, componentId?: string) {
return await call<T>("PUT", url, componentId, { data });
}
@@ -89,33 +85,21 @@ async function remove<T>(url: string, componentId?: string) {
return await call<T>("DELETE", url, componentId);
}
async function upload(url: string, fileToUpload: File, componentId?: string, method = "PUT") {
async function upload(url: string, fileToUpload: File, componentId?: string) {
const formData = new FormData();
formData.append("upload", fileToUpload);
const doUpload = async () => $.ajax({
return await $.ajax({
url: window.glob.baseApiUrl + url,
headers: await getHeaders(componentId ? {
"trilium-component-id": componentId
} : undefined),
data: formData,
type: method,
type: "PUT",
timeout: 60 * 60 * 1000,
contentType: false, // NEEDED, DON'T REMOVE THIS
processData: false // NEEDED, DON'T REMOVE THIS
});
try {
return await doUpload();
} catch (e: unknown) {
// jQuery rejects with the jqXHR object
const jqXhr = e as JQuery.jqXHR;
if (jqXhr?.status && isCsrfError(jqXhr.status, jqXhr.responseText)) {
await refreshCsrfToken();
return await doUpload();
}
throw e;
}
}
let idCounter = 1;
@@ -124,55 +108,11 @@ const idToRequestMap: Record<string, RequestData> = {};
let maxKnownEntityChangeId = 0;
let csrfRefreshInProgress: Promise<void> | null = null;
/**
* Re-fetches /bootstrap to obtain a fresh CSRF token. This is needed when the
* server session expires (e.g. mobile tab backgrounded for a long time) and the
* existing CSRF token is no longer valid.
*
* Coalesces concurrent calls so only one bootstrap request is in-flight at a time.
*/
async function refreshCsrfToken(): Promise<void> {
if (csrfRefreshInProgress) {
return csrfRefreshInProgress;
}
csrfRefreshInProgress = (async () => {
try {
const response = await fetch(`./bootstrap${window.location.search}`, { cache: "no-store" });
if (response.ok) {
const json = await response.json();
glob.csrfToken = json.csrfToken;
}
} finally {
csrfRefreshInProgress = null;
}
})();
return csrfRefreshInProgress;
}
function isCsrfError(status: number, responseText: string): boolean {
if (status !== 403) {
return false;
}
try {
const body = JSON.parse(responseText);
return body.message === "Invalid CSRF token";
} catch {
return false;
}
}
interface CallOptions {
data?: unknown;
silentNotFound?: boolean;
silentInternalServerError?: boolean;
// If `true`, the value will be returned as a string instead of a JavaScript object if JSON, XMLDocument if XML, etc.
raw?: boolean;
/** Used internally to prevent infinite retry loops on CSRF refresh. */
csrfRetried?: boolean;
}
async function call<T>(method: string, url: string, componentId?: string, options: CallOptions = {}) {
@@ -203,7 +143,7 @@ async function call<T>(method: string, url: string, componentId?: string, option
});
})) as any;
} else {
resp = await ajax(url, method, data, headers, options);
resp = await ajax(url, method, data, headers, !!options.silentNotFound, options.raw);
}
const maxEntityChangeIdStr = resp.headers["trilium-max-entity-change-id"];
@@ -215,14 +155,17 @@ async function call<T>(method: string, url: string, componentId?: string, option
return resp.body as T;
}
function ajax(url: string, method: string, data: unknown, headers: Headers, opts: CallOptions): Promise<Response> {
/**
* @param raw if `true`, the value will be returned as a string instead of a JavaScript object if JSON, XMLDocument if XML, etc.
*/
function ajax(url: string, method: string, data: unknown, headers: Headers, silentNotFound: boolean, raw?: boolean): Promise<Response> {
return new Promise((res, rej) => {
const options: JQueryAjaxSettings = {
url: window.glob.baseApiUrl + url,
type: method,
headers,
timeout: 60000,
success: (body, _textStatus, jqXhr) => {
success: (body, textStatus, jqXhr) => {
const respHeaders: Headers = {};
jqXhr
@@ -247,27 +190,7 @@ function ajax(url: string, method: string, data: unknown, headers: Headers, opts
// don't report requests that are rejected by the browser, usually when the user is refreshing or going to a different page.
rej("rejected by browser");
return;
}
// If the CSRF token is stale (e.g. session expired while tab was backgrounded),
// refresh it and retry the request once.
if (!opts.csrfRetried && isCsrfError(jqXhr.status, jqXhr.responseText)) {
try {
await refreshCsrfToken();
// Rebuild headers so the fresh glob.csrfToken is picked up
const retryHeaders = await getHeaders({ "trilium-component-id": headers["trilium-component-id"] });
const retryResult = await ajax(url, method, data, retryHeaders, { ...opts, csrfRetried: true });
res(retryResult);
return;
} catch (retryErr) {
rej(retryErr);
return;
}
}
if (opts.silentNotFound && jqXhr.status === 404) {
// report nothing
} else if (opts.silentInternalServerError && jqXhr.status === 500) {
} else if (silentNotFound && jqXhr.status === 404) {
// report nothing
} else {
await reportError(method, url, jqXhr.status, jqXhr.responseText);
@@ -277,7 +200,7 @@ function ajax(url: string, method: string, data: unknown, headers: Headers, opts
}
};
if (opts.raw) {
if (raw) {
options.dataType = "text";
}
@@ -376,7 +299,6 @@ export default {
get,
getWithSilentNotFound,
post,
postWithSilentInternalServerError,
put,
patch,
remove,

View File

@@ -12,7 +12,6 @@ export default class SpacedUpdate {
private updateInterval: number;
private changeForbidden?: boolean;
private stateCallback?: StateCallback;
private lastState: SaveState = "saved";
constructor(updater: Callback, updateInterval = 1000, stateCallback?: StateCallback) {
this.updater = updater;
@@ -25,7 +24,7 @@ export default class SpacedUpdate {
scheduleUpdate() {
if (!this.changeForbidden) {
this.changed = true;
this.onStateChanged("unsaved");
this.stateCallback?.("unsaved");
setTimeout(() => this.triggerUpdate());
}
}
@@ -35,12 +34,12 @@ export default class SpacedUpdate {
this.changed = false; // optimistic...
try {
this.onStateChanged("saving");
this.stateCallback?.("saving");
await this.updater();
this.onStateChanged("saved");
this.stateCallback?.("saved");
} catch (e) {
this.changed = true;
this.onStateChanged("error");
this.stateCallback?.("error");
logError(getErrorMessage(e));
throw e;
}
@@ -77,13 +76,13 @@ export default class SpacedUpdate {
}
if (Date.now() - this.lastUpdated > this.updateInterval) {
this.onStateChanged("saving");
this.stateCallback?.("saving");
try {
await this.updater();
this.onStateChanged("saved");
this.stateCallback?.("saved");
this.changed = false;
} catch (e) {
this.onStateChanged("error");
this.stateCallback?.("error");
logError(getErrorMessage(e));
}
this.lastUpdated = Date.now();
@@ -93,13 +92,6 @@ export default class SpacedUpdate {
}
}
onStateChanged(state: SaveState) {
if (state === this.lastState) return;
this.stateCallback?.(state);
this.lastState = state;
}
async allowUpdateWithoutChange(callback: Callback) {
this.changeForbidden = true;

View File

@@ -14,9 +14,7 @@ export function reloadFrontendApp(reason?: string) {
}
if (isElectron()) {
for (const window of dynamicRequire("@electron/remote").BrowserWindow.getAllWindows()) {
window.reload();
}
dynamicRequire("@electron/remote").BrowserWindow.getFocusedWindow()?.reload();
} else {
window.location.reload();
}
@@ -135,6 +133,8 @@ export function isElectron() {
return !!(window && window.process && window.process.type);
}
export const isStandalone = window.glob.isStandalone;
/**
* Returns `true` if the client is running as a PWA, otherwise `false`.
*/
@@ -816,7 +816,7 @@ function compareVersions(v1: string, v2: string): number {
/**
* Compares two semantic version strings and returns `true` if the latest version is greater than the current version.
*/
function isUpdateAvailable(latestVersion: string | null | undefined, currentVersion: string): boolean {
export function isUpdateAvailable(latestVersion: string | null | undefined, currentVersion: string): boolean {
if (!latestVersion) {
return false;
}

View File

@@ -133,6 +133,49 @@ async function handleMessage(event: MessageEvent<any>) {
appContext.triggerEvent("apiLogMessages", { noteId: message.noteId, messages: message.messages });
} else if (message.type === "toast") {
toastService.showMessage(message.message);
} else if (message.type === "llm-stream") {
// ENHANCED LOGGING FOR DEBUGGING
console.log(`[WS-CLIENT] >>> RECEIVED LLM STREAM MESSAGE <<<`);
console.log(`[WS-CLIENT] Message details: sessionId=${message.sessionId}, hasContent=${!!message.content}, contentLength=${message.content ? message.content.length : 0}, hasThinking=${!!message.thinking}, hasToolExecution=${!!message.toolExecution}, isDone=${!!message.done}`);
if (message.content) {
console.log(`[WS-CLIENT] CONTENT PREVIEW: "${message.content.substring(0, 50)}..."`);
}
// Create the event with detailed logging
console.log(`[WS-CLIENT] Creating CustomEvent 'llm-stream-message'`);
const llmStreamEvent = new CustomEvent('llm-stream-message', { detail: message });
// Dispatch to multiple targets to ensure delivery
try {
console.log(`[WS-CLIENT] Dispatching event to window`);
window.dispatchEvent(llmStreamEvent);
console.log(`[WS-CLIENT] Event dispatched to window`);
// Also try document for completeness
console.log(`[WS-CLIENT] Dispatching event to document`);
document.dispatchEvent(new CustomEvent('llm-stream-message', { detail: message }));
console.log(`[WS-CLIENT] Event dispatched to document`);
} catch (err) {
console.error(`[WS-CLIENT] Error dispatching event:`, err);
}
// Debug current listeners (though we can't directly check for specific event listeners)
console.log(`[WS-CLIENT] Active event listeners should receive this message now`);
// Detailed logging based on message type
if (message.content) {
console.log(`[WS-CLIENT] Content message: ${message.content.length} chars`);
} else if (message.thinking) {
console.log(`[WS-CLIENT] Thinking update: "${message.thinking}"`);
} else if (message.toolExecution) {
console.log(`[WS-CLIENT] Tool execution: action=${message.toolExecution.action}, tool=${message.toolExecution.tool || 'unknown'}`);
if (message.toolExecution.result) {
console.log(`[WS-CLIENT] Tool result preview: "${String(message.toolExecution.result).substring(0, 50)}..."`);
}
} else if (message.done) {
console.log(`[WS-CLIENT] Completion signal received`);
}
} else if (message.type === "execute-script") {
// TODO: Remove after porting the file
// @ts-ignore
@@ -261,7 +304,7 @@ async function sendPing() {
}
setTimeout(() => {
if (glob.device === "print") return;
if (glob.device === "print" || glob.isStandalone) return;
ws = connectWebSocket();

View File

@@ -1,107 +1,66 @@
import "jquery";
import utils from "./services/utils.js";
import ko from "knockout";
type SetupStep = "sync-in-progress" | "setup-type" | "new-document-in-progress" | "sync-from-desktop" | "sync-from-server";
type SetupType = "new-document" | "sync-from-desktop" | "sync-from-server" | "";
// TriliumNextTODO: properly make use of below types
// type SetupModelSetupType = "new-document" | "sync-from-desktop" | "sync-from-server" | "";
// type SetupModelStep = "sync-in-progress" | "setup-type" | "new-document-in-progress" | "sync-from-desktop";
class SetupController {
private step: SetupStep;
private setupType: SetupType = "";
private syncPollIntervalId: number | null = null;
private rootNode: HTMLElement;
private setupTypeForm: HTMLFormElement;
private syncFromServerForm: HTMLFormElement;
private setupTypeNextButton: HTMLButtonElement;
private setupTypeInputs: HTMLInputElement[];
private syncServerHostInput: HTMLInputElement;
private syncProxyInput: HTMLInputElement;
private passwordInput: HTMLInputElement;
private sections: Record<SetupStep, HTMLElement>;
class SetupModel {
syncInProgress: boolean;
step: ko.Observable<string>;
setupType: ko.Observable<string>;
setupNewDocument: ko.Observable<boolean>;
setupSyncFromDesktop: ko.Observable<boolean>;
setupSyncFromServer: ko.Observable<boolean>;
syncServerHost: ko.Observable<string | undefined>;
syncProxy: ko.Observable<string | undefined>;
password: ko.Observable<string | undefined>;
constructor(rootNode: HTMLElement, syncInProgress: boolean) {
this.rootNode = rootNode;
this.step = syncInProgress ? "sync-in-progress" : "setup-type";
this.setupTypeForm = mustGetElement("setup-type-form", HTMLFormElement);
this.syncFromServerForm = mustGetElement("sync-from-server-form", HTMLFormElement);
this.setupTypeNextButton = mustGetElement("setup-type-next", HTMLButtonElement);
this.setupTypeInputs = Array.from(document.querySelectorAll<HTMLInputElement>("input[name='setup-type']"));
this.syncServerHostInput = mustGetElement("sync-server-host", HTMLInputElement);
this.syncProxyInput = mustGetElement("sync-proxy", HTMLInputElement);
this.passwordInput = mustGetElement("password", HTMLInputElement);
this.sections = {
"setup-type": mustGetElement("setup-type-section", HTMLElement),
"new-document-in-progress": mustGetElement("new-document-in-progress-section", HTMLElement),
"sync-from-desktop": mustGetElement("sync-from-desktop-section", HTMLElement),
"sync-from-server": mustGetElement("sync-from-server-section", HTMLElement),
"sync-in-progress": mustGetElement("sync-in-progress-section", HTMLElement)
};
}
constructor(syncInProgress: boolean) {
this.syncInProgress = syncInProgress;
this.step = ko.observable(syncInProgress ? "sync-in-progress" : "setup-type");
this.setupType = ko.observable("");
this.setupNewDocument = ko.observable(false);
this.setupSyncFromDesktop = ko.observable(false);
this.setupSyncFromServer = ko.observable(false);
this.syncServerHost = ko.observable();
this.syncProxy = ko.observable();
this.password = ko.observable();
init() {
this.setupTypeForm.addEventListener("submit", (event) => {
event.preventDefault();
void this.selectSetupType();
});
this.syncFromServerForm.addEventListener("submit", (event) => {
event.preventDefault();
void this.finish();
});
for (const input of this.setupTypeInputs) {
input.addEventListener("change", () => {
this.setupType = input.value as SetupType;
this.render();
});
if (this.syncInProgress) {
setInterval(checkOutstandingSyncs, 1000);
}
for (const backButton of document.querySelectorAll<HTMLElement>("[data-action='back']")) {
backButton.addEventListener("click", () => {
this.back();
});
}
const serverAddress = `${location.protocol}//${location.host}`;
$("#current-host").html(serverAddress);
if (this.step === "sync-in-progress") {
this.startSyncPolling();
}
this.render();
this.rootNode.style.display = "";
}
private async selectSetupType() {
if (this.setupType === "new-document") {
this.setStep("new-document-in-progress");
// this is called in setup.ejs
setupTypeSelected() {
return !!this.setupType();
}
await $.post("api/setup/new-document");
window.location.replace("./setup");
return;
}
selectSetupType() {
if (this.setupType() === "new-document") {
this.step("new-document-in-progress");
if (this.setupType) {
this.setStep(this.setupType);
$.post("api/setup/new-document").then(() => {
window.location.replace("./setup");
});
} else {
this.step(this.setupType());
}
}
private back() {
this.setStep("setup-type");
this.setupType = "";
for (const input of this.setupTypeInputs) {
input.checked = false;
}
this.render();
back() {
this.step("setup-type");
this.setupType("");
}
private async finish() {
const syncServerHost = this.syncServerHostInput.value.trim();
const syncProxy = this.syncProxyInput.value.trim();
const password = this.passwordInput.value;
async finish() {
const syncServerHost = this.syncServerHost();
const syncProxy = this.syncProxy();
const password = this.password();
if (!syncServerHost) {
showAlert("Trilium server address can't be empty");
@@ -115,44 +74,21 @@ class SetupController {
// not using server.js because it loads too many dependencies
const resp = await $.post("api/setup/sync-from-server", {
syncServerHost,
syncProxy,
password
syncServerHost: syncServerHost,
syncProxy: syncProxy,
password: password
});
if (resp.result === "success") {
this.step("sync-in-progress");
setInterval(checkOutstandingSyncs, 1000);
hideAlert();
this.setStep("sync-in-progress");
this.startSyncPolling();
} else {
showAlert(`Sync setup failed: ${resp.error}`);
}
}
private setStep(step: SetupStep) {
this.step = step;
this.render();
}
private render() {
for (const [step, section] of Object.entries(this.sections) as [SetupStep, HTMLElement][]) {
section.style.display = step === this.step ? "" : "none";
}
this.setupTypeNextButton.disabled = !this.setupType;
}
private getSelectedSetupType(): SetupType {
return (this.setupTypeInputs.find((input) => input.checked)?.value ?? "") as SetupType;
}
private startSyncPolling() {
if (this.syncPollIntervalId !== null) {
return;
}
this.syncPollIntervalId = window.setInterval(checkOutstandingSyncs, 1000);
}
}
async function checkOutstandingSyncs() {
@@ -186,19 +122,7 @@ function getSyncInProgress() {
return !!parseInt(el.content);
}
function mustGetElement<T extends typeof HTMLElement>(id: string, ctor: T): InstanceType<T> {
const element = document.getElementById(id);
if (!element || !(element instanceof ctor)) {
throw new Error(`Expected element #${id}`);
}
return element as InstanceType<T>;
}
addEventListener("DOMContentLoaded", (event) => {
const rootNode = document.getElementById("setup-dialog");
if (!rootNode || !(rootNode instanceof HTMLElement)) return;
new SetupController(rootNode, getSyncInProgress()).init();
ko.applyBindings(new SetupModel(getSyncInProgress()), document.getElementById("setup-dialog"));
$("#setup-dialog").show();
});

View File

@@ -0,0 +1,450 @@
/* LLM Chat Panel Styles */
.note-context-chat {
background-color: var(--main-background-color);
}
/* Message Styling */
.chat-message {
margin-bottom: 1rem;
}
.message-avatar {
width: 36px;
height: 36px;
border-radius: 50%;
font-size: 1.25rem;
flex-shrink: 0;
}
.user-avatar {
background-color: var(--input-background-color);
color: var(--cmd-button-icon-color);
}
.assistant-avatar {
background-color: var(--subtle-border-color, var(--main-border-color));
color: var(--hover-item-text-color);
}
.message-content {
max-width: calc(100% - 50px);
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
color: var(--main-text-color);
}
.user-content {
border-radius: 0.5rem 0.5rem 0 0.5rem !important;
background-color: var(--input-background-color) !important;
}
.assistant-content {
border-radius: 0.5rem 0.5rem 0.5rem 0 !important;
background-color: var(--main-background-color);
border: 1px solid var(--subtle-border-color, var(--main-border-color));
}
/* Tool Execution Styling */
.tool-execution-info {
margin-top: 0.75rem;
margin-bottom: 1.5rem;
border: 1px solid var(--subtle-border-color);
border-radius: 0.5rem;
overflow: hidden;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.05);
background-color: var(--main-background-color);
/* Add a subtle transition effect */
transition: all 0.2s ease-in-out;
}
.tool-execution-status {
background-color: var(--accented-background-color, rgba(0, 0, 0, 0.03)) !important;
border-radius: 0 !important;
padding: 0.5rem !important;
max-height: 250px !important;
overflow-y: auto;
}
.tool-execution-status .d-flex {
border-bottom: 1px solid var(--subtle-border-color);
padding-bottom: 0.5rem;
margin-bottom: 0.5rem;
}
.tool-step {
padding: 0.5rem;
margin-bottom: 0.75rem;
border-radius: 0.375rem;
background-color: var(--main-background-color);
border: 1px solid var(--subtle-border-color);
transition: background-color 0.2s ease;
}
.tool-step:hover {
background-color: rgba(0, 0, 0, 0.01);
}
.tool-step:last-child {
margin-bottom: 0;
}
/* Tool step specific styling */
.tool-step.executing {
background-color: rgba(0, 123, 255, 0.05);
border-color: rgba(0, 123, 255, 0.2);
}
.tool-step.result {
background-color: rgba(40, 167, 69, 0.05);
border-color: rgba(40, 167, 69, 0.2);
}
.tool-step.error {
background-color: rgba(220, 53, 69, 0.05);
border-color: rgba(220, 53, 69, 0.2);
}
/* Tool result formatting */
.tool-result pre {
margin: 0.5rem 0;
padding: 0.5rem;
background-color: rgba(0, 0, 0, 0.03);
border-radius: 0.25rem;
overflow: auto;
max-height: 300px;
}
.tool-result code {
font-family: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace;
font-size: 0.9em;
}
.tool-args code {
display: block;
padding: 0.5rem;
background-color: rgba(0, 0, 0, 0.03);
border-radius: 0.25rem;
margin-top: 0.25rem;
font-size: 0.85em;
color: var(--muted-text-color);
white-space: pre-wrap;
overflow: auto;
max-height: 100px;
}
/* Tool Execution in Chat Styling */
.chat-tool-execution {
padding: 0 0 0 36px; /* Aligned with message content, accounting for avatar width */
width: 100%;
margin-bottom: 1rem;
}
.tool-execution-container {
background-color: var(--accented-background-color, rgba(245, 247, 250, 0.7));
border: 1px solid var(--subtle-border-color);
border-radius: 0.375rem;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
overflow: hidden;
max-width: calc(100% - 20px);
transition: all 0.3s ease;
}
.tool-execution-container.collapsed {
display: none;
}
.tool-execution-header {
background-color: var(--main-background-color);
border-bottom: 1px solid var(--subtle-border-color);
margin-bottom: 0.5rem;
color: var(--muted-text-color);
font-weight: 500;
padding: 0.6rem 0.8rem;
cursor: pointer;
transition: background-color 0.2s ease;
}
.tool-execution-header:hover {
background-color: var(--hover-item-background-color, rgba(0, 0, 0, 0.03));
}
.tool-execution-toggle {
color: var(--muted-text-color) !important;
background: transparent !important;
padding: 0.2rem 0.4rem !important;
transition: transform 0.2s ease;
}
.tool-execution-toggle:hover {
color: var(--main-text-color) !important;
}
.tool-execution-toggle i.bx-chevron-down {
transform: rotate(0deg);
transition: transform 0.3s ease;
}
.tool-execution-toggle i.bx-chevron-right {
transform: rotate(-90deg);
transition: transform 0.3s ease;
}
.tool-execution-chat-steps {
padding: 0.5rem;
max-height: 300px;
overflow-y: auto;
}
/* Make error text more visible */
.text-danger {
color: #dc3545 !important;
}
/* Sources Styling */
.sources-container {
background-color: var(--accented-background-color, var(--main-background-color));
border-top: 1px solid var(--main-border-color);
color: var(--main-text-color);
}
.source-item {
transition: all 0.2s ease;
background-color: var(--main-background-color);
border-color: var(--subtle-border-color, var(--main-border-color)) !important;
}
.source-item:hover {
background-color: var(--link-hover-background, var(--hover-item-background-color));
}
.source-link {
color: var(--link-color, var(--hover-item-text-color));
text-decoration: none;
display: block;
width: 100%;
}
.source-link:hover {
color: var(--link-hover-color, var(--hover-item-text-color));
}
/* Input Area Styling */
.note-context-chat-form {
background-color: var(--main-background-color);
border-top: 1px solid var(--main-border-color);
}
.context-option-container {
padding: 0.5rem 0;
border-bottom: 1px solid var(--subtle-border-color, var(--main-border-color));
color: var(--main-text-color);
}
.chat-input-container {
padding-top: 0.5rem;
}
.note-context-chat-input {
border-color: var(--subtle-border-color, var(--main-border-color));
background-color: var(--input-background-color) !important;
color: var(--input-text-color) !important;
resize: none;
transition: all 0.2s ease;
min-height: 50px;
max-height: 150px;
}
.note-context-chat-input:focus {
border-color: var(--input-focus-outline-color, var(--main-border-color));
box-shadow: 0 0 0 0.25rem var(--input-focus-outline-color, rgba(13, 110, 253, 0.25));
}
.note-context-chat-send-button {
width: 40px;
height: 40px;
align-self: flex-end;
background-color: var(--cmd-button-background-color) !important;
color: var(--cmd-button-text-color) !important;
}
/* Loading Indicator */
.loading-indicator {
align-items: center;
justify-content: center;
padding: 1rem;
color: var(--muted-text-color);
}
/* Thinking display styles */
.llm-thinking-container {
margin: 1rem 0;
animation: fadeInUp 0.3s ease-out;
}
.thinking-bubble {
background-color: var(--accented-background-color, var(--main-background-color));
border: 1px solid var(--subtle-border-color, var(--main-border-color));
border-radius: 0.75rem;
padding: 0.75rem;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
position: relative;
overflow: hidden;
transition: all 0.2s ease;
}
.thinking-bubble:hover {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
}
.thinking-bubble::before {
content: '';
position: absolute;
top: 0;
inset-inline-start: -100%;
width: 100%;
height: 100%;
background: linear-gradient(90deg, transparent, var(--hover-item-background-color, rgba(0, 0, 0, 0.03)), transparent);
animation: shimmer 2s infinite;
opacity: 0.5;
}
.thinking-header {
cursor: pointer;
transition: all 0.2s ease;
border-radius: 0.375rem;
}
.thinking-header:hover {
background-color: var(--hover-item-background-color, rgba(0, 0, 0, 0.03));
padding: 0.25rem;
margin: -0.25rem;
}
.thinking-dots {
display: flex;
gap: 3px;
align-items: center;
}
.thinking-dots span {
width: 6px;
height: 6px;
background-color: var(--link-color, var(--hover-item-text-color));
border-radius: 50%;
animation: thinkingPulse 1.4s infinite ease-in-out;
}
.thinking-dots span:nth-child(1) {
animation-delay: -0.32s;
}
.thinking-dots span:nth-child(2) {
animation-delay: -0.16s;
}
.thinking-dots span:nth-child(3) {
animation-delay: 0s;
}
.thinking-label {
font-weight: 500;
color: var(--link-color, var(--hover-item-text-color)) !important;
}
.thinking-toggle {
color: var(--muted-text-color) !important;
transition: transform 0.2s ease;
background: transparent !important;
border: none !important;
}
.thinking-toggle:hover {
color: var(--main-text-color) !important;
}
.thinking-toggle.expanded {
transform: rotate(180deg);
}
.thinking-content {
margin-top: 0.75rem;
padding-top: 0.75rem;
border-top: 1px solid var(--subtle-border-color, var(--main-border-color));
animation: expandDown 0.3s ease-out;
}
.thinking-text {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
font-size: 0.875rem;
line-height: 1.5;
color: var(--main-text-color);
white-space: pre-wrap;
word-wrap: break-word;
background-color: var(--input-background-color);
padding: 0.75rem;
border-radius: 0.5rem;
border: 1px solid var(--subtle-border-color, var(--main-border-color));
max-height: 300px;
overflow-y: auto;
transition: border-color 0.2s ease;
}
.thinking-text:hover {
border-color: var(--main-border-color);
}
/* Animations */
@keyframes thinkingPulse {
0%, 80%, 100% {
transform: scale(0.8);
opacity: 0.6;
}
40% {
transform: scale(1);
opacity: 1;
}
}
@keyframes shimmer {
0% {
inset-inline-start: -100%;
}
100% {
inset-inline-start: 100%;
}
}
@keyframes fadeInUp {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes expandDown {
from {
opacity: 0;
max-height: 0;
}
to {
opacity: 1;
max-height: 300px;
}
}
/* Responsive adjustments */
@media (max-width: 768px) {
.thinking-bubble {
margin: 0.5rem 0;
padding: 0.5rem;
}
.thinking-text {
font-size: 0.8rem;
padding: 0.5rem;
max-height: 200px;
}
}

View File

@@ -153,11 +153,6 @@ textarea,
background: var(--input-background-color);
}
.form-control:disabled {
background-color: var(--input-background-color);
opacity: 0.6;
}
.form-control:focus {
color: var(--input-text-color);
background: var(--input-background-color);
@@ -947,7 +942,6 @@ table.promoted-attributes-in-tooltip th {
color: var(--muted-text-color);
opacity: 0.6;
line-height: 1;
word-wrap: break-word;
}
.aa-dropdown-menu .aa-suggestion p {
@@ -1342,12 +1336,15 @@ body.desktop .dropdown-submenu > .dropdown-menu {
max-width: 300px;
}
.dropdown-submenu.dropstart > .dropdown-menu,
body:not(.mobile) #launcher-pane.horizontal .dropdown-submenu > .dropdown-menu {
.dropdown-submenu.dropstart > .dropdown-menu {
inset-inline-start: auto;
inset-inline-end: calc(100% - 2px);
}
body:not(.mobile) #launcher-pane.horizontal .dropdown-submenu > .dropdown-menu {
inset-inline-start: calc(-100% + 10px);
}
.right-dropdown-widget {
flex-shrink: 0;
}
@@ -1587,7 +1584,7 @@ body:not(.mobile) #launcher-pane.horizontal .dropdown-submenu > .dropdown-menu {
position: absolute;
top: 0;
inset-inline-start: 0;
height: 100dvh;
bottom: 0;
width: 85vw;
padding-top: env(safe-area-inset-top);
transition: transform 250ms ease-in-out;
@@ -1612,7 +1609,11 @@ body:not(.mobile) #launcher-pane.horizontal .dropdown-submenu > .dropdown-menu {
}
body.mobile #launcher-container {
justify-content: space-evenly;
justify-content: center;
}
body.mobile #launcher-container button {
margin: 0 16px;
}
body.mobile .modal.show {
@@ -1647,27 +1648,13 @@ body:not(.mobile) #launcher-pane.horizontal .dropdown-submenu > .dropdown-menu {
word-break: break-all;
}
body.mobile .jump-to-note-dialog {
.modal-header {
padding-bottom: 0.75rem !important;
}
body.mobile .jump-to-note-dialog .modal-content {
overflow-y: auto;
}
.modal-content {
padding-bottom: 0 !important;
}
.modal-body {
overflow-y: auto;
}
.aa-dropdown-menu {
max-height: unset;
overflow: auto;
}
.aa-suggestion {
padding-inline: 0;
}
body.mobile .jump-to-note-dialog .modal-dialog .aa-dropdown-menu {
max-height: unset;
overflow: auto;
}
body.mobile .modal-dialog .dropdown-menu {
@@ -2626,7 +2613,7 @@ iframe.print-iframe {
}
}
body:not(.ios) #root-widget.virtual-keyboard-opened .note-split:not(.active) {
#root-widget.virtual-keyboard-opened .note-split:not(.active) {
max-height: 80px;
opacity: 0.4;
}

View File

@@ -210,7 +210,6 @@
--badge-share-background-color: #4d4d4d;
--badge-clipped-note-background-color: #295773;
--badge-execute-background-color: #604180;
--badge-active-content-background-color: rgb(12, 68, 70);
--note-icon-background-color: #444444;
--note-icon-color: #d4d4d4;
@@ -239,9 +238,9 @@
--bottom-panel-background-color: #11111180;
--bottom-panel-title-bar-background-color: #3F3F3F80;
--status-bar-border-color: var(--main-border-color);
--scrollbar-thumb-color: #fdfdfd5c;
--scrollbar-thumb-hover-color: #ffffff7d;
--scrollbar-background-color: transparent;
@@ -291,15 +290,6 @@
--ck-editor-toolbar-button-on-shadow: 1px 1px 2px rgba(0, 0, 0, .75);
--ck-editor-toolbar-dropdown-button-open-background: #ffffff14;
--note-list-view-icon-color: var(--left-pane-icon-color);
--note-list-view-large-icon-background: var(--note-icon-background-color);
--note-list-view-large-icon-color: var(--note-icon-color);
--note-list-view-search-result-highlight-background: transparent;
--note-list-view-search-result-highlight-color: var(--quick-search-result-highlight-color);
--note-list-view-content-background: rgba(0, 0, 0, .2);
--note-list-view-content-search-result-highlight-background: var(--quick-search-result-highlight-color);
--note-list-view-content-search-result-highlight-color: black;
--calendar-coll-event-background-saturation: 25%;
--calendar-coll-event-background-lightness: 20%;
--calendar-coll-event-background-color: #3c3c3c;
@@ -313,9 +303,7 @@
* Dark color scheme tweaks
*/
#left-pane .fancytree-node.tinted,
.nested-note-list-item.use-note-color,
.note-book-card .note-book-header.use-note-color {
#left-pane .fancytree-node.tinted {
--custom-color: var(--dark-theme-custom-color);
/* The background color of the active item in the note tree.
@@ -349,25 +337,12 @@ body .todo-list input[type="checkbox"]:not(:checked):before {
--promoted-attribute-card-background-color: hsl(var(--custom-color-hue), 13.2%, 20.8%);
}
.modal.tab-bar-modal .tabs .tab-card.with-hue {
background-color: hsl(var(--bg-hue), 8.8%, 11.2%);
border-color: hsl(var(--bg-hue), 9.4%, 25.1%);
}
.modal.tab-bar-modal .tabs .tab-card.active.with-hue {
background-color: hsl(var(--bg-hue), 8.8%, 16.2%);
border-color: hsl(var(--bg-hue), 9.4%, 25.1%);
}
.use-note-color {
--custom-color: var(--dark-theme-custom-color);
}
.note-split.with-hue,
.quick-edit-dialog-wrapper.with-hue,
.nested-note-list-item.with-hue,
.note-book-card.with-hue .note-book-header {
.quick-edit-dialog-wrapper.with-hue {
--note-icon-custom-background-color: hsl(var(--custom-color-hue), 15.8%, 30.9%);
--note-icon-custom-color: hsl(var(--custom-color-hue), 100%, 76.5%);
--note-icon-hover-custom-background-color: hsl(var(--custom-color-hue), 28.3%, 36.7%);
@@ -376,9 +351,4 @@ body .todo-list input[type="checkbox"]:not(:checked):before {
.note-split.with-hue *::selection,
.quick-edit-dialog-wrapper.with-hue *::selection {
--selection-background-color: hsl(var(--custom-color-hue), 49.2%, 35%);
}
.note-book-card.with-hue {
--card-background-color: hsl(var(--custom-color-hue), 6%, 21%);
--card-background-hover-color: hsl(var(--custom-color-hue), 8%, 25%);
}

View File

@@ -202,7 +202,6 @@
--badge-share-background-color: #6b6b6b;
--badge-clipped-note-background-color: #2284c0;
--badge-execute-background-color: #7b47af;
--badge-active-content-background-color: rgb(27, 164, 168);
--note-icon-background-color: #4f4f4f;
--note-icon-color: white;
@@ -289,15 +288,6 @@
--ck-editor-toolbar-button-on-shadow: none;
--ck-editor-toolbar-dropdown-button-open-background: #0000000f;
--note-list-view-icon-color: var(--left-pane-icon-color);
--note-list-view-large-icon-background: var(--note-icon-background-color);
--note-list-view-large-icon-color: var(--note-icon-color);
--note-list-view-search-result-highlight-background: transparent;
--note-list-view-search-result-highlight-color: var(--quick-search-result-highlight-color);
--note-list-view-content-background: #b1b1b133;
--note-list-view-content-search-result-highlight-background: var(--quick-search-result-highlight-color);
--note-list-view-content-search-result-highlight-color: white;
--calendar-coll-event-background-lightness: 95%;
--calendar-coll-event-background-saturation: 80%;
--calendar-coll-event-background-color: #eaeaea;
@@ -307,9 +297,7 @@
--calendar-coll-today-background-color: #00000006;
}
#left-pane .fancytree-node.tinted,
.nested-note-list-item.use-note-color,
.note-book-card .note-book-header.use-note-color {
#left-pane .fancytree-node.tinted {
--custom-color: var(--light-theme-custom-color);
/* The background color of the active item in the note tree.
@@ -324,20 +312,8 @@
--promoted-attribute-card-background-color: hsl(var(--custom-color-hue), 40%, 88%);
}
.modal.tab-bar-modal .tabs .tab-card.with-hue {
background-color: hsl(var(--bg-hue), 56%, 96%);
border-color: hsl(var(--bg-hue), 33%, 41%);
}
.modal.tab-bar-modal .tabs .tab-card.active.with-hue {
background-color: hsl(var(--bg-hue), 86%, 96%);
border-color: hsl(var(--bg-hue), 33%, 41%);
}
.note-split.with-hue,
.quick-edit-dialog-wrapper.with-hue,
.nested-note-list-item.with-hue,
.note-book-card.with-hue .note-book-header {
.quick-edit-dialog-wrapper.with-hue {
--note-icon-custom-background-color: hsl(var(--custom-color-hue), 44.5%, 43.1%);
--note-icon-custom-color: hsl(var(--custom-color-hue), 91.3%, 91%);
--note-icon-hover-custom-background-color: hsl(var(--custom-color-hue), 55.1%, 50.2%);
@@ -346,9 +322,4 @@
.note-split.with-hue *::selection,
.quick-edit-dialog-wrapper.with-hue *::selection {
--selection-background-color: hsl(var(--custom-color-hue), 60%, 90%);
}
.note-book-card.with-hue {
--card-background-color: hsl(var(--custom-color-hue), 21%, 94%);
--card-background-hover-color: hsl(var(--custom-color-hue), 21%, 87%);
}

View File

@@ -643,6 +643,139 @@ li.dropdown-item a.dropdown-item-button:focus-visible {
transform: translateY(4%);
}
/*
* NOTE LIST
*/
.note-list .note-book-card {
--note-list-horizontal-padding: 22px;
--note-list-vertical-padding: 15px;
background-color: var(--card-background-color);
border: 1px solid var(--card-border-color) !important;
border-radius: 12px;
user-select: none;
padding: 0;
margin: 5px 10px 5px 0;
}
:root .note-list .note-book-card:hover {
background-color: var(--card-background-hover-color);
transition: background-color 200ms ease-out;
}
:root .note-list.grid-view .note-book-card:active {
transform: scale(.98);
}
.note-list.list-view .note-book-card {
box-shadow: 0 0 3px var(--card-shadow-color);
}
.note-list.list-view .note-book-card .note-book-header .note-icon {
vertical-align: middle;
}
.note-list-wrapper .note-book-card a {
color: inherit !important;
}
.note-list-wrapper .note-book-card .note-book-header {
font-size: 1em;
font-weight: bold;
padding: 0.5em 1rem;
border-bottom-color: var(--card-border-color);
}
.note-list-wrapper .note-book-card .note-book-header .note-icon {
font-size: 17px;
vertical-align: text-bottom;
}
.note-list-wrapper .note-book-card .note-book-header .note-book-title {
font-size: 1em;
color: var(--active-item-text-color);
vertical-align: middle;
}
.note-list-wrapper .note-book-card .note-book-header .rendered-note-attributes {
font-size: 0.7em;
font-weight: normal;
margin-bottom: 0;
}
.note-list-wrapper .note-book-card .note-book-header:last-child {
border-bottom: 0;
}
.note-list-wrapper .note-book-card .note-book-content {
padding: 0 !important;
font-size: 0.8rem;
}
.note-list-wrapper .note-book-card .note-book-content .rendered-content {
padding: 1rem;
}
.note-list-wrapper .note-book-card .note-book-content.type-image .rendered-content,
.note-list-wrapper .note-book-card .note-book-content.type-pdf .rendered-content {
padding: 0;
}
.note-list-wrapper .note-book-card .note-book-content .rendered-content.text-with-ellipsis {
padding: 1rem !important;
}
.note-list-wrapper .note-book-card .note-book-content h1,
.note-list-wrapper .note-book-card .note-book-content h2,
.note-list-wrapper .note-book-card .note-book-content h3,
.note-list-wrapper .note-book-card .note-book-content h4,
.note-list-wrapper .note-book-card .note-book-content h5,
.note-list-wrapper .note-book-card .note-book-content h6 {
font-size: 1rem;
color: var(--active-item-text-color);
}
.note-list-wrapper .note-book-card .note-book-content p:last-child {
margin-bottom: 0;
}
.note-list-wrapper .note-book-card .note-book-content.type-canvas .rendered-content,
.note-list-wrapper .note-book-card .note-book-content.type-mindMap .rendered-content,
.note-list-wrapper .note-book-card .note-book-content.type-code .rendered-content,
.note-list-wrapper .note-book-card .note-book-content.type-video .rendered-content {
padding: 0;
}
.note-list-wrapper .note-book-card .note-book-content.type-code {
height: 100%;
}
.note-list-wrapper .note-book-card .note-book-content.type-code pre {
height: 100%;
padding: 1em;
margin-bottom: 0;
}
.note-list-wrapper .note-book-card .tn-icon {
color: var(--left-pane-icon-color) !important;
}
.note-list.grid-view .note-book-card:hover {
filter: contrast(105%);
}
.note-list.grid-view .ck-content {
line-height: 1.3;
}
.note-list.grid-view .ck-content p {
margin-bottom: 0.5em;
}
.note-list.grid-view .ck-content figure.image {
width: 25%;
}
/*
* NOTE SEARCH SUGGESTIONS
*/
@@ -667,19 +800,3 @@ li.dropdown-item a.dropdown-item-button:focus-visible {
background: var(--hover-item-background-color);
color: var(--hover-item-text-color);
}
/*
* Alert bars
*/
div.alert {
margin-bottom: 8px;
background: var(--alert-bar-background) !important;
color: var(--main-text-color);
border-radius: 8px;
font-size: .85em;
}
div.alert p + p {
margin-block: 1em 0;
}

View File

@@ -84,22 +84,6 @@ button.btn.btn-success kbd {
letter-spacing: 0.5pt;
}
/*
* Low profile buttons
*/
button.tn-low-profile {
appearance: none;
background: transparent;
border: 0;
border-radius: 8px;
color: inherit;
}
button.tn-low-profile:hover {
background-color: var(--icon-button-hover-background);
}
/*
* Icon buttons
*/
@@ -145,10 +129,6 @@ button.tn-low-profile:hover {
font-size: calc(var(--icon-button-size) * var(--icon-button-icon-ratio));
}
:root .icon-action.disabled::before {
opacity: .5;
}
:root .icon-action:not(.global-menu-button):hover,
:root .icon-action:not(.global-menu-button).show,
:root .tn-tool-button:hover,
@@ -814,35 +794,3 @@ input[type="range"] {
scrollbar-width: unset;
}
}
/*
* Centered forms
*/
.tn-centered-form {
display: flex;
flex-direction: column;
align-items: center;
margin-bottom: 20vh;
}
.tn-centered-form .form-group {
text-align: center;
color: var(--muted-text-color);
}
.tn-centered-form .form-icon {
font-size: 140px;
color: var(--main-border-color);
}
.tn-centered-form .protected-session-password {
margin-inline: auto;
max-width: 350px;
text-align: center;
}
.tn-centered-form .input-group,
.tn-centered-form button {
margin-top: 12px;
}

View File

@@ -0,0 +1,122 @@
/* LLM Chat Launcher Widget Styles */
.note-context-chat {
display: flex;
flex-direction: column;
height: 100%;
width: 100%;
}
.note-context-chat-container {
flex-grow: 1;
overflow-y: auto;
padding: 15px;
}
.chat-message {
display: flex;
margin-bottom: 15px;
max-width: 85%;
}
.chat-message.user-message {
margin-inline-start: auto;
}
.chat-message.assistant-message {
margin-inline-end: auto;
}
.message-avatar {
width: 32px;
height: 32px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
margin-inline-end: 8px;
}
.user-message .message-avatar {
background-color: var(--primary-color);
color: white;
}
.assistant-message .message-avatar {
background-color: var(--secondary-color);
color: white;
}
.message-content {
background-color: var(--more-accented-background-color);
border-radius: 12px;
padding: 10px 15px;
max-width: calc(100% - 40px);
}
.user-message .message-content {
background-color: var(--accented-background-color);
}
.message-content pre {
background-color: var(--code-background-color);
border-radius: 5px;
padding: 10px;
overflow-x: auto;
max-width: 100%;
}
.message-content code {
background-color: var(--code-background-color);
padding: 2px 4px;
border-radius: 3px;
}
.loading-indicator {
display: flex;
align-items: center;
margin: 10px 0;
color: var(--muted-text-color);
}
.sources-container {
background-color: var(--accented-background-color);
border-top: 1px solid var(--main-border-color);
padding: 8px;
}
.sources-list {
font-size: 0.9em;
}
.source-item {
padding: 4px 0;
}
.source-link {
color: var(--link-color);
text-decoration: none;
}
.source-link:hover {
text-decoration: underline;
}
.note-context-chat-form {
display: flex;
background-color: var(--main-background-color);
border-top: 1px solid var(--main-border-color);
padding: 10px;
}
.note-context-chat-input {
resize: vertical;
min-height: 44px;
max-height: 200px;
}
/* Responsive adjustments */
@media (max-width: 768px) {
.chat-message {
max-width: 95%;
}
}

View File

@@ -647,10 +647,10 @@ html .note-detail-editable-text :not(figure, .include-note, hr):first-child {
font-weight: 600;
}
:root .ck-content hr {
margin-block: 5px;
height: 0;
border: thin solid var(--main-border-color);
.ck-content hr {
margin: 5px 0;
height: 1px;
background-color: var(--main-border-color);
opacity: 1;
}

View File

@@ -265,6 +265,13 @@ body.desktop .options-section:not(.tn-no-card) {
margin-bottom: 6px;
}
.options-section .alert {
margin-bottom: 8px;
background: var(--alert-bar-background) !important;
border-radius: 8px;
font-size: .85em;
}
nav.options-section-tabs {
min-width: var(--options-card-min-width);
max-width: var(--options-card-max-width);

View File

@@ -372,6 +372,10 @@ body[dir=ltr] #launcher-container {
.calendar-dropdown-widget .calendar-header [data-calendar-input="month"] {
--input-background-color: transparent;
--menu-background-color: transparent;
text-align: center;
font-size: 1.4em;
font-weight: 300;
}
.calendar-dropdown-widget .calendar-header input:not(:focus) {
@@ -421,6 +425,8 @@ body[dir=ltr] #launcher-container {
}
.calendar-dropdown-widget .calendar-week span {
font-size: 0.85em;
font-weight: 500;
color: var(--calendar-weekday-labels-color);
}
@@ -683,10 +689,9 @@ body.layout-vertical.background-effects div.quick-search .dropdown-menu {
padding-inline-start: 12px;
}
#left-pane span.fancytree-node.fancytree-active,
#left-pane span.fancytree-node.fancytree-active:hover {
#left-pane span.fancytree-node.fancytree-active {
position: relative;
background: transparent;
background: transparent !important;
color: var(--custom-color, var(--left-pane-item-selected-color));
}
@@ -699,14 +704,6 @@ body.layout-vertical.background-effects div.quick-search .dropdown-menu {
}
}
/*
* .fancytree-node pseudo-elements:
*
* - ::before: the active tree item decorator.
* - ::after: the selected tree item background. A pseudo-element is used instead of the
* element's background color, to allow alpha compositing for the hover state.
*/
#left-pane span.fancytree-node.fancytree-active::before {
position: absolute;
content: "";
@@ -721,24 +718,6 @@ body.layout-vertical.background-effects div.quick-search .dropdown-menu {
z-index: -1;
}
#left-pane span.fancytree-node.fancytree-selected {
--left-pane-item-selected-shadow-size: 4px;
position: relative;
background-color: transparent;
border-radius: 0;
}
#left-pane span.fancytree-node.fancytree-selected::after {
display: block;
position: absolute;
z-index: -2;
content: "";
inset: 0;
background: var(--selection-background-color);
animation: left-pane-item-select 100ms ease-out;
}
#left-pane span.fancytree-node.protected > span.fancytree-custom-icon {
position: relative;
filter: unset !important;
@@ -772,14 +751,12 @@ body[dir=rtl] #left-pane span.fancytree-node.protected > span.fancytree-custom-i
}
}
#left-pane .fancytree-expander,
.nested-note-list-item .note-expander {
#left-pane .fancytree-expander {
opacity: 0.65;
transition: opacity 150ms ease-in;
}
#left-pane .fancytree-expander:hover,
.nested-note-list-item .note-expander:hover {
#left-pane .fancytree-expander:hover {
opacity: 1;
transition: opacity 300ms ease-out;
}
@@ -801,8 +778,7 @@ body[dir=rtl] #left-pane span.fancytree-node.protected > span.fancytree-custom-i
opacity: 0.5;
}
#left-pane .tree-item-button,
#left-pane span.fancytree-node.fancytree-selected .fancytree-custom-icon {
#left-pane .tree-item-button {
margin-inline-end: 6px;
border: unset;
border-radius: 50%;
@@ -813,8 +789,7 @@ body[dir=rtl] #left-pane span.fancytree-node.protected > span.fancytree-custom-i
box-shadow 200ms ease-out;
}
#left-pane .tree-item-button:hover,
#left-pane span.fancytree-node.fancytree-selected .fancytree-custom-icon:hover {
#left-pane .tree-item-button:hover {
background: var(--left-pane-item-action-button-hover-background);
box-shadow: var(--left-pane-item-action-button-hover-shadow);
transition:
@@ -822,41 +797,10 @@ body[dir=rtl] #left-pane span.fancytree-node.protected > span.fancytree-custom-i
box-shadow 100ms ease-in;
}
#left-pane span.fancytree-node.fancytree-active .tree-item-button:hover,
#left-pane span.fancytree-node.fancytree-active.fancytree-selected .fancytree-custom-icon:hover {
#left-pane span.fancytree-node.fancytree-active .tree-item-button:hover {
box-shadow: var(--left-pane-item-selected-action-button-hover-shadow);
}
/* Selected item bulk action button */
@keyframes bulk-action-button-blink {
from {
opacity: 1;
}
to {
opacity: .3;
}
}
#left-pane span.fancytree-node.fancytree-selected .fancytree-custom-icon {
margin: 0;
}
#left-pane span.fancytree-node.fancytree-selected .fancytree-custom-icon::before {
border: 0;
font-size: .65em;
}
#left-pane span.fancytree-node.fancytree-selected:hover .fancytree-custom-icon:not(:hover)::before {
animation: bulk-action-button-blink 500ms linear infinite alternate;
}
#left-pane span.fancytree-node.fancytree-selected.protected .fancytree-custom-icon::after {
/* Protected note indicator */
display: none;
}
#context-menu-container {
/* The context menu of the tree */
--menu-item-icon-vert-offset: -1px;
@@ -1087,7 +1031,7 @@ body.layout-vertical.electron.platform-darwin .tab-row-container {
height: var(--tab-height) !important;
}
body.layout-vertical .tab-row-widget > * {
.tab-row-widget > * {
margin-top: calc((var(--tab-bar-height) - var(--tab-height)) / 2);
}

View File

@@ -140,22 +140,10 @@ ul.fancytree-container {
background-color: inherit;
}
.fancytree-custom-icon {
display: flex;
justify-content: center;
align-items: center;
width: 1em;
height: 1em;
font-size: 1.2em;
}
/* Fallback icon */
:where(.fancytree-custom-icon)::before {
content: "?";
}
/* Protected note icon badge */
span.fancytree-node.protected > span.fancytree-custom-icon {
filter: drop-shadow(2px 2px 2px var(--main-text-color));
}
@@ -197,7 +185,7 @@ span.fancytree-node.fancytree-active-clone:not(.fancytree-active) .fancytree-tit
span.fancytree-active {
color: var(--active-item-text-color);
background-color: var(--active-item-background-color);
background-color: var(--active-item-background-color) !important;
border-color: transparent; /* invisible border */
border-radius: 5px;
}
@@ -207,15 +195,20 @@ span.fancytree-active .fancytree-title {
border: 0;
}
span.fancytree-node.fancytree-selected {
background-color: var(--selection-background-color);
border-radius: 0;
span.fancytree-selected {
border-color: var(--main-border-color) !important;
border-radius: 5px;
}
span.fancytree-selected .fancytree-title {
text-decoration: underline;
font-style: italic;
}
span.fancytree-selected .fancytree-custom-icon::before {
font-family: "boxicons";
content: "\ef05";
border: 1px solid var(--main-text-color);
content: "\eb43";
border: 1px solid var(--main-border-color);
border-radius: 3px;
}

View File

@@ -566,6 +566,113 @@
"enable-smooth-scroll": "تمكين التمرير السلس",
"enable-motion": "تمكين الانتقالات والرسوم المتحركة"
},
"ai_llm": {
"progress": "تقدم",
"openai_tab": "OpenAI",
"actions": "أجراءات",
"retry": "أعد المحاولة",
"reprocessing_index": "جار اعادة البناء...",
"never": "ابدٱ",
"agent": {
"processing": "جار المعالجة...",
"thinking": "جار التفكير...",
"loading": "جار التحميل...",
"generating": "جار الانشاء..."
},
"name": "الذكاء الأصطناعي",
"openai": "OpenAI",
"sources": "مصادر",
"temperature": "درجة الحرارة",
"model": "نموذج",
"refreshing_models": "جار التحديث...",
"error": "خطأ",
"refreshing": "جار التحديث...",
"ollama_tab": "Ollama",
"anthropic_tab": "انتروبيك",
"not_started": "لم يبدأ بعد",
"title": "اعدادات AI",
"processed_notes": "الملاحظات المعالجة",
"total_notes": "الملاحظات الكلية",
"queued_notes": "الملاحظات في قائمة الانتظار",
"failed_notes": "الملاحظات الفاشلة",
"last_processed": "اخر معالجة",
"refresh_stats": "تحديث الاحصائيات",
"voyage_tab": "استكشاف AI",
"provider_precedence": "اولوية المزود",
"system_prompt": "موجه النظام",
"openai_configuration": "اعدادات OpenAI",
"openai_settings": "اعدادات OpenAI",
"api_key": "مفتاح واجهة برمجة التطبيقات",
"url": "عنوان URL الاساسي",
"default_model": "النموذج الافتراضي",
"base_url": "عنوان URL الأساسي",
"openai_url_description": "افتراضيا: https://api.openai.com/v1",
"anthropic_settings": "اعدادات انتروبيك",
"ollama_settings": "اعدادات Ollama",
"anthropic_configuration": "تهيئة انتروبيك",
"voyage_url_description": "افتراضيا: https://api.voyageai.com/v1",
"ollama_configuration": "تهيئة Ollama",
"enable_ollama": "تمكين Ollama",
"last_attempt": "اخر محاولة",
"active_providers": "المزودون النشطون",
"disabled_providers": "المزودون المعطلون",
"similarity_threshold": "عتبة التشابه",
"complete": "اكتمل (100%)",
"ai_settings": "اعدادات AI",
"show_thinking": "عرض التفكير",
"index_status": "حالة الفهرس",
"indexed_notes": "الملاحظات المفهرسة",
"indexing_stopped": "تم ايقاف الفهرسة",
"last_indexed": "اخر فهرسة",
"note_chat": "دردشة الملاحظة",
"start_indexing": "بدء الفهرسة",
"chat": {
"root_note_title": "دردشات AI",
"new_chat_title": "دردشة جديدة",
"create_new_ai_chat": "انشاء دردشة AI جديدة"
},
"selected_provider": "المزود المحدد",
"select_model": "اختر النموذج...",
"select_provider": "اختر المزود...",
"ollama_model": "نموذج Ollama",
"refresh_models": "تحديث النماذج",
"rebuild_index": "اعادة بناء الفهرس",
"note_title": "عنوان الملاحظة",
"processing": "جاري المعالجة ({{percentage}}%)",
"incomplete": "غير مكتمل ({{percentage}}%)",
"ollama_url": "عنوان URL الخاص ب Ollama",
"provider_configuration": "تكوين موفر AI",
"voyage_settings": "استكشاف اعدادات AI",
"enable_automatic_indexing": "تمكين الفهرسة التلقائية",
"index_rebuild_progress": "تقدم اعادة انشاء الفهرس",
"index_rebuild_complete": "اكتملت عملية تحسين الفهرس",
"use_enhanced_context": "استخدام السياق المحسن",
"enter_message": "ادخل رسالتك...",
"index_all_notes": "فهرسة جميع الملاحظات",
"indexing_in_progress": "جار فهرسة الملاحظات...",
"use_advanced_context": "استخدم السياق المتقدم",
"ai_enabled": "تمكين مميزات AI",
"ai_disabled": "الغاء تمكين مميزات AI",
"enable_ai_features": "تمكين خصائص AI/LLM",
"enable_ai": "تمكين خصائص AI/LLM",
"reprocess_index": "اعادة بناء فهرس البحث",
"index_rebuilding": "جار تحسين الفهرس {{percentage}}",
"voyage_configuration": "اعدادت Voyage AI",
"openai_model_description": "الامثلة: gpt-4o, gpt-4-turbo, gpt-3.5-turbo",
"partial": "{{ percentage }} % مكتمل",
"retry_queued": "تم جدولة الملاحظة لاعادة المحاولة",
"max_notes_per_llm_query": "اكبر عدد للملاحظات لكل استعلام",
"remove_provider": "احذف المزود من البحث",
"restore_provider": "استعادة المزود الى البحث",
"reprocess_index_error": "حدث خطأ اثناء اعادة بناء فهرس البحث",
"auto_refresh_notice": "تحديث تلقائي كل {{seconds}} ثانية",
"note_queued_for_retry": "الملاحظة جاهزة لاعادة المحاولة لاحقا",
"failed_to_retry_note": "‎فشل في اعادة محاولة معالجة المحاولة",
"failed_to_retry_all": "فشل في اعادة محاولة معالجة الملاحظة",
"error_generating_response": "‌فشل في توليد استجابة من ال AI",
"create_new_ai_chat": "انشاء دردشة AI جديدة",
"error_fetching": "فشل في استرجاع النماذج: {{error}}"
},
"code_auto_read_only_size": {
"unit": "حروف",
"title": "الحجم التلقائي للقراءه فقط"
@@ -803,13 +910,13 @@
"web-view": "عرض الويب",
"mind-map": "خريطة ذهنية",
"geo-map": "خريطة جغرافية",
"task-list": "قائمة المهام",
"spreadsheet": "جدول البيانات"
"ai-chat": "دردشة AI",
"task-list": "قائمة المهام"
},
"shared_switch": {
"shared": "مشترك",
"toggle-on-title": "مشاركة الملاحظة",
"toggle-off-title": "إلغاء مشاركة الملاحظة"
"toggle-off-title": "الغاء مشاركة الملاحظة"
},
"template_switch": {
"template": "قالب"
@@ -883,7 +990,6 @@
"electron_context_menu": {
"cut": "قص",
"copy": "نسخ",
"copy-as-markdown": "نسخ كـ Markdown",
"paste": "لصق",
"copy-link": "نسخ الرابط",
"add-term-to-dictionary": "اضافة \"{{term}}\" الى القاموس",
@@ -1069,10 +1175,14 @@
"rename_note": "اعادة تسمية الملاحظة",
"remove_relation": "حذف العلاقة",
"default_new_note_title": "ملاحظة جديدة",
"open_in_new_tab": "فتح في تبويب جديد",
"enter_new_title": "ادخل عنوان ملاحظة جديدة:",
"note_not_found": "الملاحظة {{noteId}} غير موجودة!",
"cannot_match_transform": "تعذر مطابقة التحويل: {{transform}}"
},
"web_view": {
"web_view": "عرض الويب"
},
"consistency_checks": {
"title": "فحوصات التناسق"
},
@@ -1286,10 +1396,8 @@
"search-for": "بحث ل \"{{term}}\""
},
"protect_note": {
"toggle-off": "إزالة الحماية عن الملاحظة",
"toggle-on": "حماية الملاحظة",
"toggle-on-hint": "الملاحظة غير محمة، انقر لحمايتها",
"toggle-off-hint": "الملاحظة محمية، انقر لإزالة الحماية منها"
"toggle-off": "ازالة الحماية عن الملاحظة",
"toggle-on": "حماية الملاحظة"
},
"open-help-page": "فتح صفحة المساعدة",
"empty": {

View File

@@ -1008,7 +1008,7 @@
"no_attachments": "此笔记没有附件。"
},
"book": {
"no_children_help": "此集合没有任何子笔记,因此没有内容显示。",
"no_children_help": "此类型为书籍的笔记没有任何子笔记,因此没有内容显示。请参阅 <a href=\"https://triliumnext.github.io/Docs/Wiki/book-note.html\">wiki</a> 了解详情。",
"drag_locked_title": "锁定编辑",
"drag_locked_message": "无法拖拽,因为集合已被锁定编辑。"
},
@@ -1047,6 +1047,7 @@
"unprotecting-title": "解除保护状态"
},
"relation_map": {
"open_in_new_tab": "在新标签页中打开",
"remove_note": "删除笔记",
"edit_title": "编辑标题",
"rename_note": "重命名笔记",
@@ -1063,6 +1064,15 @@
"default_new_note_title": "新笔记",
"click_on_canvas_to_place_new_note": "点击画布以放置新笔记"
},
"render": {
"note_detail_render_help_1": "之所以显示此帮助说明,是因为这个类型为渲染 HTML 的笔记没有正常工作所需的关系。",
"note_detail_render_help_2": "渲染 HTML 笔记类型用于<a class=\"external\" href=\"https://triliumnext.github.io/Docs/Wiki/scripts.html\">编写脚本</a>。简而言之,您有一份 HTML 代码笔记(可包含一些 JavaScript然后这个笔记会把页面渲染出来。要使其正常工作您需要定义一个名为 \"renderNote\" 的<a class=\"external\" href=\"https://triliumnext.github.io/Docs/Wiki/attributes.html\">关系</a>指向要渲染的 HTML 笔记。"
},
"web_view": {
"web_view": "网页视图",
"embed_websites": "网页视图类型的笔记允许您将网站嵌入到 Trilium 中。",
"create_label": "首先,请创建一个带有您要嵌入的 URL 地址的标签,例如 #webViewSrc=\"https://www.bing.com\""
},
"backend_log": {
"refresh": "刷新"
},
@@ -1411,8 +1421,7 @@
"description": "描述",
"reload_app": "重载应用以应用更改",
"set_all_to_default": "将所有快捷键重置为默认值",
"confirm_reset": "您确定要将所有键盘快捷键重置为默认值吗?",
"no_results": "未找到与“{{filter}}”匹配的快捷方式"
"confirm_reset": "您确定要将所有键盘快捷键重置为默认值吗?"
},
"spellcheck": {
"title": "拼写检查",
@@ -1531,11 +1540,10 @@
"geo-map": "地理地图",
"beta-feature": "测试版",
"task-list": "任务列表",
"ai-chat": "AI聊天",
"new-feature": "新建",
"collections": "集合",
"book": "集合",
"ai-chat": "AI聊天",
"spreadsheet": "电子表格"
"book": "集合"
},
"protect_note": {
"toggle-on": "保护笔记",
@@ -1614,9 +1622,7 @@
"print_report_title": "打印报告",
"print_report_collection_content_other": "集合中的 {{count}} 篇笔记无法打印,因为它们不受支持或受到保护。",
"print_report_collection_details_button": "查看详情",
"print_report_collection_details_ignored_notes": "忽略的笔记",
"print_report_error_title": "打印失败",
"print_report_stack_trace": "堆栈跟踪"
"print_report_collection_details_ignored_notes": "忽略的笔记"
},
"note_title": {
"placeholder": "请输入笔记标题...",
@@ -1631,8 +1637,7 @@
},
"search_result": {
"no_notes_found": "没有找到符合搜索条件的笔记。",
"search_not_executed": "尚未执行搜索。",
"search_now": "立即搜索"
"search_not_executed": "尚未执行搜索。请点击上方的\"搜索\"按钮查看结果。"
},
"spacer": {
"configure_launchbar": "配置启动栏"
@@ -1760,7 +1765,6 @@
"add-term-to-dictionary": "将 \"{{term}}\" 添加到字典",
"cut": "剪切",
"copy": "复制",
"copy-as-markdown": "复制为 Markdown",
"copy-link": "复制链接",
"paste": "粘贴",
"paste-as-plain-text": "以纯文本粘贴",
@@ -1841,6 +1845,149 @@
"yesterday": "昨天"
}
},
"ai_llm": {
"not_started": "未开始",
"title": "AI设置",
"processed_notes": "已处理笔记",
"total_notes": "笔记总数",
"progress": "进度",
"queued_notes": "排队中笔记",
"failed_notes": "失败笔记",
"last_processed": "最后处理时间",
"refresh_stats": "刷新统计数据",
"enable_ai_features": "启用AI/LLM功能",
"enable_ai_description": "启用笔记摘要、内容生成等AI功能及其他LLM能力",
"openai_tab": "OpenAI",
"anthropic_tab": "Anthropic",
"voyage_tab": "Voyage AI",
"ollama_tab": "Ollama",
"enable_ai": "启用AI/LLM功能",
"enable_ai_desc": "启用笔记摘要、内容生成等AI功能及其他LLM能力",
"provider_configuration": "AI提供商配置",
"provider_precedence": "提供商优先级",
"provider_precedence_description": "按优先级排序的提供商列表(用逗号分隔,例如:'openai,anthropic,ollama'",
"temperature": "温度参数",
"temperature_description": "控制响应的随机性0 = 确定性输出2 = 最大随机性)",
"system_prompt": "系统提示词",
"system_prompt_description": "所有AI交互使用的默认系统提示词",
"openai_configuration": "OpenAI配置",
"openai_settings": "OpenAI设置",
"api_key": "API密钥",
"url": "基础URL",
"model": "模型",
"openai_api_key_description": "用于访问OpenAI服务的API密钥",
"anthropic_api_key_description": "用于访问Claude模型的Anthropic API密钥",
"default_model": "默认模型",
"openai_model_description": "示例gpt-4o、gpt-4-turbo、gpt-3.5-turbo",
"base_url": "基础URL",
"openai_url_description": "默认https://api.openai.com/v1",
"anthropic_settings": "Anthropic设置",
"anthropic_url_description": "Anthropic API的基础URL默认https://api.anthropic.com",
"anthropic_model_description": "用于聊天补全的Anthropic Claude模型",
"voyage_settings": "Voyage AI设置",
"ollama_settings": "Ollama设置",
"ollama_url_description": "Ollama API的URL默认http://localhost:11434",
"ollama_model_description": "用于聊天补全的 Ollama 模型",
"anthropic_configuration": "Anthropic配置",
"voyage_configuration": "Voyage AI配置",
"voyage_url_description": "默认https://api.voyageai.com/v1",
"ollama_configuration": "Ollama配置",
"enable_ollama": "启用Ollama",
"enable_ollama_description": "启用Ollama以使用本地AI模型",
"ollama_url": "Ollama URL",
"ollama_model": "Ollama模型",
"refresh_models": "刷新模型",
"refreshing_models": "刷新中...",
"enable_automatic_indexing": "启用自动索引",
"rebuild_index": "重建索引",
"rebuild_index_error": "启动索引重建失败。请查看日志了解详情。",
"note_title": "笔记标题",
"error": "错误",
"last_attempt": "最后尝试时间",
"actions": "操作",
"retry": "重试",
"partial": "{{ percentage }}% 已完成",
"retry_queued": "笔记已加入重试队列",
"retry_failed": "笔记加入重试队列失败",
"max_notes_per_llm_query": "每次查询的最大笔记数",
"max_notes_per_llm_query_description": "AI上下文包含的最大相似笔记数量",
"active_providers": "活跃提供商",
"disabled_providers": "已禁用提供商",
"remove_provider": "从搜索中移除提供商",
"restore_provider": "将提供商恢复到搜索中",
"similarity_threshold": "相似度阈值",
"similarity_threshold_description": "纳入LLM查询上下文的笔记最低相似度分数0-1",
"reprocess_index": "重建搜索索引",
"reprocessing_index": "重建中...",
"reprocess_index_started": "搜索索引优化已在后台启动",
"reprocess_index_error": "重建搜索索引失败",
"index_rebuild_progress": "索引重建进度",
"index_rebuilding": "正在优化索引({{percentage}}%",
"index_rebuild_complete": "索引优化完成",
"index_rebuild_status_error": "检查索引重建状态失败",
"never": "从未",
"processing": "处理中({{percentage}}%",
"incomplete": "未完成({{percentage}}%",
"complete": "已完成100%",
"refreshing": "刷新中...",
"auto_refresh_notice": "每 {{seconds}} 秒自动刷新",
"note_queued_for_retry": "笔记已加入重试队列",
"failed_to_retry_note": "重试笔记失败",
"all_notes_queued_for_retry": "所有失败笔记已加入重试队列",
"failed_to_retry_all": "重试笔记失败",
"ai_settings": "AI设置",
"api_key_tooltip": "用于访问服务的API密钥",
"empty_key_warning": {
"anthropic": "Anthropic API密钥为空。请输入有效的API密钥。",
"openai": "OpenAI API密钥为空。请输入有效的API密钥。",
"voyage": "Voyage API密钥为空。请输入有效的API密钥。",
"ollama": "Ollama API密钥为空。请输入有效的API密钥。"
},
"agent": {
"processing": "处理中...",
"thinking": "思考中...",
"loading": "加载中...",
"generating": "生成中..."
},
"name": "AI",
"openai": "OpenAI",
"use_enhanced_context": "使用增强上下文",
"enhanced_context_description": "为AI提供来自笔记及其相关笔记的更多上下文以获得更好的响应",
"show_thinking": "显示思考过程",
"show_thinking_description": "显示AI的思维链过程",
"enter_message": "输入你的消息...",
"error_contacting_provider": "联系AI提供商失败。请检查你的设置和网络连接。",
"error_generating_response": "生成AI响应失败",
"index_all_notes": "为所有笔记建立索引",
"index_status": "索引状态",
"indexed_notes": "已索引笔记",
"indexing_stopped": "索引已停止",
"indexing_in_progress": "索引进行中...",
"last_indexed": "最后索引时间",
"note_chat": "笔记聊天",
"sources": "来源",
"start_indexing": "开始索引",
"use_advanced_context": "使用高级上下文",
"ollama_no_url": "Ollama 未配置。请输入有效的URL。",
"chat": {
"root_note_title": "AI聊天记录",
"root_note_content": "此笔记包含你保存的AI聊天对话。",
"new_chat_title": "新聊天",
"create_new_ai_chat": "创建新的AI聊天"
},
"create_new_ai_chat": "创建新的AI聊天",
"configuration_warnings": "你的AI配置存在一些问题。请检查你的设置。",
"experimental_warning": "LLM功能目前处于实验阶段 - 特此提醒。",
"selected_provider": "已选提供商",
"selected_provider_description": "选择用于聊天和补全功能的AI提供商",
"select_model": "选择模型...",
"select_provider": "选择提供商...",
"ai_enabled": "已启用 AI 功能",
"ai_disabled": "已禁用 AI 功能",
"no_models_found_online": "找不到模型。请检查您的 API 密钥及设置。",
"no_models_found_ollama": "找不到 Ollama 模型。请确认 Ollama 是否正在运行。",
"error_fetching": "获取模型失败:{{error}}"
},
"code-editor-options": {
"title": "编辑器"
},
@@ -1928,8 +2075,7 @@
"raster": "栅格",
"vector_light": "矢量(浅色)",
"vector_dark": "矢量(深色)",
"show-scale": "显示比例尺",
"show-labels": "显示标记名称"
"show-scale": "显示比例尺"
},
"table_context_menu": {
"delete_row": "删除行"
@@ -2008,9 +2154,8 @@
"app-restart-required": "(需重启程序以应用更改)"
},
"pagination": {
"total_notes": "{{count}} 篇笔记",
"prev_page": "上一页",
"next_page": "下一页"
"page_title": "第 {{startIndex}} 页 - 第 {{endIndex}} 页",
"total_notes": "{{count}} 篇笔记"
},
"collections": {
"rendering_error": "出现错误无法显示内容。"
@@ -2123,49 +2268,5 @@
},
"bookmark_buttons": {
"bookmarks": "书签"
},
"web_view_setup": {
"title": "直接在 Trilium 中创建网页的实时视图",
"url_placeholder": "输入或粘贴网站地址,例如 https://triliumnotes.org",
"create_button": "创建网页视图",
"invalid_url_title": "无效的地址",
"invalid_url_message": "请输入有效的网址,例如 https://triliumnotes.org。",
"disabled_description": "此网页视图来自外部来源。为保护您免受网络钓鱼或恶意内容侵害,该视图不会自动加载。若您信任该来源,可手动启用加载功能。",
"disabled_button_enable": "启用网页视图"
},
"render": {
"setup_title": "在此笔记中显示自定义 HTML 或 Preact JSX",
"setup_create_sample_preact": "使用 Preact 建立范例笔记",
"setup_create_sample_html": "使用 HTML 建立范例笔记",
"setup_sample_created": "已建立一个范例笔记作为子笔记。",
"disabled_description": "此渲染笔记来自外部来源。为保护您免受恶意内容侵害,该功能默认处于禁用状态。启用前请确保您信任该来源。",
"disabled_button_enable": "启用渲染笔记"
},
"active_content_badges": {
"type_icon_pack": "图标包",
"type_backend_script": "后端脚本",
"type_frontend_script": "前端脚本",
"type_widget": "小部件",
"type_app_css": "自定义 CSS",
"type_render_note": "渲染笔记",
"type_web_view": "网页视图",
"type_app_theme": "自定义主题",
"toggle_tooltip_enable_tooltip": "点击以启用此 {{type}}。",
"toggle_tooltip_disable_tooltip": "点击以禁用此 {{type}}。",
"menu_docs": "打开文档",
"menu_execute_now": "立即执行脚本",
"menu_run": "自动执行",
"menu_run_disabled": "手动",
"menu_run_backend_startup": "当后端启动时",
"menu_run_hourly": "每小时",
"menu_run_daily": "每日",
"menu_run_frontend_startup": "当桌面前端启动时",
"menu_run_mobile_startup": "当移动前端启动时",
"menu_change_to_widget": "更改为小部件",
"menu_change_to_frontend_script": "更改为前端脚本",
"menu_theme_base": "主题基底"
},
"setup_form": {
"more_info": "了解更多"
}
}

View File

@@ -428,9 +428,9 @@
"run_on_note_content_change": "Wird ausgeführt, wenn der Inhalt einer Notiz geändert wird (einschließlich der Erstellung von Notizen).",
"run_on_note_change": "Wird ausgeführt, wenn eine Notiz geändert wird (einschließlich der Erstellung von Notizen). Enthält keine Inhaltsänderungen",
"run_on_note_deletion": "Wird ausgeführt, wenn eine Notiz gelöscht wird",
"run_on_branch_creation": "wird ausgeführt, wenn ein Zweig erstellt wird. Der Zweig ist eine Verbindung zwischen der übergeordneten und der untergeordneten Notiz und wird z. B. beim Klonen oder Verschieben von Notizen erstellt.",
"run_on_branch_creation": "wird ausgeführt, wenn ein Zweig erstellt wird. Der Zweig ist eine Verbindung zwischen der übergeordneten Notiz und der untergeordneten Notiz und wird z. B. erstellt. beim Klonen oder Verschieben von Notizen.",
"run_on_branch_change": "wird ausgeführt, wenn ein Zweig aktualisiert wird.",
"run_on_branch_deletion": "wird ausgeführt, wenn ein Zweig gelöscht wird. Der Zweig ist eine Verknüpfung zwischen der übergeordneten und der untergeordneten Notiz und wird z. B. beim Verschieben der Notiz gelöscht (alter Zweig/Link wird gelöscht).",
"run_on_branch_deletion": "wird ausgeführt, wenn ein Zweig gelöscht wird. Der Zweig ist eine Verknüpfung zwischen der übergeordneten Notiz und der untergeordneten Notiz und wird z. B. gelöscht. beim Verschieben der Notiz (alter Zweig/Link wird gelöscht).",
"run_on_attribute_creation": "wird ausgeführt, wenn für die Notiz ein neues Attribut erstellt wird, das diese Beziehung definiert",
"run_on_attribute_change": " wird ausgeführt, wenn das Attribut einer Notiz geändert wird, die diese Beziehung definiert. Dies wird auch ausgelöst, wenn das Attribut gelöscht wird",
"relation_template": "Die Attribute der Notiz werden auch ohne eine Hierarchische-Beziehung vererbt. Der Inhalt und der Zweig werden den Instanznotizen hinzugefügt, wenn sie leer sind. Einzelheiten findest du in der Dokumentation.",
@@ -801,7 +801,7 @@
"expand_tooltip": "Erweitert die direkten Unterelemente dieser Sammlung (eine Ebene tiefer). Für weitere Optionen auf den Pfeil rechts klicken.",
"expand_first_level": "Direkte Unterelemente erweitern",
"expand_nth_level": "{{depth}} Ebenen erweitern",
"hide_child_notes": "Untergeordnete Notizen im Baum ausblenden"
"hide_child_notes": "Unternotizen im Baum ausblenden"
},
"edited_notes": {
"no_edited_notes_found": "An diesem Tag wurden noch keine Notizen bearbeitet...",
@@ -1007,7 +1007,7 @@
"no_attachments": "Diese Notiz enthält keine Anhänge."
},
"book": {
"no_children_help": "Diese Sammlung enthält keine untergeordnete Notizen, daher gibt es nichts anzuzeigen.",
"no_children_help": "Diese Notiz mit dem Notiztyp Buch besitzt keine Unternotizen, deshalb ist nichts zum Anzeigen vorhanden. Siehe <a href=\"https://triliumnext.github.io/Docs/Wiki/book-note.html\">Wiki</a> für mehr Details.",
"drag_locked_title": "Für Bearbeitung gesperrt",
"drag_locked_message": "Das Ziehen ist nicht möglich, da die Sammlung für die Bearbeitung gesperrt ist."
},
@@ -1046,6 +1046,7 @@
"unprotecting-title": "Ungeschützt-Status"
},
"relation_map": {
"open_in_new_tab": "In neuem Tab öffnen",
"remove_note": "Notiz entfernen",
"edit_title": "Titel bearbeiten",
"rename_note": "Notiz umbenennen",
@@ -1062,6 +1063,15 @@
"default_new_note_title": "neue Notiz",
"click_on_canvas_to_place_new_note": "Klicke auf den Canvas, um eine neue Notiz zu platzieren"
},
"render": {
"note_detail_render_help_1": "Diese Hilfesnotiz wird angezeigt, da diese Notiz vom Typ „HTML rendern“ nicht über die erforderliche Beziehung verfügt, um ordnungsgemäß zu funktionieren.",
"note_detail_render_help_2": "Render-HTML-Notiztyp wird benutzt für <a class=\"external\" href=\"https://triliumnext.github.io/Docs/Wiki/scripts.html\">scripting</a>. Kurzgesagt, du hast ein HTML-Code-Notiz (optional mit JavaScript) und diese Notiz rendert es. Damit es funktioniert, musst du eine a <a class=\"external\" href=\"https://triliumnext.github.io/Docs/Wiki/attributes.html\">Beziehung</a> namens \"renderNote\" zeigend auf die HTML-Notiz zum rendern definieren."
},
"web_view": {
"web_view": "Webansicht",
"embed_websites": "Notiz vom Typ Web View ermöglicht das Einbetten von Websites in Trilium.",
"create_label": "Um zu beginnen, erstelle bitte ein Label mit einer URL-Adresse, die eingebettet werden soll, z. B. #webViewSrc=\"https://www.google.com\""
},
"backend_log": {
"refresh": "Aktualisieren"
},
@@ -1186,7 +1196,7 @@
"tooltip_code_note_syntax": "Code-Notizen"
},
"vim_key_bindings": {
"use_vim_keybindings_in_code_notes": "Vim Tastenbelegung",
"use_vim_keybindings_in_code_notes": "Verwende VIM-Tastenkombinationen in Codenotizen (kein Ex-Modus)",
"enable_vim_keybindings": "Aktiviere Vim-Tastenkombinationen"
},
"wrap_lines": {
@@ -1377,8 +1387,7 @@
"description": "Beschreibung",
"reload_app": "Lade die App neu, um die Änderungen zu übernehmen",
"set_all_to_default": "Setze alle Verknüpfungen auf die Standardeinstellungen",
"confirm_reset": "Möchtest du wirklich alle Tastaturkürzel auf die Standardeinstellungen zurücksetzen?",
"no_results": "Keine Tastenkürzel für '{{filter}}' gefunden"
"confirm_reset": "Möchtest du wirklich alle Tastaturkürzel auf die Standardeinstellungen zurücksetzen?"
},
"spellcheck": {
"title": "Rechtschreibprüfung",
@@ -1438,7 +1447,7 @@
"open-in-a-new-tab": "In neuem Tab öffnen",
"open-in-a-new-split": "In neuem Split öffnen",
"insert-note-after": "Notiz dahinter einfügen",
"insert-child-note": "Untergeordnete Notiz einfügen",
"insert-child-note": "Unternotiz einfügen",
"delete": "Löschen",
"search-in-subtree": "Im Zweig suchen",
"hoist-note": "Notiz-Fokus setzen",
@@ -1487,21 +1496,20 @@
"mermaid-diagram": "Mermaid Diagramm",
"canvas": "Leinwand",
"web-view": "Webansicht",
"mind-map": "Mindmap",
"mind-map": "Mind Map",
"file": "Datei",
"image": "Bild",
"launcher": "Starter",
"doc": "Dokument",
"widget": "Widget",
"confirm-change": "Es ist nicht empfehlenswert den Notiz-Typ zu ändern, wenn der Inhalt der Notiz nicht leer ist. Möchtest du dennoch fortfahren?",
"confirm-change": "Es is nicht empfehlenswert den Notiz-Typ zu ändern, wenn der Inhalt der Notiz nicht leer ist. Möchtest du dennoch fortfahren?",
"geo-map": "Geo-Karte",
"beta-feature": "Beta",
"book": "Sammlung",
"ai-chat": "KI-Chat",
"ai-chat": "KI Chat",
"task-list": "Aufgabenliste",
"new-feature": "Neu",
"collections": "Sammlungen",
"spreadsheet": "Tabelle"
"collections": "Sammlungen"
},
"protect_note": {
"toggle-on": "Notiz schützen",
@@ -1558,7 +1566,7 @@
"saved-search-note-refreshed": "Gespeicherte Such-Notiz wurde aktualisiert.",
"hoist-this-note-workspace": "Diese Notiz fokussieren (Arbeitsbereich)",
"refresh-saved-search-results": "Gespeicherte Suchergebnisse aktualisieren",
"create-child-note": "Untergeordnete Notiz anlegen",
"create-child-note": "Unternotiz anlegen",
"unhoist": "Fokus verlassen",
"toggle-sidebar": "Seitenleiste ein-/ausblenden",
"dropping-not-allowed": "Ablegen von Notizen an dieser Stelle ist nicht zulässig.",
@@ -1569,7 +1577,7 @@
"subtree-hidden-tooltip_one": "{{count}} untergeordnete Notiz, die im Baum ausgeblendet ist",
"subtree-hidden-tooltip_other": "{{count}} untergeordnete Notizen, die im Baum ausgeblendet sind",
"subtree-hidden-moved-title": "Zu {{title}} hinzugefügt",
"subtree-hidden-moved-description-collection": "Diese Sammlung blendet ihre untergeordneten Notizen im Baum aus.",
"subtree-hidden-moved-description-collection": "Diese Sammlung blendet ihre Unternotizen im Baum aus.",
"subtree-hidden-moved-description-other": "Untergeordnete Notizen sind im Baum für diese Notiz ausgeblendet."
},
"title_bar_buttons": {
@@ -1583,9 +1591,7 @@
"print_report_collection_details_button": "Details anzeigen",
"print_report_collection_details_ignored_notes": "Ignorierte Notizen",
"print_report_collection_content_one": "{{count}} Notiz in der Sammlung konnte nicht gedruckt werden, weil sie nicht unterstützt oder geschützt ist.",
"print_report_collection_content_other": "{{count}} Notizen in der Sammlung konnten nicht gedruckt werden, weil sie nicht unterstützt oder geschützt sind.",
"print_report_error_title": "Druck fehlgeschlagen",
"print_report_stack_trace": "Stapelzurückverfolgung"
"print_report_collection_content_other": "{{count}} Notizen in der Sammlung konnten nicht gedruckt werden, weil sie nicht unterstützt oder geschützt sind."
},
"note_title": {
"placeholder": "Titel der Notiz hier eingeben…",
@@ -1600,8 +1606,7 @@
},
"search_result": {
"no_notes_found": "Es wurden keine Notizen mit den angegebenen Suchparametern gefunden.",
"search_not_executed": "Die Suche wurde noch nicht ausgeführt.",
"search_now": "Jetzt suchen"
"search_not_executed": "Die Suche wurde noch nicht ausgeführt. Klicke oben auf „Suchen“, um die Ergebnisse anzuzeigen."
},
"spacer": {
"configure_launchbar": "Starterleiste konfigurieren"
@@ -1729,7 +1734,6 @@
"add-term-to-dictionary": "Begriff \"{{term}}\" zum Wörterbuch hinzufügen",
"cut": "Ausschneiden",
"copy": "Kopieren",
"copy-as-markdown": "Als Markdown kopieren",
"copy-link": "Link kopieren",
"paste": "Einfügen",
"paste-as-plain-text": "Als unformatierten Text einfügen",
@@ -1758,7 +1762,7 @@
},
"note_autocomplete": {
"search-for": "Suche nach \"{{term}}\"",
"create-note": "Erstelle und verlinke untergeordnete Notiz \"{{term}}\"",
"create-note": "Erstelle und verlinke Unternotiz \"{{term}}\"",
"insert-external-link": "Einfügen von Externen Link zu \"{{term}}\"",
"clear-text-field": "Textfeldinhalt löschen",
"show-recent-notes": "Aktuelle Notizen anzeigen",
@@ -1769,7 +1773,7 @@
"quick-edit": "Schnellbearbeitung"
},
"geo-map": {
"create-child-note-title": "Neue untergeordnete Notiz anlegen und zur Karte hinzufügen",
"create-child-note-title": "Neue Unternotiz anlegen und zur Karte hinzufügen",
"create-child-note-instruction": "Auf die Karte klicken, um eine neue Notiz an der Stelle zu erstellen oder Escape drücken um abzubrechen.",
"unable-to-load-map": "Karte konnte nicht geladen werden.",
"create-child-note-text": "Marker hinzufügen"
@@ -1796,6 +1800,149 @@
"close": "Schließen",
"help_title": "Zeige mehr Informationen zu diesem Fenster"
},
"ai_llm": {
"not_started": "Nicht gestartet",
"title": "KI Einstellungen",
"processed_notes": "Verarbeitete Notizen",
"total_notes": "Gesamt Notizen",
"progress": "Fortschritt",
"queued_notes": "Eingereihte Notizen",
"failed_notes": "Fehlgeschlagenen Notizen",
"last_processed": "Zuletzt verarbeitet",
"refresh_stats": "Statistiken neu laden",
"enable_ai_features": "Aktiviere KI/LLM Funktionen",
"enable_ai_description": "Aktiviere KI-Funktionen wie Notizzusammenfassungen, Inhaltserzeugung und andere LLM-Funktionen",
"openai_tab": "OpenAI",
"anthropic_tab": "Anthropic",
"voyage_tab": "Voyage AI",
"ollama_tab": "Ollama",
"enable_ai": "Aktiviere KI/LLM Funktionen",
"enable_ai_desc": "Aktiviere KI-Funktionen wie Notizzusammenfassungen, Inhaltserzeugung und andere LLM-Funktionen",
"provider_configuration": "KI-Anbieterkonfiguration",
"provider_precedence": "Anbieter Priorität",
"provider_precedence_description": "Komma-getrennte Liste von Anbieter in der Reihenfolge ihrer Priorität (z.B. 'openai, anthropic,ollama')",
"temperature": "Temperatur",
"temperature_description": "Regelt die Zufälligkeit in Antworten (0 = deterministisch, 2 = maximale Zufälligkeit)",
"system_prompt": "Systemaufforderung",
"system_prompt_description": "Standard Systemaufforderung für alle KI-Interaktionen",
"openai_configuration": "OpenAI Konfiguration",
"openai_settings": "OpenAI Einstellungen",
"api_key": "API Schlüssel",
"url": "Basis-URL",
"model": "Modell",
"anthropic_settings": "Anthropic Einstellungen",
"partial": "{{ percentage }}% verarbeitet",
"anthropic_api_key_description": "Dein Anthropic API-Key für den Zugriff auf Claude Modelle",
"anthropic_model_description": "Anthropic Claude Modell für Chat-Vervollständigung",
"voyage_settings": "Einstellungen für Voyage AI",
"ollama_url_description": "URL für die Ollama API (Standard: http://localhost:11434)",
"ollama_model_description": "Ollama Modell für Chat-Vervollständigung",
"anthropic_configuration": "Anthropic Konfiguration",
"voyage_configuration": "Voyage AI Konfiguration",
"voyage_url_description": "Standard: https://api.voyageai.com/v1",
"ollama_configuration": "Ollama Konfiguration",
"enable_ollama": "Aktiviere Ollama",
"enable_ollama_description": "Aktiviere Ollama für lokale KI Modell Nutzung",
"ollama_url": "Ollama URL",
"ollama_model": "Ollama Modell",
"refresh_models": "Aktualisiere Modelle",
"refreshing_models": "Aktualisiere...",
"enable_automatic_indexing": "Aktiviere automatische Indizierung",
"rebuild_index": "Index neu aufbauen",
"rebuild_index_error": "Fehler beim Neuaufbau des Index. Prüfe Log für mehr Informationen.",
"retry_failed": "Fehler: Notiz konnte nicht erneut eingereiht werden",
"max_notes_per_llm_query": "Max. Notizen je Abfrage",
"max_notes_per_llm_query_description": "Maximale Anzahl ähnlicher Notizen zum Einbinden als KI Kontext",
"active_providers": "Aktive Anbieter",
"disabled_providers": "Inaktive Anbieter",
"remove_provider": "Entferne Anbieter von Suche",
"restore_provider": "Anbieter zur Suche wiederherstellen",
"similarity_threshold": "Ähnlichkeitsschwelle",
"similarity_threshold_description": "Mindestähnlichkeitswert (0-1) für Notizen, die im Kontext für LLM-Abfragen berücksichtigt werden sollen",
"reprocess_index": "Suchindex neu erstellen",
"reprocessing_index": "Neuerstellung...",
"reprocess_index_started": "Suchindex-Optimierung wurde im Hintergrund gestartet",
"reprocess_index_error": "Fehler beim Wiederaufbau des Suchindex",
"index_rebuild_progress": "Fortschritt der Index-Neuerstellung",
"index_rebuilding": "Optimierung Index ({{percentage}}%)",
"index_rebuild_complete": "Index Optimierung abgeschlossen",
"index_rebuild_status_error": "Fehler bei Überprüfung Status Index Neuerstellung",
"never": "Niemals",
"processing": "Verarbeitung ({{percentage}}%)",
"refreshing": "Aktualisiere...",
"incomplete": "Unvollständig ({{percentage}}%)",
"complete": "Abgeschlossen (100%)",
"auto_refresh_notice": "Auto-Aktualisierung alle {{seconds}} Sekunden",
"note_queued_for_retry": "Notiz in Warteschlange für erneuten Versuch hinzugefügt",
"failed_to_retry_note": "Wiederholungsversuch fehlgeschlagen für Notiz",
"ai_settings": "KI Einstellungen",
"agent": {
"processing": "Verarbeite...",
"thinking": "Nachdenken...",
"loading": "Lade...",
"generating": "Generiere..."
},
"name": "KI",
"openai": "OpenAI",
"use_enhanced_context": "Benutze verbesserten Kontext",
"openai_api_key_description": "Dein OpenAPI-Key für den Zugriff auf den KI-Dienst",
"default_model": "Standardmodell",
"openai_model_description": "Beispiele: gpt-4o, gpt-4-turbo, gpt-3.5-turbo",
"base_url": "Basis URL",
"openai_url_description": "Standard: https://api.openai.com/v1",
"anthropic_url_description": "Basis URL für Anthropic API (Standard: https://api.anthropic.com)",
"ollama_settings": "Ollama Einstellungen",
"note_title": "Notiz Titel",
"error": "Fehler",
"last_attempt": "Letzter Versuch",
"actions": "Aktionen",
"retry": "Erneut versuchen",
"retry_queued": "Notiz für weiteren Versuch eingereiht",
"empty_key_warning": {
"anthropic": "Anthropic API-Key ist leer. Bitte gültigen API-Key eingeben.",
"openai": "OpenAI API-Key ist leer. Bitte gültigen API-Key eingeben.",
"voyage": "Voyage API-Key ist leer. Bitte gültigen API-Key eingeben.",
"ollama": "Ollama API-Key ist leer. Bitte gültigen API-Key eingeben."
},
"api_key_tooltip": "API-Key für den Zugriff auf den Dienst",
"failed_to_retry_all": "Wiederholungsversuch für Notizen fehlgeschlagen",
"all_notes_queued_for_retry": "Alle fehlgeschlagenen Notizen wurden zur Wiederholung in die Warteschlange gestellt",
"enhanced_context_description": "Versorgt die KI mit mehr Kontext aus der Notiz und den zugehörigen Notizen, um bessere Antworten zu ermöglichen",
"show_thinking": "Zeige Denkprozess",
"show_thinking_description": "Zeige den Denkprozess der KI",
"enter_message": "Geben Sie Ihre Nachricht ein...",
"error_contacting_provider": "Fehler beim Kontaktieren des KI-Anbieters. Bitte überprüfe die Einstellungen und die Internetverbindung.",
"error_generating_response": "Fehler beim Generieren der KI Antwort",
"index_all_notes": "Indiziere alle Notizen",
"index_status": "Indizierungsstatus",
"indexed_notes": "Indizierte Notizen",
"indexing_stopped": "Indizierung gestoppt",
"indexing_in_progress": "Indizierung in Bearbeitung...",
"last_indexed": "Zuletzt Indiziert",
"note_chat": "Notizen-Chat",
"sources": "Quellen",
"start_indexing": "Starte Indizierung",
"use_advanced_context": "Benutze erweiterten Kontext",
"ollama_no_url": "Ollama ist nicht konfiguriert. Bitte trage eine gültige URL ein.",
"chat": {
"root_note_title": "KI Chats",
"root_note_content": "Diese Notiz enthält gespeicherte KI-Chat-Unterhaltungen.",
"new_chat_title": "Neuer Chat",
"create_new_ai_chat": "Erstelle neuen KI Chat"
},
"create_new_ai_chat": "Erstelle neuen KI Chat",
"configuration_warnings": "Es wurden Probleme mit der KI Konfiguration festgestellt. Bitte überprüfe die Einstellungen.",
"experimental_warning": "Die LLM-Funktionen sind aktuell experimentell - sei an dieser Stelle gewarnt.",
"selected_provider": "Ausgewählter Anbieter",
"selected_provider_description": "Wähle einen KI-Anbieter für Chat- und Vervollständigungsfunktionen",
"select_model": "Wähle Modell...",
"select_provider": "Wähle Anbieter...",
"ai_enabled": "KI Funktionen aktiviert",
"ai_disabled": "KI Funktionen deaktiviert",
"no_models_found_online": "Keine Modelle gefunden. Bitte überprüfe den API-Key und die Einstellungen.",
"no_models_found_ollama": "Kein Ollama Modell gefunden. Bitte prüfe, ob Ollama gerade läuft.",
"error_fetching": "Fehler beim Abrufen der Modelle: {{error}}"
},
"zen_mode": {
"button_exit": "Verlasse Zen Modus"
},
@@ -1830,7 +1977,7 @@
"no_totp_secret_warning": "Um TOTP zu aktivieren, muss zunächst ein TOTP Geheimnis generiert werden.",
"totp_secret_description_warning": "Nach der Generierung des TOTP Geheimnisses ist eine Neuanmeldung mit dem TOTP Geheimnis erforderlich.",
"totp_secret_generated": "TOTP Geheimnis generiert",
"totp_secret_warning": "Bitte speichere das generierte Geheimnis an einem sicheren Ort. Es wird nicht noch einmal angezeigt.",
"totp_secret_warning": "Bitte speichere das TOTP Geheimnis an einem sicheren Ort. Es wird nicht noch einmal angezeigt.",
"totp_secret_regenerate_confirm": "Möchten Sie das TOTP-Geheimnis wirklich neu generieren? Dadurch werden das bisherige TOTP-Geheimnis und alle vorhandenen Wiederherstellungscodes ungültig.",
"recovery_keys_title": "Einmalige Wiederherstellungsschlüssel",
"recovery_keys_description": "Einmalige Wiederherstellungsschlüssel werden verwendet, um sich anzumelden, falls Sie keinen Zugriff auf Ihre Authentifizierungscodes haben.",
@@ -1928,7 +2075,7 @@
"show-hide-columns": "Zeige/verberge Spalten",
"row-insert-above": "Zeile oberhalb einfügen",
"row-insert-below": "Zeile unterhalb einfügen",
"row-insert-child": "Untergeordnete Notiz einfügen",
"row-insert-child": "Unternotiz einfügen",
"add-column-to-the-left": "Spalte links einfügen",
"add-column-to-the-right": "Spalte rechts einfügen",
"edit-column": "Spalte editieren",
@@ -1945,8 +2092,7 @@
"raster": "Raster",
"vector_light": "Vektor (Hell)",
"vector_dark": "Vektor (Dunkel)",
"show-scale": "Zeige Skalierung",
"show-labels": "Zeige Markierungsnamen"
"show-scale": "Zeige Skalierung"
},
"table_context_menu": {
"delete_row": "Zeile entfernen"
@@ -2013,9 +2159,8 @@
"percentage": "%"
},
"pagination": {
"total_notes": "{{count}} Notizen",
"prev_page": "Vorherige Seite",
"next_page": "Nächste Seite"
"page_title": "Seite {{startIndex}} von {{endIndex}}",
"total_notes": "{{count}} Notizen"
},
"collections": {
"rendering_error": "Aufgrund eines Fehlers können keine Inhalte angezeigt werden."
@@ -2061,7 +2206,7 @@
"hoisted_badge_title": "Abgesenkt",
"workspace_badge": "Arbeitsfläche",
"scroll_to_top_title": "Zum Anfang der Notiz springen",
"create_new_note": "Neue untergeordnete Notiz erstellen",
"create_new_note": "Neue Unternotiz erstellen",
"empty_hide_archived_notes": "Archivierte Notizen ausblenden"
},
"breadcrumb_badges": {
@@ -2138,96 +2283,5 @@
},
"bookmark_buttons": {
"bookmarks": "Lesezeichen"
},
"web_view_setup": {
"title": "Erstelle eine Live-Ansicht einer Webseite direkt in Trilium",
"url_placeholder": "Gib oder füge die Adresse der Webseite ein, zum Beispiel https://triliumnotes.org",
"create_button": "Erstelle Web Ansicht",
"invalid_url_title": "Ungültige Adresse",
"invalid_url_message": "Füge eine valide Webadresse ein, zum Beispiel https://triliumnotes.org.",
"disabled_description": "Diese Webansicht wurde von einer externen Quelle importiert. Um Sie vor Phishing oder schädlichen Inhalten zu schützen, wird sie nicht automatisch geladen. Sie können sie aktivieren, wenn Sie der Quelle vertrauen.",
"disabled_button_enable": "Webansicht aktivieren"
},
"render": {
"setup_create_sample_html": "Eine Beispielnotiz mit HTML erstellen",
"setup_create_sample_preact": "Eine Beispielnotiz mit Preact erstellen",
"setup_title": "Benutzerdefiniertes HTML oder Preact JSX in dieser Notiz anzeigen",
"setup_sample_created": "Eine Beispielnotiz wurde als untergeordnete Notiz erstellt.",
"disabled_description": "Diese Rendering-Notizen stammen aus einer externen Quelle. Um Sie vor schädlichen Inhalten zu schützen, ist diese Funktion standardmäßig deaktiviert. Stellen Sie sicher, dass Sie der Quelle vertrauen, bevor Sie sie aktivieren.",
"disabled_button_enable": "Rendering-Notiz aktivieren"
},
"active_content_badges": {
"type_icon_pack": "Icon-Paket",
"type_backend_script": "Backend-Skript",
"type_frontend_script": "Frontend-Skript",
"type_widget": "Widget",
"type_app_css": "Benutzerdefiniertes CSS",
"type_render_note": "Rendering-Notiz",
"type_web_view": "Webansicht",
"type_app_theme": "Benutzerdefiniertes Thema",
"toggle_tooltip_enable_tooltip": "Klicken, um diesen {{type}} zu aktivieren.",
"toggle_tooltip_disable_tooltip": "Klicken, um diesen {{type}} zu deaktivieren.",
"menu_docs": "Dokumentation öffnen",
"menu_execute_now": "Skript jetzt ausführen",
"menu_run": "Automatisch ausführen",
"menu_run_disabled": "Manuell",
"menu_run_backend_startup": "Wenn das Backend startet",
"menu_run_hourly": "Stündlich",
"menu_run_daily": "Täglich",
"menu_run_frontend_startup": "Wenn das Desktop-Frontend startet",
"menu_run_mobile_startup": "Wenn das mobile Frontend startet",
"menu_change_to_widget": "Zum Widget wechseln",
"menu_change_to_frontend_script": "Zum Frontend-Skript wechseln",
"menu_theme_base": "Themenbasis"
},
"setup_form": {
"more_info": "Mehr erfahren"
},
"media": {
"play": "Abspielen (Arbeitsbereich)",
"pause": "Pausieren (Arbeitsbereich)",
"back-10s": "10 s zurück (Linke Pfeiltaste)",
"forward-30s": "30 s vorwärts",
"mute": "Stumm (M)",
"unmute": "Stummschaltung aufheben (M)",
"playback-speed": "Wiedergabegeschwindigkeit",
"loop": "Schleife",
"disable-loop": "Schleife deaktivieren",
"rotate": "Rotieren",
"picture-in-picture": "Bild-in-Bild",
"exit-picture-in-picture": "Bild-in-Bild verlassen",
"fullscreen": "Vollbild (F)",
"exit-fullscreen": "Vollbild verlassen",
"unsupported-format": "Medienvorschau ist für dieses Format nicht verfügbar:\n{{mime}}",
"zoom-to-fit": "Zoomen um auszufüllen",
"zoom-reset": "Zoomen um auszufüllen zurücksetzen"
},
"mermaid": {
"placeholder": "Geben den Inhalt des Mermaid-Diagramms ein oder verwenden eine der folgenden Beispieldiagramme.",
"sample_diagrams": "Beispieldiagramme:",
"sample_flowchart": "Flussdiagramm",
"sample_class": "Klasse",
"sample_sequence": "Abfolge",
"sample_entity_relationship": "Entität Beziehung",
"sample_state": "Zustandsübergangsdiagramm",
"sample_mindmap": "Mindmap",
"sample_architecture": "Architektur",
"sample_block": "Block",
"sample_c4": "C4",
"sample_gantt": "Gantt",
"sample_git": "GitGraph",
"sample_kanban": "Kanban",
"sample_packet": "Paket",
"sample_pie": "Kuchen",
"sample_quadrant": "Quadrant",
"sample_radar": "Radar",
"sample_requirement": "Anforderung",
"sample_sankey": "Sankey",
"sample_timeline": "Zeitstrahl",
"sample_treemap": "Kachel",
"sample_user_journey": "Benutzererfahrung",
"sample_xy": "XY",
"sample_venn": "Mengen",
"sample_ishikawa": "Ursache-Wirkung"
}
}

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