Compare commits

...

136 Commits

Author SHA1 Message Date
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
aaca18003d Translations update from Hosted Weblate (#8279) 2026-01-06 13:54:24 +02:00
Kim Nøglegaard
5ec521b024 Translated using Weblate (Norwegian Bokmål)
Currently translated at 68.4% (104 of 152 strings)

Translation: Trilium Notes/Website
Translate-URL: https://hosted.weblate.org/projects/trilium/website/nb_NO/
2026-01-06 04:01:53 +01:00
Yatrik Patel
b3c0be7559 Translated using Weblate (Hindi)
Currently translated at 3.0% (12 of 389 strings)

Translation: Trilium Notes/Server
Translate-URL: https://hosted.weblate.org/projects/trilium/server/hi/
2026-01-06 04:01:51 +01:00
Máté Zsólya
d52b735b99 Translated using Weblate (Hungarian)
Currently translated at 1.9% (34 of 1751 strings)

Translation: Trilium Notes/Client
Translate-URL: https://hosted.weblate.org/projects/trilium/client/hu/
2026-01-06 04:01:49 +01:00
Yatrik Patel
639b1f2863 Translated using Weblate (Hindi)
Currently translated at 5.9% (9 of 152 strings)

Translation: Trilium Notes/Website
Translate-URL: https://hosted.weblate.org/projects/trilium/website/hi/
2026-01-06 04:01:47 +01:00
Elian Doran
7f2cc885fe Feat(math): Improve legacy math input with MathLive (#7842) 2026-01-06 00:12:38 +02:00
Elian Doran
19a365a370 fix(sql_console): cannot copy table data (#8268) 2026-01-06 00:10:11 +02:00
Elian Doran
9a50da328e chore(deps): update dependency rollup-plugin-webpack-stats to v2.1.9 (#8265) 2026-01-05 23:53:05 +02:00
Elian Doran
181e36a7c1 Merge remote-tracking branch 'origin/main' into Meinzzzz/main
; Conflicts:
;	.gitignore
2026-01-05 23:46:12 +02:00
Elian Doran
178508d245 Merge branch 'main' into fix/sql_select_text 2026-01-05 23:43:29 +02:00
Elian Doran
d132d084cf Merge branch 'main' into renovate/rollup-plugin-webpack-stats-2.x 2026-01-05 23:43:06 +02:00
Elian Doran
494b55d685 fix(ckeditor): missing pl locale 2026-01-05 23:39:36 +02:00
Elian Doran
51513d3779 fix(status_bar): count not refreshing properly after change 2026-01-05 21:03:32 +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
SngAbc
458398f2ca Merge branch 'main' into fix/sql_select_text 2026-01-05 13:51:45 +08:00
SngAbc
7a6cc4f51e fix(sql_console): cannot copy table data
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
2026-01-05 12:16:16 +08:00
SiriusXT
f4ccce7de5 fix(sql_console): cannot copy table data 2026-01-05 11:23:50 +08:00
renovate[bot]
f8b5417d6c chore(deps): update dependency rollup-plugin-webpack-stats to v2.1.9 2026-01-05 01:03:52 +00:00
meinzzzz
87ab41c80c Fix shift+tab behavior in MathInputView 2025-12-23 18:02:40 +01:00
Meinzzzz
d2391f94c0 Fix offline math rendering by bundling local fonts 2025-12-15 21:32:50 +01:00
Meinzzzz
050ddb8c55 Improve css to fix tooltips 2025-12-15 20:17:58 +01:00
Meinzzzz
bc23e0984a Undo unnecessary formatting changes 2025-12-14 22:00:56 +01:00
Meinzzzz
07de353207 Adding comments and improving code quality in math input views 2025-12-14 20:21:42 +01:00
Meinzzzz
c02491d2e6 Remove unnecessary any casts in math plugin 2025-12-12 23:09:20 +01:00
Meinzzzz
a6ede8f905 Improve mathinputview 2025-12-12 21:33:59 +01:00
Meinzzzz
22941a9ce0 Fix sync issues 2025-12-12 19:48:09 +01:00
Meinzzzz
633a09d414 Fix sync bug 2025-12-11 23:06:13 +01:00
Meinzzzz
29f0881c5a Fix clicking issue in Mathfield 2025-12-10 22:44:02 +01:00
Meinzzzz
60debca37b Improve comments 2025-12-10 18:36:34 +01:00
Meinzzzz
30ea81d0fb Improve virtual keyboard logic and fix Tab issues 2025-12-08 22:59:08 +01:00
Meinzzzz
b1d92c4fe6 Fix Tab issues 2025-12-08 22:39:12 +01:00
Meinzzzz
70f46de2d8 MathLive virtual keyboard only appears when focusing the mathfield 2025-12-08 20:30:07 +01:00
Meinzzzz
f1b2d0b870 Increas Mathfield font size and ensure virtual keyboard appears above CKEditor 2025-12-08 20:22:52 +01:00
Meinzzzz
8a385972fc Close Virtual Keyboard when Mathinput is closed 2025-12-08 18:49:06 +01:00
Meinzzzz
28dd85c1d1 Merge upstream changes and resolve conflicts 2025-12-07 23:51:41 +01:00
meinzzzz
827c8e0e72 Refactor: Combine MathLive and LaTeX inputs into one single component 2025-12-07 23:19:48 +01:00
meinzzzz
162c076a14 Improve MathLive integration and lazy loading 2025-12-02 22:30:37 +01:00
meinzzzz
9386465de7 Added mathrender error class for better error handling in math rendering 2025-12-02 22:29:20 +01:00
meinzzzz
acca22f3a1 Improve Synchronization Between Mathlive and rawlatex input 2025-12-02 22:28:16 +01:00
meinzzzz
f8d84814e0 Fix differential d problems 2025-11-26 23:02:34 +01:00
meinzzzz
c46cf41842 Small improvements 2025-11-26 22:48:57 +01:00
meinzzzz
64ab1c4116 Imrovement for Latex 2025-11-26 22:29:29 +01:00
meinzzzz
a6de1041c7 Fix bug in math rendering where old content was not cleared 2025-11-26 21:59:33 +01:00
meinzzzz
c8d34e65ea Improve max window size 2025-11-26 21:49:09 +01:00
meinzzzz
51db729546 Improve and simplify Mathfield integration 2025-11-25 23:27:06 +01:00
meinzzzz
d2052ad236 Disable mathlive sound effects 2025-11-24 21:51:59 +01:00
meinzzzz
9c4301467f Remove unused icons from ckeditor5-math package 2025-11-24 19:46:04 +01:00
meinzzzz
e7355dc0e4 remove gitignore unneccesary changes 2025-11-24 18:43:52 +01:00
meinzzzz
4110fec94f Removed unnecessary declare keyboard 2025-11-24 18:28:59 +01:00
meinzzzz
d5e601eae9 Simpliyfied resize logic for math input form and improved css 2025-11-24 17:56:18 +01:00
meinzzzz
4f044c4a57 Use icons form CKEditor5 icons, instead of testing icons. 2025-11-23 22:43:07 +01:00
meinzzzz
5821c350e1 Fixing class property initialization order 2025-11-23 17:58:51 +01:00
meinzzzz
edba8188fe Fix dark selection colors in MathLive math-field 2025-11-23 13:44:28 +01:00
meinzzzz
1471a72633 refactor: avoid recursive updates in mathLiveInput by normalizing value before updateing 2025-11-23 13:34:22 +01:00
meinzzzz
56834cb88a Improve MathLive and Raw LaTeX input views to propagate mousedown events 2025-11-23 13:29:26 +01:00
meinzzzz
a0f16f9184 Fix typos in mathform.css 2025-11-23 13:09:56 +01:00
meinzzzz
de80eb4806 Improve mathform.css styling for better visual integration 2025-11-22 22:42:34 +01:00
meinzzzz
48a4b81fbe remove automated screenshot files 2025-11-22 21:40:55 +01:00
meinzzzz
e225794f72 Better window focus handling in MathFormView 2025-11-22 21:35:37 +01:00
meinzzzz
4eef30f8b5 Fix names 2025-11-22 00:20:20 +01:00
meinzzzz
569b09609d Remove mathlive dependency and chunking 2025-11-22 00:01:14 +01:00
meinzzzz
39838c25c2 Fixed chaching problems 2025-11-21 23:50:49 +01:00
meinzzzz
49e90c08a9 Better Names for Math UI Components 2025-11-20 22:45:21 +01:00
meinzzzz
e777b06fb8 Math 2025-11-20 18:53:39 +01:00
meinzzzz
497ec2ac74 Merge branch 'main' of https://github.com/Meinzzzz/Trilium-Mathlive 2025-11-20 18:00:18 +01:00
meinzzzz
c5d282d203 Mathlive 2025-11-20 00:09:10 +01:00
83 changed files with 10496 additions and 581 deletions

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

@@ -0,0 +1,65 @@
name: Deploy Standalone App
on:
# Trigger on push to main branch
push:
branches:
- main
# Only run when docs files change
paths:
- 'apps/client/**'
- 'apps/client-standalone/**'
- 'packages/trilium-core/**'
# 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/**'
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 }}

2
.gitignore vendored
View File

@@ -51,4 +51,4 @@ upload
# docs
site/
apps/*/coverage
scripts/translation/.language*.json
scripts/translation/.language*.json

View File

@@ -0,0 +1,86 @@
{
"name": "@triliumnext/client-standalone",
"version": "1.0.0",
"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.1",
"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

View File

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

View File

@@ -0,0 +1,35 @@
<!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><%= 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>
<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,92 @@
/**
* Browser route definitions.
* This integrates with the shared route builder from @triliumnext/core.
*/
import { routes, icon_packs as iconPackService } from '@triliumnext/core';
import { BrowserRouter, type BrowserRequest } 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) {
return (req: BrowserRequest) => {
// Create an Express-like request object
const expressLikeReq = {
params: req.params,
query: req.query,
body: req.body
};
return handler(expressLikeReq);
};
}
/**
* Creates an apiRoute function compatible with buildSharedApiRoutes.
* This bridges the core's route registration to the BrowserRouter.
*/
function createApiRoute(router: BrowserRouter) {
return (method: HttpMethod, path: string, handler: (req: any) => unknown) => {
router.register(method, path, wrapHandler(handler));
};
}
/**
* Register all API routes on the browser router using the shared builder.
*
* @param router - The browser router instance
*/
export function registerRoutes(router: BrowserRouter): void {
const apiRoute = createApiRoute(router);
routes.buildSharedApiRoutes(apiRoute);
apiRoute('get', '/bootstrap', bootstrapRoute);
// Dummy routes for compatibility.
apiRoute("get", "/api/script/widgets", () => []);
apiRoute("get", "/api/script/startup", () => []);
apiRoute("get", "/api/system-checks", () => ({ isCpuArchMismatch: false }))
apiRoute("get", "/api/search/:searchString", () => []);
apiRoute("get", "/api/search-templates", () => []);
apiRoute("get", "/api/autocomplete", () => []);
}
function bootstrapRoute() {
const iconPacks = iconPackService.getIconPacks();
const assetPath = ".";
return {
triliumVersion: "1.2.3",
assetPath,
baseApiUrl: "../api/",
themeCssUrl: null,
themeUseNextAsBase: "next",
device: "desktop",
headingStyle: "default",
layoutOrientation: "vertical",
platform: "web",
isElectron: false,
isStandalone: true,
hasNativeTitleBar: false,
hasBackgroundEffects: true,
currentLocale: { id: "en", rtl: false },
iconPackCss: iconPacks
.map(p => iconPackService.generateCss(p, p.builtin
? `${assetPath}/fonts/${p.fontAttachmentId}.${iconPackService.MIME_TO_EXTENSION_MAPPINGS[p.fontMime]}`
: `api/attachments/download/${p.fontAttachmentId}`))
.filter(Boolean)
.join("\n\n"),
iconRegistry: iconPackService.generateIconRegistry(iconPacks),
};
}
/**
* 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,46 @@
import { ExecutionContext } from "@triliumnext/core";
export default class BrowserExecutionContext implements ExecutionContext {
private store: Map<string, any> | null = null;
get<T = any>(key: string): T | undefined {
return this.store?.get(key);
}
set(key: string, value: any): void {
if (!this.store) {
throw new Error("ExecutionContext not initialized");
}
this.store.set(key, value);
}
reset(): void {
this.store = null;
}
init<T>(callback: () => T): T {
// Create a fresh context for this request
const prev = this.store;
this.store = new Map();
try {
const result = callback();
// If the result is a Promise, we need to handle cleanup after it resolves
if (result && typeof result === 'object' && 'then' in result && 'catch' in result) {
const promise = result as unknown as Promise<any>;
return promise.finally(() => {
this.store = prev;
}) as T;
} else {
// Synchronous result, clean up immediately
this.store = prev;
return result;
}
} catch (error) {
// Always clean up on error (for synchronous errors)
this.store = prev;
throw error;
}
}
}

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,92 @@
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);
console.log("[WorkerMessagingProvider] Initialized");
}
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 = [];
console.log("[WorkerMessagingProvider] Disposed");
}
}

View File

@@ -0,0 +1,615 @@
import type { DatabaseProvider, RunResult, Statement, Transaction } from "@triliumnext/core";
import sqlite3InitModule from "@sqlite.org/sqlite-wasm";
import type { BindableValue } from "@sqlite.org/sqlite-wasm";
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
) {}
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();
this.stmt.reset();
return {
changes,
lastInsertRowid: 0 // Would need sqlite3_last_insert_rowid for this
};
} catch (e) {
// Reset on error to allow reuse
this.stmt.reset();
throw e;
}
}
get(params: unknown): unknown {
if (this.isFinalized) {
throw new Error("Cannot call get() on finalized statement");
}
this.bindParams(Array.isArray(params) ? params : params !== undefined ? [params] : []);
try {
if (this.stmt.step()) {
if (this.isPluckMode) {
// In pluck mode, return only the first column value
const row = this.stmt.get([]);
return Array.isArray(row) && row.length > 0 ? row[0] : undefined;
}
return this.isRawMode ? this.stmt.get([]) : this.stmt.get({});
}
return undefined;
} finally {
this.stmt.reset();
}
}
all(...params: unknown[]): unknown[] {
if (this.isFinalized) {
throw new Error("Cannot call all() on finalized statement");
}
this.bindParams(params);
const results: unknown[] = [];
try {
while (this.stmt.step()) {
if (this.isPluckMode) {
// In pluck mode, return only the first column value for each row
const row = this.stmt.get([]);
if (Array.isArray(row) && row.length > 0) {
results.push(row[0]);
}
} else {
results.push(this.isRawMode ? this.stmt.get([]) : this.stmt.get({}));
}
}
return results;
} finally {
this.stmt.reset();
}
}
iterate(...params: unknown[]): IterableIterator<unknown> {
if (this.isFinalized) {
throw new Error("Cannot call iterate() on finalized statement");
}
this.bindParams(params);
const stmt = this.stmt;
const isRaw = this.isRawMode;
const isPluck = this.isPluckMode;
return {
[Symbol.iterator]() {
return this;
},
next(): IteratorResult<unknown> {
if (stmt.step()) {
if (isPluck) {
const row = stmt.get([]);
const value = Array.isArray(row) && row.length > 0 ? row[0] : undefined;
return { value, done: false };
}
return { value: isRaw ? stmt.get([]) : stmt.get({}), done: false };
}
stmt.reset();
return { value: undefined, done: true };
}
};
}
raw(toggleState?: boolean): this {
// In raw mode, rows are returned as arrays instead of objects
// If toggleState is undefined, enable raw mode (better-sqlite3 behavior)
this.isRawMode = toggleState !== undefined ? toggleState : true;
return this;
}
pluck(toggleState?: boolean): this {
// In pluck mode, only the first column of each row is returned
// If toggleState is undefined, enable pluck mode (better-sqlite3 behavior)
this.isPluckMode = toggleState !== undefined ? toggleState : true;
return this;
}
private bindParams(params: unknown[]): void {
this.stmt.clearBindings();
if (params.length === 0) {
return;
}
// Handle single object with named parameters
if (params.length === 1 && typeof params[0] === "object" && params[0] !== null && !Array.isArray(params[0])) {
const inputBindings = params[0] as { [paramName: string]: BindableValue };
// SQLite WASM expects parameter names to include the prefix (@ : or $)
// better-sqlite3 automatically maps unprefixed names to @name
// We need to add the @ prefix for compatibility
const bindings: { [paramName: string]: BindableValue } = {};
for (const [key, value] of Object.entries(inputBindings)) {
// If the key already has a prefix, use it as-is
if (key.startsWith('@') || key.startsWith(':') || key.startsWith('$')) {
bindings[key] = value;
} else {
// Add @ prefix to match better-sqlite3 behavior
bindings[`@${key}`] = value;
}
}
this.stmt.bind(bindings);
} else {
// Handle positional parameters - flatten and cast to BindableValue[]
const flatParams = params.flat() as BindableValue[];
if (flatParams.length > 0) {
this.stmt.bind(flatParams);
}
}
}
finalize(): void {
if (!this.isFinalized) {
try {
this.stmt.finalize();
} catch (e) {
console.warn("Error finalizing SQLite statement:", e);
} finally {
this.isFinalized = true;
}
}
}
}
/**
* SQLite database provider for browser environments using SQLite WASM.
*
* This provider wraps the official @sqlite.org/sqlite-wasm package to provide
* a DatabaseProvider implementation compatible with trilium-core.
*
* @example
* ```typescript
* const provider = new BrowserSqlProvider();
* await provider.initWasm(); // Initialize SQLite WASM module
* provider.loadFromMemory(); // Open an in-memory database
* // or
* provider.loadFromBuffer(existingDbBuffer); // Load from existing data
* ```
*/
export default class BrowserSqlProvider implements DatabaseProvider {
private db?: Sqlite3Database;
private sqlite3?: Sqlite3Module;
private _inTransaction = false;
private initPromise?: Promise<void>;
private initError?: Error;
private statementCache: Map<string, WasmStatement> = new Map();
// OPFS state tracking
private opfsDbPath?: string;
/**
* Get the SQLite WASM module version info.
* Returns undefined if the module hasn't been initialized yet.
*/
get version(): { libVersion: string; sourceId: string } | undefined {
return this.sqlite3?.version;
}
/**
* Initialize the SQLite WASM module.
* This must be called before using any database operations.
* Safe to call multiple times - subsequent calls return the same promise.
*
* @returns A promise that resolves when the module is initialized
* @throws Error if initialization fails
*/
async initWasm(): Promise<void> {
// Return existing promise if already initializing/initialized
if (this.initPromise) {
return this.initPromise;
}
// Fail fast if we already tried and failed
if (this.initError) {
throw this.initError;
}
this.initPromise = this.doInitWasm();
return this.initPromise;
}
private async doInitWasm(): Promise<void> {
try {
console.log("[BrowserSqlProvider] Initializing SQLite WASM...");
const startTime = performance.now();
this.sqlite3 = await sqlite3InitModule({
print: console.log,
printErr: console.error,
});
const initTime = performance.now() - startTime;
console.log(
`[BrowserSqlProvider] SQLite WASM initialized in ${initTime.toFixed(2)}ms:`,
this.sqlite3.version.libVersion
);
} catch (e) {
this.initError = e instanceof Error ? e : new Error(String(e));
console.error("[BrowserSqlProvider] SQLite WASM initialization failed:", this.initError);
throw this.initError;
}
}
/**
* Check if the SQLite WASM module has been initialized.
*/
get isInitialized(): boolean {
return this.sqlite3 !== undefined;
}
// ==================== OPFS Support ====================
/**
* Check if the OPFS VFS is available.
* This requires:
* - Running in a Worker context
* - Browser support for OPFS APIs
* - COOP/COEP headers sent by the server (for SharedArrayBuffer)
*
* @returns true if OPFS VFS is available for use
*/
isOpfsAvailable(): boolean {
this.ensureSqlite3();
// SQLite WASM automatically installs the OPFS VFS if the environment supports it
// We can check for its presence via sqlite3_vfs_find or the OpfsDb class
return this.sqlite3!.oo1.OpfsDb !== undefined;
}
/**
* Load or create a database stored in OPFS for persistent storage.
* The database will persist across browser sessions.
*
* Requires COOP/COEP headers to be set by the server:
* - Cross-Origin-Opener-Policy: same-origin
* - Cross-Origin-Embedder-Policy: require-corp
*
* @param path - The path for the database file in OPFS (e.g., "/trilium.db")
* Paths without a leading slash are treated as relative to OPFS root.
* Leading directories are created automatically.
* @param options - Additional options
* @throws Error if OPFS VFS is not available
*
* @example
* ```typescript
* const provider = new BrowserSqlProvider();
* await provider.initWasm();
* if (provider.isOpfsAvailable()) {
* provider.loadFromOpfs("/my-database.db");
* } else {
* console.warn("OPFS not available, using in-memory database");
* provider.loadFromMemory();
* }
* ```
*/
loadFromOpfs(path: string, options: { createIfNotExists?: boolean } = {}): void {
this.ensureSqlite3();
if (!this.isOpfsAvailable()) {
throw new Error(
"OPFS VFS is not available. This requires:\n" +
"1. Running in a Worker context\n" +
"2. Browser support for OPFS (Chrome 102+, Firefox 111+, Safari 17+)\n" +
"3. COOP/COEP headers from the server:\n" +
" Cross-Origin-Opener-Policy: same-origin\n" +
" Cross-Origin-Embedder-Policy: require-corp"
);
}
console.log(`[BrowserSqlProvider] Loading database from OPFS: ${path}`);
const startTime = performance.now();
try {
// OpfsDb automatically creates directories in the path
// Mode 'c' = create if not exists
const mode = options.createIfNotExists !== false ? 'c' : '';
this.db = new this.sqlite3!.oo1.OpfsDb(path, mode);
this.opfsDbPath = path;
// Configure the database for OPFS
// Note: WAL mode requires exclusive locking in OPFS environment
this.db.exec("PRAGMA journal_mode = DELETE");
this.db.exec("PRAGMA synchronous = NORMAL");
const loadTime = performance.now() - startTime;
console.log(`[BrowserSqlProvider] OPFS database loaded in ${loadTime.toFixed(2)}ms`);
} catch (e) {
const error = e instanceof Error ? e : new Error(String(e));
console.error(`[BrowserSqlProvider] Failed to load OPFS database: ${error.message}`);
throw error;
}
}
/**
* Check if the currently open database is stored in OPFS.
*/
get isUsingOpfs(): boolean {
return this.opfsDbPath !== undefined;
}
/**
* Get the OPFS path of the currently open database.
* Returns undefined if not using OPFS.
*/
get currentOpfsPath(): string | undefined {
return this.opfsDbPath;
}
/**
* Check if the database has been initialized with a schema.
* This is a simple sanity check that looks for the existence of core tables.
*
* @returns true if the database appears to be initialized
*/
isDbInitialized(): boolean {
this.ensureDb();
// Check if the 'notes' table exists (a core table that must exist in an initialized DB)
const tableExists = this.db!.selectValue(
"SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'notes'"
);
return tableExists !== undefined;
}
// ==================== End OPFS Support ====================
loadFromFile(_path: string, _isReadOnly: boolean): void {
// Browser environment doesn't have direct file system access.
// Use OPFS for persistent storage.
throw new Error(
"loadFromFile is not supported in browser environment. " +
"Use loadFromMemory() for temporary databases, loadFromBuffer() to load from data, " +
"or loadFromOpfs() for persistent storage."
);
}
/**
* Create an empty in-memory database.
* Data will be lost when the page is closed.
*
* For persistent storage, use loadFromOpfs() instead.
* To load demo data, call initializeDemoDatabase() after this.
*/
loadFromMemory(): void {
this.ensureSqlite3();
console.log("[BrowserSqlProvider] Creating in-memory database...");
const startTime = performance.now();
this.db = new this.sqlite3!.oo1.DB(":memory:", "c");
this.opfsDbPath = undefined; // Not using OPFS
this.db.exec("PRAGMA journal_mode = WAL");
// Initialize with demo data for in-memory databases
// (since they won't persist anyway)
this.initializeDemoDatabase();
const loadTime = performance.now() - startTime;
console.log(`[BrowserSqlProvider] In-memory database created in ${loadTime.toFixed(2)}ms`);
}
/**
* Initialize the database with demo/starter data.
* This should only be called once when creating a new database.
*
* For OPFS databases, this is called automatically only if the database
* doesn't already exist.
*/
initializeDemoDatabase(): void {
this.ensureDb();
console.log("[BrowserSqlProvider] Initializing database with demo data...");
const startTime = performance.now();
this.db!.exec(demoDbSql);
const loadTime = performance.now() - startTime;
console.log(`[BrowserSqlProvider] Demo data loaded in ${loadTime.toFixed(2)}ms`);
}
loadFromBuffer(buffer: Uint8Array): void {
this.ensureSqlite3();
// SQLite WASM can deserialize a database from a byte array
const p = this.sqlite3!.wasm.allocFromTypedArray(buffer);
try {
this.db = new this.sqlite3!.oo1.DB({ filename: ":memory:", flags: "c" });
this.opfsDbPath = undefined; // Not using OPFS
const rc = this.sqlite3!.capi.sqlite3_deserialize(
this.db.pointer!,
"main",
p,
buffer.byteLength,
buffer.byteLength,
this.sqlite3!.capi.SQLITE_DESERIALIZE_FREEONCLOSE |
this.sqlite3!.capi.SQLITE_DESERIALIZE_RESIZEABLE
);
if (rc !== 0) {
throw new Error(`Failed to deserialize database: ${rc}`);
}
} catch (e) {
this.sqlite3!.wasm.dealloc(p);
throw e;
}
}
backup(_destinationFile: string): void {
// In browser, we can serialize the database to a byte array
// For actual file backup, we'd need to use File System Access API or download
throw new Error(
"backup to file is not supported in browser environment. " +
"Use serialize() to get the database as a Uint8Array instead."
);
}
/**
* Serialize the database to a byte array.
* This can be used to save the database to IndexedDB, download it, etc.
*/
serialize(): Uint8Array {
this.ensureDb();
// Use the convenience wrapper which handles all the memory management
return this.sqlite3!.capi.sqlite3_js_db_export(this.db!);
}
prepare(query: string): Statement {
this.ensureDb();
// Check if we already have this statement cached
if (this.statementCache.has(query)) {
return this.statementCache.get(query)!;
}
// Create new statement and cache it
const stmt = this.db!.prepare(query);
const wasmStatement = new WasmStatement(stmt, this.db!);
this.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,89 @@
// public/local-bridge.js
let localWorker: Worker | null = null;
const pending = new Map();
export function startLocalServerWorker() {
if (localWorker) return localWorker;
localWorker = new Worker(new URL("./local-server-worker.js", import.meta.url), { type: "module" });
// Handle worker errors during initialization
localWorker.onerror = (event) => {
console.error("[LocalBridge] Worker error:", event);
// Reject all pending requests
for (const [id, 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 [id, 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,222 @@
// public/local-server-worker.js
// This will eventually import your core server and DB provider.
// import { createCoreServer } from "@trilium/core"; (bundled)
import BrowserExecutionContext from './lightweight/cls_provider';
import BrowserCryptoProvider from './lightweight/crypto_provider';
import BrowserSqlProvider from './lightweight/sql_provider';
import WorkerMessagingProvider from './lightweight/messaging_provider';
import { BrowserRouter } from './lightweight/browser_router';
import { createConfiguredRouter } from './lightweight/browser_routes';
// Global error handlers - MUST be set up before any async imports
self.onerror = (message, source, lineno, colno, error) => {
console.error("[Worker] Uncaught error:", message, source, lineno, colno, error);
// Try to notify the main thread about the error
try {
self.postMessage({
type: "WORKER_ERROR",
error: {
message: String(message),
source,
lineno,
colno,
stack: error?.stack
}
});
} catch (e) {
// Can't even post message, just log
console.error("[Worker] Failed to report error:", e);
}
return false; // Don't suppress the error
};
self.onunhandledrejection = (event) => {
console.error("[Worker] Unhandled rejection:", event.reason);
try {
self.postMessage({
type: "WORKER_ERROR",
error: {
message: String(event.reason?.message || event.reason),
stack: event.reason?.stack
}
});
} catch (e) {
console.error("[Worker] Failed to report rejection:", e);
}
};
console.log("[Worker] Error handlers installed");
// Shared SQL provider instance
const sqlProvider = new BrowserSqlProvider();
// Messaging provider for worker-to-main-thread communication
const messagingProvider = new WorkerMessagingProvider();
// Core module, router, and initialization state
let coreModule: typeof import("@triliumnext/core") | null = null;
let router: BrowserRouter | null = null;
let initPromise: Promise<void> | null = null;
let initError: Error | null = null;
/**
* Initialize SQLite WASM and load the core module.
* This happens once at worker startup.
*/
async function initialize(): Promise<void> {
if (initPromise) {
return initPromise; // Already initializing
}
if (initError) {
throw initError; // Failed before, don't retry
}
initPromise = (async () => {
try {
console.log("[Worker] Initializing SQLite WASM...");
await sqlProvider.initWasm();
// Try to use OPFS for persistent storage
if (sqlProvider.isOpfsAvailable()) {
console.log("[Worker] OPFS available, loading persistent database...");
sqlProvider.loadFromOpfs("/trilium.db");
// Check if database is initialized (schema exists)
if (!sqlProvider.isDbInitialized()) {
console.log("[Worker] Database not initialized, loading demo data...");
sqlProvider.initializeDemoDatabase();
console.log("[Worker] Demo data loaded");
} else {
console.log("[Worker] Existing initialized database loaded");
}
} else {
// Fall back to in-memory database (non-persistent)
console.warn("[Worker] OPFS not available, using in-memory database (data will not persist)");
console.warn("[Worker] To enable persistence, ensure COOP/COEP headers are set by the server");
sqlProvider.loadFromMemory();
}
console.log("[Worker] Database loaded");
console.log("[Worker] Loading @triliumnext/core...");
coreModule = await import("@triliumnext/core");
coreModule.initializeCore({
executionContext: new BrowserExecutionContext(),
crypto: new BrowserCryptoProvider(),
messaging: messagingProvider,
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;
}
const encoder = new TextEncoder();
function jsonResponse(obj: unknown, status = 200, extraHeaders = {}) {
const body = encoder.encode(JSON.stringify(obj)).buffer;
return {
status,
headers: { "content-type": "application/json; charset=utf-8", ...extraHeaders },
body
};
}
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;
console.log("[Worker] Received LOCAL_REQUEST:", id, request.method, request.url);
try {
const response = await dispatch(request);
console.log("[Worker] Dispatch completed, sending response:", id);
// 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,189 @@
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
const registration = 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 fetchWithRetry(url: string, maxRetries = 3, delayMs = 500): Promise<Response> {
let lastError: Error | null = null;
for (let attempt = 0; attempt < maxRetries; attempt++) {
try {
console.log(`[Bootstrap] Fetching ${url} (attempt ${attempt + 1}/${maxRetries})`);
const response = await fetch(url);
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
// Check if response has content
const contentType = response.headers.get("content-type");
if (!contentType || !contentType.includes("application/json")) {
throw new Error(`Invalid content-type: ${contentType || "none"}`);
}
return response;
} catch (err) {
lastError = err as Error;
console.warn(`[Bootstrap] Fetch attempt ${attempt + 1} failed:`, err);
if (attempt < maxRetries - 1) {
// Exponential backoff
const delay = delayMs * Math.pow(2, attempt);
console.log(`[Bootstrap] Retrying in ${delay}ms...`);
await new Promise(resolve => setTimeout(resolve, delay));
}
}
}
throw new Error(`Failed to fetch ${url} after ${maxRetries} attempts: ${lastError?.message}`);
}
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();
// 4) Now fetch bootstrap - SW is guaranteed to intercept this
await setupGlob();
loadStylesheets();
loadIcons();
setBodyAttributes();
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 setupGlob() {
const response = await fetchWithRetry("/bootstrap");
console.log("Service worker state", navigator.serviceWorker.controller);
console.log("Resp", response);
const json = await response.json();
console.log("Bootstrap", json);
window.glob = {
...json,
activeDialog: null
};
}
function loadStylesheets() {
const { assetPath, themeCssUrl, themeUseNextAsBase } = window.glob;
const cssToLoad = [];
cssToLoad.push(`${assetPath}/stylesheets/theme-light.css`);
if (themeCssUrl) {
cssToLoad.push(themeCssUrl);
}
if (themeUseNextAsBase === "next") {
cssToLoad.push(`${assetPath}/stylesheets/theme-next.css`)
} else if (themeUseNextAsBase === "next-dark") {
cssToLoad.push(`${assetPath}/stylesheets/theme-next-dark.css`)
} else if (themeUseNextAsBase === "next-light") {
cssToLoad.push(`${assetPath}/stylesheets/theme-next-light.css`)
}
cssToLoad.push(`${assetPath}/stylesheets/style.css`);
for (const href of cssToLoad) {
const linkEl = document.createElement("link");
linkEl.href = href;
linkEl.rel = "stylesheet";
document.body.appendChild(linkEl);
}
}
function loadIcons() {
const styleEl = document.createElement("style");
styleEl.innerText = window.glob.iconPackCss;
document.head.appendChild(styleEl);
}
function setBodyAttributes() {
const { device, headingStyle, layoutOrientation, platform, isElectron, hasNativeTitleBar, hasBackgroundEffects, currentLocale } = window.glob;
const classesToSet = [
device,
`heading-style-${headingStyle}`,
`layout-${layoutOrientation}`,
`platform-${platform}`,
isElectron && "isElectron",
hasNativeTitleBar && "native-titlebar",
hasBackgroundEffects && "background-effects"
].filter(Boolean);
for (const classToSet of classesToSet) {
document.body.classList.add(classToSet);
}
document.body.lang = currentLocale.id;
document.body.dir = currentLocale.rtl ? "rtl" : "ltr";
}
async function loadScripts() {
await import("./runtime.js");
await import("./desktop.js");
}
bootstrap();

View File

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

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,173 @@
import preact from "@preact/preset-vite";
import { defineConfig } from 'vite';
import { join } from 'path';
import { viteStaticCopy } from 'vite-plugin-static-copy';
const assets = ["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 = [
preact({
babel: {
compact: !isDev
}
}),
sqliteWasmPlugin, // Always include SQLite WASM files
viteStaticCopy({
targets: assets.map((asset) => ({
src: `../../client/src/${asset}/*`,
dest: asset
})),
// Enable watching in development
...(isDev && {
watch: {
reloadPageOnChange: true
}
})
}),
// Watch client files for changes in development
...(isDev ? [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 /
cacheDir: '../../../node_modules/.vite/apps/client-standalone',
base: "",
plugins,
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'),
'local-server-worker': join(__dirname, 'src', 'local-server-worker.ts')
},
output: {
entryFileNames: (chunkInfo) => {
// Service worker and other workers should be at root level
if (chunkInfo.name === 'sw' || chunkInfo.name === 'local-server-worker') {
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

@@ -47,7 +47,7 @@
"i18next": "25.7.3",
"i18next-http-backend": "3.0.2",
"jquery": "3.7.1",
"jquery.fancytree": "2.38.5",
"jquery.fancytree": "2.38.5",
"jsplumb": "2.15.6",
"katex": "0.16.27",
"knockout": "3.5.1",

View File

@@ -6,7 +6,7 @@
<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">
<!-- <link rel="manifest" crossorigin="use-credentials" href="manifest.webmanifest"> -->
<title>Trilium Notes</title>
</head>
@@ -35,8 +35,11 @@
}
async function setupGlob() {
const response = await fetch("./bootstrap");
const response = await fetch("/bootstrap");
console.log("Service worker state", navigator.serviceWorker.controller);
console.log("Resp", response);
const json = await response.json();
console.log("Bootstrap", json);
global = globalThis; /* fixes https://github.com/webpack/webpack/issues/10035 */
window.glob = {

View File

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

View File

@@ -133,6 +133,8 @@ export function isElectron() {
return !!(window && window.process && window.process.type);
}
export const isStandalone = window.glob.isStandalone;
/**
* Returns `true` if the client is running as a PWA, otherwise `false`.
*/
@@ -814,7 +816,7 @@ function compareVersions(v1: string, v2: string): number {
/**
* Compares two semantic version strings and returns `true` if the latest version is greater than the current version.
*/
function isUpdateAvailable(latestVersion: string | null | undefined, currentVersion: string): boolean {
export function isUpdateAvailable(latestVersion: string | null | undefined, currentVersion: string): boolean {
if (!latestVersion) {
return false;
}

View File

@@ -21,7 +21,13 @@
},
"bundle-error": {
"title": "Nem sikerült betölteni az egyéni szkriptet",
"message": "A(z) \"{{id}}\" azonosítójú, \"{{title}}\" című jegyzetből származó szkript nem hajtható végre a következő ok miatt:\n\n{{message}}"
"message": "A skript nem hajtható végre a következő ok miatt:\n\n{{message}}"
},
"widget-list-error": {
"title": "A Widget-ek letöltése sikertelen volt"
},
"widget-render-error": {
"title": "Nem sikerült renderelni a React widget-et"
}
},
"add_link": {

View File

@@ -36,6 +36,7 @@ interface CustomGlobals {
isProtectedSessionAvailable: boolean;
isDev: boolean;
isMainWindow: boolean;
isStandalone?: boolean;
maxEntityChangeIdAtLoad: number;
maxEntityChangeSyncIdAtLoad: number;
assetPath: string;

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";
@@ -249,7 +249,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";
@@ -267,7 +267,7 @@ function useTriliumUpdateStatus() {
}
useEffect(() => {
if (!checkForUpdates) {
if (!checkForUpdates || !isStandalone) {
setLatestVersion(undefined);
return;
}

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

@@ -23,7 +23,7 @@ export default function SqlResults() {
{t("sql_result.no_rows")}
</Alert>
) : (
<div class="sql-console-result-container">
<div className="sql-console-result-container selectable-text">
{results?.map(rows => {
// inserts, updates
if (typeof rows === "object" && !Array.isArray(rows)) {

View File

@@ -8,39 +8,44 @@ import Dropdown from "./react/Dropdown";
export default function SqlTableSchemas() {
const { note } = useNoteContext();
const isEnabled = note?.mime === "text/x-sqlite;schema=trilium";
return (
<div className={`sql-table-schemas-widget ${!isEnabled ? "hidden-ext" : ""}`}>
{isEnabled && <SqlTableSchemasContent />}
</div>
)
}
function SqlTableSchemasContent() {
const [ schemas, setSchemas ] = useState<SchemaResponse[]>();
useEffect(() => {
server.get<SchemaResponse[]>("sql/schema").then(setSchemas);
}, []);
const isEnabled = note?.mime === "text/x-sqlite;schema=trilium" && schemas;
return (
<div className={`sql-table-schemas-widget ${!isEnabled ? "hidden-ext" : ""}`}>
{isEnabled && (
<>
{t("sql_table_schemas.tables")}{": "}
return schemas && (
<>
{t("sql_table_schemas.tables")}{": "}
<span class="sql-table-schemas">
{schemas.map(({ name, columns }) => (
<>
<Dropdown text={name} noSelectButtonStyle hideToggleArrow
>
<table className="table-schema">
{columns.map(column => (
<tr>
<td>{column.name}</td>
<td>{column.type}</td>
</tr>
))}
</table>
</Dropdown>
{" "}
</>
))}
</span>
</>
)}
</div>
<span className="sql-table-schemas">
{schemas.map(({ name, columns }) => (
<>
<Dropdown text={name} noSelectButtonStyle hideToggleArrow
>
<table className="table-schema">
{columns.map(column => (
<tr>
<td>{column.name}</td>
<td>{column.type}</td>
</tr>
))}
</table>
</Dropdown>
{" "}
</>
))}
</span>
</>
)
}
}

View File

@@ -134,11 +134,12 @@ export async function buildConfig(opts: BuildEditorOptions): Promise<EditorConfi
defaultProtocol: "https://",
allowedProtocols: ALLOWED_PROTOCOLS
},
emoji: {
definitionsUrl: window.glob.isDev
? new URL(import.meta.url).origin + emojiDefinitionsUrl
: emojiDefinitionsUrl
},
// TODO: Disabled for now
// emoji: {
// definitionsUrl: window.glob.isDev
// ? new URL(import.meta.url).origin + emojiDefinitionsUrl
// : emojiDefinitionsUrl
// },
syntaxHighlighting: {
loadHighlightJs: async () => {
await ensureMimeTypesForHighlighting();

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

@@ -8,12 +8,25 @@ import { viteStaticCopy } from 'vite-plugin-static-copy'
const assets = [ "assets", "stylesheets", "fonts", "translations" ];
const isDev = process.env.NODE_ENV === "development";
// Always copy SQLite WASM files so they're available to the module
const sqliteWasmPlugin = viteStaticCopy({
targets: [
{
// Copy the entire jswasm directory to maintain the module's expected structure
src: "../../node_modules/@sqlite.org/sqlite-wasm/sqlite-wasm/jswasm/*",
dest: "node_modules/@sqlite.org/sqlite-wasm/sqlite-wasm/jswasm"
}
]
});
let plugins: any = [
preact({
babel: {
compact: !isDev
}
})
}),
sqliteWasmPlugin // Always include SQLite WASM files
];
if (!isDev) {

View File

@@ -10,6 +10,7 @@
"creating-and-moving-notes": "नोट्स बनाना और स्थानांतरित करना",
"move-note-up": "नोट को ऊपर ले जाएं",
"move-note-down": "नोट को नीचे ले जाएं",
"note-clipboard": "नोट क्लिपबोर्ड"
"note-clipboard": "नोट क्लिपबोर्ड",
"duplicate-subtree": "डुप्लिकेट सबट्री"
}
}

View File

@@ -341,6 +341,7 @@ export function findExcerpts(sourceNote: BNote, referencedNoteId: string) {
return excerpts;
}
// TODO: Deduplicate with core
function getFilteredBacklinks(note: BNote): BAttribute[] {
return (
note
@@ -350,16 +351,6 @@ function getFilteredBacklinks(note: BNote): BAttribute[] {
);
}
function getBacklinkCount(req: Request) {
const { noteId } = req.params;
const note = becca.getNoteOrThrow(noteId);
return {
count: getFilteredBacklinks(note).length
} satisfies BacklinkCountResponse;
}
function getBacklinks(req: Request): BacklinksResponse {
const { noteId } = req.params;
const note = becca.getNoteOrThrow(noteId);
@@ -389,6 +380,5 @@ function getBacklinks(req: Request): BacklinksResponse {
export default {
getLinkMap,
getTreeMap,
getBacklinkCount,
getBacklinks
};

View File

@@ -1,34 +1,9 @@
import type { Request } from "express";
import becca from "../../becca/becca.js";
import markdownService from "../../services/import/markdown.js";
import markdown from "../../services/export/markdown.js";
import { RenderMarkdownResponse, ToMarkdownResponse } from "@triliumnext/commons";
function getIconUsage() {
const iconClassToCountMap: Record<string, number> = {};
for (const { value: iconClass, noteId } of becca.findAttributes("label", "iconClass")) {
if (noteId.startsWith("_")) {
continue; // ignore icons of "system" notes since they were not set by the user
}
if (!iconClass?.trim()) {
continue;
}
for (const clazz of iconClass.trim().split(/\s+/)) {
if (clazz === "bx") {
continue;
}
iconClassToCountMap[clazz] = (iconClassToCountMap[clazz] || 0) + 1;
}
}
return { iconClassToCountMap };
}
function renderMarkdown(req: Request) {
const { markdownContent } = req.body;
if (!markdownContent || typeof markdownContent !== 'string') {
@@ -50,7 +25,6 @@ function toMarkdown(req: Request) {
}
export default {
getIconUsage,
renderMarkdown,
toMarkdown
};

View File

@@ -7,7 +7,7 @@ import assetPath from "../services/asset_path.js";
import attributeService from "../services/attributes.js";
import config from "../services/config.js";
import { getCurrentLocale } from "../services/i18n.js";
import { generateCss, generateIconRegistry, getIconPacks, MIME_TO_EXTENSION_MAPPINGS } from "../services/icon_packs.js";
import { icon_packs as iconPackService } from "@triliumnext/core";
import log from "../services/log.js";
import optionService from "../services/options.js";
import protectedSessionService from "../services/protected_session.js";
@@ -30,7 +30,7 @@ export function bootstrap(req: Request, res: Response) {
const theme = options.theme;
const themeNote = attributeService.getNoteWithLabel("appTheme", theme);
const nativeTitleBarVisible = options.nativeTitleBarVisible === "true";
const iconPacks = getIconPacks();
const iconPacks = iconPackService.getIconPacks();
const currentLocale = getCurrentLocale();
res.send({
@@ -62,12 +62,12 @@ export function bootstrap(req: Request, res: Response) {
currentLocale,
isRtl: !!currentLocale.rtl,
iconPackCss: iconPacks
.map(p => generateCss(p, p.builtin
? `${assetPath}/fonts/${p.fontAttachmentId}.${MIME_TO_EXTENSION_MAPPINGS[p.fontMime]}`
.map(p => iconPackService.generateCss(p, p.builtin
? `${assetPath}/fonts/${p.fontAttachmentId}.${iconPackService.MIME_TO_EXTENSION_MAPPINGS[p.fontMime]}`
: `api/attachments/download/${p.fontAttachmentId}`))
.filter(Boolean)
.join("\n\n"),
iconRegistry: generateIconRegistry(iconPacks),
iconRegistry: iconPackService.generateIconRegistry(iconPacks),
TRILIUM_SAFE_MODE: !!process.env.TRILIUM_SAFE_MODE
});
}

View File

@@ -1,4 +1,4 @@
import { AbstractBeccaEntity,NotFoundError, ValidationError } from "@triliumnext/core";
import { routes, NotFoundError, ValidationError } from "@triliumnext/core";
import express, { type RequestHandler } from "express";
import multer from "multer";
@@ -23,38 +23,10 @@ type NotAPromise<T> = T & { then?: void };
export type ApiRequestHandler = (req: express.Request, res: express.Response, next: express.NextFunction) => unknown;
export type SyncRouteRequestHandler = (req: express.Request, res: express.Response, next: express.NextFunction) => NotAPromise<object> | number | string | void | null;
/** Handling common patterns. If entity is not caught, serialization to JSON will fail */
function convertEntitiesToPojo(result: unknown) {
if (result instanceof AbstractBeccaEntity) {
result = result.getPojo();
} else if (Array.isArray(result)) {
for (const idx in result) {
if (result[idx] instanceof AbstractBeccaEntity) {
result[idx] = result[idx].getPojo();
}
}
} else if (result && typeof result === "object") {
if ("note" in result && result.note instanceof AbstractBeccaEntity) {
result.note = result.note.getPojo();
}
if ("branch" in result && result.branch instanceof AbstractBeccaEntity) {
result.branch = result.branch.getPojo();
}
}
if (result && typeof result === "object" && "executionResult" in result) {
// from runOnBackend()
result.executionResult = convertEntitiesToPojo(result.executionResult);
}
return result;
}
export function apiResultHandler(req: express.Request, res: express.Response, result: unknown) {
res.setHeader("trilium-max-entity-change-id", entityChangesService.getMaxEntityChangeId());
result = convertEntitiesToPojo(result);
result = routes.convertEntitiesToPojo(result);
// if it's an array and the first element is integer, then we consider this to be [statusCode, response] format
if (Array.isArray(result) && result.length > 0 && Number.isInteger(result[0])) {

View File

@@ -18,7 +18,6 @@ import { isElectron } from "../services/utils.js";
import shareRoutes from "../share/routes.js";
import anthropicRoute from "./api/anthropic.js";
import appInfoRoute from "./api/app_info.js";
import attachmentsApiRoute from "./api/attachments.js";
import attributesRoute from "./api/attributes.js";
import autocompleteApiRoute from "./api/autocomplete.js";
import backendLogRoute from "./api/backend_log.js";
@@ -33,19 +32,15 @@ import filesRoute from "./api/files.js";
import fontsRoute from "./api/fonts.js";
import imageRoute from "./api/image.js";
import importRoute from "./api/import.js";
import keysRoute from "./api/keys.js";
import llmRoute from "./api/llm.js";
import loginApiRoute from "./api/login.js";
import metricsRoute from "./api/metrics.js";
import noteMapRoute from "./api/note_map.js";
import notesApiRoute from "./api/notes.js";
import ollamaRoute from "./api/ollama.js";
import openaiRoute from "./api/openai.js";
import optionsApiRoute from "./api/options.js";
import otherRoute from "./api/other.js";
import passwordApiRoute from "./api/password.js";
import recentChangesApiRoute from "./api/recent_changes.js";
import recentNotesRoute from "./api/recent_notes.js";
import recoveryCodes from './api/recovery_codes.js';
import relationMapApiRoute from "./api/relation-map.js";
import revisionsApiRoute from "./api/revisions.js";
@@ -61,13 +56,13 @@ import syncApiRoute from "./api/sync.js";
import systemInfoRoute from "./api/system_info.js";
import totp from './api/totp.js';
// API routes
import treeApiRoute from "./api/tree.js";
import { doubleCsrfProtection as csrfMiddleware } from "./csrf_protection.js";
import * as indexRoute from "./index.js";
import loginRoute from "./login.js";
import { apiResultHandler, apiRoute, asyncApiRoute, asyncRoute, route, router, uploadMiddlewareWithErrorHandling } from "./route_api.js";
// page routes
import setupRoute from "./setup.js";
import { routes } from "@triliumnext/core";
const GET = "get",
PST = "post",
@@ -105,22 +100,8 @@ function register(app: express.Application) {
apiRoute(GET, '/api/totp_recovery/enabled', recoveryCodes.checkForRecoveryKeys);
apiRoute(GET, '/api/totp_recovery/used', recoveryCodes.getUsedRecoveryCodes);
apiRoute(GET, '/api/tree', treeApiRoute.getTree);
apiRoute(PST, '/api/tree/load', treeApiRoute.load);
routes.buildSharedApiRoutes(apiRoute);
apiRoute(GET, "/api/notes/:noteId", notesApiRoute.getNote);
apiRoute(GET, "/api/notes/:noteId/blob", notesApiRoute.getNoteBlob);
apiRoute(GET, "/api/notes/:noteId/metadata", notesApiRoute.getNoteMetadata);
apiRoute(PUT, "/api/notes/:noteId/data", notesApiRoute.updateNoteData);
apiRoute(DEL, "/api/notes/:noteId", notesApiRoute.deleteNote);
apiRoute(PUT, "/api/notes/:noteId/undelete", notesApiRoute.undeleteNote);
apiRoute(PST, "/api/notes/:noteId/revision", notesApiRoute.forceSaveRevision);
apiRoute(PST, "/api/notes/:parentNoteId/children", notesApiRoute.createNote);
apiRoute(PUT, "/api/notes/:noteId/sort-children", notesApiRoute.sortChildNotes);
apiRoute(PUT, "/api/notes/:noteId/protect/:isProtected", notesApiRoute.protectNote);
apiRoute(PUT, "/api/notes/:noteId/type", notesApiRoute.setNoteTypeMime);
apiRoute(PUT, "/api/notes/:noteId/title", notesApiRoute.changeTitle);
apiRoute(PST, "/api/notes/:noteId/duplicate/:parentNoteId", notesApiRoute.duplicateSubtree);
apiRoute(PUT, "/api/notes/:noteId/clone-to-branch/:parentBranchId", cloningApiRoute.cloneNoteToBranch);
apiRoute(PUT, "/api/notes/:noteId/toggle-in-parent/:parentNoteId/:present", cloningApiRoute.toggleNoteInParent);
apiRoute(PUT, "/api/notes/:noteId/clone-to-note/:parentNoteId", cloningApiRoute.cloneNoteToParentNote);
@@ -142,7 +123,6 @@ function register(app: express.Application) {
route(GET, "/api/notes/download/:noteId", [auth.checkApiAuthOrElectron], filesRoute.downloadFile);
apiRoute(PST, "/api/notes/:noteId/save-to-tmp-dir", filesRoute.saveNoteToTmpDir);
apiRoute(PST, "/api/notes/:noteId/upload-modified-file", filesRoute.uploadModifiedFileToNote);
apiRoute(PST, "/api/notes/:noteId/convert-to-attachment", notesApiRoute.convertNoteToAttachment);
apiRoute(PUT, "/api/branches/:branchId/move-to/:parentBranchId", branchesApiRoute.moveBranchToParent);
apiRoute(PUT, "/api/branches/:branchId/move-before/:beforeBranchId", branchesApiRoute.moveBranchBeforeNote);
@@ -153,15 +133,8 @@ function register(app: express.Application) {
apiRoute(PUT, "/api/branches/:branchId/set-prefix", branchesApiRoute.setPrefix);
apiRoute(PUT, "/api/branches/set-prefix-batch", branchesApiRoute.setPrefixBatch);
apiRoute(GET, "/api/notes/:noteId/attachments", attachmentsApiRoute.getAttachments);
apiRoute(PST, "/api/notes/:noteId/attachments", attachmentsApiRoute.saveAttachment);
route(PST, "/api/notes/:noteId/attachments/upload", [auth.checkApiAuthOrElectron, uploadMiddlewareWithErrorHandling, csrfMiddleware], attachmentsApiRoute.uploadAttachment, apiResultHandler);
apiRoute(GET, "/api/attachments/:attachmentId", attachmentsApiRoute.getAttachment);
apiRoute(GET, "/api/attachments/:attachmentId/all", attachmentsApiRoute.getAllAttachments);
apiRoute(PST, "/api/attachments/:attachmentId/convert-to-note", attachmentsApiRoute.convertAttachmentToNote);
apiRoute(DEL, "/api/attachments/:attachmentId", attachmentsApiRoute.deleteAttachment);
apiRoute(PUT, "/api/attachments/:attachmentId/rename", attachmentsApiRoute.renameAttachment);
apiRoute(GET, "/api/attachments/:attachmentId/blob", attachmentsApiRoute.getAttachmentBlob);
// TODO: Bring back attachment uploading
// route(PST, "/api/notes/:noteId/attachments/upload", [auth.checkApiAuthOrElectron, uploadMiddlewareWithErrorHandling, csrfMiddleware], attachmentsApiRoute.uploadAttachment, apiResultHandler);
route(GET, "/api/attachments/:attachmentId/image/:filename", [auth.checkApiAuthOrElectron], imageRoute.returnAttachedImage);
route(GET, "/api/attachments/:attachmentId/open", [auth.checkApiAuthOrElectron], filesRoute.openAttachment);
asyncRoute(
@@ -211,13 +184,6 @@ function register(app: express.Application) {
route(GET, "/api/images/:noteId/:filename", [auth.checkApiAuthOrElectron], imageRoute.returnImageFromNote);
route(PUT, "/api/images/:noteId", [auth.checkApiAuthOrElectron, uploadMiddlewareWithErrorHandling, csrfMiddleware], imageRoute.updateImage, apiResultHandler);
apiRoute(GET, "/api/options", optionsApiRoute.getOptions);
// FIXME: possibly change to sending value in the body to avoid host of HTTP server issues with slashes
apiRoute(PUT, "/api/options/:name/:value", optionsApiRoute.updateOption);
apiRoute(PUT, "/api/options", optionsApiRoute.updateOptions);
apiRoute(GET, "/api/options/user-themes", optionsApiRoute.getUserThemes);
apiRoute(GET, "/api/options/locales", optionsApiRoute.getSupportedLocales);
apiRoute(PST, "/api/password/change", passwordApiRoute.changePassword);
apiRoute(PST, "/api/password/reset", passwordApiRoute.resetPassword);
@@ -233,7 +199,6 @@ function register(app: express.Application) {
route(PST, "/api/sync/queue-sector/:entityName/:sector", [auth.checkApiAuth], syncApiRoute.queueSector, apiResultHandler);
route(GET, "/api/sync/stats", [], syncApiRoute.getStats, apiResultHandler);
apiRoute(PST, "/api/recent-notes", recentNotesRoute.addRecentNote);
apiRoute(GET, "/api/app-info", appInfoRoute.getAppInfo);
apiRoute(GET, "/api/metrics", metricsRoute.getMetrics);
apiRoute(GET, "/api/system-checks", systemInfoRoute.systemChecks);
@@ -331,19 +296,12 @@ function register(app: express.Application) {
asyncRoute(PST, "/api/sender/image", [auth.checkEtapiToken, uploadMiddlewareWithErrorHandling], senderRoute.uploadImage, apiResultHandler);
asyncRoute(PST, "/api/sender/note", [auth.checkEtapiToken], senderRoute.saveNote, apiResultHandler);
apiRoute(GET, "/api/keyboard-actions", keysRoute.getKeyboardActions);
apiRoute(GET, "/api/keyboard-shortcuts-for-notes", keysRoute.getShortcutsForNotes);
apiRoute(PST, "/api/relation-map", relationMapApiRoute.getRelationMap);
apiRoute(PST, "/api/notes/erase-deleted-notes-now", notesApiRoute.eraseDeletedNotesNow);
apiRoute(PST, "/api/notes/erase-unused-attachments-now", notesApiRoute.eraseUnusedAttachmentsNow);
asyncApiRoute(GET, "/api/similar-notes/:noteId", similarNotesRoute.getSimilarNotes);
asyncApiRoute(GET, "/api/backend-log", backendLogRoute.getBackendLog);
apiRoute(GET, "/api/stats/note-size/:noteId", statsRoute.getNoteSize);
apiRoute(GET, "/api/stats/subtree-size/:noteId", statsRoute.getSubtreeSize);
apiRoute(PST, "/api/delete-notes-preview", notesApiRoute.getDeleteNotesPreview);
route(GET, "/api/fonts", [auth.checkApiAuthOrElectron], fontsRoute.getFontCss);
apiRoute(GET, "/api/other/icon-usage", otherRoute.getIconUsage);
apiRoute(PST, "/api/other/render-markdown", otherRoute.renderMarkdown);
apiRoute(PST, "/api/other/to-markdown", otherRoute.toMarkdown);
apiRoute(GET, "/api/recent-changes/:ancestorNoteId", recentChangesApiRoute.getRecentChanges);
@@ -351,7 +309,6 @@ function register(app: express.Application) {
apiRoute(PST, "/api/note-map/:noteId/tree", noteMapRoute.getTreeMap);
apiRoute(PST, "/api/note-map/:noteId/link", noteMapRoute.getLinkMap);
apiRoute(GET, "/api/note-map/:noteId/backlink-count", noteMapRoute.getBacklinkCount);
apiRoute(GET, "/api/note-map/:noteId/backlinks", noteMapRoute.getBacklinks);
shareRoutes.register(router);

View File

@@ -9,7 +9,7 @@ import type BBranch from "../../../becca/entities/bbranch.js";
import type BNote from "../../../becca/entities/bnote.js";
import { getClientDir, getShareThemeAssetDir } from "../../../routes/assets";
import { getDefaultTemplatePath, readTemplate, renderNoteForExport } from "../../../share/content_renderer";
import { getIconPacks, MIME_TO_EXTENSION_MAPPINGS, ProcessedIconPack } from "../../icon_packs";
import { icon_packs as iconPackService } from "@triliumnext/core";
import log from "../../log";
import NoteMeta, { NoteMetaFile } from "../../meta/note_meta";
import { RESOURCE_DIR } from "../../resource_dir";
@@ -31,7 +31,7 @@ export default class ShareThemeExportProvider extends ZipExportProvider {
private indexMeta: NoteMeta | null = null;
private searchIndex: Map<string, SearchIndexEntry> = new Map();
private rootMeta: NoteMeta | null = null;
private iconPacks: ProcessedIconPack[] = [];
private iconPacks: iconPackService.ProcessedIconPack[] = [];
prepareMeta(metaFile: NoteMetaFile): void {
const assets = [
@@ -56,7 +56,7 @@ export default class ShareThemeExportProvider extends ZipExportProvider {
dataFileName: "index.html"
};
this.rootMeta = metaFile.files[0];
this.iconPacks = getIconPacks();
this.iconPacks = iconPackService.getIconPacks();
metaFile.files.push(this.indexMeta);
}
@@ -165,7 +165,7 @@ export default class ShareThemeExportProvider extends ZipExportProvider {
// Inject the custom fonts.
for (const iconPack of this.iconPacks) {
const extension = MIME_TO_EXTENSION_MAPPINGS[iconPack.fontMime];
const extension = iconPackService.MIME_TO_EXTENSION_MAPPINGS[iconPack.fontMime];
let fontData: Uint8Array | undefined;
if (iconPack.builtin) {
fontData = readFileSync(join(getClientDir(), "fonts", `${iconPack.fontAttachmentId}.${extension}`));

View File

@@ -1,10 +1,11 @@
import { type Dayjs, dayjs, type Locale, type LOCALE_IDS,LOCALES, setDayjsLocale } from "@triliumnext/commons";
import i18next from "i18next";
import { join } from "path";
import hidden_subtree from "./hidden_subtree.js";
import options from "./options.js";
import sql_init from "./sql_init.js";
import { join } from "path";
import { getResourceDir } from "./utils.js";
import hidden_subtree from "./hidden_subtree.js";
import { dayjs, LOCALES, setDayjsLocale, type Dayjs, type Locale, type LOCALE_IDS } from "@triliumnext/commons";
export async function initializeTranslations() {
const resourceDir = getResourceDir();

View File

@@ -343,10 +343,6 @@ export function processStringOrBuffer(data: string | Buffer | null) {
}
}
export function safeExtractMessageAndStackFromError(err: unknown): [errMessage: string, errStack: string | undefined] {
return (err instanceof Error) ? [err.message, err.stack] as const : ["Unknown Error", undefined] as const;
}
/**
* Normalizes URL by removing trailing slashes and fixing double slashes.
* Preserves the protocol (http://, https://) but removes trailing slashes from the rest.
@@ -453,9 +449,14 @@ function slugify(text: string) {
.replace(/(^-|-$)+/g, ""); // trim dashes
}
/** @deprecated */
export const escapeHtml = coreUtils.escapeHtml;
/** @deprecated */
export const unescapeHtml = coreUtils.unescapeHtml;
/** @deprecated */
export const randomSecureToken = coreUtils.randomSecureToken;
/** @deprecated */
export const safeExtractMessageAndStackFromError = coreUtils.safeExtractMessageAndStackFromError;
export default {
compareVersions,

View File

@@ -12,7 +12,6 @@ import BAttachment from '../becca/entities/battachment.js';
import type BBranch from "../becca/entities/bbranch.js";
import BNote from "../becca/entities/bnote.js";
import assetPath, { assetUrlFragment } from "../services/asset_path.js";
import { generateCss, getIconPacks, MIME_TO_EXTENSION_MAPPINGS, ProcessedIconPack } from "../services/icon_packs.js";
import log from "../services/log.js";
import options from "../services/options.js";
import utils, { getResourceDir, isDev, safeExtractMessageAndStackFromError } from "../services/utils.js";
@@ -21,6 +20,7 @@ import SBranch from "./shaca/entities/sbranch.js";
import type SNote from "./shaca/entities/snote.js";
import shaca from "./shaca/shaca.js";
import shareRoot from "./share_root.js";
import { icon_packs as iconPackService } from "@triliumnext/core";
const shareAdjustedAssetPath = isDev ? assetPath : `../${assetPath}`;
const templateCache: Map<string, string> = new Map();
@@ -69,7 +69,7 @@ function getSharedSubTreeRoot(note: SNote | BNote | undefined): Subroot {
return getSharedSubTreeRoot(parentBranch.getParentNote());
}
export function renderNoteForExport(note: BNote, parentBranch: BBranch, basePath: string, ancestors: string[], iconPacks: ProcessedIconPack[]) {
export function renderNoteForExport(note: BNote, parentBranch: BBranch, basePath: string, ancestors: string[], iconPacks: iconPackService.ProcessedIconPack[]) {
const subRoot: Subroot = {
branch: parentBranch,
note: parentBranch.getNote()
@@ -95,7 +95,7 @@ export function renderNoteForExport(note: BNote, parentBranch: BBranch, basePath
faviconUrl: `${basePath}favicon.ico`,
ancestors,
isStatic: true,
iconPackCss: iconPacks.map(p => generateCss(p, `${basePath}assets/icon-pack-${p.prefix.toLowerCase()}.${MIME_TO_EXTENSION_MAPPINGS[p.fontMime]}`))
iconPackCss: iconPacks.map(p => iconPackService.generateCss(p, `${basePath}assets/icon-pack-${p.prefix.toLowerCase()}.${iconPackService.MIME_TO_EXTENSION_MAPPINGS[p.fontMime]}`))
.filter(Boolean)
.join("\n\n"),
iconPackSupportedPrefixes: iconPacks.map(p => p.prefix)
@@ -136,7 +136,7 @@ export function renderNoteContent(note: SNote) {
const customLogoId = note.getRelation("shareLogo")?.value;
const logoUrl = customLogoId ? `api/images/${customLogoId}/image.png` : `../${assetUrlFragment}/images/icon-color.svg`;
const iconPacks = getIconPacks().filter(p => p.builtin || !!shaca.notes[p.manifestNoteId]);
const iconPacks = iconPackService.getIconPacks().filter(p => p.builtin || !!shaca.notes[p.manifestNoteId]);
return renderNoteContentInternal(note, {
subRoot,
@@ -147,8 +147,8 @@ export function renderNoteContent(note: SNote) {
ancestors,
isStatic: false,
faviconUrl: note.hasRelation("shareFavicon") ? `api/notes/${note.getRelationValue("shareFavicon")}/download` : `../favicon.ico`,
iconPackCss: iconPacks.map(p => generateCss(p, p.builtin
? `/share/assets/fonts/${p.fontAttachmentId}.${MIME_TO_EXTENSION_MAPPINGS[p.fontMime]}`
iconPackCss: iconPacks.map(p => iconPackService.generateCss(p, p.builtin
? `/share/assets/fonts/${p.fontAttachmentId}.${iconPackService.MIME_TO_EXTENSION_MAPPINGS[p.fontMime]}`
: `/share/api/attachments/${p.fontAttachmentId}/download`
))
.filter(Boolean)

View File

@@ -13,6 +13,7 @@
"note_structure_description": "नोटों को पदानुक्रमिक रूप से व्यवस्थित किया जा सकता है। फ़ोल्डर्स की कोई आवश्यकता नहीं है, क्योंकि प्रत्येक नोट में उप-नोट हो सकते हैं। एक एकल नोट को पदानुक्रम में कई स्थानों पर जोड़ा जा सकता है।"
},
"productivity_benefits": {
"protected_notes_title": "संरक्षित नोट्स"
"protected_notes_title": "संरक्षित नोट्स",
"web_clipper_title": "वेब क्लिपर"
}
}

View File

@@ -10,13 +10,18 @@
"title": "Organiser tankene dine. Bygg din personlige kunnskapsbase.",
"github": "GitHub",
"get_started": "Kom i gang",
"dockerhub": "Docker Hub"
"dockerhub": "Docker Hub",
"screenshot_alt": "Screenshot fra Trilium Notes skrivebordsprogram",
"subtitle": "Trilium er en open-source-løsning for å ta notater og organisere en personlig kunnskapsbase. Kan brukes lokalt på arbeidsstasjonen din eller synkroniseres med en selv-hostet løsning for å ha dine notater med deg overalt."
},
"organization_benefits": {
"title": "Organisering",
"note_structure_title": "Notatstruktur",
"hoisting_title": "Arbeidsflate og fokusering",
"attributes_description": "Bruk relasjoner mellom notater eller legg til etiketter for enkel kategorisering. Bruk fremhevede attributter for å legge inn strukturert informasjon som kan brukes i tabeller og tavler."
"attributes_description": "Bruk relasjoner mellom notater eller legg til etiketter for enkel kategorisering. Bruk fremhevede attributter for å legge inn strukturert informasjon som kan brukes i tabeller og tavler.",
"note_structure_description": "Notater kan arrangeres herarkisk. Det trengs ikke mapper, siden alle notater kan inneholde undernotater. Ett notat kan legges inn flere steder i herarkiet.",
"attributes_title": "Notatetiketter og -relasjoner",
"hoisting_description": "Du kan enkelt skille personlige og arbeidsnotater ved å gruppere de under arbeidsrom, som fokuserer notat-treet ditt på kun ønskede notater."
},
"productivity_benefits": {
"sync_title": "Synkronisering",
@@ -26,7 +31,12 @@
"protected_notes_title": "Beskyttede notater",
"title": "Produktivitet og sikkerhet",
"sync_content": "Bruk en selv-hostet eller cloud-instans for å enkelt synkronisere notater på tvers av enheter, og ha de tilgjengelige fra din mobiltelefon ved hjelp av progressiv web-app.",
"jump_to_content": "Hopp raskt til notater eller grensesnittkommandoer over hele hierarkiet ved å søke etter tittel, med \"fuzzy\" matching for å ta hensyn til skrivefeil eller små differanser."
"jump_to_content": "Hopp raskt til notater eller grensesnittkommandoer over hele hierarkiet ved å søke etter tittel, med \"fuzzy\" matching for å ta hensyn til skrivefeil eller små differanser.",
"revisions_content": "Notater lagres periodisk i bakgrunnen og revisjonshistorikk kan brukes for tilbakeblikk eller å omgjøre uønskede endringer. Revisjoner kan også lages manuelt.",
"protected_notes_content": "Beskytt sensitiv personlig informasjon ved å kryptere notater og låse de med en passordkryptert sesjon.",
"jump_to_title": "Hurtigsøk og kommandoer",
"search_content": "Eller søk etter tekst i notatene og finjuster søket ved å filtrere på foreldrenotat eller dybde.",
"web_clipper_content": "Hent nettsider (eller screenshots) og legg de direkte i Trilium ved hjelp av web clipper nettleserutvidelse."
},
"note_types": {
"canvas_title": "Kanvas",
@@ -34,7 +44,14 @@
"text_title": "Tekstnotat",
"code_title": "Kodenotat",
"file_title": "Filnotat",
"mermaid_title": "Mermaid diagrammer"
"mermaid_title": "Mermaid diagrammer",
"title": "Flere måter å presentere informasjonen din",
"text_description": "Notatene redigeres med en visuell editor (WYSIWYG), som støtter tabeller, bilder, matematiske uttrykk og kodeblokker med syntaksutheving. Formater tekst hurtig med Markdown-inspirert syntaks eller \"slash-kommandoer\".",
"code_description": "Store samlinger med kildekode eller skript bruker en dedikert editor med syntaksfremheving for mange programmeringsspråk og med flere fargetema.",
"file_description": "Integrer multimediafiler som PDFer, bilder og video med forhåndsvisning i programmet.",
"mermaid_description": "Lag diagrammer som flytskjema, klasse- og sekvensdiagrammer, Ganttdiagrammer og mye mer ved hjelp av Mermaidsyntaks.",
"mindmap_description": "Organiser dine tanker visuelt eller gjør en brainstorming.",
"others_list": "og andre: <0>notatkart</0>, <1>relasjonskart</1>, <2>lagrede søk</2>, <3>rendret notat</3>, og <4>web view</4>."
},
"extensibility_benefits": {
"import_export_title": "Import/eksport",

View File

@@ -66,7 +66,7 @@
"jiti": "2.6.1",
"jsonc-eslint-parser": "2.4.2",
"react-refresh": "0.18.0",
"rollup-plugin-webpack-stats": "2.1.8",
"rollup-plugin-webpack-stats": "2.1.9",
"tslib": "2.8.1",
"tsx": "4.21.0",
"typescript": "~5.9.0",

View File

@@ -70,6 +70,7 @@
]
},
"dependencies": {
"@ckeditor/ckeditor5-icons": "47.3.0"
"@ckeditor/ckeditor5-icons": "47.3.0",
"mathlive": "0.108.2"
}
}

View File

@@ -1,6 +1,9 @@
import ckeditor from './../theme/icons/math.svg?raw';
import './augmentation.js';
import "../theme/mathform.css";
import 'mathlive';
import 'mathlive/fonts.css';
import 'mathlive/static.css';
export { default as Math } from './math.js';
export { default as MathUI } from './mathui.js';

View File

@@ -55,9 +55,9 @@ export default class MathUI extends Plugin {
this._balloon.showStack( 'main' );
requestAnimationFrame(() => {
this.formView?.mathInputView.fieldView.element?.focus();
});
requestAnimationFrame( () => {
this.formView?.mathInputView.focus();
} );
}
private _createFormView() {
@@ -71,31 +71,37 @@ export default class MathUI extends Plugin {
throw new CKEditorError( 'math-command' );
}
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const mathConfig = editor.config.get( 'math' )!;
const formView = new MainFormView(
editor.locale,
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
mathConfig.engine!,
mathConfig.lazyLoad,
{
engine: mathConfig.engine!,
lazyLoad: mathConfig.lazyLoad,
previewUid: this._previewUid,
previewClassName: mathConfig.previewClassName!,
katexRenderOptions: mathConfig.katexRenderOptions!
},
mathConfig.enablePreview,
this._previewUid,
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
mathConfig.previewClassName!,
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
mathConfig.popupClassName!,
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
mathConfig.katexRenderOptions!
mathConfig.popupClassName!
);
formView.mathInputView.bind( 'value' ).to( mathCommand, 'value' );
formView.displayButtonView.bind( 'isOn' ).to( mathCommand, 'display' );
// Form elements should be read-only when corresponding commands are disabled.
formView.mathInputView.bind( 'isReadOnly' ).to( mathCommand, 'isEnabled', value => !value );
formView.saveButtonView.bind( 'isEnabled' ).to( mathCommand );
formView.displayButtonView.bind( 'isEnabled' ).to( mathCommand );
formView.mathInputView.bind( 'isReadOnly' ).to( mathCommand, 'isEnabled', ( value: boolean ) => !value );
formView.saveButtonView.bind( 'isEnabled' ).to(
mathCommand,
'isEnabled',
formView.mathInputView,
'value',
( commandEnabled, equation ) => {
const normalizedEquation = ( equation ?? '' ).trim();
return commandEnabled && normalizedEquation.length > 0;
}
);
formView.displayButtonView.bind( 'isEnabled' ).to( mathCommand, 'isEnabled' );
// Listen to submit button click
this.listenTo( formView, 'submit', () => {
@@ -115,24 +121,12 @@ export default class MathUI extends Plugin {
} );
// Allow pressing Enter to submit changes, and use Shift+Enter to insert a new line
formView.keystrokes.set('enter', (data, cancel) => {
if (!data.shiftKey) {
formView.fire('submit');
formView.keystrokes.set( 'enter', ( data, cancel ) => {
if ( !data.shiftKey ) {
formView.fire( 'submit' );
cancel();
}
});
// Allow the textarea to be resizable
formView.mathInputView.fieldView.once('render', () => {
const textarea = formView.mathInputView.fieldView.element;
if (!textarea) return;
Object.assign(textarea.style, {
resize: 'both',
height: '100px',
width: '400px',
minWidth: '100%',
});
});
} );
return formView;
}
@@ -162,14 +156,12 @@ export default class MathUI extends Plugin {
} );
if ( this._balloon.visibleView === this.formView ) {
this.formView.mathInputView.fieldView.element?.select();
this.formView.mathInputView.focus();
}
// Show preview element
const previewEl = document.getElementById( this._previewUid );
if ( previewEl && this.formView.previewEnabled ) {
// Force refresh preview
this.formView.mathView?.updateMath();
if ( previewEl && this.formView.mathView ) {
this.formView.mathView.updateMath();
}
this.formView.equation = mathCommand.value ?? '';
@@ -206,8 +198,10 @@ export default class MathUI extends Plugin {
private _removeFormView() {
if ( this._isFormInPanel && this.formView ) {
this.formView.saveButtonView.focus();
// Hide virtual keyboard before removing the form
this.formView.hideKeyboard();
this.formView.saveButtonView.focus();
this._balloon.remove( this.formView );
// Hide preview element

View File

@@ -1,91 +1,59 @@
import { ButtonView, createLabeledTextarea, FocusCycler, LabelView, LabeledFieldView, submitHandler, SwitchButtonView, View, ViewCollection, type TextareaView, type FocusableView, Locale, FocusTracker, KeystrokeHandler } from 'ckeditor5';
import { ButtonView, FocusCycler, FocusTracker, KeystrokeHandler, LabelView, submitHandler, SwitchButtonView, View, ViewCollection, type FocusableView, type Locale } from 'ckeditor5';
import IconCheck from "@ckeditor/ckeditor5-icons/theme/icons/check.svg?raw";
import IconCancel from "@ckeditor/ckeditor5-icons/theme/icons/cancel.svg?raw";
import { extractDelimiters, hasDelimiters } from '../utils.js';
import MathView from './mathview.js';
import MathView, { type MathViewOptions } from './mathview.js';
import MathInputView from './mathinputview.js';
import '../../theme/mathform.css';
import type { KatexOptions } from '../typings-external.js';
class MathInputView extends LabeledFieldView<TextareaView> {
public value: null | string = null;
public isReadOnly = false;
constructor( locale: Locale ) {
super( locale, createLabeledTextarea );
}
}
export default class MainFormView extends View {
public saveButtonView: ButtonView;
public mathInputView: MathInputView;
public displayButtonView: SwitchButtonView;
public cancelButtonView: ButtonView;
public previewEnabled: boolean;
public previewLabel?: LabelView;
public displayButtonView: SwitchButtonView;
public mathInputView: MathInputView;
public mathView?: MathView;
public override locale: Locale = new Locale();
public lazyLoad: undefined | ( () => Promise<void> );
public focusTracker = new FocusTracker();
public keystrokes = new KeystrokeHandler();
private _focusables = new ViewCollection<FocusableView>();
private _focusCycler: FocusCycler;
constructor(
locale: Locale,
engine:
| 'mathjax'
| 'katex'
| ( (
equation: string,
element: HTMLElement,
display: boolean,
) => void ),
lazyLoad: undefined | ( () => Promise<void> ),
mathViewOptions: MathViewOptions,
previewEnabled = false,
previewUid: string,
previewClassName: Array<string>,
popupClassName: Array<string>,
katexRenderOptions: KatexOptions
popupClassName: Array<string> = []
) {
super( locale );
const t = locale.t;
// Submit button
this.saveButtonView = this._createButton( t( 'Save' ), IconCheck, 'ck-button-save', null );
this.saveButtonView.type = 'submit';
// Create views
this.mathInputView = new MathInputView( locale );
this.saveButtonView = this._createButton( t( 'Save' ), IconCheck, 'ck-button-save', 'submit' );
this.cancelButtonView = this._createButton( t( 'Cancel' ), IconCancel, 'ck-button-cancel' );
this.cancelButtonView.delegate( 'execute' ).to( this, 'cancel' );
this.displayButtonView = this._createDisplayButton( t );
// Equation input
this.mathInputView = this._createMathInput();
// Build children
// Display button
this.displayButtonView = this._createDisplayButton();
const children: Array<View> = [
this.mathInputView,
this.displayButtonView
];
// Cancel button
this.cancelButtonView = this._createButton( t( 'Cancel' ), IconCancel, 'ck-button-cancel', 'cancel' );
if ( previewEnabled ) {
const previewLabel = new LabelView( locale );
previewLabel.text = t( 'Equation preview' );
this.previewEnabled = previewEnabled;
let children = [];
if ( this.previewEnabled ) {
// Preview label
this.previewLabel = new LabelView( locale );
this.previewLabel.text = t( 'Equation preview' );
// Math element
this.mathView = new MathView( engine, lazyLoad, locale, previewUid, previewClassName, katexRenderOptions );
this.mathView = new MathView( locale, mathViewOptions );
this.mathView.bind( 'display' ).to( this.displayButtonView, 'isOn' );
children = [
this.mathInputView,
this.displayButtonView,
this.previewLabel,
this.mathView
];
} else {
children = [
this.mathInputView,
this.displayButtonView
];
children.push( previewLabel, this.mathView );
}
// Add UI elements to template
this._setupSync( previewEnabled );
this.setTemplate( {
tag: 'form',
attributes: {
@@ -107,10 +75,30 @@ export default class MainFormView extends View {
},
children
},
this.saveButtonView,
this.cancelButtonView
{
tag: 'div',
attributes: {
class: [
'ck-math-button-row'
]
},
children: [
this.saveButtonView,
this.cancelButtonView
]
}
]
} );
this._focusCycler = new FocusCycler( {
focusables: this._focusables,
focusTracker: this.focusTracker,
keystrokeHandler: this.keystrokes,
actions: {
focusPrevious: 'shift + tab',
focusNext: 'tab'
}
} );
}
public override render(): void {
@@ -121,103 +109,73 @@ export default class MainFormView extends View {
view: this
} );
// Register form elements to focusable elements
const childViews = [
this.mathInputView,
const focusableViews = [
this.mathInputView.latexTextAreaView,
this.displayButtonView,
this.saveButtonView,
this.cancelButtonView
];
childViews.forEach( v => {
focusableViews.forEach( v => {
this._focusables.add( v );
if ( v.element ) {
this._focusables.add( v );
this.focusTracker.add( v.element );
}
} );
// Listen to keypresses inside form element
this.mathInputView.on( 'mathfieldReady', () => {
const mathfieldView = this.mathInputView.mathFieldFocusableView;
if ( mathfieldView.element ) {
if ( this._focusables.has( mathfieldView ) ) {
this._focusables.remove( mathfieldView );
}
this._focusables.add( mathfieldView, 0 );
this.focusTracker.add( mathfieldView.element );
}
} );
if ( this.element ) {
this.keystrokes.listenTo( this.element );
}
}
public get equation(): string {
return this.mathInputView.value ?? '';
}
public set equation( equation: string ) {
const norm = equation.trim();
this.mathInputView.value = norm.length ? norm : null;
if ( this.mathView ) {
this.mathView.value = norm;
}
}
public focus(): void {
this._focusCycler.focusFirst();
}
public get equation(): string {
return this.mathInputView.fieldView.element?.value ?? '';
}
private _setupSync( previewEnabled: boolean ): void {
this.mathInputView.on( 'change:value', () => {
let eq = ( this.mathInputView.value ?? '' ).trim();
public set equation( equation: string ) {
if ( this.mathInputView.fieldView.element ) {
this.mathInputView.fieldView.element.value = equation;
}
if ( this.previewEnabled && this.mathView ) {
this.mathView.value = equation;
}
}
if ( hasDelimiters( eq ) ) {
const params = extractDelimiters( eq );
eq = params.equation;
this.displayButtonView.isOn = params.display;
public focusTracker: FocusTracker = new FocusTracker();
public keystrokes: KeystrokeHandler = new KeystrokeHandler();
private _focusables = new ViewCollection<FocusableView>();
private _focusCycler: FocusCycler = new FocusCycler( {
focusables: this._focusables,
focusTracker: this.focusTracker,
keystrokeHandler: this.keystrokes,
actions: {
focusPrevious: 'shift + tab',
focusNext: 'tab'
}
} );
private _createMathInput() {
const t = this.locale.t;
// Create equation input
const mathInput = new MathInputView( this.locale );
const fieldView = mathInput.fieldView;
mathInput.infoText = t( 'Insert equation in TeX format.' );
const onInput = () => {
if ( fieldView.element != null ) {
let equationInput = fieldView.element.value.trim();
// If input has delimiters
if ( hasDelimiters( equationInput ) ) {
// Get equation without delimiters
const params = extractDelimiters( equationInput );
// Remove delimiters from input field
fieldView.element.value = params.equation;
equationInput = params.equation;
// update display button and preview
this.displayButtonView.isOn = params.display;
if ( this.mathInputView.value !== eq ) {
this.mathInputView.value = eq.length ? eq : null;
}
if ( this.previewEnabled && this.mathView ) {
// Update preview view
this.mathView.value = equationInput;
}
this.saveButtonView.isEnabled = !!equationInput;
}
};
fieldView.on( 'render', onInput );
fieldView.on( 'input', onInput );
return mathInput;
if ( previewEnabled && this.mathView && this.mathView.value !== eq ) {
this.mathView.value = eq;
}
} );
}
private _createButton(
label: string,
icon: string,
className: string,
eventName: string | null
) {
private _createButton( label: string, icon: string, className: string, type?: 'submit' | 'button' ): ButtonView {
const button = new ButtonView( this.locale );
button.set( {
@@ -232,16 +190,14 @@ export default class MainFormView extends View {
}
} );
if ( eventName ) {
button.delegate( 'execute' ).to( this, eventName );
if ( type ) {
button.type = type;
}
return button;
}
private _createDisplayButton() {
const t = this.locale.t;
private _createDisplayButton( t: ( str: string ) => string ): SwitchButtonView {
const switchButton = new SwitchButtonView( this.locale );
switchButton.set( {
@@ -256,15 +212,13 @@ export default class MainFormView extends View {
} );
switchButton.on( 'execute', () => {
// Toggle state
switchButton.isOn = !switchButton.isOn;
if ( this.previewEnabled && this.mathView ) {
// Update preview view
this.mathView.display = switchButton.isOn;
}
} );
return switchButton;
}
public hideKeyboard(): void {
this.mathInputView.hideKeyboard();
}
}

View File

@@ -0,0 +1,268 @@
// Math input widget: wraps a MathLive <math-field> and a LaTeX textarea
// and keeps them in sync for the CKEditor 5 math dialog.
import { View, type Locale, type FocusableView } from 'ckeditor5';
import 'mathlive/fonts.css'; // Auto-bundles offline fonts
declare global {
interface Window {
mathVirtualKeyboard?: {
visible: boolean;
show: () => void;
hide: () => void;
addEventListener: ( event: string, cb: () => void ) => void;
removeEventListener: ( event: string, cb: () => void ) => void;
};
}
}
interface MathFieldElement extends HTMLElement {
value: string;
readOnly: boolean;
mathVirtualKeyboardPolicy: string;
inlineShortcuts?: Record<string, string>;
setValue?: ( value: string, options?: { silenceNotifications?: boolean } ) => void;
}
// Wrapper for the MathLive element to make it focusable in CKEditor's UI system
export class MathFieldFocusableView extends View implements FocusableView {
public declare element: HTMLElement | null;
private _view: MathInputView;
constructor( locale: Locale, view: MathInputView ) {
super( locale );
this._view = view;
}
public focus(): void {
this._view.mathfield?.focus();
}
public setElement( el: HTMLElement ): void {
this.element = el;
}
}
// Wrapper for the LaTeX textarea to make it focusable in CKEditor's UI system
export class LatexTextAreaView extends View implements FocusableView {
declare public element: HTMLTextAreaElement;
constructor( locale: Locale ) {
super( locale );
this.setTemplate( { tag: 'textarea', attributes: {
class: [ 'ck', 'ck-textarea', 'ck-latex-textarea' ], spellcheck: 'false', tabindex: 0
} } );
}
public focus(): void {
this.element?.focus();
}
}
// Main view class for the math input
export default class MathInputView extends View {
public declare value: string | null;
public declare isReadOnly: boolean;
public mathfield: MathFieldElement | null = null;
public readonly latexTextAreaView: LatexTextAreaView;
public readonly mathFieldFocusableView: MathFieldFocusableView;
private _destroyed = false;
private _vkGeometryHandler?: () => void;
private _updating = false;
private static _configured = false;
constructor( locale: Locale ) {
super( locale );
this.latexTextAreaView = new LatexTextAreaView( locale );
this.mathFieldFocusableView = new MathFieldFocusableView( locale, this );
this.set( 'value', null );
this.set( 'isReadOnly', false );
this.setTemplate( {
tag: 'div', attributes: { class: [ 'ck', 'ck-math-input' ] },
children: [
{ tag: 'div', attributes: { class: [ 'ck-mathlive-container' ] } },
{ tag: 'label', attributes: { class: [ 'ck-latex-label' ] }, children: [ locale.t( 'LaTeX' ) ] },
{ tag: 'div', attributes: { class: [ 'ck-latex-wrapper' ] }, children: [ this.latexTextAreaView ] }
]
} );
}
public override render(): void {
super.render();
const textarea = this.latexTextAreaView.element;
// Sync changes from the LaTeX textarea to the mathfield and model
this.listenTo( textarea, 'input', () => {
if ( this._updating ) {
return;
}
this._updating = true;
const val = textarea.value;
this.value = val || null;
if ( this.mathfield ) {
if ( val === '' ) {
this.mathfield.remove();
this.mathfield = null;
this._initMathField( false );
} else if ( this.mathfield.value.trim() !== val.trim() ) {
this._setMathfieldValue( val );
}
}
this._updating = false;
} );
// Sync changes from the model (this.value) to the UI elements
this.on( 'change:value', ( _e, _n, val ) => {
if ( this._updating ) {
return;
}
this._updating = true;
const newVal = val ?? '';
if ( textarea.value !== newVal ) {
textarea.value = newVal;
}
if ( this.mathfield ) {
if ( this.mathfield.value.trim() !== newVal.trim() ) {
this._setMathfieldValue( newVal );
}
} else if ( newVal !== '' ) {
this._initMathField( false );
}
this._updating = false;
} );
// Handle read-only state changes
this.on( 'change:isReadOnly', ( _e, _n, val ) => {
textarea.readOnly = val;
if ( this.mathfield ) {
this.mathfield.readOnly = val;
}
} );
// Handle virtual keyboard geometry changes
const vk = window.mathVirtualKeyboard;
if ( vk && !this._vkGeometryHandler ) {
this._vkGeometryHandler = () => {
if ( vk.visible && this.mathfield ) {
this.mathfield.focus();
}
};
vk.addEventListener( 'geometrychange', this._vkGeometryHandler );
}
const initial = this.value ?? '';
if ( textarea.value !== initial ) {
textarea.value = initial;
}
this._loadMathLive();
}
// Loads the MathLive library dynamically
private async _loadMathLive(): Promise<void> {
try {
await import( 'mathlive' );
await customElements.whenDefined( 'math-field' );
if ( this._destroyed ) {
return;
}
if ( !MathInputView._configured ) {
const MathfieldClass = customElements.get( 'math-field' ) as any;
if ( MathfieldClass ) {
MathfieldClass.soundsDirectory = null;
MathfieldClass.plonkSound = null;
MathInputView._configured = true;
}
}
if ( this.element && !this._destroyed ) {
this._initMathField( true );
}
} catch {
const c = this.element?.querySelector( '.ck-mathlive-container' );
if ( c ) {
c.textContent = 'Math editor unavailable';
}
}
}
// Initializes the <math-field> element
private _initMathField( shouldFocus: boolean ): void {
const container = this.element?.querySelector( '.ck-mathlive-container' );
if ( !container ) {
return;
}
if ( this.mathfield ) {
this._setMathfieldValue( this.value ?? '' );
return;
}
const mf = document.createElement( 'math-field' ) as MathFieldElement;
mf.mathVirtualKeyboardPolicy = 'auto';
mf.setAttribute( 'tabindex', '0' );
mf.value = this.value ?? '';
mf.readOnly = this.isReadOnly;
container.appendChild( mf );
// Set shortcuts after mounting (accessing inlineShortcuts requires mounted element)
try {
if ( mf.inlineShortcuts ) {
mf.inlineShortcuts = { ...mf.inlineShortcuts, dx: 'dx', dy: 'dy', dt: 'dt' };
}
} catch {
// Inline shortcut configuration is optional; ignore failures to avoid breaking the math field.
}
mf.addEventListener( 'keydown', ev => {
if ( ev.key === 'Tab' ) {
if ( ev.shiftKey ) {
ev.preventDefault();
} else {
ev.preventDefault();
ev.stopImmediatePropagation();
this.latexTextAreaView.focus();
}
}
}, { capture: true } );
mf.addEventListener( 'input', () => {
if ( this._updating ) {
return;
}
this._updating = true;
const textarea = this.latexTextAreaView.element;
if ( textarea.value.trim() !== mf.value.trim() ) {
textarea.value = mf.value;
}
this.value = mf.value || null;
this._updating = false;
} );
this.mathfield = mf;
this.mathFieldFocusableView.setElement( mf );
this.fire( 'mathfieldReady' );
if ( shouldFocus ) {
requestAnimationFrame( () => mf.focus() );
}
}
// Updates the mathfield value without triggering loops
private _setMathfieldValue( value: string ): void {
if ( !this.mathfield ) {
return;
}
if ( this.mathfield.setValue ) {
this.mathfield.setValue( value, { silenceNotifications: true } );
} else {
this.mathfield.value = value;
}
}
public hideKeyboard(): void {
window.mathVirtualKeyboard?.hide();
}
public focus(): void {
this.mathfield?.focus();
}
public override destroy(): void {
this._destroyed = true;
const vk = window.mathVirtualKeyboard;
if ( vk && this._vkGeometryHandler ) {
vk.removeEventListener( 'geometrychange', this._vkGeometryHandler );
this._vkGeometryHandler = undefined;
}
this.hideKeyboard();
this.mathfield?.remove();
this.mathfield = null;
super.destroy();
}
}

View File

@@ -2,44 +2,44 @@ import { View, type Locale } from 'ckeditor5';
import type { KatexOptions } from '../typings-external.js';
import { renderEquation } from '../utils.js';
/**
* Configuration options for the MathView.
*/
export interface MathViewOptions {
engine: 'mathjax' | 'katex' | ( ( equation: string, element: HTMLElement, display: boolean ) => void );
lazyLoad: undefined | ( () => Promise<void> );
previewUid: string;
previewClassName: Array<string>;
katexRenderOptions: KatexOptions;
}
export default class MathView extends View {
/**
* The LaTeX equation value to render.
* @observable
*/
public declare value: string;
/**
* Whether to render in display mode (centered) or inline.
* @observable
*/
public declare display: boolean;
public previewUid: string;
public previewClassName: Array<string>;
public katexRenderOptions: KatexOptions;
public engine:
| 'mathjax'
| 'katex'
| ( ( equation: string, element: HTMLElement, display: boolean ) => void );
public lazyLoad: undefined | ( () => Promise<void> );
constructor(
engine:
| 'mathjax'
| 'katex'
| ( (
equation: string,
element: HTMLElement,
display: boolean,
) => void ),
lazyLoad: undefined | ( () => Promise<void> ),
locale: Locale,
previewUid: string,
previewClassName: Array<string>,
katexRenderOptions: KatexOptions
) {
/**
* Configuration options passed during initialization.
*/
private options: MathViewOptions;
constructor( locale: Locale, options: MathViewOptions ) {
super( locale );
this.engine = engine;
this.lazyLoad = lazyLoad;
this.previewUid = previewUid;
this.katexRenderOptions = katexRenderOptions;
this.previewClassName = previewClassName;
this.options = options;
this.set( 'value', '' );
this.set( 'display', false );
// Update rendering when state changes.
// Checking isRendered prevents errors during initialization.
this.on( 'change', () => {
if ( this.isRendered ) {
this.updateMath();
@@ -55,19 +55,39 @@ export default class MathView extends View {
}
public updateMath(): void {
if ( this.element ) {
void renderEquation(
this.value,
this.element,
this.engine,
this.lazyLoad,
this.display,
true,
this.previewUid,
this.previewClassName,
this.katexRenderOptions
);
if ( !this.element ) {
return;
}
// Handle empty equations
if ( !this.value || !this.value.trim() ) {
this.element.textContent = '';
this.element.classList.remove( 'ck-math-render-error' );
return;
}
// Clear previous render
this.element.textContent = '';
this.element.classList.remove( 'ck-math-render-error' );
renderEquation(
this.value,
this.element,
this.options.engine,
this.options.lazyLoad,
this.display,
true, // isPreview
this.options.previewUid,
this.options.previewClassName,
this.options.katexRenderOptions
).catch( error => {
console.error( 'Math rendering failed:', error );
if ( this.element ) {
this.element.textContent = 'Error rendering equation';
this.element.classList.add( 'ck-math-render-error' );
}
} );
}
public override render(): void {

View File

@@ -3,6 +3,20 @@ import Math from '../src/math';
import AutoformatMath from '../src/autoformatmath';
import { describe, it, expect } from 'vitest';
// Suppress MathLive errors during async cleanup in tests
if (typeof window !== 'undefined') {
window.addEventListener('unhandledrejection', event => {
if (event.reason?.message?.includes('options') || event.reason?.message?.includes('mathlive')) {
event.preventDefault();
}
});
window.addEventListener('error', event => {
if (event.message?.includes('options') || event.message?.includes('mathlive')) {
event.preventDefault();
}
});
}
describe( 'CKEditor5 Math DLL', () => {
it( 'exports Math', () => {
expect( MathDll ).to.equal( Math );

View File

@@ -2,6 +2,20 @@ import { ClassicEditor, type EditorConfig } from 'ckeditor5';
import MathUI from '../src/mathui';
import { describe, beforeEach, it, afterEach, expect } from "vitest";
// Suppress MathLive errors during async cleanup
if (typeof window !== 'undefined') {
window.addEventListener('unhandledrejection', event => {
if (event.reason?.message?.includes('options') || event.reason?.message?.includes('mathlive')) {
event.preventDefault();
}
});
window.addEventListener('error', event => {
if (event.message?.includes('options') || event.message?.includes('mathlive')) {
event.preventDefault();
}
});
}
describe( 'Lazy load', () => {
let editorElement: HTMLDivElement;
let editor: ClassicEditor;
@@ -24,11 +38,14 @@ describe( 'Lazy load', () => {
beforeEach( () => {
editorElement = document.createElement( 'div' );
document.body.appendChild( editorElement );
lazyLoadInvoked = false;
} );
afterEach( () => {
afterEach( async () => {
if ( mathUIFeature?.formView ) {
mathUIFeature._hideUI();
}
await new Promise( resolve => setTimeout( resolve, 50 ) );
editorElement.remove();
return editor.destroy();
} );
@@ -37,6 +54,7 @@ describe( 'Lazy load', () => {
await buildEditor( {
math: {
engine: 'katex',
enablePreview: true,
lazyLoad: async () => {
lazyLoadInvoked = true;
}
@@ -44,6 +62,15 @@ describe( 'Lazy load', () => {
} );
mathUIFeature._showUI();
// Trigger render with a non-empty value to bypass empty check optimization
if ( mathUIFeature.formView ) {
mathUIFeature.formView.equation = 'x^2';
}
// Wait for async rendering and lazy loading
await new Promise( resolve => setTimeout( resolve, 100 ) );
expect( lazyLoadInvoked ).to.be.true;
} );
} );

View File

@@ -410,7 +410,7 @@ describe( 'MathUI', () => {
it( 'should bind mainFormView.mathInputView#value to math command value', () => {
const command = editor.commands.get( 'math' );
expect( formView!.mathInputView.value ).to.null;
expect( formView!.mathInputView.value ).to.be.null;
command!.value = 'x^2';
expect( formView!.mathInputView.value ).to.equal( 'x^2' );
@@ -419,10 +419,18 @@ describe( 'MathUI', () => {
it( 'should execute math command on mainFormView#submit event', () => {
const executeSpy = vi.spyOn( editor, 'execute' );
formView!.mathInputView.fieldView.element!.value = 'x^2';
formView!.mathInputView.value = 'x^2';
formView!.fire( 'submit' );
expect(executeSpy.mock.lastCall?.slice(0, 2)).toMatchObject(['math', 'x^2']);
expect( executeSpy.mock.lastCall?.slice( 0, 2 ) ).toMatchObject( [ 'math', 'x^2' ] );
} );
it( 'should update equation value when mathInputView changes', () => {
formView!.mathInputView.value = 'x^2';
expect( formView!.equation ).to.equal( 'x^2' );
formView!.mathInputView.value = '\\frac{1}{2}';
expect( formView!.equation ).to.equal( '\\frac{1}{2}' );
} );
it( 'should hide the balloon on mainFormView#cancel if math command does not have a value', () => {

View File

@@ -1,35 +1,220 @@
/**
* Math Equation Editor Dialog Styles - Compact & Readable
*/
/* === Z-INDEX: MathLive UI above CKEditor === */
.ML__keyboard, .ML__popover, .ML__menu, .ML__suggestions, .ML__autocomplete,
.ML__tooltip, .ML__sr-only, [data-ml-root], #mathlive-suggestion-popover,
.mathlive-suggestions-popover, [data-ml-tooltip], .ML__base {
z-index: calc(var(--ck-z-panel) + 1000) !important;
}
.ML__tooltip, [role="tooltip"], .ML__popover[role="tooltip"], .popover, [data-ml-tooltip] {
z-index: calc(var(--ck-z-panel) + 2000) !important;
position: fixed !important;
}
.ck.ck-balloon-panel, .ck.ck-balloon-panel .ck-balloon-panel__content {
overflow: visible !important;
}
/* === MAIN DIALOG === */
.ck.ck-math-form {
display: flex;
align-items: flex-start;
flex-direction: row;
flex-wrap: nowrap;
padding: var(--ck-spacing-standard);
@media screen and (max-width: 600px) {
flex-wrap: wrap;
& .ck-math-view {
flex-basis: 100%;
& .ck-labeled-view {
flex-basis: 100%;
}
& .ck-label {
flex-basis: 100%;
}
}
& .ck-button {
flex-basis: 50%;
}
}
display: flex;
flex-direction: column;
padding: var(--ck-spacing-standard);
box-sizing: border-box;
max-width: 80vw;
max-height: 80vh;
overflow: visible;
user-select: text;
}
.ck-math-tex.ck-placeholder::before {
display: none !important;
/* Scrollable content - vertical scroll, horizontal visible for tooltips */
.ck-math-view {
overflow-y: auto;
overflow-x: visible;
display: flex;
flex-direction: column;
flex: 1 1 auto;
gap: var(--ck-spacing-standard);
min-height: 0;
width: 100%;
}
.ck.ck-toolbar-container {
z-index: calc(var(--ck-z-panel) + 2);
/* === MATH INPUT === */
.ck.ck-math-input {
display: flex;
flex-direction: column;
gap: var(--ck-spacing-standard);
width: fit-content;
min-width: 100%;
max-width: 100%;
flex: 1 1 auto;
min-height: 0;
overflow: visible !important;
}
/* === MATHLIVE EDITOR === */
.ck.ck-math-input .ck-mathlive-container {
position: relative;
width: 100%;
min-height: 50px;
padding: var(--ck-spacing-small);
border: 1px solid var(--ck-color-input-border);
border-radius: var(--ck-border-radius);
background: var(--ck-color-input-background) !important;
transition: border-color 120ms ease;
overflow: visible !important;
clip-path: none !important;
}
.ck.ck-math-input .ck-mathlive-container:focus-within {
border-color: var(--ck-color-focus-border);
}
/* Position keyboard & menu buttons */
.ck-mathlive-container math-field::part(virtual-keyboard-toggle),
.ck-mathlive-container math-field::part(menu-toggle) {
position: absolute;
top: 8px;
}
.ck-mathlive-container math-field::part(virtual-keyboard-toggle) { right: 40px; }
.ck-mathlive-container math-field::part(menu-toggle) {
right: 8px;
display: flex !important;
visibility: visible !important;
}
/* Math field element */
.ck.ck-math-form math-field {
display: block !important;
width: 100%;
font-size: 1.5em;
background: transparent !important;
color: var(--ck-color-input-text);
border: none !important;
padding: 0;
outline: none !important;
--selection-background-color: rgba(33, 150, 243, 0.2);
--selection-color: inherit;
--contains-highlight-background-color: rgba(0, 0, 0, 0.05);
}
/* === LATEX TEXTAREA === */
.ck.ck-math-input .ck-latex-wrapper {
display: flex;
flex-direction: column;
width: fit-content;
min-width: 100%;
max-width: 100%;
padding: var(--ck-spacing-small);
border: 1px solid var(--ck-color-input-border);
border-radius: var(--ck-border-radius);
background: var(--ck-color-input-background) !important;
transition: border-color 120ms ease;
box-sizing: border-box;
}
.ck.ck-math-input .ck-latex-wrapper:focus-within {
border-color: var(--ck-color-focus-border);
}
.ck.ck-math-input .ck-latex-label {
font-size: 12px;
font-weight: 600;
color: var(--ck-color-text);
opacity: 0.8;
margin: 0 0 var(--ck-spacing-small) 0;
flex-shrink: 0;
}
.ck.ck-math-input .ck-latex-textarea {
width: fit-content;
min-width: 100%;
max-width: 100%;
min-height: 60px;
max-height: calc(80vh - 300px);
resize: both;
overflow: auto;
font-family: 'Courier New', monospace;
font-size: 0.95em;
background: transparent !important;
color: var(--ck-color-input-text);
border: none !important;
padding: 0;
outline: none !important;
box-sizing: border-box;
}
/* === DISPLAY TOGGLE === */
.ck-button-display-toggle {
align-self: flex-start;
padding: var(--ck-spacing-small) var(--ck-spacing-standard);
background: var(--ck-color-input-background);
color: var(--ck-color-text);
border: 1px solid var(--ck-color-input-border);
border-radius: var(--ck-border-radius);
cursor: pointer;
transition: all 0.2s ease;
}
.ck-button-display-toggle:hover { background: var(--ck-color-focus-border); }
/* === PREVIEW === */
.ck-math-preview,
.ck.ck-math-preview {
width: 100%;
min-height: 40px;
max-height: none !important;
height: auto !important;
padding: var(--ck-spacing-small);
background: transparent !important;
border: none !important;
display: block;
text-align: left;
overflow-x: auto !important;
overflow-y: visible !important;
flex-shrink: 0;
}
/* Center equation when in display mode */
.ck-math-preview[data-display="true"],
.ck.ck-math-preview[data-display="true"] {
text-align: center;
}
.ck-math-preview.ck-error, .ck-math-render-error {
border-color: var(--ck-color-error-text);
background: var(--ck-color-base-background);
color: var(--ck-color-error-text);
}
/* === BUTTONS === */
.ck-math-button-row {
display: flex;
gap: var(--ck-spacing-standard);
justify-content: flex-end;
margin-top: var(--ck-spacing-standard);
}
.ck-button-save, .ck-button-cancel {
padding: var(--ck-spacing-small) var(--ck-spacing-standard);
border: 1px solid var(--ck-color-input-border);
border-radius: var(--ck-border-radius);
cursor: pointer;
font-weight: 500;
}
.ck-button-save {
background: var(--ck-color-focus-border);
color: white;
}
.ck-button-cancel {
background: var(--ck-color-input-background);
color: var(--ck-color-text);
}
.ck-button-save:hover { opacity: 0.9; }
.ck-button-cancel:hover { background: var(--ck-color-base-background); }
/* === OVERFLOW FIX: Allow tooltips to escape === */
.ck.ck-balloon-panel,
.ck.ck-balloon-panel .ck-balloon-panel__content,
.ck.ck-math-form,
.ck-math-view,
.ck.ck-math-input,
.ck.ck-math-input .ck-mathlive-container {
overflow: visible !important;
clip-path: none !important;
}

View File

@@ -22,6 +22,9 @@ export default defineConfig( {
include: [
'tests/**/*.[jt]s'
],
exclude: [
'tests/setup.ts'
],
globals: true,
watch: false,
coverage: {

View File

@@ -50,6 +50,11 @@ const LOCALE_MAPPINGS: Record<DISPLAYABLE_LOCALE_IDS, LocaleMapping | null> = {
coreTranslation: () => import("ckeditor5/translations/ja.js"),
premiumFeaturesTranslation: () => import("ckeditor5-premium-features/translations/ja.js"),
},
pl: {
languageCode: "pl",
coreTranslation: () => import("ckeditor5/translations/pl.js"),
premiumFeaturesTranslation: () => import("ckeditor5-premium-features/translations/pl.js"),
},
pt: {
languageCode: "pt",
coreTranslation: () => import("ckeditor5/translations/pt.js"),

View File

@@ -3,6 +3,7 @@ import { CryptoProvider, initCrypto } from "./services/encryption/crypto";
import { getLog, initLog } from "./services/log";
import { initSql } from "./services/sql/index";
import { SqlService, SqlServiceParams } from "./services/sql/sql";
import { initMessaging, MessagingProvider } from "./services/messaging/index";
export type * from "./services/sql/types";
export * from "./services/sql/index";
@@ -20,6 +21,7 @@ export { default as app_info } from "./services/app_info";
export { default as keyboard_actions } from "./services/keyboard_actions";
export { default as entity_changes } from "./services/entity_changes";
export { default as hidden_subtree } from "./services/hidden_subtree";
export * as icon_packs from "./services/icon_packs";
export { getContext, type ExecutionContext } from "./services/context";
export * as cls from "./services/context";
export * from "./errors";
@@ -33,6 +35,10 @@ export { default as TaskContext } from "./services/task_context";
export { default as revisions } from "./services/revisions";
export { default as erase } from "./services/erase";
// Messaging system
export * from "./services/messaging/index";
export type { MessagingProvider, ServerMessagingProvider, MessageClient, MessageHandler } from "./services/messaging/types";
export { default as becca } from "./becca/becca";
export { default as becca_loader } from "./becca/becca_loader";
export { default as becca_service } from "./becca/becca_service";
@@ -52,16 +58,22 @@ export { default as Becca } from "./becca/becca-interface";
export type { NotePojo } from "./becca/becca-interface";
export { default as NoteSet } from "./services/search/note_set";
export { default as note_service, NoteParams } from "./services/notes";
export { default as note_service } from "./services/notes";
export type { NoteParams } from "./services/notes";
export * as sanitize from "./services/sanitizer";
export * as routes from "./routes";
export function initializeCore({ dbConfig, executionContext, crypto }: {
export function initializeCore({ dbConfig, executionContext, crypto, messaging }: {
dbConfig: SqlServiceParams,
executionContext: ExecutionContext,
crypto: CryptoProvider
crypto: CryptoProvider,
messaging?: MessagingProvider
}) {
initLog();
initCrypto(crypto);
initSql(new SqlService(dbConfig, getLog()));
initContext(executionContext);
if (messaging) {
initMessaging(messaging);
}
};

View File

@@ -1,5 +1,6 @@
import { ConvertAttachmentToNoteResponse } from "@triliumnext/commons";
import { blob as blobService, ValidationError } from "@triliumnext/core";
import blobService from "../../services/blob";
import { ValidationError } from "../../errors";
import type { Request } from "express";
import becca from "../../becca/becca.js";

View File

@@ -1,10 +1,10 @@
"use strict";
import keyboardActions from "../../services/keyboard_actions.js";
import becca from "../../becca/becca.js";
import becca from "../../becca/becca";
import keyboard_actions from "../../services/keyboard_actions";
function getKeyboardActions() {
return keyboardActions.getKeyboardActions();
return keyboard_actions.getKeyboardActions();
}
function getShortcutsForNotes() {

View File

@@ -0,0 +1,28 @@
import type { Request } from "express";
import BAttribute from "../../becca/entities/battribute";
import BNote from "../../becca/entities/bnote";
import becca from "../../becca/becca";
import type { BacklinkCountResponse } from "@triliumnext/commons";
function getFilteredBacklinks(note: BNote): BAttribute[] {
return (
note
.getTargetRelations()
// search notes have "ancestor" relations which are not interesting
.filter((relation) => !!relation.getNote() && relation.getNote().type !== "search")
);
}
function getBacklinkCount(req: Request) {
const { noteId } = req.params;
const note = becca.getNoteOrThrow(noteId);
return {
count: getFilteredBacklinks(note).length
} satisfies BacklinkCountResponse;
}
export default {
getBacklinkCount
}

View File

@@ -1,15 +1,17 @@
import type { AttributeRow, CreateChildrenResponse, DeleteNotesPreview, MetadataResponse } from "@triliumnext/commons";
import { blob as blobService, erase as eraseService, ValidationError } from "@triliumnext/core";
import type { Request } from "express";
import blobService from "../../services/blob";
import eraseService from "../../services/erase.js";
import { ValidationError } from "../../errors.js";
import becca from "../../becca/becca.js";
import type BBranch from "../../becca/entities/bbranch.js";
import log from "../../services/log.js";
import { getLog } from "../../services/log.js";
import noteService from "../../services/notes.js";
import sql from "../../services/sql.js";
import { getSql } from "../../services/sql/index";
import TaskContext from "../../services/task_context.js";
import treeService from "../../services/tree.js";
import utils from "../../services/utils.js";
import { randomString } from "../../services/utils/index";
/**
* @swagger
@@ -174,7 +176,7 @@ function deleteNote(req: Request) {
const last = req.query.last === "true";
// note how deleteId is separate from taskId - single taskId produces separate deleteId for each "top level" deleted note
const deleteId = utils.randomString(10);
const deleteId = randomString(10);
const note = becca.getNoteOrThrow(noteId);
@@ -195,7 +197,7 @@ function deleteNote(req: Request) {
}
function undeleteNote(req: Request) {
const taskContext = TaskContext.getInstance(utils.randomString(10), "undeleteNotes", null);
const taskContext = TaskContext.getInstance(randomString(10), "undeleteNotes", null);
noteService.undeleteNote(req.params.noteId, taskContext);
@@ -206,7 +208,7 @@ function sortChildNotes(req: Request) {
const noteId = req.params.noteId;
const { sortBy, sortDirection, foldersFirst, sortNatural, sortLocale } = req.body;
log.info(`Sorting '${noteId}' children with ${sortBy} ${sortDirection}, foldersFirst=${foldersFirst}, sortNatural=${sortNatural}, sortLocale=${sortLocale}`);
getLog().info(`Sorting '${noteId}' children with ${sortBy} ${sortDirection}, foldersFirst=${foldersFirst}, sortNatural=${sortNatural}, sortLocale=${sortLocale}`);
const reverse = sortDirection === "desc";
@@ -219,7 +221,7 @@ function protectNote(req: Request) {
const protect = !!parseInt(req.params.isProtected);
const includingSubTree = !!parseInt(req.query?.subtree as string);
const taskContext = new TaskContext(utils.randomString(10), "protectNotes", { protect });
const taskContext = new TaskContext(randomString(10), "protectNotes", { protect });
noteService.protectNoteRecursively(note, protect, includingSubTree, taskContext);
@@ -307,7 +309,7 @@ function getDeleteNotesPreview(req: Request) {
const branch = becca.getBranch(branchId);
if (!branch) {
log.error(`Branch ${branchId} was not found and delete preview can't be calculated for this note.`);
getLog().error(`Branch ${branchId} was not found and delete preview can't be calculated for this note.`);
continue;
}
@@ -318,6 +320,7 @@ function getDeleteNotesPreview(req: Request) {
let brokenRelations: AttributeRow[] = [];
if (noteIdsToBeDeleted.size > 0) {
const sql = getSql();
sql.fillParamList(noteIdsToBeDeleted);
// FIXME: No need to do this in database, can be done with becca data

View File

@@ -1,14 +1,14 @@
import type { OptionNames } from "@triliumnext/commons";
import { ValidationError } from "@triliumnext/core";
import type { Request } from "express";
import config from "../../services/config.js";
import { changeLanguage, getLocales } from "../../services/i18n.js";
import log from "../../services/log.js";
import { getLog } from "../../services/log.js";
import optionService from "../../services/options.js";
import searchService from "../../services/search/services/search.js";
import { ValidationError } from "../../errors.js";
interface UserTheme {
val: string; // value of the theme, used in the URL
@@ -122,6 +122,7 @@ const ALLOWED_OPTIONS = new Set<OptionNames>([
]);
function getOptions() {
console.log("Got opts");
const optionMap = optionService.getOptionMap();
const resultMap: Record<string, string> = {};
@@ -166,7 +167,7 @@ function update(name: string, value: string) {
}
if (name !== "openNoteContexts") {
log.info(`Updating option '${name}' to '${value}'`);
getLog().info(`Updating option '${name}' to '${value}'`);
}
optionService.setOption(name as OptionNames, value);

View File

@@ -0,0 +1,29 @@
import becca from "../../becca/becca";
function getIconUsage() {
const iconClassToCountMap: Record<string, number> = {};
for (const { value: iconClass, noteId } of becca.findAttributes("label", "iconClass")) {
if (noteId.startsWith("_")) {
continue; // ignore icons of "system" notes since they were not set by the user
}
if (!iconClass?.trim()) {
continue;
}
for (const clazz of iconClass.trim().split(/\s+/)) {
if (clazz === "bx") {
continue;
}
iconClassToCountMap[clazz] = (iconClassToCountMap[clazz] || 0) + 1;
}
}
return { iconClassToCountMap };
}
export default {
getIconUsage
}

View File

@@ -1,8 +1,9 @@
"use strict";
import BRecentNote from "../../becca/entities/brecent_note.js";
import sql from "../../services/sql.js";
import dateUtils from "../../services/date_utils.js";
import { getSql } from "../../services/sql/index.js";
import dateUtils from "../../services/utils/date.js";
import type { Request } from "express";
function addRecentNote(req: Request) {
@@ -15,7 +16,7 @@ function addRecentNote(req: Request) {
// it's not necessary to run this every time ...
const cutOffDate = dateUtils.utcDateTimeStr(new Date(Date.now() - 24 * 3600 * 1000));
sql.execute(/*sql*/`DELETE FROM recent_notes WHERE utcDateCreated < ?`, [cutOffDate]);
getSql().execute(/*sql*/`DELETE FROM recent_notes WHERE utcDateCreated < ?`, [cutOffDate]);
}
}

View File

@@ -1,10 +1,10 @@
import type { AttributeRow, BranchRow, NoteRow } from "@triliumnext/commons";
import { NotFoundError } from "@triliumnext/core";
import type { Request } from "express";
import becca from "../../becca/becca.js";
import { NotFoundError } from "../../errors.js";
import { getLog } from "../../services/log.js";
import type BNote from "../../becca/entities/bnote.js";
import log from "../../services/log.js";
function getNotesAndBranchesAndAttributes(_noteIds: string[] | Set<string>) {
const noteIds = new Set(_noteIds);
@@ -85,7 +85,7 @@ function getNotesAndBranchesAndAttributes(_noteIds: string[] | Set<string>) {
const branch = becca.branches[branchId];
if (!branch) {
log.error(`Could not find branch for branchId=${branchId}`);
getLog().error(`Could not find branch for branchId=${branchId}`);
continue;
}
@@ -105,7 +105,7 @@ function getNotesAndBranchesAndAttributes(_noteIds: string[] | Set<string>) {
const attribute = becca.attributes[attributeId];
if (!attribute) {
log.error(`Could not find attribute for attributeId=${attributeId}`);
getLog().error(`Could not find attribute for attributeId=${attributeId}`);
continue;
}

View File

@@ -0,0 +1,92 @@
import optionsApiRoute from "./api/options";
import treeApiRoute from "./api/tree";
import keysApiRoute from "./api/keys";
import notesApiRoute from "./api/notes";
import attachmentsApiRoute from "./api/attachments";
import noteMapRoute from "./api/note_map";
import recentNotesRoute from "./api/recent_notes";
import otherRoute from "./api/others";
import AbstractBeccaEntity from "../becca/entities/abstract_becca_entity";
// TODO: Deduplicate with routes.ts
const GET = "get",
PST = "post",
PUT = "put",
PATCH = "patch",
DEL = "delete";
export function buildSharedApiRoutes(apiRoute: any) {
apiRoute(GET, '/api/tree', treeApiRoute.getTree);
apiRoute(PST, '/api/tree/load', treeApiRoute.load);
apiRoute(GET, "/api/options", optionsApiRoute.getOptions);
// FIXME: possibly change to sending value in the body to avoid host of HTTP server issues with slashes
apiRoute(PUT, "/api/options/:name/:value", optionsApiRoute.updateOption);
apiRoute(PUT, "/api/options", optionsApiRoute.updateOptions);
apiRoute(GET, "/api/options/user-themes", optionsApiRoute.getUserThemes);
apiRoute(GET, "/api/options/locales", optionsApiRoute.getSupportedLocales);
apiRoute(PST, "/api/notes/:noteId/convert-to-attachment", notesApiRoute.convertNoteToAttachment);
apiRoute(GET, "/api/notes/:noteId", notesApiRoute.getNote);
apiRoute(GET, "/api/notes/:noteId/blob", notesApiRoute.getNoteBlob);
apiRoute(GET, "/api/notes/:noteId/metadata", notesApiRoute.getNoteMetadata);
apiRoute(PUT, "/api/notes/:noteId/data", notesApiRoute.updateNoteData);
apiRoute(DEL, "/api/notes/:noteId", notesApiRoute.deleteNote);
apiRoute(PUT, "/api/notes/:noteId/undelete", notesApiRoute.undeleteNote);
apiRoute(PST, "/api/notes/:noteId/revision", notesApiRoute.forceSaveRevision);
apiRoute(PST, "/api/notes/:parentNoteId/children", notesApiRoute.createNote);
apiRoute(PUT, "/api/notes/:noteId/sort-children", notesApiRoute.sortChildNotes);
apiRoute(PUT, "/api/notes/:noteId/protect/:isProtected", notesApiRoute.protectNote);
apiRoute(PUT, "/api/notes/:noteId/type", notesApiRoute.setNoteTypeMime);
apiRoute(PUT, "/api/notes/:noteId/title", notesApiRoute.changeTitle);
apiRoute(PST, "/api/notes/:noteId/duplicate/:parentNoteId", notesApiRoute.duplicateSubtree);
apiRoute(PST, "/api/notes/erase-deleted-notes-now", notesApiRoute.eraseDeletedNotesNow);
apiRoute(PST, "/api/notes/erase-unused-attachments-now", notesApiRoute.eraseUnusedAttachmentsNow);
apiRoute(PST, "/api/delete-notes-preview", notesApiRoute.getDeleteNotesPreview);
apiRoute(GET, "/api/notes/:noteId/attachments", attachmentsApiRoute.getAttachments);
apiRoute(PST, "/api/notes/:noteId/attachments", attachmentsApiRoute.saveAttachment);
apiRoute(GET, "/api/attachments/:attachmentId", attachmentsApiRoute.getAttachment);
apiRoute(GET, "/api/attachments/:attachmentId/all", attachmentsApiRoute.getAllAttachments);
apiRoute(PST, "/api/attachments/:attachmentId/convert-to-note", attachmentsApiRoute.convertAttachmentToNote);
apiRoute(DEL, "/api/attachments/:attachmentId", attachmentsApiRoute.deleteAttachment);
apiRoute(PUT, "/api/attachments/:attachmentId/rename", attachmentsApiRoute.renameAttachment);
apiRoute(GET, "/api/attachments/:attachmentId/blob", attachmentsApiRoute.getAttachmentBlob);
apiRoute(GET, "/api/note-map/:noteId/backlink-count", noteMapRoute.getBacklinkCount);
apiRoute(PST, "/api/recent-notes", recentNotesRoute.addRecentNote);
apiRoute(GET, "/api/keyboard-actions", keysApiRoute.getKeyboardActions);
apiRoute(GET, "/api/keyboard-shortcuts-for-notes", keysApiRoute.getShortcutsForNotes);
apiRoute(GET, "/api/other/icon-usage", otherRoute.getIconUsage);
}
/** Handling common patterns. If entity is not caught, serialization to JSON will fail */
export function convertEntitiesToPojo(result: unknown) {
if (result instanceof AbstractBeccaEntity) {
result = result.getPojo();
} else if (Array.isArray(result)) {
for (const idx in result) {
if (result[idx] instanceof AbstractBeccaEntity) {
result[idx] = result[idx].getPojo();
}
}
} else if (result && typeof result === "object") {
if ("note" in result && result.note instanceof AbstractBeccaEntity) {
result.note = result.note.getPojo();
}
if ("branch" in result && result.branch instanceof AbstractBeccaEntity) {
result.branch = result.branch.getPojo();
}
}
if (result && typeof result === "object" && "executionResult" in result) {
// from runOnBackend()
result.executionResult = convertEntitiesToPojo(result.executionResult);
}
return result;
}

View File

@@ -0,0 +1,6 @@
// TODO: Real implementation.
export default {
General: {
readOnly: false
}
};

View File

@@ -0,0 +1,10 @@
import { LOCALES } from "@triliumnext/commons";
// TODO: Real impl.
export function changeLanguage(languageCode: string) {
console.log("Got request to change language", languageCode);
}
export function getLocales() {
return LOCALES;
}

View File

@@ -3,9 +3,9 @@ import { IconRegistry } from "@triliumnext/commons";
import type BAttachment from "../becca/entities/battachment";
import type BNote from "../becca/entities/bnote";
import boxiconsManifest from "./icon_pack_boxicons-v2.json" with { type: "json" };
import log from "./log";
import { getLog } from "./log";
import search from "./search/services/search";
import { safeExtractMessageAndStackFromError } from "./utils";
import { safeExtractMessageAndStackFromError } from "./utils/index";
const PREFERRED_MIME_TYPE = [
"font/woff2",
@@ -64,7 +64,7 @@ export function getIconPacks() {
if (!iconPack) return false;
if (iconPack.prefix === "bx" || usedPrefixes.has(iconPack.prefix)) {
log.info(`Skipping icon pack with duplicate prefix '${iconPack.prefix}': ${iconPack.title} (${iconPack.manifestNoteId})`);
getLog().info(`Skipping icon pack with duplicate prefix '${iconPack.prefix}': ${iconPack.title} (${iconPack.manifestNoteId})`);
return false;
}
usedPrefixes.add(iconPack.prefix);
@@ -103,25 +103,25 @@ export function generateIconRegistry(iconPacks: ProcessedIconPack[]): IconRegist
export function processIconPack(iconPackNote: BNote): ProcessedIconPack | undefined {
const manifest = iconPackNote.getJsonContentSafely() as IconPackManifest;
if (!manifest) {
log.error(`Icon pack is missing JSON manifest (or has syntax errors): ${iconPackNote.title} (${iconPackNote.noteId})`);
getLog().error(`Icon pack is missing JSON manifest (or has syntax errors): ${iconPackNote.title} (${iconPackNote.noteId})`);
return;
}
const attachment = determineBestFontAttachment(iconPackNote);
if (!attachment || !attachment.attachmentId) {
log.error(`Icon pack is missing WOFF/WOFF2/TTF attachment: ${iconPackNote.title} (${iconPackNote.noteId})`);
getLog().error(`Icon pack is missing WOFF/WOFF2/TTF attachment: ${iconPackNote.title} (${iconPackNote.noteId})`);
return;
}
const prefix = iconPackNote.getLabelValue("iconPack");
if (!prefix) {
log.error(`Icon pack is missing 'iconPack' label defining its prefix: ${iconPackNote.title} (${iconPackNote.noteId})`);
getLog().error(`Icon pack is missing 'iconPack' label defining its prefix: ${iconPackNote.title} (${iconPackNote.noteId})`);
return;
}
// Ensure prefix is alphanumeric only, dashes and underscores.
if (!/^[a-zA-Z0-9-_]+$/.test(prefix)) {
log.error(`Icon pack has invalid 'iconPack' prefix (only alphanumeric characters, dashes and underscores are allowed): ${iconPackNote.title} (${iconPackNote.noteId})`);
getLog().error(`Icon pack has invalid 'iconPack' prefix (only alphanumeric characters, dashes and underscores are allowed): ${iconPackNote.title} (${iconPackNote.noteId})`);
return;
}
@@ -185,7 +185,7 @@ export function generateCss({ manifest, fontMime, builtin, fontAttachmentId, pre
${iconDeclarations.join("\n")}
`;
} catch (e) {
log.error(safeExtractMessageAndStackFromError(e));
getLog().error(safeExtractMessageAndStackFromError(e));
return null;
}
}

View File

@@ -0,0 +1,46 @@
import type { WebSocketMessage } from "@triliumnext/commons";
import type { MessagingProvider } from "./types.js";
let messagingProvider: MessagingProvider | null = null;
/**
* Initialize the messaging system with a provider.
* This should be called during application startup.
*/
export function initMessaging(provider: MessagingProvider): void {
messagingProvider = provider;
}
/**
* Get the current messaging provider.
* Throws if messaging hasn't been initialized.
*/
export function getMessagingProvider(): MessagingProvider {
if (!messagingProvider) {
throw new Error("Messaging provider not initialized. Call initMessaging() first.");
}
return messagingProvider;
}
/**
* Check if messaging has been initialized.
*/
export function isMessagingInitialized(): boolean {
return messagingProvider !== null;
}
/**
* Send a message to all connected clients.
* This is a convenience function that uses the current provider.
*/
export function sendMessageToAllClients(message: WebSocketMessage): void {
if (!messagingProvider) {
// Silently ignore if no provider - allows core to work without messaging
console.debug("[Messaging] No provider initialized, message not sent:", message.type);
return;
}
messagingProvider.sendMessageToAllClients(message);
}
// Re-export types
export * from "./types.js";

View File

@@ -0,0 +1,97 @@
import type { EntityChange, WebSocketMessage } from "@triliumnext/commons";
/**
* Handler function for incoming messages from clients.
*/
export type MessageHandler = (message: WebSocketMessage) => void | Promise<void>;
/**
* Represents a connected client that can receive messages.
*/
export interface MessageClient {
/** Unique identifier for this client */
readonly id: string;
/** Send a message to this specific client */
send(message: WebSocketMessage): void;
/** Check if the client is still connected */
isConnected(): boolean;
}
/**
* Provider interface for server-to-client messaging.
*
* This abstraction allows different transport mechanisms:
* - WebSocket for traditional server environments
* - Worker postMessage for browser environments
* - Mock implementations for testing
*/
export interface MessagingProvider {
/**
* Send a message to all connected clients.
* This is the primary method used by core services like TaskContext.
*/
sendMessageToAllClients(message: WebSocketMessage): void;
/**
* Send a message to a specific client by ID.
* Returns false if the client is not found or disconnected.
*/
sendMessageToClient?(clientId: string, message: WebSocketMessage): boolean;
/**
* Subscribe to incoming messages from clients.
* Returns an unsubscribe function.
*/
onMessage?(handler: MessageHandler): () => void;
/**
* Get the number of connected clients.
*/
getClientCount?(): number;
/**
* Called when the provider should clean up resources.
*/
dispose?(): void;
}
/**
* Extended interface for server-side messaging with entity change support.
* This is used by the WebSocket implementation to handle entity sync.
*/
export interface ServerMessagingProvider extends MessagingProvider {
/**
* Send entity changes to all clients (for frontend-update messages).
*/
sendEntityChangesToAllClients(entityChanges: EntityChange[]): void;
/**
* Set the last synced push ID for sync status messages.
*/
setLastSyncedPush(entityChangeId: number): void;
/**
* Notify clients that sync pull is in progress.
*/
syncPullInProgress(): void;
/**
* Notify clients that sync push is in progress.
*/
syncPushInProgress(): void;
/**
* Notify clients that sync has finished.
*/
syncFinished(): void;
/**
* Notify clients that sync has failed.
*/
syncFailed(): void;
/**
* Request all clients to reload their frontend.
*/
reloadFrontend(reason: string): void;
}

View File

@@ -5,7 +5,7 @@ export default {
console.warn("Ignore search ", note.title);
},
searchNotes(searchString: string) {
searchNotes(searchString: string, opts?: {}): BNote[] {
console.warn("Ignore search", searchString);
return [];
}

View File

@@ -277,6 +277,8 @@ export class SqlService {
return null;
}
console.error(`Error executing query: ${query} with parameters ${JSON.stringify((e as any).params || [])}`);
throw e;
}

View File

@@ -126,3 +126,7 @@ export const unescapeHtml = unescape;
export function randomSecureToken(bytes = 32) {
return encodeBase64(getCrypto().randomBytes(32));
}
export function safeExtractMessageAndStackFromError(err: unknown): [errMessage: string, errStack: string | undefined] {
return (err instanceof Error) ? [err.message, err.stack] as const : ["Unknown Error", undefined] as const;
}

View File

@@ -1,5 +1,20 @@
import type { WebSocketMessage } from "@triliumnext/commons";
import { sendMessageToAllClients as sendMessage } from "./messaging/index.js";
/**
* WebSocket service abstraction for core.
*
* This module provides a simple interface for sending messages to clients.
* The actual transport mechanism is provided by the messaging provider
* configured during initialization.
*
* @deprecated Use the messaging module directly instead.
*/
export default {
sendMessageToAllClients(message: object) {
console.warn("Ignored ws", message);
/**
* Send a message to all connected clients.
*/
sendMessageToAllClients(message: WebSocketMessage) {
sendMessage(message);
}
}

383
pnpm-lock.yaml generated
View File

@@ -104,8 +104,8 @@ importers:
specifier: 0.18.0
version: 0.18.0
rollup-plugin-webpack-stats:
specifier: 2.1.8
version: 2.1.8(rolldown@1.0.0-beta.29)(rollup@4.52.0)(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(less@4.1.3)(lightningcss@1.30.1)(sass-embedded@1.91.0)(sass@1.91.0)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.1))
specifier: 2.1.9
version: 2.1.9(rolldown@1.0.0-beta.29)(rollup@4.52.0)(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(less@4.1.3)(lightningcss@1.30.1)(sass-embedded@1.91.0)(sass@1.91.0)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.1))
tslib:
specifier: 2.8.1
version: 2.8.1
@@ -348,6 +348,214 @@ importers:
specifier: 3.1.4
version: 3.1.4(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(less@4.1.3)(lightningcss@1.30.1)(sass-embedded@1.91.0)(sass@1.91.0)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.1))
apps/client-standalone:
dependencies:
'@excalidraw/excalidraw':
specifier: 0.18.0
version: 0.18.0(@types/react-dom@19.1.6(@types/react@19.1.7))(@types/react@19.1.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
'@fullcalendar/core':
specifier: 6.1.20
version: 6.1.20
'@fullcalendar/daygrid':
specifier: 6.1.20
version: 6.1.20(@fullcalendar/core@6.1.20)
'@fullcalendar/interaction':
specifier: 6.1.20
version: 6.1.20(@fullcalendar/core@6.1.20)
'@fullcalendar/list':
specifier: 6.1.20
version: 6.1.20(@fullcalendar/core@6.1.20)
'@fullcalendar/multimonth':
specifier: 6.1.20
version: 6.1.20(@fullcalendar/core@6.1.20)
'@fullcalendar/timegrid':
specifier: 6.1.20
version: 6.1.20(@fullcalendar/core@6.1.20)
'@maplibre/maplibre-gl-leaflet':
specifier: 0.1.3
version: 0.1.3(@types/leaflet@1.9.21)(leaflet@1.9.4)(maplibre-gl@5.6.1)
'@mermaid-js/layout-elk':
specifier: 0.2.0
version: 0.2.0(mermaid@11.12.2)
'@mind-elixir/node-menu':
specifier: 5.0.1
version: 5.0.1(mind-elixir@5.4.0)
'@popperjs/core':
specifier: 2.11.8
version: 2.11.8
'@preact/signals':
specifier: 2.5.1
version: 2.5.1(preact@10.28.1)
'@sqlite.org/sqlite-wasm':
specifier: 3.51.1-build2
version: 3.51.1-build2
'@triliumnext/ckeditor5':
specifier: workspace:*
version: link:../../packages/ckeditor5
'@triliumnext/codemirror':
specifier: workspace:*
version: link:../../packages/codemirror
'@triliumnext/commons':
specifier: workspace:*
version: link:../../packages/commons
'@triliumnext/core':
specifier: workspace:*
version: link:../../packages/trilium-core
'@triliumnext/highlightjs':
specifier: workspace:*
version: link:../../packages/highlightjs
'@triliumnext/share-theme':
specifier: workspace:*
version: link:../../packages/share-theme
'@triliumnext/split.js':
specifier: workspace:*
version: link:../../packages/splitjs
'@zumer/snapdom':
specifier: 2.0.1
version: 2.0.1
autocomplete.js:
specifier: 0.38.1
version: 0.38.1
bootstrap:
specifier: 5.3.8
version: 5.3.8(@popperjs/core@2.11.8)
boxicons:
specifier: 2.1.4
version: 2.1.4
clsx:
specifier: 2.1.1
version: 2.1.1
color:
specifier: 5.0.3
version: 5.0.3
debounce:
specifier: 3.0.0
version: 3.0.0
draggabilly:
specifier: 3.0.0
version: 3.0.0
force-graph:
specifier: 1.51.0
version: 1.51.0
globals:
specifier: 17.0.0
version: 17.0.0
i18next:
specifier: 25.7.3
version: 25.7.3(typescript@5.9.3)
i18next-http-backend:
specifier: 3.0.2
version: 3.0.2(encoding@0.1.13)
jquery:
specifier: 3.7.1
version: 3.7.1
jquery.fancytree:
specifier: 2.38.5
version: 2.38.5(jquery@3.7.1)
js-sha1:
specifier: 0.7.0
version: 0.7.0
js-sha512:
specifier: 0.9.0
version: 0.9.0
jsplumb:
specifier: 2.15.6
version: 2.15.6
katex:
specifier: 0.16.27
version: 0.16.27
knockout:
specifier: 3.5.1
version: 3.5.1
leaflet:
specifier: 1.9.4
version: 1.9.4
leaflet-gpx:
specifier: 2.2.0
version: 2.2.0
mark.js:
specifier: 8.11.1
version: 8.11.1
marked:
specifier: 17.0.1
version: 17.0.1
mermaid:
specifier: 11.12.2
version: 11.12.2
mind-elixir:
specifier: 5.4.0
version: 5.4.0
normalize.css:
specifier: 8.0.1
version: 8.0.1
panzoom:
specifier: 9.4.3
version: 9.4.3
preact:
specifier: 10.28.1
version: 10.28.1
react-i18next:
specifier: 16.5.1
version: 16.5.1(i18next@25.7.3(typescript@5.9.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3)
react-window:
specifier: 2.2.3
version: 2.2.3(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
reveal.js:
specifier: 5.2.1
version: 5.2.1
svg-pan-zoom:
specifier: 3.6.2
version: 3.6.2
tabulator-tables:
specifier: 6.3.1
version: 6.3.1
vanilla-js-wheel-zoom:
specifier: 9.0.4
version: 9.0.4
devDependencies:
'@ckeditor/ckeditor5-inspector':
specifier: 5.0.0
version: 5.0.0
'@preact/preset-vite':
specifier: 2.10.2
version: 2.10.2(@babel/core@7.28.0)(preact@10.28.1)(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(less@4.1.3)(lightningcss@1.30.1)(sass-embedded@1.91.0)(sass@1.91.0)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.1))
'@types/bootstrap':
specifier: 5.2.10
version: 5.2.10
'@types/jquery':
specifier: 3.5.33
version: 3.5.33
'@types/leaflet':
specifier: 1.9.21
version: 1.9.21
'@types/leaflet-gpx':
specifier: 1.3.8
version: 1.3.8
'@types/mark.js':
specifier: 8.11.12
version: 8.11.12
'@types/reveal.js':
specifier: 5.2.2
version: 5.2.2
'@types/tabulator-tables':
specifier: 6.3.1
version: 6.3.1
copy-webpack-plugin:
specifier: 13.0.1
version: 13.0.1(webpack@5.101.3(esbuild@0.27.2))
cross-env:
specifier: 7.0.3
version: 7.0.3
happy-dom:
specifier: 20.0.11
version: 20.0.11
script-loader:
specifier: 0.7.2
version: 0.7.2
vite-plugin-static-copy:
specifier: 3.1.4
version: 3.1.4(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(less@4.1.3)(lightningcss@1.30.1)(sass-embedded@1.91.0)(sass@1.91.0)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.1))
apps/db-compare:
dependencies:
colors:
@@ -513,9 +721,6 @@ importers:
'@anthropic-ai/sdk':
specifier: 0.71.2
version: 0.71.2(zod@4.1.12)
'@braintree/sanitize-url':
specifier: 7.1.1
version: 7.1.1
'@electron/remote':
specifier: 2.1.3
version: 2.1.3(electron@39.2.7)
@@ -558,9 +763,6 @@ importers:
'@types/ejs':
specifier: 3.1.5
version: 3.1.5
'@types/escape-html':
specifier: 1.0.4
version: 1.0.4
'@types/express-http-proxy':
specifier: 1.6.7
version: 1.6.7
@@ -576,18 +778,12 @@ importers:
'@types/ini':
specifier: 4.1.1
version: 4.1.1
'@types/mime-types':
specifier: 3.0.1
version: 3.0.1
'@types/multer':
specifier: 2.0.0
version: 2.0.0
'@types/safe-compare':
specifier: 1.1.2
version: 1.1.2
'@types/sanitize-html':
specifier: 2.16.0
version: 2.16.0
'@types/sax':
specifier: 1.2.7
version: 1.2.7
@@ -669,9 +865,6 @@ importers:
electron-window-state:
specifier: 5.0.3
version: 5.0.3
escape-html:
specifier: 1.0.3
version: 1.0.3
express:
specifier: 5.2.1
version: 5.2.1
@@ -735,9 +928,6 @@ importers:
marked:
specifier: 17.0.1
version: 17.0.1
mime-types:
specifier: 3.0.2
version: 3.0.2
multer:
specifier: 2.0.2
version: 2.0.2
@@ -756,12 +946,6 @@ importers:
safe-compare:
specifier: 1.1.4
version: 1.1.4
sanitize-filename:
specifier: 1.6.3
version: 1.6.3
sanitize-html:
specifier: 2.17.0
version: 2.17.0
sax:
specifier: 1.4.3
version: 1.4.3
@@ -792,9 +976,6 @@ importers:
turndown:
specifier: 7.2.2
version: 7.2.2
unescape:
specifier: 1.0.1
version: 1.0.1
vite:
specifier: 7.3.0
version: 7.3.0(@types/node@24.10.4)(jiti@2.6.1)(less@4.1.3)(lightningcss@1.30.1)(sass-embedded@1.91.0)(sass@1.91.0)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.1)
@@ -1076,6 +1257,9 @@ importers:
'@ckeditor/ckeditor5-icons':
specifier: 47.3.0
version: 47.3.0
mathlive:
specifier: 0.108.2
version: 0.108.2
devDependencies:
'@ckeditor/ckeditor5-dev-build-tools':
specifier: 54.2.3
@@ -1446,9 +1630,37 @@ importers:
packages/trilium-core:
dependencies:
'@braintree/sanitize-url':
specifier: 7.1.1
version: 7.1.1
'@triliumnext/commons':
specifier: workspace:*
version: link:../commons
escape-html:
specifier: 1.0.3
version: 1.0.3
mime-types:
specifier: 3.0.2
version: 3.0.2
sanitize-filename:
specifier: 1.6.3
version: 1.6.3
sanitize-html:
specifier: 2.17.0
version: 2.17.0
unescape:
specifier: 1.0.1
version: 1.0.1
devDependencies:
'@types/escape-html':
specifier: 1.0.4
version: 1.0.4
'@types/mime-types':
specifier: 3.0.1
version: 3.0.1
'@types/sanitize-html':
specifier: 2.16.0
version: 2.16.0
packages/turndown-plugin-gfm:
devDependencies:
@@ -2138,6 +2350,10 @@ packages:
resolution: {integrity: sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ==}
engines: {node: '>=0.1.90'}
'@cortex-js/compute-engine@0.30.2':
resolution: {integrity: sha512-Zx+iisk9WWdbxjm8EYsneIBszvjfUs7BHNwf1jBtSINIgfWGpHrTTq9vW0J59iGCFt6bOFxbmWyxNMRSmksHMA==}
engines: {node: '>=21.7.3', npm: '>=10.5.0'}
'@cspotcode/source-map-support@0.8.1':
resolution: {integrity: sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==}
engines: {node: '>=12'}
@@ -5199,6 +5415,10 @@ packages:
'@socket.io/component-emitter@3.1.2':
resolution: {integrity: sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==}
'@sqlite.org/sqlite-wasm@3.51.1-build2':
resolution: {integrity: sha512-lVPTBlFsEijJ3wuoIbMfC9QMZKfL8huHN8D/lijNKoVxPqUDNvDtXse0wafe7USSmyfKAMb1JZ3ISSr/Vgbn5w==}
hasBin: true
'@ssddanbrown/codemirror-lang-smarty@1.0.0':
resolution: {integrity: sha512-F0ut1kmdbT3eORk3xVIKfQsGCZiQdh+6sLayBa0+FTex2gyIQlVQZRRA7bPSlchI3uZtWwNnqGNz5O/QLWRlFg==}
@@ -7085,6 +7305,10 @@ packages:
compare-versions@6.1.1:
resolution: {integrity: sha512-4hm4VPpIecmlg59CHXnRDnqGplJFrbLG4aFEl5vl6cK1u76ws3LLvX7ikFnTDl5vo39sjWD6AaDPYodJp/NNHg==}
complex-esm@2.1.1-esm1:
resolution: {integrity: sha512-IShBEWHILB9s7MnfyevqNGxV0A1cfcSnewL/4uPFiSxkcQL4Mm3FxJ0pXMtCXuWLjYz3lRRyk6OfkeDZcjD6nw==}
engines: {node: '>=16.14.2', npm: '>=8.5.0'}
component-emitter@1.3.1:
resolution: {integrity: sha512-T0+barUSQRTUQASh8bx02dl+DhF54GtIDY13Y3m9oWTklKbb3Wv974meRpeZ3lp1JpLVECWWNHC4vaG2XHXouQ==}
@@ -9717,6 +9941,12 @@ packages:
resolution: {integrity: sha512-X2BB11YZtrRqY4EnQcLX5Rh373zbK4alC1FW7D7MBhL2gtcC17cTnr6DmfHZeS0s2rTHjUTMMHfG7gO8SSdw+g==}
engines: {node: '>=0.10.0'}
js-sha1@0.7.0:
resolution: {integrity: sha512-oQZ1Mo7440BfLSv9TX87VNEyU52pXPVG19F9PL3gTgNt0tVxlZ8F4O6yze3CLuLx28TxotxvlyepCNaaV0ZjMw==}
js-sha512@0.9.0:
resolution: {integrity: sha512-mirki9WS/SUahm+1TbAPkqvbCiCfOAAsyXeHxK1UkullnJVVqoJG2pL9ObvT05CN+tM7fxhfYm0NbXn+1hWoZg==}
js-tokens@4.0.0:
resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
@@ -10278,6 +10508,9 @@ packages:
resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==}
engines: {node: '>= 0.4'}
mathlive@0.108.2:
resolution: {integrity: sha512-GIZkfprGTxrbHckOvwo92ZmOOxdD018BHDzlrEwYUU+pzR5KabhqI1s43lxe/vqXdF5RLiQKgDcuk5jxEjhkYg==}
mathml-tag-names@2.1.3:
resolution: {integrity: sha512-APMBEanjybaPzUrfqU0IMU5I0AswKMH7k8OTLs0vvV4KZpExkTkY87nR/zpbuTPj+gARop7aGUbl11pnDfW6xg==}
@@ -11853,10 +12086,6 @@ packages:
resolution: {integrity: sha512-OCVPnIObs4N29kxTjzLfUryOkvZEq+pf8jTF0lg8E7uETuWHA+v7j3c/xJmiqpX450191LlmZfUKkXxkTry7nA==}
engines: {node: ^10 || ^12 || >=14}
postcss@8.5.3:
resolution: {integrity: sha512-dle9A3yYxlBSrt8Fu+IpjGT8SY8hN0mlaA6GY8t0P5PjIOZemULz/E2Bnm/2dcUOena75OTNkHI76uZBNUUq3A==}
engines: {node: ^10 || ^12 || >=14}
postcss@8.5.6:
resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==}
engines: {node: ^10 || ^12 || >=14}
@@ -12398,8 +12627,8 @@ packages:
resolution: {integrity: sha512-EsoOi8moHN6CAYyTZipxDDVTJn0j2nBCWor4wRU45RQ8ER2qREDykXLr3Ulz6hBh6oBKCFTQIjo21i0FXNo/IA==}
hasBin: true
rollup-plugin-stats@1.5.3:
resolution: {integrity: sha512-0IYVGhsFTjcddpqcElzU7Mi4vmDLihCCTH5QgCCgWpNY1VKMXVoEpxmCmGjivtJKLzI6t5QIicsPBC93UWWN2g==}
rollup-plugin-stats@1.5.4:
resolution: {integrity: sha512-b1hYagYLTyr8mCVUb7e1x9fjxOXFyeWmV9hIr7vYqq/agN+WDaGNzz+KmM3GAx0KGGI2qllOL+zAUi/l39s/Sg==}
engines: {node: '>=18'}
peerDependencies:
rolldown: ^1.0.0-beta.0
@@ -12425,8 +12654,8 @@ packages:
peerDependencies:
rollup: ^3.0.0||^4.0.0
rollup-plugin-webpack-stats@2.1.8:
resolution: {integrity: sha512-agc1OE+QwG3sGeTSdruh16DkxPb6QkgR7I3gntPDFHMXsK1bR2ADHUVod1eoE+epAOqiv3idx/hcSqZAI3a1yg==}
rollup-plugin-webpack-stats@2.1.9:
resolution: {integrity: sha512-ft1vdp3xPjE+zw8A22yCToo5cpymoWCjNDefWNO1awywsDrSDoRJhkoZTENkhJwmfh6oe5ztpGu7PfnJOMXc2g==}
engines: {node: '>=18'}
peerDependencies:
rolldown: ^1.0.0-beta.0
@@ -15294,8 +15523,6 @@ snapshots:
'@ckeditor/ckeditor5-ui': 47.3.0
'@ckeditor/ckeditor5-utils': 47.3.0
ckeditor5: 47.3.0
transitivePeerDependencies:
- supports-color
'@ckeditor/ckeditor5-block-quote@47.3.0':
dependencies:
@@ -15306,8 +15533,6 @@ snapshots:
'@ckeditor/ckeditor5-ui': 47.3.0
'@ckeditor/ckeditor5-utils': 47.3.0
ckeditor5: 47.3.0
transitivePeerDependencies:
- supports-color
'@ckeditor/ckeditor5-bookmark@47.3.0':
dependencies:
@@ -15370,8 +15595,6 @@ snapshots:
'@ckeditor/ckeditor5-core': 47.3.0
'@ckeditor/ckeditor5-utils': 47.3.0
ckeditor5: 47.3.0
transitivePeerDependencies:
- supports-color
'@ckeditor/ckeditor5-code-block@47.3.0(patch_hash=2361d8caad7d6b5bddacc3a3b4aa37dbfba260b1c1b22a450413a79c1bb1ce95)':
dependencies:
@@ -15543,6 +15766,8 @@ snapshots:
'@ckeditor/ckeditor5-widget': 47.3.0
ckeditor5: 47.3.0
es-toolkit: 1.39.5
transitivePeerDependencies:
- supports-color
'@ckeditor/ckeditor5-easy-image@47.3.0':
dependencies:
@@ -15562,8 +15787,6 @@ snapshots:
'@ckeditor/ckeditor5-utils': 47.3.0
ckeditor5: 47.3.0
es-toolkit: 1.39.5
transitivePeerDependencies:
- supports-color
'@ckeditor/ckeditor5-editor-classic@47.3.0':
dependencies:
@@ -15582,6 +15805,8 @@ snapshots:
'@ckeditor/ckeditor5-utils': 47.3.0
ckeditor5: 47.3.0
es-toolkit: 1.39.5
transitivePeerDependencies:
- supports-color
'@ckeditor/ckeditor5-editor-inline@47.3.0':
dependencies:
@@ -15591,8 +15816,6 @@ snapshots:
'@ckeditor/ckeditor5-utils': 47.3.0
ckeditor5: 47.3.0
es-toolkit: 1.39.5
transitivePeerDependencies:
- supports-color
'@ckeditor/ckeditor5-editor-multi-root@47.3.0':
dependencies:
@@ -15615,8 +15838,6 @@ snapshots:
'@ckeditor/ckeditor5-table': 47.3.0
'@ckeditor/ckeditor5-utils': 47.3.0
ckeditor5: 47.3.0
transitivePeerDependencies:
- supports-color
'@ckeditor/ckeditor5-emoji@47.3.0':
dependencies:
@@ -15642,6 +15863,8 @@ snapshots:
'@ckeditor/ckeditor5-core': 47.3.0
'@ckeditor/ckeditor5-engine': 47.3.0
'@ckeditor/ckeditor5-utils': 47.3.0
transitivePeerDependencies:
- supports-color
'@ckeditor/ckeditor5-essentials@47.3.0':
dependencies:
@@ -15673,8 +15896,6 @@ snapshots:
'@ckeditor/ckeditor5-ui': 47.3.0
'@ckeditor/ckeditor5-utils': 47.3.0
ckeditor5: 47.3.0
transitivePeerDependencies:
- supports-color
'@ckeditor/ckeditor5-export-word@47.3.0':
dependencies:
@@ -15699,6 +15920,8 @@ snapshots:
'@ckeditor/ckeditor5-utils': 47.3.0
ckeditor5: 47.3.0
es-toolkit: 1.39.5
transitivePeerDependencies:
- supports-color
'@ckeditor/ckeditor5-font@47.3.0':
dependencies:
@@ -15708,8 +15931,6 @@ snapshots:
'@ckeditor/ckeditor5-ui': 47.3.0
'@ckeditor/ckeditor5-utils': 47.3.0
ckeditor5: 47.3.0
transitivePeerDependencies:
- supports-color
'@ckeditor/ckeditor5-footnotes@47.3.0':
dependencies:
@@ -15740,8 +15961,6 @@ snapshots:
'@ckeditor/ckeditor5-ui': 47.3.0
'@ckeditor/ckeditor5-utils': 47.3.0
ckeditor5: 47.3.0
transitivePeerDependencies:
- supports-color
'@ckeditor/ckeditor5-heading@47.3.0':
dependencies:
@@ -15773,6 +15992,8 @@ snapshots:
'@ckeditor/ckeditor5-utils': 47.3.0
'@ckeditor/ckeditor5-widget': 47.3.0
ckeditor5: 47.3.0
transitivePeerDependencies:
- supports-color
'@ckeditor/ckeditor5-html-embed@47.3.0':
dependencies:
@@ -15832,8 +16053,6 @@ snapshots:
'@ckeditor/ckeditor5-ui': 47.3.0
'@ckeditor/ckeditor5-utils': 47.3.0
ckeditor5: 47.3.0
transitivePeerDependencies:
- supports-color
'@ckeditor/ckeditor5-indent@47.3.0':
dependencies:
@@ -15868,6 +16087,8 @@ snapshots:
'@ckeditor/ckeditor5-ui': 47.3.0
'@ckeditor/ckeditor5-utils': 47.3.0
ckeditor5: 47.3.0
transitivePeerDependencies:
- supports-color
'@ckeditor/ckeditor5-link@47.3.0':
dependencies:
@@ -15894,6 +16115,8 @@ snapshots:
'@ckeditor/ckeditor5-ui': 47.3.0
'@ckeditor/ckeditor5-utils': 47.3.0
ckeditor5: 47.3.0
transitivePeerDependencies:
- supports-color
'@ckeditor/ckeditor5-list@47.3.0':
dependencies:
@@ -15946,8 +16169,6 @@ snapshots:
'@ckeditor/ckeditor5-utils': 47.3.0
'@ckeditor/ckeditor5-widget': 47.3.0
ckeditor5: 47.3.0
transitivePeerDependencies:
- supports-color
'@ckeditor/ckeditor5-mention@47.3.0(patch_hash=5981fb59ba35829e4dff1d39cf771000f8a8fdfa7a34b51d8af9549541f2d62d)':
dependencies:
@@ -15957,8 +16178,6 @@ snapshots:
'@ckeditor/ckeditor5-utils': 47.3.0
ckeditor5: 47.3.0
es-toolkit: 1.39.5
transitivePeerDependencies:
- supports-color
'@ckeditor/ckeditor5-merge-fields@47.3.0':
dependencies:
@@ -15971,8 +16190,6 @@ snapshots:
'@ckeditor/ckeditor5-widget': 47.3.0
ckeditor5: 47.3.0
es-toolkit: 1.39.5
transitivePeerDependencies:
- supports-color
'@ckeditor/ckeditor5-minimap@47.3.0':
dependencies:
@@ -15981,8 +16198,6 @@ snapshots:
'@ckeditor/ckeditor5-ui': 47.3.0
'@ckeditor/ckeditor5-utils': 47.3.0
ckeditor5: 47.3.0
transitivePeerDependencies:
- supports-color
'@ckeditor/ckeditor5-operations-compressor@47.3.0':
dependencies:
@@ -16102,8 +16317,6 @@ snapshots:
'@ckeditor/ckeditor5-ui': 47.3.0
'@ckeditor/ckeditor5-utils': 47.3.0
ckeditor5: 47.3.0
transitivePeerDependencies:
- supports-color
'@ckeditor/ckeditor5-restricted-editing@47.3.0':
dependencies:
@@ -16225,8 +16438,6 @@ snapshots:
'@ckeditor/ckeditor5-widget': 47.3.0
ckeditor5: 47.3.0
es-toolkit: 1.39.5
transitivePeerDependencies:
- supports-color
'@ckeditor/ckeditor5-template@47.3.0':
dependencies:
@@ -16508,6 +16719,11 @@ snapshots:
'@colors/colors@1.5.0': {}
'@cortex-js/compute-engine@0.30.2':
dependencies:
complex-esm: 2.1.1-esm1
decimal.js: 10.6.0
'@cspotcode/source-map-support@0.8.1':
dependencies:
'@jridgewell/trace-mapping': 0.3.9
@@ -19933,6 +20149,8 @@ snapshots:
'@socket.io/component-emitter@3.1.2': {}
'@sqlite.org/sqlite-wasm@3.51.1-build2': {}
'@ssddanbrown/codemirror-lang-smarty@1.0.0': {}
'@ssddanbrown/codemirror-lang-twig@1.0.0':
@@ -22314,6 +22532,8 @@ snapshots:
compare-versions@6.1.1: {}
complex-esm@2.1.1-esm1: {}
component-emitter@1.3.1: {}
compress-commons@6.0.2:
@@ -23000,8 +23220,7 @@ snapshots:
decimal.js@10.5.0: {}
decimal.js@10.6.0:
optional: true
decimal.js@10.6.0: {}
decko@1.2.0: {}
@@ -25655,6 +25874,10 @@ snapshots:
js-levenshtein@1.1.6: {}
js-sha1@0.7.0: {}
js-sha512@0.9.0: {}
js-tokens@4.0.0: {}
js-tokens@9.0.1: {}
@@ -26347,6 +26570,10 @@ snapshots:
math-intrinsics@1.1.0: {}
mathlive@0.108.2:
dependencies:
'@cortex-js/compute-engine': 0.30.2
mathml-tag-names@2.1.3: {}
mdast-util-find-and-replace@3.0.2:
@@ -28159,12 +28386,6 @@ snapshots:
picocolors: 1.1.1
source-map-js: 1.2.1
postcss@8.5.3:
dependencies:
nanoid: 3.3.11
picocolors: 1.1.1
source-map-js: 1.2.1
postcss@8.5.6:
dependencies:
nanoid: 3.3.11
@@ -28817,7 +29038,7 @@ snapshots:
'@rolldown/binding-win32-x64-msvc': 1.0.0-beta.29
optional: true
rollup-plugin-stats@1.5.3(rolldown@1.0.0-beta.29)(rollup@4.52.0)(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(less@4.1.3)(lightningcss@1.30.1)(sass-embedded@1.91.0)(sass@1.91.0)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.1)):
rollup-plugin-stats@1.5.4(rolldown@1.0.0-beta.29)(rollup@4.52.0)(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(less@4.1.3)(lightningcss@1.30.1)(sass-embedded@1.91.0)(sass@1.91.0)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.1)):
optionalDependencies:
rolldown: 1.0.0-beta.29
rollup: 4.52.0
@@ -28850,9 +29071,9 @@ snapshots:
'@rollup/pluginutils': 5.1.4(rollup@4.52.0)
rollup: 4.52.0
rollup-plugin-webpack-stats@2.1.8(rolldown@1.0.0-beta.29)(rollup@4.52.0)(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(less@4.1.3)(lightningcss@1.30.1)(sass-embedded@1.91.0)(sass@1.91.0)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.1)):
rollup-plugin-webpack-stats@2.1.9(rolldown@1.0.0-beta.29)(rollup@4.52.0)(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(less@4.1.3)(lightningcss@1.30.1)(sass-embedded@1.91.0)(sass@1.91.0)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.1)):
dependencies:
rollup-plugin-stats: 1.5.3(rolldown@1.0.0-beta.29)(rollup@4.52.0)(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(less@4.1.3)(lightningcss@1.30.1)(sass-embedded@1.91.0)(sass@1.91.0)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.1))
rollup-plugin-stats: 1.5.4(rolldown@1.0.0-beta.29)(rollup@4.52.0)(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(less@4.1.3)(lightningcss@1.30.1)(sass-embedded@1.91.0)(sass@1.91.0)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.1))
optionalDependencies:
rolldown: 1.0.0-beta.29
rollup: 4.52.0
@@ -28965,7 +29186,7 @@ snapshots:
htmlparser2: 8.0.2
is-plain-object: 5.0.0
parse-srcset: 1.0.2
postcss: 8.5.3
postcss: 8.5.6
sass-embedded-all-unknown@1.91.0:
dependencies: