Compare commits

..

208 Commits

Author SHA1 Message Date
Elian Doran
0b5ce95093 fix(standalone): some sql queries not executing properly 2026-03-22 15:48:40 +02:00
Elian Doran
77971a10d1 feat(core): integrate special notes with route 2026-03-22 14:30:33 +02:00
Elian Doran
28a56ff7bf feat(core): integrate search with route 2026-03-22 14:03:48 +02:00
Elian Doran
d7d28bcf58 chore(standalone): align version with the rest 2026-03-22 13:37:52 +02:00
Elian Doran
682e1549f8 fix(standalone): failing due to type error 2026-03-22 13:03:54 +02:00
Elian Doran
d7d2b21935 feat(standalone): improve error handling on initialization 2026-03-22 13:02:50 +02:00
Elian Doran
1b7d2da6cb Merge remote-tracking branch 'origin/main' into standalone
; Conflicts:
;	apps/client/src/layouts/mobile_layout.tsx
;	apps/client/src/services/promoted_attribute_definition_parser.ts
;	apps/server/package.json
;	apps/server/src/becca/entities/bnote.ts
;	apps/server/src/etapi/etapi_utils.ts
;	apps/server/src/etapi/notes.ts
;	apps/server/src/routes/api/clipper.ts
;	apps/server/src/routes/api/export.ts
;	apps/server/src/routes/api/files.ts
;	apps/server/src/routes/api/image.ts
;	apps/server/src/routes/api/import.ts
;	apps/server/src/routes/api/note_map.ts
;	apps/server/src/routes/api/search.ts
;	apps/server/src/routes/api/similar_notes.ts
;	apps/server/src/routes/api/sync.ts
;	apps/server/src/routes/error_handlers.ts
;	apps/server/src/routes/index.ts
;	apps/server/src/routes/route_api.ts
;	apps/server/src/routes/routes.ts
;	apps/server/src/services/anonymization.ts
;	apps/server/src/services/app_info.ts
;	apps/server/src/services/builtin_attributes.ts
;	apps/server/src/services/export/zip.ts
;	apps/server/src/services/hidden_subtree.ts
;	apps/server/src/services/llm/ai_service_manager.ts
;	apps/server/src/services/llm/context/modules/context_formatter.ts
;	apps/server/src/services/llm/context/note_content.ts
;	apps/server/src/services/llm/formatters/base_formatter.ts
;	apps/server/src/services/llm/formatters/ollama_formatter.ts
;	apps/server/src/services/llm/formatters/openai_formatter.ts
;	apps/server/src/services/llm/tools/read_note_tool.ts
;	apps/server/src/services/note_types.ts
;	apps/server/src/services/notes.ts
;	apps/server/src/services/options.ts
;	apps/server/src/services/options_init.ts
;	apps/server/src/services/search/expressions/note_content_fulltext.ts
;	apps/server/src/services/utils.ts
;	apps/server/src/services/ws.ts
;	apps/server/src/share/content_renderer.ts
;	packages/commons/src/lib/builtin_attributes.ts
;	packages/commons/src/lib/rows.ts
;	packages/trilium-core/src/routes/api/attachments.ts
;	packages/trilium-core/src/routes/api/attributes.ts
;	packages/trilium-core/src/routes/api/branches.ts
;	packages/trilium-core/src/routes/api/notes.ts
;	packages/trilium-core/src/routes/api/recent_changes.ts
;	packages/trilium-core/src/routes/api/revisions.ts
;	packages/trilium-core/src/routes/api/sql.ts
;	packages/trilium-core/src/routes/api/stats.ts
;	packages/trilium-core/src/services/attributes.ts
;	packages/trilium-core/src/services/builtin_attributes.ts
;	packages/trilium-core/src/services/promoted_attribute_definition_parser.ts
;	pnpm-lock.yaml
2026-03-22 12:56:14 +02:00
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
332 changed files with 21352 additions and 12772 deletions

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

@@ -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 }}

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.102.1",
"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, transactional: boolean) {
return (method: HttpMethod, path: string, handler: (req: any) => unknown) => {
router.register(method, path, wrapHandler(handler, transactional));
};
}
/**
* 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, true);
routes.buildSharedApiRoutes({
apiRoute,
asyncApiRoute: createApiRoute(router, false)
});
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/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,637 @@
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,
private sql: string
) {}
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;
}
/**
* Detect the prefix used for a parameter name in the SQL query.
* SQLite supports @name, :name, and $name parameter styles.
* Returns the prefix character, or ':' as default if not found.
*/
private detectParamPrefix(paramName: string): string {
// Search for the parameter with each possible prefix
for (const prefix of [':', '@', '$']) {
// Use word boundary to avoid partial matches
const pattern = new RegExp(`\\${prefix}${paramName}(?![a-zA-Z0-9_])`);
if (pattern.test(this.sql)) {
return prefix;
}
}
// Default to ':' if not found (most common in Trilium)
return ':';
}
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 $)
// We detect the prefix used in the SQL for each parameter
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 {
// Detect the prefix used in the SQL and apply it
const prefix = this.detectParamPrefix(key);
bindings[`${prefix}${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!, query);
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,257 @@
// =============================================================================
// ERROR HANDLERS FIRST - No static imports above this!
// ES modules hoist static imports, so they execute BEFORE any code runs.
// We use dynamic imports below to ensure error handlers are registered first.
// =============================================================================
self.onerror = (message, source, lineno, colno, error) => {
const errorMsg = `[Worker] Uncaught error: ${message}\n at ${source}:${lineno}:${colno}`;
console.error(errorMsg, error);
try {
self.postMessage({
type: "WORKER_ERROR",
error: {
message: String(message),
source,
lineno,
colno,
stack: error?.stack || new Error().stack
}
});
} catch (e) {
console.error("[Worker] Failed to report error:", e);
}
return false;
};
self.onunhandledrejection = (event) => {
const reason = event.reason;
const errorMsg = `[Worker] Unhandled rejection: ${reason?.message || reason}`;
console.error(errorMsg, reason);
try {
self.postMessage({
type: "WORKER_ERROR",
error: {
message: String(reason?.message || reason),
stack: reason?.stack || new Error().stack
}
});
} catch (e) {
console.error("[Worker] Failed to report rejection:", e);
}
};
console.log("[Worker] Error handlers installed, loading modules...");
// =============================================================================
// TYPE-ONLY IMPORTS (erased at runtime, safe as static imports)
// =============================================================================
import type { BrowserRouter } from './lightweight/browser_router';
// =============================================================================
// MODULE STATE (populated by dynamic imports)
// =============================================================================
let BrowserSqlProvider: typeof import('./lightweight/sql_provider').default;
let WorkerMessagingProvider: typeof import('./lightweight/messaging_provider').default;
let BrowserExecutionContext: typeof import('./lightweight/cls_provider').default;
let BrowserCryptoProvider: typeof import('./lightweight/crypto_provider').default;
let translationProvider: typeof import('./lightweight/translation_provider').default;
let createConfiguredRouter: typeof import('./lightweight/browser_routes').createConfiguredRouter;
// Instance state
let sqlProvider: InstanceType<typeof BrowserSqlProvider> | null = null;
let messagingProvider: InstanceType<typeof WorkerMessagingProvider> | null = null;
// 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;
/**
* Load all required modules using dynamic imports.
* This allows errors to be caught by our error handlers.
*/
async function loadModules(): Promise<void> {
console.log("[Worker] Loading lightweight modules...");
const [
sqlModule,
messagingModule,
clsModule,
cryptoModule,
translationModule,
routesModule
] = await Promise.all([
import('./lightweight/sql_provider.js'),
import('./lightweight/messaging_provider.js'),
import('./lightweight/cls_provider.js'),
import('./lightweight/crypto_provider.js'),
import('./lightweight/translation_provider.js'),
import('./lightweight/browser_routes.js')
]);
BrowserSqlProvider = sqlModule.default;
WorkerMessagingProvider = messagingModule.default;
BrowserExecutionContext = clsModule.default;
BrowserCryptoProvider = cryptoModule.default;
translationProvider = translationModule.default;
createConfiguredRouter = routesModule.createConfiguredRouter;
// Create instances
sqlProvider = new BrowserSqlProvider();
messagingProvider = new WorkerMessagingProvider();
console.log("[Worker] Lightweight modules loaded successfully");
}
/**
* 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 {
// First, load all modules dynamically
await loadModules();
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

@@ -16,7 +16,6 @@
"circular-deps": "dpdm -T src/**/*.ts --tree=false --warning=false --skip-dynamic-imports=circular"
},
"dependencies": {
"@algolia/autocomplete-js": "1.19.6",
"@excalidraw/excalidraw": "0.18.0",
"@fullcalendar/core": "6.1.20",
"@fullcalendar/daygrid": "6.1.20",
@@ -45,6 +44,7 @@
"@univerjs/preset-sheets-sort": "0.18.0",
"@univerjs/presets": "0.18.0",
"@zumer/snapdom": "2.5.0",
"autocomplete.js": "0.38.1",
"bootstrap": "5.3.8",
"boxicons": "2.1.4",
"clsx": "2.1.1",
@@ -91,4 +91,4 @@
"script-loader": "0.7.2",
"vite-plugin-static-copy": "3.3.0"
}
}
}

View File

@@ -1,6 +1,5 @@
import { CreateChildrenResponse, SqlExecuteResponse } from "@triliumnext/commons";
import { closeAllHeadlessAutocompletes } from "../services/autocomplete_core.js";
import bundleService from "../services/bundle.js";
import dateNoteService from "../services/date_notes.js";
import froca from "../services/froca.js";
@@ -198,7 +197,7 @@ export default class Entrypoints extends Component {
hideAllPopups() {
if (utils.isDesktop()) {
closeAllHeadlessAutocompletes();
$(".aa-input").autocomplete("close");
}
}

View File

@@ -1,16 +1,15 @@
import type FNote from "../entities/fnote.js";
import { closeAllHeadlessAutocompletes } from "../services/autocomplete_core.js";
import froca from "../services/froca.js";
import linkService from "../services/link.js";
import options from "../services/options.js";
import server from "../services/server.js";
import SpacedUpdate from "../services/spaced_update.js";
import treeService from "../services/tree.js";
import Mutex from "../utils/mutex.js";
import type { EventData } from "./app_context.js";
import appContext from "./app_context.js";
import Component from "./component.js";
import SpacedUpdate from "../services/spaced_update.js";
import server from "../services/server.js";
import options from "../services/options.js";
import froca from "../services/froca.js";
import treeService from "../services/tree.js";
import NoteContext from "./note_context.js";
import appContext from "./app_context.js";
import Mutex from "../utils/mutex.js";
import linkService from "../services/link.js";
import type { EventData } from "./app_context.js";
import type FNote from "../entities/fnote.js";
interface TabState {
contexts: NoteContext[];
@@ -430,7 +429,10 @@ export default class TabManager extends Component {
}
// close dangling autocompletes after closing the tab
closeAllHeadlessAutocompletes();
const $autocompleteEl = $(".aa-input");
if ("autocomplete" in $autocompleteEl) {
$autocompleteEl.autocomplete("close");
}
// close dangling tooltips
$("body > div.tooltip").remove();

View File

@@ -1,3 +1,5 @@
import "autocomplete.js/index_jquery.js";
import type ElectronRemote from "@electron/remote";
import type Electron from "electron";

View File

@@ -16,6 +16,17 @@ async function initJQuery() {
const $ = (await import("jquery")).default;
window.$ = $;
window.jQuery = $;
// Polyfill removed jQuery methods for autocomplete.js compatibility
($ as any).isArray = Array.isArray;
($ as any).isFunction = function(obj: any) { return typeof obj === 'function'; };
($ as any).isPlainObject = function(obj: any) {
if (obj == null || typeof obj !== 'object') { return false; }
const proto = Object.getPrototypeOf(obj);
if (proto === null) { return true; }
const Ctor = Object.prototype.hasOwnProperty.call(proto, 'constructor') && proto.constructor;
return typeof Ctor === 'function' && Ctor === Object;
};
}
async function setupGlob() {
@@ -25,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";
@@ -55,6 +56,7 @@ export default class MobileLayout {
.child(
new SplitNoteContainer(() =>
new NoteWrapperWidget()
.optChild(glob.isStandalone, <StandaloneWarningBar />)
.child(
new FlexContainer("row")
.class("title-row note-split-title")

View File

@@ -1,3 +1,5 @@
import "autocomplete.js/index_jquery.js";
import appContext from "./components/app_context.js";
import glob from "./services/glob.js";
import noteAutocompleteService from "./services/note_autocomplete.js";

View File

@@ -8,6 +8,17 @@ async function loadBootstrap() {
}
}
// Polyfill removed jQuery methods for autocomplete.js compatibility
($ as any).isArray = Array.isArray;
($ as any).isFunction = function(obj: any) { return typeof obj === 'function'; };
($ as any).isPlainObject = function(obj: any) {
if (obj == null || typeof obj !== 'object') { return false; }
const proto = Object.getPrototypeOf(obj);
if (proto === null) { return true; }
const Ctor = Object.prototype.hasOwnProperty.call(proto, 'constructor') && proto.constructor;
return typeof Ctor === 'function' && Ctor === Object;
};
(window as any).$ = $;
(window as any).jQuery = $;
await loadBootstrap();

View File

@@ -1,47 +0,0 @@
import { describe, expect, it } from "vitest";
import { shouldAutocompleteHandleEnterKey } from "./attribute_autocomplete.js";
describe("attribute autocomplete enter handling", () => {
it("delegates plain Enter when the panel is open and an item is active", () => {
expect(shouldAutocompleteHandleEnterKey(
{ key: "Enter", ctrlKey: false, metaKey: false },
{ isPanelOpen: true, hasActiveItem: true }
)).toBe(true);
});
it("does not delegate plain Enter when there is no active suggestion", () => {
expect(shouldAutocompleteHandleEnterKey(
{ key: "Enter", ctrlKey: false, metaKey: false },
{ isPanelOpen: true, hasActiveItem: false }
)).toBe(false);
});
it("does not delegate plain Enter when the panel is closed", () => {
expect(shouldAutocompleteHandleEnterKey(
{ key: "Enter", ctrlKey: false, metaKey: false },
{ isPanelOpen: false, hasActiveItem: false }
)).toBe(false);
});
it("does not delegate Ctrl+Enter even when an item is active", () => {
expect(shouldAutocompleteHandleEnterKey(
{ key: "Enter", ctrlKey: true, metaKey: false },
{ isPanelOpen: true, hasActiveItem: true }
)).toBe(false);
});
it("does not delegate Cmd+Enter even when an item is active", () => {
expect(shouldAutocompleteHandleEnterKey(
{ key: "Enter", ctrlKey: false, metaKey: true },
{ isPanelOpen: true, hasActiveItem: true }
)).toBe(false);
});
it("ignores non-Enter keys", () => {
expect(shouldAutocompleteHandleEnterKey(
{ key: "ArrowDown", ctrlKey: false, metaKey: false },
{ isPanelOpen: false, hasActiveItem: false }
)).toBe(true);
});
});

View File

@@ -1,450 +1,114 @@
import type { AutocompleteApi as CoreAutocompleteApi, BaseItem } from "@algolia/autocomplete-core";
import { createAutocomplete } from "@algolia/autocomplete-core";
import type { AttributeType } from "../entities/fattribute.js";
import { bindAutocompleteInput, createHeadlessPanelController, registerHeadlessAutocompleteCloser, withHeadlessSourceDefaults } from "./autocomplete_core.js";
import server from "./server.js";
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
interface NameItem extends BaseItem {
name: string;
}
export function shouldAutocompleteHandleEnterKey(
event: Pick<KeyboardEvent, "key" | "ctrlKey" | "metaKey">,
{ isPanelOpen, hasActiveItem }: { isPanelOpen: boolean; hasActiveItem: boolean }
) {
if (event.key !== "Enter") {
return true;
}
if (event.ctrlKey || event.metaKey) {
return false;
}
return isPanelOpen && hasActiveItem;
}
interface InitAttributeNameOptions {
/** The <input> element where the user types */
interface InitOptions {
$el: JQuery<HTMLElement>;
attributeType?: AttributeType | (() => AttributeType);
open: boolean;
/** Called when the user selects a value or the panel closes */
onValueChange?: (value: string) => void;
}
// ---------------------------------------------------------------------------
// Instance tracking
// ---------------------------------------------------------------------------
interface ManagedInstance {
autocomplete: CoreAutocompleteApi<NameItem>;
panelEl: HTMLElement;
cleanup: () => void;
}
const instanceMap = new WeakMap<HTMLElement, ManagedInstance>();
function renderItems(
panelEl: HTMLElement,
items: NameItem[],
activeItemId: number | null,
onSelect: (item: NameItem) => void,
onActivate: (index: number) => void,
onDeactivate: () => void
): void {
panelEl.innerHTML = "";
if (items.length === 0) {
panelEl.style.display = "none";
return;
}
const list = document.createElement("ul");
list.className = "aa-core-list";
items.forEach((item, index) => {
const li = document.createElement("li");
li.className = "aa-core-item";
if (index === activeItemId) {
li.classList.add("aa-core-item--active");
}
li.textContent = item.name;
li.addEventListener("mousemove", () => {
if (activeItemId === index) {
return;
}
onActivate(index);
});
li.addEventListener("mouseleave", (event) => {
const relatedTarget = event.relatedTarget;
if (relatedTarget instanceof HTMLElement && li.contains(relatedTarget)) {
return;
}
onDeactivate();
});
li.addEventListener("mousedown", (e) => {
e.preventDefault(); // prevent input blur
e.stopPropagation();
onSelect(item);
});
list.appendChild(li);
});
panelEl.appendChild(list);
}
// ---------------------------------------------------------------------------
// Attribute name autocomplete — new (autocomplete-core, headless)
// ---------------------------------------------------------------------------
function initAttributeNameAutocomplete({ $el, attributeType, open, onValueChange }: InitAttributeNameOptions) {
const inputEl = $el[0] as HTMLInputElement;
const syncQueryFromInputValue = (autocomplete: CoreAutocompleteApi<NameItem>) => {
autocomplete.setQuery(inputEl.value || "");
};
// Already initialized — just open if requested
if (instanceMap.has(inputEl)) {
if (open) {
const inst = instanceMap.get(inputEl)!;
syncQueryFromInputValue(inst.autocomplete);
inst.autocomplete.setIsOpen(true);
inst.autocomplete.refresh();
}
return;
}
const panelController = createHeadlessPanelController({ inputEl });
const { panelEl } = panelController;
let isPanelOpen = false;
let hasActiveItem = false;
const autocomplete = createAutocomplete<NameItem>({
openOnFocus: true,
defaultActiveItemId: 0,
shouldPanelOpen() {
return true;
},
getSources({ query }) {
return [
withHeadlessSourceDefaults({
sourceId: "attribute-names",
getItems() {
const type = typeof attributeType === "function" ? attributeType() : attributeType;
return server
.get<string[]>(`attribute-names/?type=${type}&query=${encodeURIComponent(query)}`)
.then((names) => names.map((name) => ({ name })));
},
getItemInputValue({ item }) {
return item.name;
},
onSelect({ item }) {
inputEl.value = item.name;
autocomplete.setQuery(item.name);
autocomplete.setIsOpen(false);
onValueChange?.(item.name);
},
}),
];
},
onStateChange({ state }) {
isPanelOpen = state.isOpen;
hasActiveItem = state.activeItemId !== null;
// Render items
const collections = state.collections;
const items = collections.length > 0 ? (collections[0].items as NameItem[]) : [];
const activeId = state.activeItemId ?? null;
if (state.isOpen && items.length > 0) {
renderItems(
panelEl,
items,
activeId,
(item) => {
inputEl.value = item.name;
autocomplete.setQuery(item.name);
autocomplete.setIsOpen(false);
onValueChange?.(item.name);
},
(index) => {
autocomplete.setActiveItemId(index);
},
() => {
autocomplete.setActiveItemId(null);
}
);
panelController.startPositioning();
} else {
panelController.hide();
}
if (!state.isOpen) {
panelController.hide();
}
},
});
const unregisterGlobalCloser = registerHeadlessAutocompleteCloser(() => {
autocomplete.setIsOpen(false);
panelController.hide();
});
const cleanupInputBindings = bindAutocompleteInput<NameItem>({
inputEl,
autocomplete,
onInput(e, handlers) {
handlers.onChange(e as any);
},
onFocus(e, handlers) {
syncQueryFromInputValue(autocomplete);
handlers.onFocus(e as any);
},
onBlur() {
// Delay to allow mousedown on panel items
setTimeout(() => {
autocomplete.setIsOpen(false);
panelController.hide();
onValueChange?.(inputEl.value);
}, 50);
},
onKeyDown(e, handlers) {
if (!shouldAutocompleteHandleEnterKey(e, { isPanelOpen, hasActiveItem })) {
return;
}
if (e.key === "Enter") {
// Prevent the enter key from propagating to parent dialogs
// (which might interpret it as "submit" or "save and close")
e.stopPropagation();
}
handlers.onKeyDown(e as any);
}
});
const cleanup = () => {
unregisterGlobalCloser();
cleanupInputBindings();
panelController.destroy();
};
instanceMap.set(inputEl, { autocomplete, panelEl, cleanup });
if (open) {
syncQueryFromInputValue(autocomplete);
autocomplete.setIsOpen(true);
autocomplete.refresh();
panelController.startPositioning();
}
}
// ---------------------------------------------------------------------------
// Label value autocomplete (headless autocomplete-core)
// ---------------------------------------------------------------------------
interface LabelValueInitOptions {
$el: JQuery<HTMLElement>;
open: boolean;
nameCallback?: () => string;
onValueChange?: (value: string) => void;
}
function initLabelValueAutocomplete({ $el, open, nameCallback, onValueChange }: LabelValueInitOptions) {
const inputEl = $el[0] as HTMLInputElement;
const syncQueryFromInputValue = (autocomplete: CoreAutocompleteApi<NameItem>) => {
autocomplete.setQuery(inputEl.value || "");
};
/**
* @param $el - element on which to init autocomplete
* @param attributeType - "relation" or "label" or callback providing one of those values as a type of autocompleted attributes
* @param open - should the autocomplete be opened after init?
*/
function initAttributeNameAutocomplete({ $el, attributeType, open }: InitOptions) {
if (!$el.hasClass("aa-input")) {
$el.autocomplete(
{
appendTo: document.querySelector("body"),
hint: false,
openOnFocus: true,
minLength: 0,
tabAutocomplete: false
},
[
{
displayKey: "name",
// disabling cache is important here because otherwise cache can stay intact when switching between attribute type which will lead to autocomplete displaying attribute names for incorrect attribute type
cache: false,
source: async (term, cb) => {
const type = typeof attributeType === "function" ? attributeType() : attributeType;
if (instanceMap.has(inputEl)) {
if (open) {
const inst = instanceMap.get(inputEl)!;
syncQueryFromInputValue(inst.autocomplete);
inst.autocomplete.setIsOpen(true);
inst.autocomplete.refresh();
}
const names = await server.get<string[]>(`attribute-names/?type=${type}&query=${encodeURIComponent(term)}`);
const result = names.map((name) => ({ name }));
cb(result);
}
}
]
);
$el.on("autocomplete:opened", () => {
if ($el.attr("readonly")) {
$el.autocomplete("close");
}
});
}
if (open) {
$el.autocomplete("open");
}
}
async function initLabelValueAutocomplete({ $el, open, nameCallback }: InitOptions) {
if ($el.hasClass("aa-input")) {
// we reinit every time because autocomplete seems to have a bug where it retains state from last
// open even though the value was reset
$el.autocomplete("destroy");
}
let attributeName = "";
if (nameCallback) {
attributeName = nameCallback();
}
if (attributeName.trim() === "") {
return;
}
const panelController = createHeadlessPanelController({ inputEl });
const { panelEl } = panelController;
const attributeValues = (await server.get<string[]>(`attribute-values/${encodeURIComponent(attributeName)}`)).map((attribute) => ({ value: attribute }));
let isPanelOpen = false;
let hasActiveItem = false;
let isSelecting = false;
if (attributeValues.length === 0) {
return;
}
let cachedAttributeName = "";
let cachedAttributeValues: NameItem[] = [];
const handleSelect = (item: NameItem) => {
isSelecting = true;
inputEl.value = item.name;
inputEl.dispatchEvent(new Event("input", { bubbles: true }));
autocomplete.setQuery(item.name);
autocomplete.setIsOpen(false);
onValueChange?.(item.name);
isSelecting = false;
setTimeout(() => {
// Preserve the legacy contract: several consumers still commit the
// selected value from their existing Enter key handlers instead of
// listening to the autocomplete selection event directly.
inputEl.dispatchEvent(new KeyboardEvent("keydown", {
key: "Enter",
code: "Enter",
keyCode: 13,
which: 13,
bubbles: true,
cancelable: true
}));
}, 0);
};
const autocomplete = createAutocomplete<NameItem>({
openOnFocus: true,
defaultActiveItemId: null,
shouldPanelOpen() {
return true;
$el.autocomplete(
{
appendTo: document.querySelector("body"),
hint: false,
openOnFocus: false, // handled manually
minLength: 0,
tabAutocomplete: false
},
[
{
displayKey: "value",
cache: false,
source: async function (term, cb) {
term = term.toLowerCase();
getSources({ query }) {
return [
withHeadlessSourceDefaults({
sourceId: "attribute-values",
async getItems() {
const attributeName = nameCallback ? nameCallback() : "";
if (!attributeName.trim()) {
return [];
}
const filtered = attributeValues.filter((attr) => attr.value.toLowerCase().includes(term));
if (attributeName !== cachedAttributeName || cachedAttributeValues.length === 0) {
cachedAttributeName = attributeName;
const values = await server.get<string[]>(`attribute-values/${encodeURIComponent(attributeName)}`);
cachedAttributeValues = values.map((name) => ({ name }));
}
const q = query.toLowerCase();
return cachedAttributeValues.filter((attr) => attr.name.toLowerCase().includes(q));
},
getItemInputValue({ item }) {
return item.name;
},
onSelect({ item }) {
handleSelect(item);
},
}),
];
},
onStateChange({ state }) {
isPanelOpen = state.isOpen;
hasActiveItem = state.activeItemId !== null;
const collections = state.collections;
const items = collections.length > 0 ? (collections[0].items as NameItem[]) : [];
const activeId = state.activeItemId ?? null;
if (state.isOpen && items.length > 0) {
renderItems(
panelEl,
items,
activeId,
handleSelect,
(index) => {
autocomplete.setActiveItemId(index);
},
() => {
autocomplete.setActiveItemId(null);
}
);
panelController.startPositioning();
} else {
panelController.hide();
cb(filtered);
}
}
]
);
if (!state.isOpen) {
panelController.hide();
}
},
});
const unregisterGlobalCloser = registerHeadlessAutocompleteCloser(() => {
autocomplete.setIsOpen(false);
panelController.hide();
});
const cleanupInputBindings = bindAutocompleteInput<NameItem>({
inputEl,
autocomplete,
onInput(e, handlers) {
if (!isSelecting) {
handlers.onChange(e as any);
}
},
onFocus(e, handlers) {
const attributeName = nameCallback ? nameCallback() : "";
if (attributeName !== cachedAttributeName) {
cachedAttributeName = "";
cachedAttributeValues = [];
}
syncQueryFromInputValue(autocomplete);
handlers.onFocus(e as any);
},
onBlur() {
setTimeout(() => {
autocomplete.setIsOpen(false);
panelController.hide();
onValueChange?.(inputEl.value);
}, 50);
},
onKeyDown(e, handlers) {
if (!shouldAutocompleteHandleEnterKey(e, { isPanelOpen, hasActiveItem })) {
return;
}
if (e.key === "Enter") {
e.stopPropagation();
}
handlers.onKeyDown(e as any);
$el.on("autocomplete:opened", () => {
if ($el.attr("readonly")) {
$el.autocomplete("close");
}
});
const cleanup = () => {
unregisterGlobalCloser();
cleanupInputBindings();
panelController.destroy();
};
instanceMap.set(inputEl, { autocomplete, panelEl, cleanup });
if (open) {
syncQueryFromInputValue(autocomplete);
autocomplete.setIsOpen(true);
autocomplete.refresh();
panelController.startPositioning();
}
}
export function destroyAutocomplete($el: JQuery<HTMLElement> | HTMLElement) {
const inputEl = $el instanceof HTMLElement ? $el : $el[0] as HTMLInputElement;
const instance = instanceMap.get(inputEl);
if (instance) {
instance.cleanup();
instanceMap.delete(inputEl);
$el.autocomplete("open");
}
}
export default {
initAttributeNameAutocomplete,
destroyAutocomplete,
initLabelValueAutocomplete,
initLabelValueAutocomplete
};

View File

@@ -1,93 +0,0 @@
import $ from "jquery";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const {
showSpy,
hideSpy,
updateDisplayedShortcutsSpy,
saveFocusedElementSpy,
focusSavedElementSpy
} = vi.hoisted(() => ({
showSpy: vi.fn(),
hideSpy: vi.fn(),
updateDisplayedShortcutsSpy: vi.fn(),
saveFocusedElementSpy: vi.fn(),
focusSavedElementSpy: vi.fn()
}));
vi.mock("bootstrap", () => ({
Modal: {
getOrCreateInstance: vi.fn(() => ({
show: showSpy,
hide: hideSpy
}))
}
}));
vi.mock("./keyboard_actions.js", () => ({
default: {
updateDisplayedShortcuts: updateDisplayedShortcutsSpy
}
}));
vi.mock("./focus.js", () => ({
saveFocusedElement: saveFocusedElementSpy,
focusSavedElement: focusSavedElementSpy
}));
import { closeAllHeadlessAutocompletes, registerHeadlessAutocompleteCloser } from "./autocomplete_core.js";
import { openDialog } from "./dialog.js";
describe("headless autocomplete closing", () => {
const unregisterClosers: Array<() => void> = [];
beforeEach(() => {
vi.clearAllMocks();
(window as any).glob = {
...(window as any).glob,
activeDialog: null
};
});
afterEach(() => {
while (unregisterClosers.length > 0) {
unregisterClosers.pop()?.();
}
});
it("closes every registered closer and skips unregistered ones", () => {
const closer1 = vi.fn();
const closer2 = vi.fn();
const closer3 = vi.fn();
unregisterClosers.push(registerHeadlessAutocompleteCloser(closer1));
const unregister2 = registerHeadlessAutocompleteCloser(closer2);
unregisterClosers.push(unregister2);
unregisterClosers.push(registerHeadlessAutocompleteCloser(closer3));
unregister2();
closeAllHeadlessAutocompletes();
expect(closer1).toHaveBeenCalledTimes(1);
expect(closer2).not.toHaveBeenCalled();
expect(closer3).toHaveBeenCalledTimes(1);
});
it("closes registered autocompletes when a dialog finishes hiding", async () => {
const closer = vi.fn();
unregisterClosers.push(registerHeadlessAutocompleteCloser(closer));
const dialogEl = document.createElement("div");
const $dialog = $(dialogEl);
await openDialog($dialog, false);
$dialog.trigger("hidden.bs.modal");
expect(showSpy).toHaveBeenCalledTimes(1);
expect(updateDisplayedShortcutsSpy).toHaveBeenCalledWith($dialog);
expect(saveFocusedElementSpy).toHaveBeenCalledTimes(1);
expect(closer).toHaveBeenCalledTimes(1);
expect(focusSavedElementSpy).toHaveBeenCalledTimes(1);
});
});

View File

@@ -1,195 +0,0 @@
import type { AutocompleteApi, AutocompleteSource, BaseItem } from "@algolia/autocomplete-core";
export const HEADLESS_AUTOCOMPLETE_PANEL_SELECTOR = ".aa-core-panel";
type HeadlessSourceDefaults = Required<Pick<AutocompleteSource<any>, "getItemUrl" | "onActive" | "onResolve">>;
const headlessAutocompleteClosers = new Set<() => void>();
export function withHeadlessSourceDefaults<TSource extends AutocompleteSource<any>>(
source: TSource
): TSource & HeadlessSourceDefaults {
return {
getItemUrl() {
return undefined;
},
onActive() {
// Headless consumers handle highlight side effects themselves.
},
onResolve() {
// Headless consumers resolve and render items manually.
},
...source
} as TSource & HeadlessSourceDefaults;
}
export function registerHeadlessAutocompleteCloser(close: () => void) {
headlessAutocompleteClosers.add(close);
return () => {
headlessAutocompleteClosers.delete(close);
};
}
export function closeAllHeadlessAutocompletes() {
for (const close of Array.from(headlessAutocompleteClosers)) {
close();
}
}
interface HeadlessPanelControllerOptions {
inputEl: HTMLElement;
container?: HTMLElement | null;
className?: string;
containedClassName?: string;
}
export function createHeadlessPanelController({
inputEl,
container,
className = "aa-core-panel",
containedClassName = "aa-core-panel--contained"
}: HeadlessPanelControllerOptions) {
const panelEl = document.createElement("div");
panelEl.className = className;
const isContained = Boolean(container);
if (isContained) {
panelEl.classList.add(containedClassName);
container!.appendChild(panelEl);
} else {
document.body.appendChild(panelEl);
}
panelEl.style.display = "none";
let rafId: number | null = null;
const positionPanel = () => {
if (isContained) {
panelEl.style.position = "static";
panelEl.style.top = "";
panelEl.style.left = "";
panelEl.style.width = "100%";
panelEl.style.display = "block";
return;
}
const rect = inputEl.getBoundingClientRect();
panelEl.style.position = "fixed";
panelEl.style.top = `${rect.bottom}px`;
panelEl.style.left = `${rect.left}px`;
panelEl.style.width = `${rect.width}px`;
panelEl.style.display = "block";
};
const stopPositioning = () => {
if (rafId !== null) {
cancelAnimationFrame(rafId);
rafId = null;
}
};
const startPositioning = () => {
if (isContained) {
positionPanel();
return;
}
if (rafId !== null) {
return;
}
const update = () => {
positionPanel();
rafId = requestAnimationFrame(update);
};
update();
};
const hide = () => {
panelEl.style.display = "none";
stopPositioning();
};
const destroy = () => {
hide();
panelEl.remove();
};
return {
panelEl,
hide,
destroy,
startPositioning,
stopPositioning
};
}
type InputHandlers<TItem extends BaseItem> = ReturnType<AutocompleteApi<TItem>["getInputProps"]>;
interface InputBinding<TEvent extends Event = Event> {
type: string;
listener: (event: TEvent) => void;
}
interface BindAutocompleteInputOptions<TItem extends BaseItem> {
inputEl: HTMLInputElement;
autocomplete: AutocompleteApi<TItem>;
onInput?: (event: Event, handlers: InputHandlers<TItem>) => void;
onFocus?: (event: Event, handlers: InputHandlers<TItem>) => void;
onBlur?: (event: Event, handlers: InputHandlers<TItem>) => void;
onKeyDown?: (event: KeyboardEvent, handlers: InputHandlers<TItem>) => void;
extraBindings?: InputBinding[];
}
export function bindAutocompleteInput<TItem extends BaseItem>({
inputEl,
autocomplete,
onInput,
onFocus,
onBlur,
onKeyDown,
extraBindings = []
}: BindAutocompleteInputOptions<TItem>) {
const handlers = autocomplete.getInputProps({ inputElement: inputEl });
const bindings: InputBinding[] = [
{
type: "input",
listener: (event: Event) => {
onInput?.(event, handlers);
}
},
{
type: "focus",
listener: (event: Event) => {
onFocus?.(event, handlers);
}
},
{
type: "blur",
listener: (event: Event) => {
onBlur?.(event, handlers);
}
},
{
type: "keydown",
listener: (event: Event) => {
onKeyDown?.(event as KeyboardEvent, handlers);
}
},
...extraBindings
];
bindings.forEach(({ type, listener }) => {
inputEl.addEventListener(type, listener as EventListener);
});
return () => {
bindings.forEach(({ type, listener }) => {
inputEl.removeEventListener(type, listener as EventListener);
});
};
}

View File

@@ -1,11 +1,9 @@
import { Modal } from "bootstrap";
import appContext from "../components/app_context.js";
import type { ConfirmDialogOptions, ConfirmDialogResult, ConfirmWithMessageOptions, MessageType } from "../widgets/dialogs/confirm.js";
import { InfoExtraProps } from "../widgets/dialogs/info.jsx";
import type { PromptDialogOptions } from "../widgets/dialogs/prompt.js";
import { closeAllHeadlessAutocompletes } from "./autocomplete_core.js";
import { focusSavedElement, saveFocusedElement } from "./focus.js";
import { InfoExtraProps } from "../widgets/dialogs/info.jsx";
export async function openDialog($dialog: JQuery<HTMLElement>, closeActDialog = true, config?: Partial<Modal.Options>) {
if (closeActDialog) {
@@ -17,7 +15,10 @@ export async function openDialog($dialog: JQuery<HTMLElement>, closeActDialog =
Modal.getOrCreateInstance($dialog[0], config).show();
$dialog.on("hidden.bs.modal", () => {
closeAllHeadlessAutocompletes();
const $autocompleteEl = $(".aa-input");
if ("autocomplete" in $autocompleteEl) {
$autocompleteEl.autocomplete("close");
}
if (!glob.activeDialog || glob.activeDialog === $dialog) {
focusSavedElement();

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

@@ -206,7 +206,7 @@ export interface Api {
* Instance name identifies particular Trilium instance. It can be useful for scripts
* if some action needs to happen on only one specific instance.
*/
getInstanceName(): string;
getInstanceName(): string | null;
/**
* @returns date in YYYY-MM-DD format

View File

@@ -1,9 +1,8 @@
import type { ActionKeyboardShortcut } from "@triliumnext/commons";
import appContext from "../components/app_context.js";
import type Component from "../components/component.js";
import server from "./server.js";
import appContext from "../components/app_context.js";
import shortcutService, { ShortcutBinding } from "./shortcuts.js";
import type Component from "../components/component.js";
import type { ActionKeyboardShortcut } from "@triliumnext/commons";
const keyboardActionRepo: Record<string, ActionKeyboardShortcut> = {};
@@ -52,10 +51,7 @@ async function setupActionsForElement(scope: string, $el: JQuery<HTMLElement>, c
getActionsForScope("window").then((actions) => {
for (const action of actions) {
for (const shortcut of action.effectiveShortcuts ?? []) {
shortcutService.bindGlobalShortcut(shortcut, () => {
const ntxId = appContext.tabManager?.activeNtxId ?? null;
appContext.triggerCommand(action.actionName, { ntxId });
});
shortcutService.bindGlobalShortcut(shortcut, () => appContext.triggerCommand(action.actionName, { ntxId: appContext.tabManager.activeNtxId }));
}
}
});

File diff suppressed because it is too large Load Diff

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());

View File

@@ -135,6 +135,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 +818,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

@@ -261,7 +261,7 @@ async function sendPing() {
}
setTimeout(() => {
if (glob.device === "print") return;
if (glob.device === "print" || glob.isStandalone) return;
ws = connectWebSocket();

View File

@@ -892,6 +892,33 @@ table.promoted-attributes-in-tooltip th {
opacity: 1;
}
.algolia-autocomplete {
width: calc(100% - 30px);
z-index: 2000 !important;
}
.algolia-autocomplete-container .aa-dropdown-menu {
position: inherit !important;
overflow: auto;
}
.algolia-autocomplete .aa-input,
.algolia-autocomplete .aa-hint {
width: 100%;
}
.algolia-autocomplete .aa-dropdown-menu {
width: 100%;
background-color: var(--main-background-color);
border: 1px solid var(--main-border-color);
border-top: none;
z-index: 2000 !important;
max-height: 500px;
overflow: auto;
padding: 0;
margin: 0;
}
.aa-dropdown-menu .aa-suggestion {
cursor: pointer;
padding: 6px 16px;
@@ -933,153 +960,6 @@ table.promoted-attributes-in-tooltip th {
background-color: var(--active-item-background-color);
}
/* ===== @algolia/autocomplete-core (headless, custom panel) ===== */
.aa-core-panel {
z-index: 10000;
background-color: var(--main-background-color);
border: 1px solid var(--main-border-color);
border-top: none;
max-height: 500px;
overflow: auto;
padding: 0;
margin: 0;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
}
.aa-core-panel.aa-dropdown-menu {
width: 100%;
}
.aa-core-panel--contained {
position: static !important;
border: 0;
background: transparent;
box-shadow: none;
}
.aa-core-list {
list-style: none;
padding: 0;
margin: 0;
}
.aa-core-item {
cursor: pointer;
padding: 7px 16px;
margin: 0;
white-space: normal;
}
.aa-core-item--active {
color: var(--active-item-text-color);
background-color: var(--active-item-background-color);
}
.aa-core-item .note-suggestion {
display: flex;
align-items: flex-start;
gap: 8px;
width: 100%;
}
.aa-core-item .icon,
.aa-core-item .command-icon {
flex-shrink: 0;
line-height: 1.4;
margin-top: 1px;
}
.aa-core-item .text {
min-width: 0;
flex: 1;
}
.aa-core-item .aa-core-primary-row {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 12px;
}
.aa-core-item .search-result-title {
display: block;
min-width: 0;
line-height: 1.35;
word-break: break-word;
font-size: 1.02em;
}
.aa-core-item .search-result-attributes {
display: block;
margin-top: 1px;
font-size: 0.8em;
color: var(--muted-text-color);
opacity: 0.65;
line-height: 1.2;
word-break: break-word;
}
.aa-core-item .search-result-attributes {
padding-inline-start: 14px;
}
.aa-core-item .aa-core-shortcut,
.aa-core-item kbd.command-shortcut {
flex-shrink: 0;
padding: 0;
border: 0;
background: transparent;
color: var(--muted-text-color);
font-family: inherit !important;
font-size: 0.8em;
opacity: 0.85;
}
.aa-core-item .command-suggestion {
display: flex;
align-items: center;
gap: 0.75rem;
width: 100%;
font-size: 0.9em;
}
.aa-core-item .command-content {
flex-grow: 1;
min-width: 0;
}
.aa-core-item .command-name {
font-weight: bold;
line-height: 1.35;
}
.aa-core-item .command-description {
font-size: 0.8em;
line-height: 1.3;
opacity: 0.75;
}
.aa-core-item .search-result-title b,
.aa-core-item .search-result-path b,
.aa-core-item .search-result-attributes b,
.aa-core-item .command-name b,
.aa-core-item .command-description b {
color: var(--admonition-warning-accent-color);
text-decoration: underline;
}
.aa-core-item .aa-core-separator {
padding: 0 2px;
}
.jump-to-note-results .aa-core-panel--contained {
max-height: calc(80vh - 200px);
overflow-y: auto;
overflow-x: hidden;
text-overflow: ellipsis;
}
.help-button {
float: inline-end;
background: none;

View File

@@ -128,8 +128,8 @@
margin-inline: auto;
}
/* The headless autocomplete panel rendered into the empty-note results container */
.note-detail-empty .aa-core-panel--contained {
/* The search results list */
.note-detail-empty span.aa-dropdown-menu {
margin-top: 1em;
border: unset;
}

View File

@@ -1,4 +1,4 @@
import { IconRegistry, Locale } from "@triliumnext/commons";
import { BootstrapDefinition } from "@triliumnext/commons";
import appContext, { AppContext } from "./components/app_context";
import type FNote from "./entities/fnote";
@@ -6,6 +6,7 @@ import type { PrintReport } from "./print";
import type { lint } from "./services/eslint";
import type { Froca } from "./services/froca-interface";
import { Library } from "./services/library_loader";
import { Suggestion } from "./services/note_autocomplete";
import server from "./services/server";
import utils from "./services/utils";
@@ -14,10 +15,9 @@ interface ElectronProcess {
platform: string;
}
interface CustomGlobals {
interface CustomGlobals extends BootstrapDefinition {
isDesktop: typeof utils.isDesktop;
isMobile: typeof utils.isMobile;
device: "mobile" | "desktop" | "print";
getComponentByEl: typeof appContext.getComponentByEl;
getHeaders: typeof server.getHeaders;
getReferenceLinkTitle: (href: string) => Promise<string>;
@@ -30,32 +30,7 @@ interface CustomGlobals {
SEARCH_HELP_TEXT: string;
activeDialog: JQuery<HTMLElement> | null;
componentId: string;
csrfToken: string;
baseApiUrl: string;
isProtectedSessionAvailable: boolean;
isDev: boolean;
isMainWindow: boolean;
maxEntityChangeIdAtLoad: number;
maxEntityChangeSyncIdAtLoad: number;
assetPath: string;
appPath: string;
instanceName: string;
appCssNoteIds: string[];
triliumVersion: string;
TRILIUM_SAFE_MODE: boolean;
platform?: typeof process.platform;
linter: typeof lint;
hasNativeTitleBar: boolean;
hasBackgroundEffects: boolean;
isElectron: boolean;
isRtl: boolean;
iconRegistry: IconRegistry;
themeCssUrl: string;
themeUseNextAsBase?: "next" | "next-light" | "next-dark";
iconPackCss: string;
headingStyle: "plain" | "underline" | "markdown";
layoutOrientation: "vertical" | "horizontal";
currentLocale: Locale;
}
type RequireMethod = (moduleName: string) => any;
@@ -82,7 +57,34 @@ declare global {
"note-load-progress": CustomEvent<{ progress: number }>;
}
interface AutoCompleteConfig {
appendTo?: HTMLElement | null;
hint?: boolean;
openOnFocus?: boolean;
minLength?: number;
tabAutocomplete?: boolean;
autoselect?: boolean;
dropdownMenuContainer?: HTMLElement;
debug?: boolean;
}
type AutoCompleteCallback = (values: AutoCompleteArg[]) => void;
interface AutoCompleteArg {
name?: string;
value?: string;
notePathTitle?: string;
displayKey?: "name" | "value" | "notePathTitle";
cache?: boolean;
source?: (term: string, cb: AutoCompleteCallback) => void,
templates?: {
suggestion: (suggestion: Suggestion) => string | undefined
}
}
interface JQuery {
autocomplete: (action?: "close" | "open" | "destroy" | "val" | AutoCompleteConfig, args?: AutoCompleteArg[] | string) => JQuery<HTMLElement>;
getSelectedNotePath(): string | undefined;
getSelectedNoteId(): string | null;
setSelectedNotePath(notePath: string | null | undefined);

View File

@@ -8,7 +8,6 @@ import { Dispatch, StateUpdater, useCallback, useEffect, useRef, useState } from
import NoteContext from "../components/note_context";
import FAttribute from "../entities/fattribute";
import FNote from "../entities/fnote";
import attributeAutocompleteService from "../services/attribute_autocomplete";
import { Attribute } from "../services/attribute_parser";
import attributes from "../services/attributes";
import { t } from "../services/i18n";
@@ -37,7 +36,8 @@ interface CellProps {
setCellToFocus(cell: Cell): void;
}
type OnChangeEventData = TargetedEvent<HTMLInputElement | HTMLTextAreaElement, Event> | InputEvent;
type OnChangeEventData = TargetedEvent<HTMLInputElement | HTMLTextAreaElement, Event> | InputEvent | JQuery.TriggeredEvent<HTMLInputElement, undefined, HTMLInputElement, HTMLInputElement>;
type OnChangeListener = (e: OnChangeEventData) => void | Promise<void>;
export default function PromotedAttributes() {
const { note, componentId, noteContext } = useNoteContext();
@@ -201,9 +201,10 @@ function LabelInput(props: CellProps & { inputId: string }) {
}, [ cell, componentId, note, setCells ]);
const extraInputProps: InputHTMLAttributes = {};
useTextLabelAutocomplete(inputId, valueAttr, definition, async (value) => {
setDraft(value);
await updateAttribute(note, cell, componentId, value, setCells);
useTextLabelAutocomplete(inputId, valueAttr, definition, (e) => {
if (e.currentTarget instanceof HTMLInputElement) {
setDraft(e.currentTarget.value);
}
});
// React to model changes.
@@ -259,7 +260,7 @@ function LabelInput(props: CellProps & { inputId: string }) {
className="open-external-link-button"
icon="bx bx-window-open"
title={t("promoted_attributes.open_external_link")}
onClick={() => {
onClick={(e) => {
const inputEl = document.getElementById(inputId) as HTMLInputElement | null;
const url = inputEl?.value;
if (url) {
@@ -414,31 +415,55 @@ function InputButton({ icon, className, title, onClick }: {
);
}
function useTextLabelAutocomplete(inputId: string, valueAttr: Attribute, definition: DefinitionObject, onValueChange: (value: string) => void) {
function useTextLabelAutocomplete(inputId: string, valueAttr: Attribute, definition: DefinitionObject, onChangeListener: OnChangeListener) {
const [ attributeValues, setAttributeValues ] = useState<{ value: string }[] | null>(null);
// Obtain data.
useEffect(() => {
if (definition.labelType !== "text") {
return;
}
server.get<string[]>(`attribute-values/${encodeURIComponent(valueAttr.name)}`).then((_attributesValues) => {
setAttributeValues(_attributesValues.map((attribute) => ({ value: attribute })));
});
}, [ definition.labelType, valueAttr.name ]);
// Initialize autocomplete.
useEffect(() => {
if (attributeValues?.length === 0) return;
const el = document.getElementById(inputId) as HTMLInputElement | null;
if (!el) {
return;
}
if (!el) return;
const $input = $(el);
attributeAutocompleteService.initLabelValueAutocomplete({
$el: $input,
open: false,
nameCallback: () => valueAttr.name,
onValueChange: (value) => {
onValueChange(value);
}
});
$input.autocomplete(
{
appendTo: document.querySelector("body"),
hint: false,
autoselect: false,
openOnFocus: true,
minLength: 0,
tabAutocomplete: false
},
[
{
displayKey: "value",
source (term, cb) {
term = term.toLowerCase();
return () => {
attributeAutocompleteService.destroyAutocomplete($input);
};
}, [ definition.labelType, inputId, onValueChange, valueAttr.name ]);
const filtered = (attributeValues ?? []).filter((attr) => attr.value.toLowerCase().includes(term));
cb(filtered);
}
}
]
);
$input.off("autocomplete:selected");
$input.on("autocomplete:selected", onChangeListener);
return () => $input.autocomplete("destroy");
}, [ inputId, attributeValues, onChangeListener ]);
}
async function updateAttribute(note: FNote, cell: Cell, componentId: string, value: string | undefined, setCells: Dispatch<StateUpdater<Cell[] | undefined>>) {

View File

@@ -1,14 +1,16 @@
import { useState } from "preact/hooks";
import FNote from "../../entities/fnote";
import "./UserAttributesList.css";
import { useTriliumEvent } from "../react/hooks";
import attributes from "../../services/attributes";
import { DefinitionObject } from "../../services/promoted_attribute_definition_parser";
import { formatDateTime } from "../../utils/formatters";
import type { DefinitionObject } from "@triliumnext/commons";
import { ComponentChildren, CSSProperties } from "preact";
import { useState } from "preact/hooks";
import FNote from "../../entities/fnote";
import attributes from "../../services/attributes";
import { getReadableTextColor } from "../../services/css_class_manager";
import { formatDateTime } from "../../utils/formatters";
import { useTriliumEvent } from "../react/hooks";
import Icon from "../react/Icon";
import NoteLink from "../react/NoteLink";
import { getReadableTextColor } from "../../services/css_class_manager";
interface UserAttributesListProps {
note: FNote;
@@ -29,7 +31,7 @@ export default function UserAttributesDisplay({ note, ignoredAttributes }: UserA
<div className="user-attributes">
{userAttributes?.map(attr => buildUserAttribute(attr))}
</div>
)
);
}
@@ -46,13 +48,13 @@ function useNoteAttributesWithDefinitions(note: FNote, attributesToIgnore: stri
}
function UserAttribute({ attr, children, style }: { attr: AttributeWithDefinitions, children: ComponentChildren, style?: CSSProperties }) {
const className = `${attr.type === "label" ? "label" + " " + attr.def.labelType : "relation"}`;
const className = attr.type === "label" ? `label ${attr.def.labelType}` : "relation";
return (
<span key={attr.friendlyName} className={`user-attribute type-${className}`} style={style}>
{children}
</span>
)
);
}
function buildUserAttribute(attr: AttributeWithDefinitions): ComponentChildren {
@@ -61,7 +63,7 @@ function buildUserAttribute(attr: AttributeWithDefinitions): ComponentChildren {
let style: CSSProperties | undefined;
if (attr.type === "label") {
let value = attr.value;
const value = attr.value;
switch (attr.def.labelType) {
case "number":
let formattedValue = value;
@@ -102,7 +104,7 @@ function buildUserAttribute(attr: AttributeWithDefinitions): ComponentChildren {
content = <>{defaultLabel}<NoteLink notePath={attr.value} showNoteIcon /></>;
}
return <UserAttribute attr={attr} style={style}>{content}</UserAttribute>
return <UserAttribute attr={attr} style={style}>{content}</UserAttribute>;
}
function getAttributesWithDefinitions(note: FNote, attributesToIgnore: string[] = []): AttributeWithDefinitions[] {

View File

@@ -1,7 +1,6 @@
import appContext from "../../components/app_context.js";
import attributeAutocompleteService from "../../services/attribute_autocomplete.js";
import type { Attribute } from "../../services/attribute_parser.js";
import { HEADLESS_AUTOCOMPLETE_PANEL_SELECTOR } from "../../services/autocomplete_core.js";
import { isExperimentalFeatureEnabled } from "../../services/experimental_features.js";
import { focusSavedElement, saveFocusedElement } from "../../services/focus.js";
import froca from "../../services/froca.js";
@@ -376,13 +375,13 @@ export default class AttributeDetailWidget extends NoteContextAwareWidget {
}
});
this.$inputName.on("change", () => this.userEditedAttribute());
this.$inputName.on("autocomplete:closed", () => this.userEditedAttribute());
this.$inputName.on("focus", () => {
attributeAutocompleteService.initAttributeNameAutocomplete({
$el: this.$inputName,
attributeType: () => (["relation", "relation-definition"].includes(this.attrType || "") ? "relation" : "label"),
open: true,
onValueChange: () => this.userEditedAttribute(),
open: true
});
});
@@ -395,12 +394,12 @@ export default class AttributeDetailWidget extends NoteContextAwareWidget {
}
});
this.$inputValue.on("change", () => this.userEditedAttribute());
this.$inputValue.on("autocomplete:closed", () => this.userEditedAttribute());
this.$inputValue.on("focus", () => {
attributeAutocompleteService.initLabelValueAutocomplete({
$el: this.$inputValue,
open: true,
nameCallback: () => String(this.$inputName.val()),
onValueChange: () => this.userEditedAttribute(),
nameCallback: () => String(this.$inputName.val())
});
});
@@ -481,9 +480,7 @@ export default class AttributeDetailWidget extends NoteContextAwareWidget {
this.$relatedNotesMoreNotes = this.$relatedNotesContainer.find(".related-notes-more-notes");
$(window).on("mousedown", (e) => {
if (!$(e.target).closest(this.$widget[0]).length
&& !$(e.target).closest(HEADLESS_AUTOCOMPLETE_PANEL_SELECTOR).length
&& !$(e.target).closest("#context-menu-container").length) {
if (!$(e.target).closest(this.$widget[0]).length && !$(e.target).closest(".algolia-autocomplete").length && !$(e.target).closest("#context-menu-container").length) {
this.hide();
}
});

View File

@@ -8,7 +8,7 @@ import { CommandNames } from "../../components/app_context";
import Component from "../../components/component";
import { ExperimentalFeature, ExperimentalFeatureId, experimentalFeatures, isExperimentalFeatureEnabled, toggleExperimentalFeature } from "../../services/experimental_features";
import { t } from "../../services/i18n";
import utils, { dynamicRequire, isElectron, isMobile, reloadFrontendApp } from "../../services/utils";
import utils, { dynamicRequire, isElectron, isMobile, isStandalone, reloadFrontendApp } from "../../services/utils";
import Dropdown from "../react/Dropdown";
import { FormDropdownDivider, FormDropdownSubmenu, FormListHeader, FormListItem } from "../react/FormList";
import { useStaticTooltip, useStaticTooltipWithKeyboardShortcut, useTriliumOption, useTriliumOptionBool, useTriliumOptionInt } from "../react/hooks";
@@ -251,7 +251,7 @@ function ToggleWindowOnTop() {
function useTriliumUpdateStatus() {
const [ latestVersion, setLatestVersion ] = useState<string>();
const [ checkForUpdates ] = useTriliumOptionBool("checkForUpdates");
const isUpdateAvailable = utils.isUpdateAvailable(latestVersion, glob.triliumVersion);
const isUpdateAvailable = utils.isUpdateAvailable(latestVersion, window.glob.triliumVersion);
async function updateVersionStatus() {
const RELEASES_API_URL = "https://api.github.com/repos/TriliumNext/Trilium/releases/latest";
@@ -269,7 +269,7 @@ function useTriliumUpdateStatus() {
}
useEffect(() => {
if (!checkForUpdates) {
if (!checkForUpdates || !isStandalone) {
setLatestVersion(undefined);
return;
}

View File

@@ -1,11 +1,12 @@
import type { CellComponent, ColumnDefinition, EmptyCallback, FormatterParams, ValueBooleanCallback, ValueVoidCallback } from "tabulator-tables";
import { LabelType } from "../../../services/promoted_attribute_definition_parser.js";
import { LabelType } from "@triliumnext/commons";
import { JSX } from "preact";
import { renderReactWidget } from "../../react/react_utils.jsx";
import Icon from "../../react/Icon.jsx";
import { useEffect, useRef, useState } from "preact/hooks";
import type { CellComponent, ColumnDefinition, EmptyCallback, FormatterParams, ValueBooleanCallback, ValueVoidCallback } from "tabulator-tables";
import froca from "../../../services/froca.js";
import Icon from "../../react/Icon.jsx";
import NoteAutocomplete from "../../react/NoteAutocomplete.jsx";
import { renderReactWidget } from "../../react/react_utils.jsx";
type ColumnType = LabelType | "relation";
@@ -85,7 +86,7 @@ export function buildColumnDefinitions({ info, movableRows, existingColumnData,
rowHandle: movableRows,
width: calculateIndexColumnWidth(rowNumberHint, movableRows),
formatter: wrapFormatter(({ cell, formatterParams }) => <div>
{(formatterParams as RowNumberFormatterParams).movableRows && <><span class="bx bx-dots-vertical-rounded"></span>{" "}</>}
{(formatterParams as RowNumberFormatterParams).movableRows && <><span class="bx bx-dots-vertical-rounded" />{" "}</>}
{cell.getRow().getPosition(true)}
</div>),
formatterParams: { movableRows } satisfies RowNumberFormatterParams
@@ -207,14 +208,14 @@ function wrapEditor(Component: (opts: EditorOpts) => JSX.Element): ((
editorParams: {},
) => HTMLElement | false) {
return (cell, _, success, cancel, editorParams) => {
const elWithParams = <Component cell={cell} success={success} cancel={cancel} editorParams={editorParams} />
const elWithParams = <Component cell={cell} success={success} cancel={cancel} editorParams={editorParams} />;
return renderReactWidget(null, elWithParams)[0];
};
}
function NoteFormatter({ cell }: FormatterOpts) {
const noteId = cell.getValue();
const [ note, setNote ] = useState(noteId ? froca.getNoteFromCache(noteId) : null)
const [ note, setNote ] = useState(noteId ? froca.getNoteFromCache(noteId) : null);
useEffect(() => {
if (!noteId || note?.noteId === noteId) return;
@@ -238,5 +239,5 @@ function RelationEditor({ cell, success }: EditorOpts) {
hideAllButtons: true
}}
noteIdChanged={success}
/>
/>;
}

View File

@@ -1,5 +1,7 @@
import { LabelType } from "@triliumnext/commons";
import FNote from "../../../entities/fnote.js";
import { extractAttributeDefinitionTypeAndName, type LabelType } from "../../../services/promoted_attribute_definition_parser.js";
import { extractAttributeDefinitionTypeAndName } from "../../../services/promoted_attribute_definition_parser.js";
import type { AttributeDefinitionInformation } from "./columns.js";
export type TableData = {
@@ -49,7 +51,7 @@ export async function buildRowDefinitions(parentNote: FNote, infos: AttributeDef
isArchived: note.isArchived,
branchId: branch.branchId,
colorClass: note.getColorClass()
}
};
if (note.hasChildren() && (maxDepth < 0 || currentDepth < maxDepth)) {
const { definitions, rowNumber: subRowNumber } = (await buildRowDefinitions(note, infos, includeArchived, maxDepth, currentDepth + 1));

View File

@@ -1,13 +1,14 @@
import Modal from "../react/Modal.js";
import type { AppInfo } from "@triliumnext/commons";
import type { CSSProperties } from "preact/compat";
import { useState } from "preact/hooks";
import { t } from "../../services/i18n.js";
import { formatDateTime } from "../../utils/formatters.js";
import openService from "../../services/open.js";
import server from "../../services/server.js";
import utils from "../../services/utils.js";
import openService from "../../services/open.js";
import { useState } from "preact/hooks";
import type { CSSProperties } from "preact/compat";
import type { AppInfo } from "@triliumnext/commons";
import { formatDateTime } from "../../utils/formatters.js";
import { useTriliumEvent } from "../react/hooks.jsx";
import Modal from "../react/Modal.js";
export default function AboutDialog() {
const [appInfo, setAppInfo] = useState<AppInfo | null>(null);
@@ -54,15 +55,15 @@ export default function AboutDialog() {
<tr>
<th>{t("about.build_revision")}</th>
<td className="selectable-text">
{appInfo?.buildRevision && <a className="tn-link build-revision external" href={`https://github.com/TriliumNext/Trilium/commit/${appInfo.buildRevision}`} target="_blank" style={forceWordBreak}>{appInfo.buildRevision}</a>}
{appInfo?.buildRevision && <a className="tn-link build-revision external" href={`https://github.com/TriliumNext/Trilium/commit/${appInfo.buildRevision}`} target="_blank" style={forceWordBreak} rel="noreferrer">{appInfo.buildRevision}</a>}
</td>
</tr>
<tr>
{ appInfo?.dataDirectory && <tr>
<th>{t("about.data_directory")}</th>
<td className="data-directory">
{appInfo?.dataDirectory && (<DirectoryLink directory={appInfo.dataDirectory} style={forceWordBreak} />)}
</td>
</tr>
</tr>}
</tbody>
</table>
</Modal>
@@ -76,8 +77,8 @@ function DirectoryLink({ directory, style }: { directory: string, style?: CSSPro
openService.openDirectory(directory);
};
return <a className="tn-link selectable-text" href="#" onClick={onClick} style={style}>{directory}</a>
} else {
return <span className="selectable-text" style={style}>{directory}</span>;
}
return <a className="tn-link selectable-text" href="#" onClick={onClick} style={style}>{directory}</a>;
}
return <span className="selectable-text" style={style}>{directory}</span>;
}

View File

@@ -1,160 +0,0 @@
import $ from "jquery";
import type { ComponentChildren } from "preact";
import { render } from "preact";
import { act } from "preact/test-utils";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const {
triliumEventHandlers,
latestModalPropsRef,
latestNoteAutocompletePropsRef,
addLinkSpy,
logErrorSpy,
showRecentNotesSpy,
setTextSpy
} = vi.hoisted(() => ({
triliumEventHandlers: new Map<string, (payload: any) => void>(),
latestModalPropsRef: { current: null as any },
latestNoteAutocompletePropsRef: { current: null as any },
addLinkSpy: vi.fn(() => Promise.resolve()),
logErrorSpy: vi.fn(),
showRecentNotesSpy: vi.fn(),
setTextSpy: vi.fn()
}));
vi.mock("../../services/i18n", () => ({
t: (key: string) => key
}));
vi.mock("../../services/tree", () => ({
default: {
getNoteIdFromUrl: (notePath: string) => notePath.split("/").at(-1),
getNoteTitle: vi.fn(async () => "Target note")
}
}));
vi.mock("../../services/ws", () => ({
logError: logErrorSpy
}));
vi.mock("../../services/note_autocomplete", () => ({
__esModule: true,
default: {
showRecentNotes: showRecentNotesSpy,
setText: setTextSpy
}
}));
vi.mock("../react/react_utils", () => ({
refToJQuerySelector: (ref: { current: HTMLInputElement | null }) => ref.current ? $(ref.current) : $()
}));
vi.mock("../react/hooks", () => ({
useTriliumEvent: (name: string, handler: (payload: any) => void) => {
triliumEventHandlers.set(name, handler);
}
}));
vi.mock("../react/Modal", () => ({
default: (props: any) => {
latestModalPropsRef.current = props;
if (!props.show) {
return null;
}
return (
<form onSubmit={(e) => {
e.preventDefault();
props.onSubmit?.();
}}>
{props.children}
{props.footer}
</form>
);
}
}));
vi.mock("../react/FormGroup", () => ({
default: ({ children }: { children: ComponentChildren }) => <div>{children}</div>
}));
vi.mock("../react/Button", () => ({
default: ({ text }: { text: string }) => <button type="submit">{text}</button>
}));
vi.mock("../react/FormRadioGroup", () => ({
default: () => null
}));
vi.mock("../react/NoteAutocomplete", () => ({
default: (props: any) => {
latestNoteAutocompletePropsRef.current = props;
return <input ref={props.inputRef} />;
}
}));
import AddLinkDialog from "./add_link";
describe("AddLinkDialog", () => {
let container: HTMLDivElement;
beforeEach(() => {
vi.clearAllMocks();
latestModalPropsRef.current = null;
latestNoteAutocompletePropsRef.current = null;
triliumEventHandlers.clear();
container = document.createElement("div");
document.body.appendChild(container);
});
afterEach(() => {
act(() => {
render(null, container);
});
container.remove();
});
it("submits the selected note when Enter picks an autocomplete suggestion", async () => {
act(() => {
render(<AddLinkDialog />, container);
});
const showDialog = triliumEventHandlers.get("showAddLinkDialog");
expect(showDialog).toBeTypeOf("function");
await act(async () => {
showDialog?.({
text: "",
hasSelection: false,
addLink: addLinkSpy
});
});
const suggestion = {
notePath: "root/target-note",
noteTitle: "Target note"
};
act(() => {
latestNoteAutocompletePropsRef.current.onKeyDownCapture({
key: "Enter",
ctrlKey: false,
metaKey: false,
shiftKey: false,
altKey: false,
isComposing: false
});
latestNoteAutocompletePropsRef.current.onChange(suggestion);
});
expect(latestModalPropsRef.current.show).toBe(false);
expect(logErrorSpy).not.toHaveBeenCalled();
await act(async () => {
latestModalPropsRef.current.onHidden();
});
expect(addLinkSpy).toHaveBeenCalledWith("root/target-note", null);
});
});

View File

@@ -1,17 +1,15 @@
import type { JSX } from "preact";
import { useEffect,useRef, useState } from "preact/hooks";
import { t } from "../../services/i18n";
import note_autocomplete, { Suggestion } from "../../services/note_autocomplete";
import tree from "../../services/tree";
import { logError } from "../../services/ws";
import Button from "../react/Button";
import FormGroup from "../react/FormGroup.js";
import FormRadioGroup from "../react/FormRadioGroup";
import { useTriliumEvent } from "../react/hooks";
import Modal from "../react/Modal";
import Button from "../react/Button";
import FormRadioGroup from "../react/FormRadioGroup";
import NoteAutocomplete from "../react/NoteAutocomplete";
import { useRef, useState, useEffect } from "preact/hooks";
import tree from "../../services/tree";
import note_autocomplete, { Suggestion } from "../../services/note_autocomplete";
import { logError } from "../../services/ws";
import FormGroup from "../react/FormGroup.js";
import { refToJQuerySelector } from "../react/react_utils";
import { useTriliumEvent } from "../react/hooks";
type LinkType = "reference-link" | "external-link" | "hyper-link";
@@ -28,8 +26,6 @@ export default function AddLinkDialog() {
const [ suggestion, setSuggestion ] = useState<Suggestion | null>(null);
const [ shown, setShown ] = useState(false);
const hasSubmittedRef = useRef(false);
const suggestionRef = useRef<Suggestion | null>(null);
const submitOnSelectionRef = useRef(false);
useTriliumEvent("showAddLinkDialog", opts => {
setOpts(opts);
@@ -89,44 +85,15 @@ export default function AddLinkDialog() {
.trigger("select");
}
function submitSelectedLink(selectedSuggestion: Suggestion | null) {
submitOnSelectionRef.current = false;
hasSubmittedRef.current = Boolean(selectedSuggestion);
if (!selectedSuggestion) {
logError("No link to add.");
return;
}
// Insertion logic in onHidden because it needs focus.
setShown(false);
}
function onSuggestionChange(nextSuggestion: Suggestion | null) {
suggestionRef.current = nextSuggestion;
setSuggestion(nextSuggestion);
if (submitOnSelectionRef.current && nextSuggestion) {
submitSelectedLink(nextSuggestion);
}
}
function onAutocompleteKeyDownCapture(e: JSX.TargetedKeyboardEvent<HTMLInputElement>) {
if (e.key !== "Enter" || e.ctrlKey || e.metaKey || e.shiftKey || e.altKey || e.isComposing) {
return;
}
submitOnSelectionRef.current = true;
}
function onAutocompleteKeyUpCapture(e: JSX.TargetedKeyboardEvent<HTMLInputElement>) {
if (e.key === "Enter") {
submitOnSelectionRef.current = false;
}
}
function onSubmit() {
submitSelectedLink(suggestionRef.current);
hasSubmittedRef.current = true;
if (suggestion) {
// Insertion logic in onHidden because it needs focus.
setShown(false);
} else {
logError("No link to add.");
}
}
const autocompleteRef = useRef<HTMLInputElement>(null);
@@ -142,22 +109,19 @@ export default function AddLinkDialog() {
onSubmit={onSubmit}
onShown={onShown}
onHidden={() => {
submitOnSelectionRef.current = false;
// Insert the link.
if (hasSubmittedRef.current && suggestionRef.current && opts) {
if (hasSubmittedRef.current && suggestion && opts) {
hasSubmittedRef.current = false;
if (suggestionRef.current.notePath) {
if (suggestion.notePath) {
// Handle note link
opts.addLink(suggestionRef.current.notePath, linkType === "reference-link" ? null : linkTitle);
} else if (suggestionRef.current.externalLink) {
opts.addLink(suggestion.notePath, linkType === "reference-link" ? null : linkTitle);
} else if (suggestion.externalLink) {
// Handle external link
opts.addLink(suggestionRef.current.externalLink, linkTitle, true);
opts.addLink(suggestion.externalLink, linkTitle, true);
}
}
suggestionRef.current = null;
setSuggestion(null);
setShown(false);
}}
@@ -166,9 +130,7 @@ export default function AddLinkDialog() {
<FormGroup label={t("add_link.note")} name="note">
<NoteAutocomplete
inputRef={autocompleteRef}
onChange={onSuggestionChange}
onKeyDownCapture={onAutocompleteKeyDownCapture}
onKeyUpCapture={onAutocompleteKeyUpCapture}
onChange={setSuggestion}
opts={{
allowExternalLinks: true,
allowCreatingNotes: true

View File

@@ -1,15 +1,14 @@
import Modal from "../react/Modal";
import Button from "../react/Button";
import NoteAutocomplete from "../react/NoteAutocomplete";
import { t } from "../../services/i18n";
import { useRef, useState } from "preact/hooks";
import note_autocomplete, { Suggestion } from "../../services/note_autocomplete";
import appContext from "../../components/app_context";
import commandRegistry from "../../services/command_registry";
import { t } from "../../services/i18n";
import note_autocomplete, { Suggestion } from "../../services/note_autocomplete";
import shortcutService from "../../services/shortcuts";
import Button from "../react/Button";
import { useTriliumEvent } from "../react/hooks";
import Modal from "../react/Modal";
import NoteAutocomplete from "../react/NoteAutocomplete";
import { refToJQuerySelector } from "../react/react_utils";
import { useTriliumEvent } from "../react/hooks";
import shortcutService from "../../services/shortcuts";
const KEEP_LAST_SEARCH_FOR_X_SECONDS = 120;
@@ -24,14 +23,14 @@ export default function JumpToNoteDialogComponent() {
const [ initialText, setInitialText ] = useState(isCommandMode ? "> " : "");
const actualText = useRef<string>(initialText);
const [ shown, setShown ] = useState(false);
async function openDialog(commandMode: boolean) {
async function openDialog(commandMode: boolean) {
let newMode: Mode;
let initialText = "";
if (commandMode) {
newMode = "commands";
initialText = ">";
initialText = ">";
} else if (Date.now() - lastOpenedTs <= KEEP_LAST_SEARCH_FOR_X_SECONDS * 1000 && actualText.current) {
// if you open the Jump To dialog soon after using it previously, it can often mean that you
// actually want to search for the same thing (e.g., you opened the wrong note at first try)
@@ -59,7 +58,7 @@ export default function JumpToNoteDialogComponent() {
if (!suggestion) {
return;
}
setShown(false);
if (suggestion.notePath) {
appContext.tabManager.getActiveContext()?.setNote(suggestion.notePath);
@@ -84,7 +83,7 @@ export default function JumpToNoteDialogComponent() {
$autoComplete
.trigger("focus")
.trigger("select");
// Add keyboard shortcut for full search
shortcutService.bindElShortcut($autoComplete, "ctrl+return", () => {
if (!isCommandMode) {
@@ -92,7 +91,7 @@ export default function JumpToNoteDialogComponent() {
}
});
}
async function showInFullSearch() {
try {
setShown(false);
@@ -127,18 +126,18 @@ export default function JumpToNoteDialogComponent() {
setIsCommandMode(text.startsWith(">"));
}}
onChange={onItemSelected}
/>}
/>}
onShown={onShown}
onHidden={() => setShown(false)}
footer={!isCommandMode && <Button
className="show-in-full-text-button"
text={t("jump_to_note.search_button")}
footer={!isCommandMode && <Button
className="show-in-full-text-button"
text={t("jump_to_note.search_button")}
keyboardShortcut="Ctrl+Enter"
onClick={showInFullSearch}
/>}
show={shown}
>
<div className="jump-to-note-results" ref={containerRef} />
<div className="algolia-autocomplete-container jump-to-note-results" ref={containerRef}></div>
</Modal>
);
}

View File

@@ -1,12 +1,11 @@
import { useRef, useState } from "preact/hooks";
import { t } from "../../services/i18n";
import Button from "../react/Button";
import FormGroup from "../react/FormGroup";
import FormTextBox from "../react/FormTextBox";
import { useTriliumEvent } from "../react/hooks";
import Modal from "../react/Modal";
import FormTextBox from "../react/FormTextBox";
import FormGroup from "../react/FormGroup";
import { refToJQuerySelector } from "../react/react_utils";
import { useTriliumEvent } from "../react/hooks";
// JQuery here is maintained for compatibility with existing code.
interface ShownCallbackData {
@@ -41,7 +40,7 @@ export default function PromptDialog() {
opts.current = newOpts;
setValue(newOpts.defaultValue ?? "");
setShown(true);
});
})
return (
<Modal
@@ -61,7 +60,7 @@ export default function PromptDialog() {
answerRef.current?.select();
}}
onSubmit={() => {
submitValue.current = answerRef.current?.value || value;
submitValue.current = value;
setShown(false);
}}
onHidden={() => {

View File

@@ -27,6 +27,7 @@ export default function RecentChangesDialog() {
});
useEffect(() => {
if (!ancestorNoteId) return;
server.get<RecentChangeRow[]>(`recent-changes/${ancestorNoteId}`)
.then(async (recentChanges) => {
// preload all notes into cache

View File

@@ -0,0 +1,26 @@
import { isMobile } from "../../services/utils";
import Admonition from "../react/Admonition";
export default function StandaloneWarningBar() {
return (
<div
className="standalone-warning-bar"
style={{
contain: "none"
}}
>
<Admonition
type="caution"
style={{
margin: 0,
fontSize: "0.8em"
}}
>
{isMobile()
? "Running Trilium standalone. Beware of data loss and other issues."
: "You are running Trilium in standalone mode. Some features are not available, and you may experience issues or data loss. Use the desktop application or self-hosted server for the best experience."
}
</Admonition>
</div>
);
}

View File

@@ -1,15 +1,16 @@
import { ComponentChildren } from "preact";
import { HTML } from "mermaid/dist/diagram-api/types.js";
import { ComponentChildren, HTMLAttributes } from "preact";
interface AdmonitionProps {
interface AdmonitionProps extends Pick<HTMLAttributes<HTMLDivElement>, "style"> {
type: "warning" | "note" | "caution";
children: ComponentChildren;
className?: string;
}
export default function Admonition({ type, children, className }: AdmonitionProps) {
export default function Admonition({ type, children, className, ...props }: AdmonitionProps) {
return (
<div className={`admonition ${type} ${className}`} role="alert">
<div className={`admonition ${type} ${className}`} role="alert" {...props}>
{children}
</div>
)
);
}

View File

@@ -1,135 +0,0 @@
import $ from "jquery";
import { render } from "preact";
import { act } from "preact/test-utils";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const {
initNoteAutocompleteSpy,
setTextSpy,
clearTextSpy
} = vi.hoisted(() => ({
initNoteAutocompleteSpy: vi.fn(($el) => $el),
setTextSpy: vi.fn(),
clearTextSpy: vi.fn()
}));
vi.mock("../../services/i18n", () => ({
t: (key: string) => key
}));
vi.mock("../../services/note_autocomplete", () => ({
__esModule: true,
default: {
initNoteAutocomplete: initNoteAutocompleteSpy,
setText: setTextSpy,
clearText: clearTextSpy
}
}));
import NoteAutocomplete from "./NoteAutocomplete";
describe("NoteAutocomplete", () => {
let container: HTMLDivElement;
let setNoteSpy: ReturnType<typeof vi.fn>;
let getSelectedNoteIdSpy: ReturnType<typeof vi.fn>;
beforeEach(() => {
vi.clearAllMocks();
container = document.createElement("div");
document.body.appendChild(container);
setNoteSpy = vi.fn(() => Promise.resolve());
getSelectedNoteIdSpy = vi.fn(() => "selected-note-id");
($.fn as any).setNote = setNoteSpy;
($.fn as any).getSelectedNoteId = getSelectedNoteIdSpy;
});
afterEach(() => {
act(() => {
render(null, container);
});
container.remove();
});
it("syncs text props through the headless helper functions", () => {
act(() => {
render(<NoteAutocomplete text="hello" />, container);
});
const input = container.querySelector("input") as HTMLInputElement;
expect(initNoteAutocompleteSpy).toHaveBeenCalledTimes(1);
expect(initNoteAutocompleteSpy.mock.calls[0][0][0]).toBe(input);
expect(setTextSpy).toHaveBeenCalledTimes(1);
expect(setTextSpy.mock.calls[0][0][0]).toBe(input);
expect(setTextSpy).toHaveBeenCalledWith(expect.anything(), "hello");
act(() => {
render(<NoteAutocomplete text="" />, container);
});
expect(clearTextSpy).toHaveBeenCalled();
});
it("syncs noteId props through the jQuery setNote extension", () => {
act(() => {
render(<NoteAutocomplete noteId="note-123" />, container);
});
expect(setNoteSpy).toHaveBeenCalledWith("note-123");
expect(clearTextSpy).not.toHaveBeenCalled();
});
it("forwards autocomplete selection and clear events to consumers", () => {
const onChange = vi.fn();
const noteIdChanged = vi.fn();
act(() => {
render(<NoteAutocomplete onChange={onChange} noteIdChanged={noteIdChanged} />, container);
});
const input = container.querySelector("input") as HTMLInputElement;
const $input = $(input);
const suggestion = { notePath: "root/child-note", noteTitle: "Child note" };
$input.trigger("autocomplete:noteselected", [suggestion]);
expect(onChange).toHaveBeenCalledWith(suggestion);
expect(noteIdChanged).toHaveBeenCalledWith("child-note");
input.value = "";
$input.trigger("change");
expect(onChange).toHaveBeenCalledWith(null);
});
it("forwards onTextChange, onKeyDown and onBlur events", () => {
const onTextChange = vi.fn();
const onKeyDown = vi.fn();
const onBlur = vi.fn();
act(() => {
render(
<NoteAutocomplete
onTextChange={onTextChange}
onKeyDown={onKeyDown}
onBlur={onBlur}
/>,
container
);
});
const input = container.querySelector("input") as HTMLInputElement;
const $input = $(input);
input.value = "typed text";
$input.trigger("input");
$input.trigger($.Event("keydown", { originalEvent: new KeyboardEvent("keydown", { key: "Enter" }) }));
$input.trigger("blur");
expect(onTextChange).toHaveBeenCalledWith("typed text");
expect(onKeyDown).toHaveBeenCalledWith(expect.any(KeyboardEvent));
expect(onBlur).toHaveBeenCalledWith("selected-note-id");
});
});

View File

@@ -1,9 +1,8 @@
import type { JSX,RefObject } from "preact";
import type { CSSProperties } from "preact/compat";
import { useEffect } from "preact/hooks";
import { t } from "../../services/i18n";
import { useEffect } from "preact/hooks";
import note_autocomplete, { Options, type Suggestion } from "../../services/note_autocomplete";
import type { RefObject } from "preact";
import type { CSSProperties } from "preact/compat";
import { useSyncedRef } from "./hooks";
interface NoteAutocompleteProps {
@@ -17,111 +16,85 @@ interface NoteAutocompleteProps {
onChange?: (suggestion: Suggestion | null) => void;
onTextChange?: (text: string) => void;
onKeyDown?: (e: KeyboardEvent) => void;
onKeyDownCapture?: JSX.KeyboardEventHandler<HTMLInputElement>;
onKeyUpCapture?: JSX.KeyboardEventHandler<HTMLInputElement>;
onBlur?: (newValue: string) => void;
noteIdChanged?: (noteId: string) => void;
noteId?: string;
}
export default function NoteAutocomplete({ id, inputRef: externalInputRef, text, placeholder, onChange, onTextChange, container, containerStyle, opts, noteId, noteIdChanged, onKeyDown, onKeyDownCapture, onKeyUpCapture, onBlur }: NoteAutocompleteProps) {
export default function NoteAutocomplete({ id, inputRef: externalInputRef, text, placeholder, onChange, onTextChange, container, containerStyle, opts, noteId, noteIdChanged, onKeyDown, onBlur }: NoteAutocompleteProps) {
const ref = useSyncedRef<HTMLInputElement>(externalInputRef);
useEffect(() => {
if (!ref.current) return;
const $autoComplete = $(ref.current);
// clear any event listener added in previous invocation of this function
$autoComplete
.off("autocomplete:noteselected")
.off("autocomplete:commandselected")
note_autocomplete.initNoteAutocomplete($autoComplete, {
...opts,
container: container?.current
});
}, [opts, container?.current]);
useEffect(() => {
if (!ref.current) return;
const $autoComplete = $(ref.current);
const inputListener = () => onTextChange?.($autoComplete[0].value);
const keyDownListener = (e) => e.originalEvent && onKeyDown?.(e.originalEvent);
const blurListener = () => onBlur?.($autoComplete.getSelectedNoteId() ?? "");
if (onTextChange) {
$autoComplete.on("input", inputListener);
$autoComplete.on("input", () => onTextChange($autoComplete[0].value));
}
if (onKeyDown) {
$autoComplete.on("keydown", keyDownListener);
$autoComplete.on("keydown", (e) => e.originalEvent && onKeyDown(e.originalEvent));
}
if (onBlur) {
$autoComplete.on("blur", blurListener);
$autoComplete.on("blur", () => onBlur($autoComplete.getSelectedNoteId() ?? ""));
}
}, [opts, container?.current]);
return () => {
if (onTextChange) {
$autoComplete.off("input", inputListener);
}
if (onKeyDown) {
$autoComplete.off("keydown", keyDownListener);
}
if (onBlur) {
$autoComplete.off("blur", blurListener);
}
};
}, [onBlur, onKeyDown, onTextChange]);
// On change event handlers.
useEffect(() => {
if (!ref.current) return;
const $autoComplete = $(ref.current);
if (!(onChange || noteIdChanged)) {
return;
}
const autoCompleteListener = (_e, suggestion) => {
onChange?.(suggestion);
if (onChange || noteIdChanged) {
const autoCompleteListener = (_e, suggestion) => {
onChange?.(suggestion);
if (noteIdChanged) {
const noteId = suggestion?.notePath?.split("/")?.at(-1);
noteIdChanged(noteId);
}
};
const changeListener = (e) => {
if (!ref.current?.value) {
autoCompleteListener(e, null);
}
};
$autoComplete
.on("autocomplete:noteselected", autoCompleteListener)
.on("autocomplete:externallinkselected", autoCompleteListener)
.on("autocomplete:commandselected", autoCompleteListener)
.on("change", changeListener);
return () => {
if (noteIdChanged) {
const noteId = suggestion?.notePath?.split("/")?.at(-1);
noteIdChanged(noteId);
}
};
const changeListener = (e) => {
if (!ref.current?.value) {
autoCompleteListener(e, null);
}
};
$autoComplete
.off("autocomplete:noteselected", autoCompleteListener)
.off("autocomplete:externallinkselected", autoCompleteListener)
.off("autocomplete:commandselected", autoCompleteListener)
.off("change", changeListener);
};
}, [onChange, noteIdChanged]);
.on("autocomplete:noteselected", autoCompleteListener)
.on("autocomplete:externallinkselected", autoCompleteListener)
.on("autocomplete:commandselected", autoCompleteListener)
.on("change", changeListener);
return () => {
$autoComplete
.off("autocomplete:noteselected", autoCompleteListener)
.off("autocomplete:externallinkselected", autoCompleteListener)
.off("autocomplete:commandselected", autoCompleteListener)
.off("change", changeListener);
};
}
}, [opts, container?.current, onChange, noteIdChanged])
useEffect(() => {
if (!ref.current) return;
const $autoComplete = $(ref.current);
if (noteId) {
void $autoComplete.setNote(noteId);
return;
$autoComplete.setNote(noteId);
} else if (text) {
note_autocomplete.setText($autoComplete, text);
} else {
$autoComplete.setSelectedNotePath("");
$autoComplete.autocomplete("val", "");
ref.current.value = "";
}
if (text !== undefined) {
if (text) {
note_autocomplete.setText($autoComplete, text);
} else {
note_autocomplete.clearText($autoComplete);
}
return;
}
note_autocomplete.clearText($autoComplete);
}, [text, noteId]);
return (
@@ -130,8 +103,6 @@ export default function NoteAutocomplete({ id, inputRef: externalInputRef, text,
id={id}
ref={ref}
className="note-autocomplete form-control"
onKeyDownCapture={onKeyDownCapture}
onKeyUpCapture={onKeyUpCapture}
placeholder={placeholder ?? t("add_link.search_note")} />
</div>
);

View File

@@ -50,8 +50,9 @@ body.desktop {
border-radius: 8px;
}
.note-detail-empty-results .aa-core-panel--contained {
border: 0;
.note-detail-empty-results .aa-dropdown-menu {
border: var(--bs-border-width) solid var(--bs-border-color);
border-top: 0;
}
.empty-tab-search label {

View File

@@ -3,7 +3,6 @@ import froca from "../../../services/froca.js";
import type LoadResults from "../../../services/load_results.js";
import search from "../../../services/search.js";
import type { TemplateDefinition } from "@triliumnext/ckeditor5";
import appContext from "../../../components/app_context.js";
import type FNote from "../../../entities/fnote.js";
interface TemplateData {
@@ -21,20 +20,25 @@ const debouncedHandleContentUpdate = debounce(handleContentUpdate, 1000);
* @returns the list of templates.
*/
export default async function getTemplates() {
// Build the definitions and populate the cache.
const snippets = await search.searchForNotes("#textSnippet");
const definitions: TemplateDefinition[] = [];
for (const snippet of snippets) {
const { description } = await invalidateCacheFor(snippet);
try {
// Build the definitions and populate the cache.
const snippets = await search.searchForNotes("#textSnippet");
const definitions: TemplateDefinition[] = [];
for (const snippet of snippets) {
const { description } = await invalidateCacheFor(snippet);
definitions.push({
title: snippet.title,
data: () => templateCache.get(snippet.noteId)?.content ?? "",
icon: buildIcon(snippet),
description
});
definitions.push({
title: snippet.title,
data: () => templateCache.get(snippet.noteId)?.content ?? "",
icon: buildIcon(snippet),
description
});
}
return definitions;
} catch (e) {
logError("Error while building text snippet templates: ", e);
return [];
}
return definitions;
}
async function invalidateCacheFor(snippet: FNote) {

View File

@@ -1,5 +1,4 @@
import { expect,test } from "@playwright/test";
import { test, expect } from "@playwright/test";
import App from "../support/app";
const TEXT_NOTE_TITLE = "Text notes";
@@ -33,7 +32,8 @@ test("Open the note in the correct split pane", async ({ page, context }) => {
await noteContent.focus();
// Click the search result in the second split.
await app.getNoteAutocompleteSuggestion(resultsSelector, CODE_NOTE_TITLE).click();
await resultsSelector.locator(".aa-suggestion", { hasText: CODE_NOTE_TITLE })
.nth(1).click();
await expect(split2).toContainText(CODE_NOTE_TITLE);
});
@@ -69,4 +69,4 @@ test("Can directly focus the autocomplete input within the split", async ({ page
await page.waitForTimeout(100);
await expect(autocomplete).toBeFocused();
});
});

View File

@@ -27,7 +27,6 @@ export default class App {
readonly currentNoteSplitContent: Locator;
readonly sidebar: Locator;
private isMobile: boolean = false;
private readonly noteAutocompleteSuggestionSelector = ".aa-suggestion:not(.create-note-action):not(.search-notes-action):not(.command-action):not(.external-link-action)";
constructor(page: Page, context: BrowserContext) {
this.page = page;
@@ -77,19 +76,12 @@ export default class App {
const resultsSelector = this.currentNoteSplit.locator(".note-detail-empty-results");
await expect(resultsSelector).toContainText(noteTitle);
const suggestionSelector = resultsSelector
.locator(this.noteAutocompleteSuggestionSelector, { hasText: noteTitle })
.first();
const suggestionSelector = resultsSelector.locator(".aa-suggestion")
.nth(1); // Select the second one (best candidate), as the first one is "Create a new note"
await expect(suggestionSelector).toContainText(noteTitle);
await suggestionSelector.click();
}
getNoteAutocompleteSuggestion(resultsContainer: Locator, noteTitle: string) {
return resultsContainer
.locator(this.noteAutocompleteSuggestionSelector, { hasText: noteTitle })
.first();
}
async goToSettings() {
await this.page.locator(".launcher-button.bx-cog").click();
}

View File

@@ -39,6 +39,7 @@
"@braintree/sanitize-url": "7.1.2",
"@electron/remote": "2.1.3",
"@triliumnext/commons": "workspace:*",
"@triliumnext/core": "workspace:*",
"@triliumnext/express-partial-content": "workspace:*",
"@triliumnext/highlightjs": "workspace:*",
"@triliumnext/turndown-plugin-gfm": "workspace:*",
@@ -49,17 +50,13 @@
"@types/cookie-parser": "1.4.10",
"@types/debounce": "1.2.4",
"@types/ejs": "3.1.5",
"@types/escape-html": "1.0.4",
"@types/express-http-proxy": "1.6.7",
"@types/express-session": "1.18.2",
"@types/fs-extra": "11.0.4",
"@types/html": "1.0.4",
"@types/ini": "4.1.1",
"@types/mime-types": "3.0.1",
"@types/multer": "2.1.0",
"@types/safe-compare": "1.1.2",
"@types/sanitize-html": "2.16.1",
"@types/sax": "1.2.7",
"@types/serve-favicon": "2.5.7",
"@types/serve-static": "2.2.0",
"@types/stream-throttle": "0.1.4",
@@ -86,7 +83,6 @@
"electron": "41.0.3",
"electron-debug": "4.1.0",
"electron-window-state": "5.0.3",
"escape-html": "1.0.3",
"express": "5.2.1",
"express-http-proxy": "2.1.2",
"express-openid-connect": "2.19.4",
@@ -108,13 +104,10 @@
"jimp": "1.6.0",
"lorem-ipsum": "2.0.8",
"marked": "17.0.5",
"mime-types": "3.0.2",
"multer": "2.1.1",
"normalize-strings": "1.1.1",
"rand-token": "1.0.1",
"safe-compare": "1.1.4",
"sanitize-filename": "1.6.4",
"sanitize-html": "2.17.2",
"sax": "1.6.0",
"serve-favicon": "2.5.1",
"stream-throttle": "0.1.3",
@@ -125,7 +118,6 @@
"time2fa": "1.4.2",
"tmp": "0.2.5",
"turnish": "1.8.0",
"unescape": "1.0.1",
"vite": "8.0.1",
"ws": "8.20.0",
"xml2js": "0.6.2",

View File

@@ -1,6 +1,6 @@
import anonymizationService from "./services/anonymization.js";
import sqlInit from "./services/sql_init.js";
await import("./becca/entity_constructor.js");
await import("@triliumnext/core");
sqlInit.dbReady.then(async () => {
try {

View File

@@ -1,6 +1,6 @@
import "./services/handlers.js";
import "./becca/becca_loader.js";
import("@triliumnext/core");
import { erase } from "@triliumnext/core";
import compression from "compression";
import cookieParser from "cookie-parser";
import ejs from "ejs";
@@ -16,7 +16,6 @@ import custom from "./routes/custom.js";
import error_handlers from "./routes/error_handlers.js";
import routes from "./routes/routes.js";
import config from "./services/config.js";
import { startScheduledCleanup } from "./services/erase.js";
import log from "./services/log.js";
import openID from "./services/open_id.js";
import { RESOURCE_DIR } from "./services/resource_dir.js";
@@ -110,7 +109,7 @@ export default async function buildApp() {
const { startScheduler } = await import("./services/scheduler.js");
startScheduler();
startScheduledCleanup();
erase.startScheduledCleanup();
if (utils.isElectron) {
(await import("@electron/remote/main/index.js")).initialize();

View File

@@ -1,32 +0,0 @@
<!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>
<script src="../<%= appPath %>/runtime.js" crossorigin type="module"></script>
</head>
<body
id="trilium-print"
lang="<%= currentLocale.id %>" dir="<%= currentLocale.rtl ? 'rtl' : 'ltr' %>"
>
<noscript><%= t("javascript-required") %></noscript>
<script>
// hide body to reduce flickering on the startup. This is done through JS and not CSS to not hide <noscript>
document.getElementsByTagName("body")[0].style.display = "none";
</script>
<%- include("./partials/windowGlobal.ejs", locals) %>
<!-- Required for correct loading of scripts in Electron -->
<script>if (typeof module === 'object') {window.module = module; module = undefined;}</script>
<script src="../<%= appPath %>/print.js" crossorigin type="module"></script>
</body>
</html>

View File

@@ -1,7 +1,2 @@
"use strict";
import Becca from "./becca-interface.js";
const becca = new Becca();
import { becca } from "@triliumnext/core";
export default becca;

View File

@@ -1,260 +1,2 @@
import type { AttachmentRow } from "@triliumnext/commons";
import dateUtils from "../../services/date_utils.js";
import log from "../../services/log.js";
import noteService from "../../services/notes.js";
import protectedSessionService from "../../services/protected_session.js";
import sql from "../../services/sql.js";
import utils from "../../services/utils.js";
import AbstractBeccaEntity from "./abstract_becca_entity.js";
import type BBranch from "./bbranch.js";
import type BNote from "./bnote.js";
const attachmentRoleToNoteTypeMapping = {
image: "image",
file: "file"
};
interface ContentOpts {
// TODO: Found in bnote.ts, to check if it's actually used and not a typo.
forceSave?: boolean;
/** will also save this BAttachment entity */
forceFullSave?: boolean;
/** override frontend heuristics on when to reload, instruct to reload */
forceFrontendReload?: boolean;
}
/**
* Attachment represent data related/attached to the note. Conceptually similar to attributes, but intended for
* larger amounts of data and generally not accessible to the user.
*/
class BAttachment extends AbstractBeccaEntity<BAttachment> {
static get entityName() {
return "attachments";
}
static get primaryKeyName() {
return "attachmentId";
}
static get hashedProperties() {
return ["attachmentId", "ownerId", "role", "mime", "title", "blobId", "utcDateScheduledForErasureSince"];
}
noteId?: number;
attachmentId?: string;
/** either noteId or revisionId to which this attachment belongs */
ownerId!: string;
role!: string;
mime!: string;
title!: string;
type?: keyof typeof attachmentRoleToNoteTypeMapping;
position?: number;
utcDateScheduledForErasureSince?: string | null;
/** optionally added to the entity */
contentLength?: number;
isDecrypted?: boolean;
constructor(row: AttachmentRow) {
super();
this.updateFromRow(row);
this.decrypt();
}
updateFromRow(row: AttachmentRow): void {
if (!row.ownerId?.trim()) {
throw new Error("'ownerId' must be given to initialize a Attachment entity");
} else if (!row.role?.trim()) {
throw new Error("'role' must be given to initialize a Attachment entity");
} else if (!row.mime?.trim()) {
throw new Error("'mime' must be given to initialize a Attachment entity");
} else if (!row.title?.trim()) {
throw new Error("'title' must be given to initialize a Attachment entity");
}
this.attachmentId = row.attachmentId;
this.ownerId = row.ownerId;
this.role = row.role;
this.mime = row.mime;
this.title = row.title;
this.position = row.position;
this.blobId = row.blobId;
this.isProtected = !!row.isProtected;
this.dateModified = row.dateModified;
this.utcDateModified = row.utcDateModified;
this.utcDateScheduledForErasureSince = row.utcDateScheduledForErasureSince;
this.contentLength = row.contentLength;
}
copy(): BAttachment {
return new BAttachment({
ownerId: this.ownerId,
role: this.role,
mime: this.mime,
title: this.title,
blobId: this.blobId,
isProtected: this.isProtected
});
}
getNote(): BNote {
return this.becca.notes[this.ownerId];
}
/** @returns true if the note has string content (not binary) */
override hasStringContent(): boolean {
return utils.isStringNote(this.type, this.mime); // here was !== undefined && utils.isStringNote(this.type, this.mime); I dont know why we need !=undefined. But it filters out canvas libary items
}
isContentAvailable() {
return (
!this.attachmentId || // new attachment which was not encrypted yet
!this.isProtected ||
protectedSessionService.isProtectedSessionAvailable()
);
}
getTitleOrProtected() {
return this.isContentAvailable() ? this.title : "[protected]";
}
decrypt() {
if (!this.isProtected || !this.attachmentId) {
this.isDecrypted = true;
return;
}
if (!this.isDecrypted && protectedSessionService.isProtectedSessionAvailable()) {
try {
this.title = protectedSessionService.decryptString(this.title) || "";
this.isDecrypted = true;
} catch (e: any) {
log.error(`Could not decrypt attachment ${this.attachmentId}: ${e.message} ${e.stack}`);
}
}
}
getContent(): Buffer {
return this._getContent() as Buffer;
}
setContent(content: string | Buffer, opts?: ContentOpts) {
this._setContent(content, opts);
}
convertToNote(): { note: BNote; branch: BBranch } {
// TODO: can this ever be "search"?
if ((this.type as string) === "search") {
throw new Error(`Note of type search cannot have child notes`);
}
if (!this.getNote()) {
throw new Error("Cannot find note of this attachment. It is possible that this is note revision's attachment. " + "Converting note revision's attachments to note is not (yet) supported.");
}
if (!(this.role in attachmentRoleToNoteTypeMapping)) {
throw new Error(`Mapping from attachment role '${this.role}' to note's type is not defined`);
}
if (!this.isContentAvailable()) {
// isProtected is the same for attachment
throw new Error(`Cannot convert protected attachment outside of protected session`);
}
const { note, branch } = noteService.createNewNote({
parentNoteId: this.ownerId,
title: this.title,
type: (attachmentRoleToNoteTypeMapping as any)[this.role],
mime: this.mime,
content: this.getContent(),
isProtected: this.isProtected
});
this.markAsDeleted();
const parentNote = this.getNote();
if (this.role === "image" && parentNote.type === "text") {
const origContent = parentNote.getContent();
if (typeof origContent !== "string") {
throw new Error(`Note with ID '${note.noteId} has a text type but non-string content.`);
}
const oldAttachmentUrl = `api/attachments/${this.attachmentId}/image/`;
const newNoteUrl = `api/images/${note.noteId}/`;
const fixedContent = utils.replaceAll(origContent, oldAttachmentUrl, newNoteUrl);
if (fixedContent !== origContent) {
parentNote.setContent(fixedContent);
}
noteService.asyncPostProcessContent(note, fixedContent);
}
return { note, branch };
}
getFileName() {
const type = this.role === "image" ? "image" : "file";
return utils.formatDownloadTitle(this.title, type, this.mime);
}
override beforeSaving() {
super.beforeSaving();
if (this.position === undefined || this.position === null) {
this.position =
10 +
sql.getValue<number>(
/*sql*/`SELECT COALESCE(MAX(position), 0)
FROM attachments
WHERE ownerId = ?`,
[this.noteId]
);
}
this.dateModified = dateUtils.localNowDateTime();
this.utcDateModified = dateUtils.utcNowDateTime();
}
getPojo() {
return {
attachmentId: this.attachmentId,
ownerId: this.ownerId,
role: this.role,
mime: this.mime,
title: this.title || undefined,
position: this.position,
blobId: this.blobId,
isProtected: !!this.isProtected,
isDeleted: false,
dateModified: this.dateModified,
utcDateModified: this.utcDateModified,
utcDateScheduledForErasureSince: this.utcDateScheduledForErasureSince,
contentLength: this.contentLength
};
}
override getPojoToSave() {
const pojo = this.getPojo();
delete pojo.contentLength;
if (pojo.isProtected) {
if (this.isDecrypted) {
pojo.title = protectedSessionService.encrypt(pojo.title || "") || undefined;
} else {
// updating protected note outside of protected session means we will keep original ciphertexts
delete pojo.title;
}
}
return pojo;
}
}
import { BAttachment } from "@triliumnext/core";
export default BAttachment;

View File

@@ -1,227 +1,2 @@
"use strict";
import BNote from "./bnote.js";
import AbstractBeccaEntity from "./abstract_becca_entity.js";
import dateUtils from "../../services/date_utils.js";
import promotedAttributeDefinitionParser from "../../services/promoted_attribute_definition_parser.js";
import sanitizeAttributeName from "../../services/sanitize_attribute_name.js";
import type { AttributeRow, AttributeType } from "@triliumnext/commons";
interface SavingOpts {
skipValidation?: boolean;
}
/**
* Attribute is an abstract concept which has two real uses - label (key - value pair)
* and relation (representing named relationship between source and target note)
*/
class BAttribute extends AbstractBeccaEntity<BAttribute> {
static get entityName() {
return "attributes";
}
static get primaryKeyName() {
return "attributeId";
}
static get hashedProperties() {
return ["attributeId", "noteId", "type", "name", "value", "isInheritable"];
}
attributeId!: string;
noteId!: string;
type!: AttributeType;
name!: string;
position!: number;
value!: string;
isInheritable!: boolean;
constructor(row?: AttributeRow) {
super();
if (!row) {
return;
}
this.updateFromRow(row);
this.init();
}
updateFromRow(row: AttributeRow) {
this.update([row.attributeId, row.noteId, row.type, row.name, row.value, row.isInheritable, row.position, row.utcDateModified]);
}
update([attributeId, noteId, type, name, value, isInheritable, position, utcDateModified]: any) {
this.attributeId = attributeId;
this.noteId = noteId;
this.type = type;
this.name = name;
this.position = position;
this.value = value || "";
this.isInheritable = !!isInheritable;
this.utcDateModified = utcDateModified;
return this;
}
override init() {
if (this.attributeId) {
this.becca.attributes[this.attributeId] = this;
}
if (!(this.noteId in this.becca.notes)) {
// entities can come out of order in sync, create skeleton which will be filled later
this.becca.addNote(this.noteId, new BNote({ noteId: this.noteId }));
}
this.becca.notes[this.noteId].ownedAttributes.push(this);
const key = `${this.type}-${this.name.toLowerCase()}`;
this.becca.attributeIndex[key] = this.becca.attributeIndex[key] || [];
this.becca.attributeIndex[key].push(this);
const targetNote = this.targetNote;
if (targetNote) {
targetNote.targetRelations.push(this);
}
}
validate() {
if (!["label", "relation"].includes(this.type)) {
throw new Error(`Invalid attribute type '${this.type}' in attribute '${this.attributeId}' of note '${this.noteId}'`);
}
if (!this.name?.trim()) {
throw new Error(`Invalid empty name in attribute '${this.attributeId}' of note '${this.noteId}'`);
}
if (this.type === "relation" && !(this.value in this.becca.notes)) {
throw new Error(`Cannot save relation '${this.name}' of note '${this.noteId}' since it targets not existing note '${this.value}'.`);
}
}
get isAffectingSubtree() {
return this.isInheritable || (this.type === "relation" && ["template", "inherit"].includes(this.name));
}
get targetNoteId() {
// alias
return this.type === "relation" ? this.value : undefined;
}
isAutoLink() {
return this.type === "relation" && ["internalLink", "imageLink", "relationMapLink", "includeNoteLink"].includes(this.name);
}
get note() {
return this.becca.notes[this.noteId];
}
get targetNote() {
if (this.type === "relation") {
return this.becca.notes[this.value];
}
}
getNote() {
const note = this.becca.getNote(this.noteId);
if (!note) {
throw new Error(`Note '${this.noteId}' of attribute '${this.attributeId}', type '${this.type}', name '${this.name}' does not exist.`);
}
return note;
}
getTargetNote() {
if (this.type !== "relation") {
throw new Error(`Attribute '${this.attributeId}' is not a relation.`);
}
if (!this.value) {
return null;
}
return this.becca.getNote(this.value);
}
isDefinition() {
return this.type === "label" && (this.name.startsWith("label:") || this.name.startsWith("relation:"));
}
getDefinition() {
return promotedAttributeDefinitionParser.parse(this.value);
}
getDefinedName() {
if (this.type === "label" && this.name.startsWith("label:")) {
return this.name.substr(6);
} else if (this.type === "label" && this.name.startsWith("relation:")) {
return this.name.substr(9);
} else {
return this.name;
}
}
override get isDeleted() {
return !(this.attributeId in this.becca.attributes);
}
override beforeSaving(opts: SavingOpts = {}) {
if (!opts.skipValidation) {
this.validate();
}
this.name = sanitizeAttributeName(this.name);
if (!this.value) {
// null value isn't allowed
this.value = "";
}
if (this.position === undefined || this.position === null) {
const maxExistingPosition = this.getNote()
.getAttributes()
.reduce((maxPosition, attr) => Math.max(maxPosition, attr.position || 0), 0);
this.position = maxExistingPosition + 10;
}
if (!this.isInheritable) {
this.isInheritable = false;
}
this.utcDateModified = dateUtils.utcNowDateTime();
super.beforeSaving();
this.becca.attributes[this.attributeId] = this;
}
getPojo() {
return {
attributeId: this.attributeId,
noteId: this.noteId,
type: this.type,
name: this.name,
position: this.position,
value: this.value,
isInheritable: this.isInheritable,
utcDateModified: this.utcDateModified,
isDeleted: false
};
}
createClone(type: AttributeType, name: string, value: string, isInheritable?: boolean) {
return new BAttribute({
noteId: this.noteId,
type: type,
name: name,
value: value,
position: this.position,
isInheritable: isInheritable,
utcDateModified: this.utcDateModified
});
}
}
import { BAttribute } from "@triliumnext/core";
export default BAttribute;

View File

@@ -1,288 +1,2 @@
"use strict";
import BNote from "./bnote.js";
import AbstractBeccaEntity from "./abstract_becca_entity.js";
import dateUtils from "../../services/date_utils.js";
import utils from "../../services/utils.js";
import TaskContext from "../../services/task_context.js";
import cls from "../../services/cls.js";
import log from "../../services/log.js";
import type { BranchRow } from "@triliumnext/commons";
import handlers from "../../services/handlers.js";
/**
* Branch represents a relationship between a child note and its parent note. Trilium allows a note to have multiple
* parents.
*
* Note that you should not rely on the branch's identity, since it can change easily with a note's move.
* Always check noteId instead.
*/
class BBranch extends AbstractBeccaEntity<BBranch> {
static get entityName() {
return "branches";
}
static get primaryKeyName() {
return "branchId";
}
// notePosition is not part of hash because it would produce a lot of updates in case of reordering
static get hashedProperties() {
return ["branchId", "noteId", "parentNoteId", "prefix"];
}
branchId?: string;
noteId!: string;
parentNoteId!: string;
prefix!: string | null;
notePosition!: number;
isExpanded!: boolean;
constructor(row?: BranchRow) {
super();
if (!row) {
return;
}
this.updateFromRow(row);
this.init();
}
updateFromRow(row: BranchRow) {
this.update([row.branchId, row.noteId, row.parentNoteId, row.prefix, row.notePosition, row.isExpanded, row.utcDateModified]);
}
update([branchId, noteId, parentNoteId, prefix, notePosition, isExpanded, utcDateModified]: any) {
this.branchId = branchId;
this.noteId = noteId;
this.parentNoteId = parentNoteId;
this.prefix = prefix;
this.notePosition = notePosition;
this.isExpanded = !!isExpanded;
this.utcDateModified = utcDateModified;
return this;
}
override init() {
if (this.branchId) {
this.becca.branches[this.branchId] = this;
}
this.becca.childParentToBranch[`${this.noteId}-${this.parentNoteId}`] = this;
const childNote = this.childNote;
if (!childNote.parentBranches.includes(this)) {
childNote.parentBranches.push(this);
}
if (this.noteId === "root") {
return;
}
const parentNote = this.parentNote;
if (parentNote) {
if (!childNote.parents.includes(parentNote)) {
childNote.parents.push(parentNote);
}
if (!parentNote.children.includes(childNote)) {
parentNote.children.push(childNote);
}
}
}
get childNote(): BNote {
if (!(this.noteId in this.becca.notes)) {
// entities can come out of order in sync/import, create skeleton which will be filled later
this.becca.addNote(this.noteId, new BNote({ noteId: this.noteId }));
}
return this.becca.notes[this.noteId];
}
getNote(): BNote {
return this.childNote;
}
/** @returns root branch will have undefined parent, all other branches have to have a parent note */
get parentNote(): BNote | undefined {
if (!(this.parentNoteId in this.becca.notes) && this.parentNoteId !== "none") {
// entities can come out of order in sync/import, create skeleton which will be filled later
this.becca.addNote(this.parentNoteId, new BNote({ noteId: this.parentNoteId }));
}
return this.becca.notes[this.parentNoteId];
}
override get isDeleted() {
return this.branchId == undefined || !(this.branchId in this.becca.branches);
}
/**
* Branch is weak when its existence should not hinder deletion of its note.
* As a result, note with only weak branches should be immediately deleted.
* An example is shared or bookmarked clones - they are created automatically and exist for technical reasons,
* not as user-intended actions. From user perspective, they don't count as real clones and for the purpose
* of deletion should not act as a clone.
*/
get isWeak() {
return ["_share", "_lbBookmarks"].includes(this.parentNoteId);
}
/**
* Delete a branch. If this is a last note's branch, delete the note as well.
*
* @param deleteId - optional delete identified
*
* @returns true if note has been deleted, false otherwise
*/
deleteBranch(deleteId?: string, taskContext?: TaskContext<"deleteNotes">): boolean {
if (!deleteId) {
deleteId = utils.randomString(10);
}
if (!taskContext) {
taskContext = new TaskContext("no-progress-reporting", "deleteNotes", null);
}
taskContext.increaseProgressCount();
const note = this.getNote();
if (!taskContext.noteDeletionHandlerTriggered) {
const parentBranches = note.getParentBranches();
if (parentBranches.length === 1 && parentBranches[0] === this) {
// needs to be run before branches and attributes are deleted and thus attached relations disappear
handlers.runAttachedRelations(note, "runOnNoteDeletion", note);
}
}
if ((this.noteId === "root" || this.noteId === cls.getHoistedNoteId()) && !this.isWeak) {
throw new Error("Can't delete root or hoisted branch/note");
}
this.markAsDeleted(deleteId);
const notDeletedBranches = note.getStrongParentBranches();
if (notDeletedBranches.length === 0) {
for (const weakBranch of note.getParentBranches()) {
weakBranch.markAsDeleted(deleteId);
}
for (const childBranch of note.getChildBranches()) {
if (childBranch) {
childBranch.deleteBranch(deleteId, taskContext);
}
}
// first delete children and then parent - this will show up better in recent changes
log.info(`Deleting note '${note.noteId}'`);
this.becca.notes[note.noteId].isBeingDeleted = true;
for (const attribute of note.getOwnedAttributes().slice()) {
attribute.markAsDeleted(deleteId);
}
for (const relation of note.getTargetRelations()) {
relation.markAsDeleted(deleteId);
}
for (const attachment of note.getAttachments()) {
attachment.markAsDeleted(deleteId);
}
note.markAsDeleted(deleteId);
return true;
} else {
return false;
}
}
override beforeSaving() {
if (!this.noteId || !this.parentNoteId) {
throw new Error(`noteId and parentNoteId are mandatory properties for Branch`);
}
this.branchId = `${this.parentNoteId}_${this.noteId}`;
if (this.notePosition === undefined || this.notePosition === null) {
let maxNotePos = 0;
if (this.parentNote) {
for (const childBranch of this.parentNote.getChildBranches()) {
if (!childBranch) {
continue;
}
if (
maxNotePos < childBranch.notePosition &&
childBranch.noteId !== "_hidden" // hidden has a very large notePosition to always stay last
) {
maxNotePos = childBranch.notePosition;
}
}
}
this.notePosition = maxNotePos + 10;
}
if (!this.isExpanded) {
this.isExpanded = false;
}
if (!this.prefix?.trim()) {
this.prefix = null;
}
this.utcDateModified = dateUtils.utcNowDateTime();
super.beforeSaving();
this.becca.branches[this.branchId] = this;
}
getPojo() {
return {
branchId: this.branchId,
noteId: this.noteId,
parentNoteId: this.parentNoteId,
prefix: this.prefix,
notePosition: this.notePosition,
isExpanded: this.isExpanded,
isDeleted: false,
utcDateModified: this.utcDateModified
};
}
createClone(parentNoteId: string, notePosition?: number) {
const existingBranch = this.becca.getBranchFromChildAndParent(this.noteId, parentNoteId);
if (existingBranch) {
if (notePosition) {
existingBranch.notePosition = notePosition;
}
return existingBranch;
} else {
return new BBranch({
noteId: this.noteId,
parentNoteId: parentNoteId,
notePosition: notePosition || null,
prefix: this.prefix,
isExpanded: this.isExpanded
});
}
}
getParentNote() {
return this.parentNote;
}
}
import { BBranch } from "@triliumnext/core";
export default BBranch;

View File

@@ -1,89 +1,2 @@
"use strict";
import type { EtapiTokenRow } from "@triliumnext/commons";
import dateUtils from "../../services/date_utils.js";
import AbstractBeccaEntity from "./abstract_becca_entity.js";
/**
* EtapiToken is an entity representing token used to authenticate against Trilium REST API from client applications.
* Used by:
* - Trilium Sender
* - ETAPI clients
*
* The format user is presented with is "<etapiTokenId>_<tokenHash>". This is also called "authToken" to distinguish it
* from tokenHash and token.
*/
class BEtapiToken extends AbstractBeccaEntity<BEtapiToken> {
static get entityName() {
return "etapi_tokens";
}
static get primaryKeyName() {
return "etapiTokenId";
}
static get hashedProperties() {
return ["etapiTokenId", "name", "tokenHash", "utcDateCreated", "utcDateModified", "isDeleted"];
}
etapiTokenId?: string;
name!: string;
tokenHash!: string;
private _isDeleted?: boolean;
constructor(row?: EtapiTokenRow) {
super();
if (!row) {
return;
}
this.updateFromRow(row);
this.init();
}
override get isDeleted() {
return !!this._isDeleted;
}
updateFromRow(row: EtapiTokenRow) {
this.etapiTokenId = row.etapiTokenId;
this.name = row.name;
this.tokenHash = row.tokenHash;
this.utcDateCreated = row.utcDateCreated || dateUtils.utcNowDateTime();
this.utcDateModified = row.utcDateModified || this.utcDateCreated;
this._isDeleted = !!row.isDeleted;
if (this.etapiTokenId) {
this.becca.etapiTokens[this.etapiTokenId] = this;
}
}
override init() {
if (this.etapiTokenId) {
this.becca.etapiTokens[this.etapiTokenId] = this;
}
}
getPojo() {
return {
etapiTokenId: this.etapiTokenId,
name: this.name,
tokenHash: this.tokenHash,
utcDateCreated: this.utcDateCreated,
utcDateModified: this.utcDateModified,
isDeleted: this.isDeleted
};
}
override beforeSaving() {
this.utcDateModified = dateUtils.utcNowDateTime();
super.beforeSaving();
if (this.etapiTokenId) {
this.becca.etapiTokens[this.etapiTokenId] = this;
}
}
}
import { BEtapiToken } from "@triliumnext/core";
export default BEtapiToken;

File diff suppressed because it is too large Load Diff

View File

@@ -1,56 +1,2 @@
"use strict";
import dateUtils from "../../services/date_utils.js";
import AbstractBeccaEntity from "./abstract_becca_entity.js";
import type { OptionRow } from "@triliumnext/commons";
/**
* Option represents a name-value pair, either directly configurable by the user or some system property.
*/
class BOption extends AbstractBeccaEntity<BOption> {
static get entityName() {
return "options";
}
static get primaryKeyName() {
return "name";
}
static get hashedProperties() {
return ["name", "value"];
}
name!: string;
value!: string;
constructor(row?: OptionRow) {
super();
if (row) {
this.updateFromRow(row);
}
this.becca.options[this.name] = this;
}
updateFromRow(row: OptionRow) {
this.name = row.name;
this.value = row.value;
this.isSynced = !!row.isSynced;
this.utcDateModified = row.utcDateModified;
}
override beforeSaving() {
super.beforeSaving();
this.utcDateModified = dateUtils.utcNowDateTime();
}
getPojo() {
return {
name: this.name,
value: this.value,
isSynced: this.isSynced,
utcDateModified: this.utcDateModified
};
}
}
import { BOption } from "@triliumnext/core";
export default BOption;

View File

@@ -1,46 +1,2 @@
"use strict";
import type { RecentNoteRow } from "@triliumnext/commons";
import dateUtils from "../../services/date_utils.js";
import AbstractBeccaEntity from "./abstract_becca_entity.js";
/**
* RecentNote represents recently visited note.
*/
class BRecentNote extends AbstractBeccaEntity<BRecentNote> {
static get entityName() {
return "recent_notes";
}
static get primaryKeyName() {
return "noteId";
}
static get hashedProperties() {
return ["noteId", "notePath"];
}
noteId!: string;
notePath!: string;
constructor(row: RecentNoteRow) {
super();
this.updateFromRow(row);
}
updateFromRow(row: RecentNoteRow): void {
this.noteId = row.noteId;
this.notePath = row.notePath;
this.utcDateCreated = row.utcDateCreated || dateUtils.utcNowDateTime();
}
getPojo() {
return {
noteId: this.noteId,
notePath: this.notePath,
utcDateCreated: this.utcDateCreated
};
}
}
import { BRecentNote } from "@triliumnext/core";
export default BRecentNote;

View File

@@ -1,225 +1,2 @@
"use strict";
import protectedSessionService from "../../services/protected_session.js";
import utils from "../../services/utils.js";
import dateUtils from "../../services/date_utils.js";
import becca from "../becca.js";
import AbstractBeccaEntity from "./abstract_becca_entity.js";
import sql from "../../services/sql.js";
import BAttachment from "./battachment.js";
import type { AttachmentRow, NoteType, RevisionPojo, RevisionRow } from "@triliumnext/commons";
import eraseService from "../../services/erase.js";
interface ContentOpts {
/** will also save this BRevision entity */
forceSave?: boolean;
}
interface GetByIdOpts {
includeContentLength?: boolean;
}
/**
* Revision represents a snapshot of note's title and content at some point in the past.
* It's used for seamless note versioning.
*/
class BRevision extends AbstractBeccaEntity<BRevision> {
static get entityName() {
return "revisions";
}
static get primaryKeyName() {
return "revisionId";
}
static get hashedProperties() {
return ["revisionId", "noteId", "title", "isProtected", "dateLastEdited", "dateCreated", "utcDateLastEdited", "utcDateCreated", "utcDateModified", "blobId"];
}
revisionId?: string;
noteId!: string;
type!: NoteType;
mime!: string;
title!: string;
dateLastEdited?: string;
utcDateLastEdited?: string;
contentLength?: number;
content?: string | Buffer;
constructor(row: RevisionRow, titleDecrypted = false) {
super();
this.updateFromRow(row);
if (this.isProtected && !titleDecrypted) {
const decryptedTitle = protectedSessionService.isProtectedSessionAvailable() ? protectedSessionService.decryptString(this.title) : null;
this.title = decryptedTitle || "[protected]";
}
}
updateFromRow(row: RevisionRow) {
this.revisionId = row.revisionId;
this.noteId = row.noteId;
this.type = row.type;
this.mime = row.mime;
this.isProtected = !!row.isProtected;
this.title = row.title;
this.blobId = row.blobId;
this.dateLastEdited = row.dateLastEdited;
this.dateCreated = row.dateCreated;
this.utcDateLastEdited = row.utcDateLastEdited;
this.utcDateCreated = row.utcDateCreated;
this.utcDateModified = row.utcDateModified;
this.contentLength = row.contentLength;
}
getNote() {
return becca.notes[this.noteId];
}
/** @returns true if the note has string content (not binary) */
override hasStringContent(): boolean {
return utils.isStringNote(this.type, this.mime);
}
isContentAvailable() {
return (
!this.revisionId || // new note which was not encrypted yet
!this.isProtected ||
protectedSessionService.isProtectedSessionAvailable()
);
}
/*
* Note revision content has quite special handling - it's not a separate entity, but a lazily loaded
* part of Revision entity with its own sync. The reason behind this hybrid design is that
* content can be quite large, and it's not necessary to load it / fill memory for any note access even
* if we don't need a content, especially for bulk operations like search.
*
* This is the same approach as is used for Note's content.
*/
getContent(): string | Buffer {
return this._getContent();
}
/**
* @throws Error in case of invalid JSON */
getJsonContent(): {} | null {
const content = this.getContent();
if (!content || typeof content !== "string" || !content.trim()) {
return null;
}
return JSON.parse(content);
}
/** @returns valid object or null if the content cannot be parsed as JSON */
getJsonContentSafely(): {} | null {
try {
return this.getJsonContent();
} catch (e) {
return null;
}
}
setContent(content: string | Buffer, opts: ContentOpts = {}) {
this._setContent(content, opts);
}
getAttachments(): BAttachment[] {
return sql
.getRows<AttachmentRow>(
`
SELECT attachments.*
FROM attachments
WHERE ownerId = ?
AND isDeleted = 0`,
[this.revisionId]
)
.map((row) => new BAttachment(row));
}
getAttachmentById(attachmentId: String, opts: GetByIdOpts = {}): BAttachment | null {
opts.includeContentLength = !!opts.includeContentLength;
const query = opts.includeContentLength
? /*sql*/`SELECT attachments.*, LENGTH(blobs.content) AS contentLength
FROM attachments
JOIN blobs USING (blobId)
WHERE ownerId = ? AND attachmentId = ? AND isDeleted = 0`
: /*sql*/`SELECT * FROM attachments WHERE ownerId = ? AND attachmentId = ? AND isDeleted = 0`;
return sql.getRows<AttachmentRow>(query, [this.revisionId, attachmentId]).map((row) => new BAttachment(row))[0];
}
getAttachmentsByRole(role: string): BAttachment[] {
return sql
.getRows<AttachmentRow>(
`
SELECT attachments.*
FROM attachments
WHERE ownerId = ?
AND role = ?
AND isDeleted = 0
ORDER BY position`,
[this.revisionId, role]
)
.map((row) => new BAttachment(row));
}
getAttachmentByTitle(title: string): BAttachment {
// cannot use SQL to filter by title since it can be encrypted
return this.getAttachments().filter((attachment) => attachment.title === title)[0];
}
/**
* Revisions are not soft-deletable, they are immediately hard-deleted (erased).
*/
eraseRevision() {
if (this.revisionId) {
eraseService.eraseRevisions([this.revisionId]);
}
}
override beforeSaving() {
super.beforeSaving();
this.utcDateModified = dateUtils.utcNowDateTime();
}
getPojo() {
return {
revisionId: this.revisionId,
noteId: this.noteId,
type: this.type,
mime: this.mime,
isProtected: this.isProtected,
title: this.title,
blobId: this.blobId,
dateLastEdited: this.dateLastEdited,
dateCreated: this.dateCreated,
utcDateLastEdited: this.utcDateLastEdited,
utcDateCreated: this.utcDateCreated,
utcDateModified: this.utcDateModified,
content: this.content, // used when retrieving full note revision to frontend
contentLength: this.contentLength
} satisfies RevisionPojo;
}
override getPojoToSave() {
const pojo = this.getPojo();
delete pojo.content; // not getting persisted
delete pojo.contentLength; // not getting persisted
if (pojo.isProtected) {
if (protectedSessionService.isProtectedSessionAvailable()) {
pojo.title = protectedSessionService.encrypt(this.title) ?? "";
} else {
// updating protected note outside of protected session means we will keep original ciphertexts
pojo.title = "";
}
}
return pojo;
}
}
import { BRevision } from "@triliumnext/core";
export default BRevision;

View File

@@ -0,0 +1,24 @@
import { ExecutionContext } from "@triliumnext/core";
import clsHooked from "cls-hooked";
export const namespace = clsHooked.createNamespace("trilium");
export default class ClsHookedExecutionContext implements ExecutionContext {
get<T = any>(key: string): T | undefined {
return namespace.get(key);
}
set(key: string, value: any): void {
namespace.set(key, value);
}
reset(): void {
clsHooked.reset();
}
init<T>(callback: () => T): T {
return namespace.runAndReturn(callback);
}
}

View File

@@ -0,0 +1,29 @@
import { CryptoProvider } from "@triliumnext/core";
import crypto from "crypto";
import { generator } from "rand-token";
const randtoken = generator({ source: "crypto" });
export default class NodejsCryptoProvider implements CryptoProvider {
createHash(algorithm: "sha1", content: string | Uint8Array): Uint8Array {
return crypto.createHash(algorithm).update(content).digest();
}
createCipheriv(algorithm: "aes-128-cbc", key: Uint8Array, iv: Uint8Array): { update(data: Uint8Array): Uint8Array; final(): Uint8Array; } {
return crypto.createCipheriv(algorithm, key, iv);
}
createDecipheriv(algorithm: "aes-128-cbc", key: Uint8Array, iv: Uint8Array) {
return crypto.createDecipheriv(algorithm, key, iv);
}
randomBytes(size: number): Uint8Array {
return crypto.randomBytes(size);
}
randomString(length: number): string {
return randtoken.generate(length);
}
}

View File

@@ -1,12 +0,0 @@
import HttpError from "./http_error.js";
class ForbiddenError extends HttpError {
constructor(message: string) {
super(message, 403);
this.name = "ForbiddenError";
}
}
export default ForbiddenError;

View File

@@ -1,13 +0,0 @@
class HttpError extends Error {
statusCode: number;
constructor(message: string, statusCode: number) {
super(message);
this.name = "HttpError";
this.statusCode = statusCode;
}
}
export default HttpError;

View File

@@ -1,12 +0,0 @@
import HttpError from "./http_error.js";
class NotFoundError extends HttpError {
constructor(message: string) {
super(message, 404);
this.name = "NotFoundError";
}
}
export default NotFoundError;

View File

@@ -1,9 +0,0 @@
class OpenIdError {
message: string;
constructor(message: string) {
this.message = message;
}
}
export default OpenIdError;

View File

@@ -1,12 +0,0 @@
import HttpError from "./http_error.js";
class ValidationError extends HttpError {
constructor(message: string) {
super(message, 400)
this.name = "ValidationError";
}
}
export default ValidationError;

View File

@@ -2,6 +2,7 @@ import type { NextFunction, Request, RequestHandler, Response, Router } from "ex
import type { ParamsDictionary } from "express-serve-static-core";
import becca from "../becca/becca.js";
import { namespace } from "../cls_provider.js";
import type { ApiRequestHandler, SyncRouteRequestHandler } from "../routes/route_api.js";
import cls from "../services/cls.js";
import config from "../services/config.js";
@@ -53,8 +54,8 @@ function checkEtapiAuth(req: Request, res: Response, next: NextFunction) {
function processRequest<P extends ParamsDictionary>(req: Request<P>, res: Response, routeHandler: ApiRequestHandler<P>, next: NextFunction, method: string, path: string) {
try {
cls.namespace.bindEmitter(req);
cls.namespace.bindEmitter(res);
namespace.bindEmitter(req);
namespace.bindEmitter(res);
cls.init(() => {
cls.set("componentId", "etapi");
@@ -90,6 +91,7 @@ function getAndCheckNote(noteId: string) {
return note;
}
throw new EtapiError(404, "NOTE_NOT_FOUND", `Note '${noteId}' not found.`);
}
function getAndCheckAttachment(attachmentId: string) {
@@ -99,6 +101,7 @@ function getAndCheckAttachment(attachmentId: string) {
return attachment;
}
throw new EtapiError(404, "ATTACHMENT_NOT_FOUND", `Attachment '${attachmentId}' not found.`);
}
function getAndCheckBranch(branchId: string) {
@@ -108,6 +111,7 @@ function getAndCheckBranch(branchId: string) {
return branch;
}
throw new EtapiError(404, "BRANCH_NOT_FOUND", `Branch '${branchId}' not found.`);
}
function getAndCheckAttribute(attributeId: string) {
@@ -117,6 +121,7 @@ function getAndCheckAttribute(attributeId: string) {
return attribute;
}
throw new EtapiError(404, "ATTRIBUTE_NOT_FOUND", `Attribute '${attributeId}' not found.`);
}
function getAndCheckRevision(revisionId: string) {

View File

@@ -1,3 +1,4 @@
import { NoteParams } from "@triliumnext/core";
import type { Request, Router } from "express";
import type { ParsedQs } from "qs";
@@ -5,7 +6,6 @@ import becca from "../becca/becca.js";
import zipExportService from "../services/export/zip.js";
import type { ExportFormat } from "../services/export/zip/abstract_provider.js";
import zipImportService from "../services/import/zip.js";
import type { NoteParams } from "../services/note-interface.js";
import noteService from "../services/notes.js";
import SearchContext from "../services/search/search_context.js";
import searchService from "../services/search/services/search.js";

View File

@@ -3,10 +3,55 @@
* are loaded later and will result in an empty string.
*/
import { initializeTranslations } from "./services/i18n.js";
import { initializeCore } from "@triliumnext/core";
import path from "path";
import ClsHookedExecutionContext from "./cls_provider.js";
import NodejsCryptoProvider from "./crypto_provider.js";
import dataDirs from "./services/data_dir.js";
import BetterSqlite3Provider from "./sql_provider.js";
async function startApplication() {
await initializeTranslations();
const config = (await import("./services/config.js")).default;
const { DOCUMENT_PATH } = (await import("./services/data_dir.js")).default;
const dbProvider = new BetterSqlite3Provider();
dbProvider.loadFromFile(DOCUMENT_PATH, config.General.readOnly);
await initializeCore({
dbConfig: {
provider: dbProvider,
isReadOnly: config.General.readOnly,
async onTransactionCommit() {
const ws = (await import("./services/ws.js")).default;
ws.sendTransactionEntityChangesToAllClients();
},
async onTransactionRollback() {
const cls = (await import("./services/cls.js")).default;
const becca_loader = (await import("@triliumnext/core")).becca_loader;
const entity_changes = (await import("./services/entity_changes.js")).default;
const log = (await import("./services/log")).default;
const entityChangeIds = cls.getAndClearEntityChangeIds();
if (entityChangeIds.length > 0) {
log.info("Transaction rollback dirtied the becca, forcing reload.");
becca_loader.load();
}
// the maxEntityChangeId has been incremented during failed transaction, need to recalculate
entity_changes.recalculateMaxEntityChangeId();
},
},
crypto: new NodejsCryptoProvider(),
executionContext: new ClsHookedExecutionContext(),
translations: (await import("./services/i18n.js")).initializeTranslations,
extraAppInfo: {
nodeVersion: process.version,
dataDirectory: path.resolve(dataDirs.TRILIUM_DATA_DIR)
}
});
const startTriliumServer = (await import("./www.js")).default;
await startTriliumServer();
}

View File

@@ -1,5 +1,6 @@
import { becca_loader } from "@triliumnext/core";
import becca from "../becca/becca.js";
import becca_loader from "../becca/becca_loader.js";
import cls from "../services/cls.js";
import log from "../services/log.js";
import sql from "../services/sql.js";

View File

@@ -1,5 +1,6 @@
import { becca_loader } from "@triliumnext/core";
import becca from "../becca/becca";
import becca_loader from "../becca/becca_loader";
import cls from "../services/cls.js";
import hidden_subtree from "../services/hidden_subtree";

View File

@@ -1,8 +1,7 @@
import { becca_service, ValidationError } from "@triliumnext/core";
import type { Request } from "express";
import becca from "../../becca/becca.js";
import beccaService from "../../becca/becca_service.js";
import ValidationError from "../../errors/validation_error.js";
import cls from "../../services/cls.js";
import log from "../../services/log.js";
import searchService from "../../services/search/services/search.js";
@@ -67,8 +66,8 @@ function getRecentNotes(activeNoteId: string) {
return recentNotes.map((rn) => {
const notePathArray = rn.notePath.split("/");
const { title, icon } = beccaService.getNoteTitleAndIcon(notePathArray[notePathArray.length - 1]);
const notePathTitle = beccaService.getNoteTitleForPath(notePathArray);
const { title, icon } = becca_service.getNoteTitleAndIcon(notePathArray[notePathArray.length - 1]);
const notePathTitle = becca_service.getNoteTitleForPath(notePathArray);
return {
notePath: rn.notePath,

View File

@@ -1,16 +1,15 @@
import { sanitize, ValidationError } from "@triliumnext/core";
import type { Request } from "express";
import { parse } from "node-html-parser";
import path from "path";
import type BNote from "../../becca/entities/bnote.js";
import ValidationError from "../../errors/validation_error.js";
import appInfo from "../../services/app_info.js";
import attributeFormatter from "../../services/attribute_formatter.js";
import attributeService from "../../services/attributes.js";
import cloneService from "../../services/cloning.js";
import dateNoteService from "../../services/date_notes.js";
import dateUtils from "../../services/date_utils.js";
import htmlSanitizer from "../../services/html_sanitizer.js";
import imageService from "../../services/image.js";
import log from "../../services/log.js";
import noteService from "../../services/notes.js";
@@ -32,7 +31,7 @@ async function addClipping(req: Request) {
const clipperInbox = await getClipperInboxNote();
const pageUrl = htmlSanitizer.sanitizeUrl(req.body.pageUrl);
const pageUrl = sanitize.sanitizeUrl(req.body.pageUrl);
let clippingNote = findClippingNote(clipperInbox, pageUrl, clipType);
if (!clippingNote) {
@@ -99,8 +98,8 @@ async function getClipperInboxNote() {
async function createNote(req: Request) {
const { content, images, labels } = req.body;
const clipType = htmlSanitizer.sanitize(req.body.clipType);
const pageUrl = htmlSanitizer.sanitizeUrl(req.body.pageUrl);
const clipType = sanitize.sanitizeHtml(req.body.clipType);
const pageUrl = sanitize.sanitizeUrl(req.body.pageUrl);
const trimmedTitle = (typeof req.body.title === "string") ? req.body.title.trim() : "";
const title = trimmedTitle || `Clipped note from ${pageUrl}`;
@@ -126,7 +125,7 @@ async function createNote(req: Request) {
if (labels) {
for (const labelName in labels) {
const labelValue = htmlSanitizer.sanitize(labels[labelName]);
const labelValue = sanitize.sanitizeHtml(labels[labelName]);
note.setLabel(labelName, labelValue);
}
}
@@ -147,7 +146,7 @@ async function createNote(req: Request) {
}
export function processContent(images: Image[], note: BNote, content: string) {
let rewrittenContent = htmlSanitizer.sanitize(content);
let rewrittenContent = sanitize.sanitizeHtml(content);
if (images) {
for (const { src, dataUrl, imageId } of images) {
@@ -198,11 +197,10 @@ function openNote(req: Request<{ noteId: string }>) {
return {
result: "ok"
};
}
}
return {
result: "open-in-browser"
};
}
function handshake() {

View File

@@ -1,15 +1,13 @@
"use strict";
import sql from "../../services/sql.js";
import log from "../../services/log.js";
import backupService from "../../services/backup.js";
import anonymizationService from "../../services/anonymization.js";
import consistencyChecksService from "../../services/consistency_checks.js";
import type { Request } from "express";
import ValidationError from "../../errors/validation_error.js";
import sql_init from "../../services/sql_init.js";
import becca_loader from "../../becca/becca_loader.js";
import { BackupDatabaseNowResponse, DatabaseCheckIntegrityResponse } from "@triliumnext/commons";
import { becca_loader, ValidationError } from "@triliumnext/core";
import type { Request } from "express";
import anonymizationService from "../../services/anonymization.js";
import backupService from "../../services/backup.js";
import consistencyChecksService from "../../services/consistency_checks.js";
import log from "../../services/log.js";
import sql from "../../services/sql.js";
import sql_init from "../../services/sql_init.js";
function getExistingBackups() {
return backupService.getExistingBackups();

View File

@@ -1,8 +1,7 @@
import { NotFoundError, ValidationError } from "@triliumnext/core";
import type { Request, Response } from "express";
import becca from "../../becca/becca.js";
import NotFoundError from "../../errors/not_found_error.js";
import ValidationError from "../../errors/validation_error.js";
import opmlExportService from "../../services/export/opml.js";
import singleExportService from "../../services/export/single.js";
import zipExportService from "../../services/export/zip.js";

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