mirror of
https://github.com/zadam/trilium.git
synced 2026-03-26 22:00:12 +01:00
Compare commits
4 Commits
feature/st
...
feature/im
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0e052bffe8 | ||
|
|
3fa2673b55 | ||
|
|
a2130f4aa3 | ||
|
|
6c9246bc5b |
2
.github/actions/build-server/action.yml
vendored
2
.github/actions/build-server/action.yml
vendored
@@ -8,7 +8,7 @@ inputs:
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- uses: pnpm/action-setup@v5
|
||||
- uses: pnpm/action-setup@v4
|
||||
- name: Set up node & dependencies
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
|
||||
2
.github/actions/report-size/action.yml
vendored
2
.github/actions/report-size/action.yml
vendored
@@ -69,7 +69,7 @@ runs:
|
||||
|
||||
# Post github action comment
|
||||
- name: Post comment
|
||||
uses: marocchino/sticky-pull-request-comment@v3
|
||||
uses: marocchino/sticky-pull-request-comment@v2
|
||||
if: ${{ steps.bundleSize.outputs.hasDifferences == 'true' }} # post only in case of changes
|
||||
with:
|
||||
number: ${{ github.event.pull_request.number }}
|
||||
|
||||
67
.github/workflows/deploy-app.yml
vendored
67
.github/workflows/deploy-app.yml
vendored
@@ -1,67 +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: Trigger build of app
|
||||
run: pnpm --filter=client-standalone build
|
||||
|
||||
- name: Deploy
|
||||
uses: ./.github/actions/deploy-to-cloudflare-pages
|
||||
if: github.repository == vars.REPO_MAIN
|
||||
with:
|
||||
project_name: "trilium-app"
|
||||
comment_body: "🖥️ App preview is ready"
|
||||
production_url: "https://app.triliumnotes.org"
|
||||
deploy_dir: "apps/client-standalone/dist"
|
||||
cloudflare_api_token: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
cloudflare_account_id: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
||||
github_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
2
.github/workflows/deploy-docs.yml
vendored
2
.github/workflows/deploy-docs.yml
vendored
@@ -45,7 +45,7 @@ jobs:
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v5
|
||||
uses: pnpm/action-setup@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
|
||||
41
.github/workflows/dev.yml
vendored
41
.github/workflows/dev.yml
vendored
@@ -1,9 +1,9 @@
|
||||
name: Dev
|
||||
on:
|
||||
push:
|
||||
branches: [ main, standalone ]
|
||||
branches: [ main ]
|
||||
pull_request:
|
||||
branches: [ main, standalone ]
|
||||
branches: [ main ]
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
@@ -26,7 +26,7 @@ jobs:
|
||||
- name: Checkout the repository
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- uses: pnpm/action-setup@v5
|
||||
- uses: pnpm/action-setup@v4
|
||||
- name: Set up node & dependencies
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
@@ -40,32 +40,11 @@ jobs:
|
||||
- name: Run the client-side tests
|
||||
run: pnpm run --filter=client test
|
||||
|
||||
- name: Upload client test report
|
||||
uses: actions/upload-artifact@v7
|
||||
if: always()
|
||||
with:
|
||||
name: client-test-report
|
||||
path: apps/client/test-output/vitest/html/
|
||||
retention-days: 30
|
||||
|
||||
- name: Run the server-side tests
|
||||
run: pnpm run --filter=server test
|
||||
|
||||
- name: Upload server test report
|
||||
uses: actions/upload-artifact@v7
|
||||
if: always()
|
||||
with:
|
||||
name: server-test-report
|
||||
path: apps/server/test-output/vitest/html/
|
||||
retention-days: 30
|
||||
|
||||
- name: Run CKEditor e2e tests
|
||||
run: |
|
||||
pnpm run --filter=ckeditor5-mermaid test
|
||||
pnpm run --filter=ckeditor5-math test
|
||||
|
||||
- name: Run the rest of the tests
|
||||
run: pnpm run --filter=\!client --filter=\!server --filter=\!ckeditor5-mermaid --filter=\!ckeditor5-math test
|
||||
run: pnpm run --filter=\!client --filter=\!server test"
|
||||
|
||||
build_docker:
|
||||
name: Build Docker image
|
||||
@@ -74,7 +53,7 @@ jobs:
|
||||
- test_dev
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: pnpm/action-setup@v5
|
||||
- uses: pnpm/action-setup@v4
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
- name: Update build info
|
||||
@@ -89,8 +68,8 @@ jobs:
|
||||
key: ${{ secrets.RELATIVE_CI_CLIENT_KEY }}
|
||||
- name: Trigger server build
|
||||
run: pnpm run server:build
|
||||
- uses: docker/setup-buildx-action@v4
|
||||
- uses: docker/build-push-action@v7
|
||||
- uses: docker/setup-buildx-action@v3
|
||||
- uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: apps/server
|
||||
cache-from: type=gha
|
||||
@@ -109,7 +88,7 @@ jobs:
|
||||
- name: Checkout the repository
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- uses: pnpm/action-setup@v5
|
||||
- uses: pnpm/action-setup@v4
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
@@ -124,10 +103,10 @@ jobs:
|
||||
run: echo "TEST_TAG=${TEST_TAG,,}" >> $GITHUB_ENV
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v4
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Build and export to Docker
|
||||
uses: docker/build-push-action@v7
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: apps/server
|
||||
file: apps/server/${{ matrix.dockerfile }}
|
||||
|
||||
2
.github/workflows/i18n.yml
vendored
2
.github/workflows/i18n.yml
vendored
@@ -18,7 +18,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: pnpm/action-setup@v5
|
||||
- uses: pnpm/action-setup@v4
|
||||
- name: Set up node & dependencies
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
|
||||
26
.github/workflows/main-docker.yml
vendored
26
.github/workflows/main-docker.yml
vendored
@@ -40,9 +40,9 @@ jobs:
|
||||
run: echo "TEST_TAG=${TEST_TAG,,}" >> $GITHUB_ENV
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v4
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- uses: pnpm/action-setup@v5
|
||||
- uses: pnpm/action-setup@v4
|
||||
- name: Set up node & dependencies
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
@@ -59,7 +59,7 @@ jobs:
|
||||
run: pnpm run server:build
|
||||
|
||||
- name: Build and export to Docker
|
||||
uses: docker/build-push-action@v7
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: apps/server
|
||||
file: apps/server/${{ matrix.dockerfile }}
|
||||
@@ -142,7 +142,7 @@ jobs:
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
- uses: pnpm/action-setup@v5
|
||||
- uses: pnpm/action-setup@v4
|
||||
- name: Set up node & dependencies
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
@@ -164,7 +164,7 @@ jobs:
|
||||
|
||||
- name: Docker meta
|
||||
id: meta
|
||||
uses: docker/metadata-action@v6
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ${{ env.GHCR_REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||
tags: |
|
||||
@@ -175,13 +175,13 @@ jobs:
|
||||
latest=false
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v4
|
||||
uses: docker/setup-qemu-action@v3
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v4
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Login to GHCR
|
||||
uses: docker/login-action@v4
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.GHCR_REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
@@ -189,7 +189,7 @@ jobs:
|
||||
|
||||
- name: Build and push by digest
|
||||
id: build
|
||||
uses: docker/build-push-action@v7
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: apps/server
|
||||
file: apps/server/${{ matrix.dockerfile }}
|
||||
@@ -229,17 +229,17 @@ jobs:
|
||||
run: echo "TEST_TAG=${TEST_TAG,,}" >> $GITHUB_ENV
|
||||
|
||||
- name: Set up crane
|
||||
uses: imjasonh/setup-crane@v0.5
|
||||
uses: imjasonh/setup-crane@v0.4
|
||||
|
||||
- name: Login to GHCR
|
||||
uses: docker/login-action@v4
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.GHCR_REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Login to DockerHub
|
||||
uses: docker/login-action@v4
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.DOCKERHUB_REGISTRY }}
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
@@ -247,7 +247,7 @@ jobs:
|
||||
|
||||
- name: Docker meta
|
||||
id: meta
|
||||
uses: docker/metadata-action@v6
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ${{ env.GHCR_REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||
tags: |
|
||||
|
||||
6
.github/workflows/nightly.yml
vendored
6
.github/workflows/nightly.yml
vendored
@@ -61,7 +61,7 @@ jobs:
|
||||
runs-on: ${{ matrix.os.image }}
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: pnpm/action-setup@v5
|
||||
- uses: pnpm/action-setup@v4
|
||||
- name: Set up node & dependencies
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
@@ -91,7 +91,7 @@ jobs:
|
||||
GPG_SIGNING_KEY: ${{ secrets.GPG_SIGN_KEY }}
|
||||
|
||||
- name: Publish release
|
||||
uses: softprops/action-gh-release@v2.6.1
|
||||
uses: softprops/action-gh-release@v2.5.0
|
||||
if: ${{ github.event_name != 'pull_request' }}
|
||||
with:
|
||||
make_latest: false
|
||||
@@ -132,7 +132,7 @@ jobs:
|
||||
arch: ${{ matrix.arch }}
|
||||
|
||||
- name: Publish release
|
||||
uses: softprops/action-gh-release@v2.6.1
|
||||
uses: softprops/action-gh-release@v2.5.0
|
||||
if: ${{ github.event_name != 'pull_request' }}
|
||||
with:
|
||||
make_latest: false
|
||||
|
||||
2
.github/workflows/playwright.yml
vendored
2
.github/workflows/playwright.yml
vendored
@@ -38,7 +38,7 @@ jobs:
|
||||
filter: tree:0
|
||||
fetch-depth: 0
|
||||
|
||||
- uses: pnpm/action-setup@v5
|
||||
- uses: pnpm/action-setup@v4
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 24
|
||||
|
||||
6
.github/workflows/release.yml
vendored
6
.github/workflows/release.yml
vendored
@@ -17,7 +17,7 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- uses: pnpm/action-setup@v5
|
||||
- uses: pnpm/action-setup@v4
|
||||
- name: Set up node & dependencies
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
@@ -66,7 +66,7 @@ jobs:
|
||||
runs-on: ${{ matrix.os.image }}
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: pnpm/action-setup@v5
|
||||
- uses: pnpm/action-setup@v4
|
||||
- name: Set up node & dependencies
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
@@ -150,7 +150,7 @@ jobs:
|
||||
path: upload
|
||||
|
||||
- name: Publish stable release
|
||||
uses: softprops/action-gh-release@v2.6.1
|
||||
uses: softprops/action-gh-release@v2.5.0
|
||||
with:
|
||||
draft: false
|
||||
body_path: docs/Release Notes/Release Notes/${{ github.ref_name }}.md
|
||||
|
||||
4
.github/workflows/web-clipper.yml
vendored
4
.github/workflows/web-clipper.yml
vendored
@@ -32,7 +32,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: pnpm/action-setup@v5
|
||||
- uses: pnpm/action-setup@v4
|
||||
- name: Set up node & dependencies
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
@@ -58,7 +58,7 @@ jobs:
|
||||
compression-level: 0
|
||||
|
||||
- name: Release web clipper extension
|
||||
uses: softprops/action-gh-release@v2.6.1
|
||||
uses: softprops/action-gh-release@v2.5.0
|
||||
if: ${{ startsWith(github.ref, 'refs/tags/web-clipper-v') }}
|
||||
with:
|
||||
draft: false
|
||||
|
||||
2
.github/workflows/website.yml
vendored
2
.github/workflows/website.yml
vendored
@@ -26,7 +26,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: pnpm/action-setup@v5
|
||||
- uses: pnpm/action-setup@v4
|
||||
- name: Set up node & dependencies
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
|
||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -46,11 +46,9 @@ upload
|
||||
|
||||
/.direnv
|
||||
/result
|
||||
.svelte-kit
|
||||
|
||||
# docs
|
||||
site/
|
||||
apps/*/coverage
|
||||
scripts/translation/.language*.json
|
||||
|
||||
# AI
|
||||
.claude/settings.local.json
|
||||
261
CLAUDE.md
261
CLAUDE.md
@@ -4,184 +4,149 @@ 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
|
||||
server-e2e/ # Playwright E2E tests for server
|
||||
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
|
||||
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.
|
||||
#### 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`)
|
||||
|
||||
Fluent builder pattern: `.child()`, `.class()`, `.css()` chaining with position-based ordering.
|
||||
### Key Files for Understanding Architecture
|
||||
|
||||
### API Architecture
|
||||
1. **Application Entry Points**:
|
||||
- `apps/server/src/main.ts` - Server startup
|
||||
- `apps/client/src/desktop.ts` - Client initialization
|
||||
|
||||
- **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
|
||||
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
|
||||
|
||||
### Platform Abstraction
|
||||
3. **Database Schema**:
|
||||
- `apps/server/src/assets/db/schema.sql` - Core database structure
|
||||
|
||||
`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()`.
|
||||
4. **Configuration**:
|
||||
- `package.json` - Project dependencies and scripts
|
||||
|
||||
### Database
|
||||
## Note Types and Features
|
||||
|
||||
SQLite via `better-sqlite3`. SQL abstraction in `packages/trilium-core/src/services/sql/` with `DatabaseProvider` interface, prepared statement caching, and transaction support.
|
||||
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
|
||||
|
||||
- Schema: `apps/server/src/assets/db/schema.sql`
|
||||
- Migrations: `apps/server/src/migrations/YYMMDD_HHMM__description.sql`
|
||||
## Development Guidelines
|
||||
|
||||
### Attribute Inheritance
|
||||
### Testing Strategy
|
||||
- Server tests run sequentially due to shared database
|
||||
- Client tests can run in parallel
|
||||
- E2E tests use Playwright for both server and desktop apps
|
||||
- Build validation tests check artifact integrity
|
||||
|
||||
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
|
||||
### 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/`
|
||||
|
||||
Use `note.getOwnedAttribute()` for direct, `note.getAttribute()` for inherited.
|
||||
### Internationalization
|
||||
- Translation files in `apps/client/src/translations/`
|
||||
- Supported languages: English, German, Spanish, French, Romanian, Chinese
|
||||
|
||||
## Important Patterns
|
||||
### Security Considerations
|
||||
- Per-note encryption with granular protected sessions
|
||||
- CSRF protection for API endpoints
|
||||
- OpenID and TOTP authentication support
|
||||
- Sanitization of user-generated content
|
||||
|
||||
- **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
|
||||
## Common Development Tasks
|
||||
|
||||
## Code Style
|
||||
### 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`
|
||||
|
||||
- 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`
|
||||
### Extending Search
|
||||
- Search expressions handled in `apps/server/src/services/search/`
|
||||
- Add new search operators in search context files
|
||||
|
||||
## Testing
|
||||
### Custom CKEditor Plugins
|
||||
- Create new package in `packages/` following existing plugin structure
|
||||
- Register in `packages/ckeditor5/src/plugins.ts`
|
||||
|
||||
- **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** (`apps/server-e2e/`): Playwright, Chromium, server started automatically on port 8082
|
||||
- **ETAPI tests** (`apps/server/spec/etapi/`): External API contract tests
|
||||
### Database Migrations
|
||||
- Add migration scripts in `apps/server/src/migrations/`
|
||||
- Update schema in `apps/server/src/assets/db/schema.sql`
|
||||
|
||||
## 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
|
||||
## Build System Notes
|
||||
- Uses pnpm for monorepo management
|
||||
- Vite for fast development builds
|
||||
- ESBuild for production optimization
|
||||
- pnpm workspaces for dependency management
|
||||
- Docker support with multi-stage builds
|
||||
@@ -14,9 +14,9 @@
|
||||
"keywords": [],
|
||||
"author": "Elian Doran <contact@eliandoran.me>",
|
||||
"license": "AGPL-3.0-only",
|
||||
"packageManager": "pnpm@10.32.1",
|
||||
"packageManager": "pnpm@10.30.3",
|
||||
"devDependencies": {
|
||||
"@redocly/cli": "2.24.1",
|
||||
"@redocly/cli": "2.20.2",
|
||||
"archiver": "7.0.1",
|
||||
"fs-extra": "11.3.4",
|
||||
"js-yaml": "4.1.1",
|
||||
|
||||
@@ -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
|
||||
@@ -1 +0,0 @@
|
||||
VITE_CKEDITOR_ENABLE_INSPECTOR=false
|
||||
@@ -1,87 +0,0 @@
|
||||
{
|
||||
"name": "@triliumnext/client-standalone",
|
||||
"version": "0.102.1",
|
||||
"description": "Standalone client for TriliumNext with SQLite WASM backend",
|
||||
"private": true,
|
||||
"license": "AGPL-3.0-only",
|
||||
"scripts": {
|
||||
"build": "cross-env NODE_OPTIONS=--max-old-space-size=4096 vite build",
|
||||
"dev": "vite dev",
|
||||
"test": "vitest",
|
||||
"start-prod": "pnpm build && pnpm http-server dist -p 8888",
|
||||
"coverage": "vitest --coverage"
|
||||
},
|
||||
"dependencies": {
|
||||
"@excalidraw/excalidraw": "0.18.0",
|
||||
"@fullcalendar/core": "6.1.20",
|
||||
"@fullcalendar/daygrid": "6.1.20",
|
||||
"@fullcalendar/interaction": "6.1.20",
|
||||
"@fullcalendar/list": "6.1.20",
|
||||
"@fullcalendar/multimonth": "6.1.20",
|
||||
"@fullcalendar/timegrid": "6.1.20",
|
||||
"@maplibre/maplibre-gl-leaflet": "0.1.3",
|
||||
"@mermaid-js/layout-elk": "0.2.0",
|
||||
"@mind-elixir/node-menu": "5.0.1",
|
||||
"@popperjs/core": "2.11.8",
|
||||
"@preact/signals": "2.5.1",
|
||||
"@sqlite.org/sqlite-wasm": "3.51.1-build2",
|
||||
"@triliumnext/ckeditor5": "workspace:*",
|
||||
"@triliumnext/codemirror": "workspace:*",
|
||||
"@triliumnext/commons": "workspace:*",
|
||||
"@triliumnext/core": "workspace:*",
|
||||
"@triliumnext/highlightjs": "workspace:*",
|
||||
"@triliumnext/share-theme": "workspace:*",
|
||||
"@triliumnext/split.js": "workspace:*",
|
||||
"@zumer/snapdom": "2.0.1",
|
||||
"autocomplete.js": "0.38.1",
|
||||
"bootstrap": "5.3.8",
|
||||
"boxicons": "2.1.4",
|
||||
"clsx": "2.1.1",
|
||||
"color": "5.0.3",
|
||||
"debounce": "3.0.0",
|
||||
"draggabilly": "3.0.0",
|
||||
"force-graph": "1.51.0",
|
||||
"globals": "17.0.0",
|
||||
"i18next": "25.7.3",
|
||||
"i18next-http-backend": "3.0.2",
|
||||
"jquery": "3.7.1",
|
||||
"jquery.fancytree": "2.38.5",
|
||||
"js-sha1": "0.7.0",
|
||||
"js-sha256": "0.11.1",
|
||||
"js-sha512": "0.9.0",
|
||||
"jsplumb": "2.15.6",
|
||||
"katex": "0.16.27",
|
||||
"knockout": "3.5.1",
|
||||
"leaflet": "1.9.4",
|
||||
"leaflet-gpx": "2.2.0",
|
||||
"mark.js": "8.11.1",
|
||||
"marked": "17.0.1",
|
||||
"mermaid": "11.12.2",
|
||||
"mind-elixir": "5.4.0",
|
||||
"normalize.css": "8.0.1",
|
||||
"panzoom": "9.4.3",
|
||||
"preact": "10.28.2",
|
||||
"react-i18next": "16.5.1",
|
||||
"react-window": "2.2.3",
|
||||
"reveal.js": "5.2.1",
|
||||
"svg-pan-zoom": "3.6.2",
|
||||
"tabulator-tables": "6.3.1",
|
||||
"vanilla-js-wheel-zoom": "9.0.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@ckeditor/ckeditor5-inspector": "5.0.0",
|
||||
"@preact/preset-vite": "2.10.2",
|
||||
"@types/bootstrap": "5.2.10",
|
||||
"@types/jquery": "3.5.33",
|
||||
"@types/leaflet": "1.9.21",
|
||||
"@types/leaflet-gpx": "1.3.8",
|
||||
"@types/mark.js": "8.11.12",
|
||||
"@types/reveal.js": "5.2.2",
|
||||
"@types/tabulator-tables": "6.3.1",
|
||||
"copy-webpack-plugin": "13.0.1",
|
||||
"cross-env": "7.0.3",
|
||||
"happy-dom": "20.0.11",
|
||||
"script-loader": "0.7.2",
|
||||
"vite-plugin-static-copy": "3.1.4"
|
||||
}
|
||||
}
|
||||
@@ -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 |
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
// Re-export desktop from client
|
||||
export * from "../../client/src/desktop";
|
||||
@@ -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>
|
||||
@@ -1,283 +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 BrowserRequest {
|
||||
method: string;
|
||||
url: string;
|
||||
path: string;
|
||||
params: Record<string, string>;
|
||||
query: Record<string, string | undefined>;
|
||||
headers?: Record<string, string>;
|
||||
body?: unknown;
|
||||
}
|
||||
|
||||
export interface BrowserResponse {
|
||||
status: number;
|
||||
headers: Record<string, string>;
|
||||
body: ArrayBuffer | null;
|
||||
}
|
||||
|
||||
export type RouteHandler = (req: BrowserRequest) => unknown | Promise<unknown>;
|
||||
|
||||
interface Route {
|
||||
method: string;
|
||||
pattern: RegExp;
|
||||
paramNames: string[];
|
||||
handler: RouteHandler;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 JSON body if it's an ArrayBuffer and content-type suggests JSON
|
||||
let parsedBody = body;
|
||||
if (body instanceof ArrayBuffer && headers) {
|
||||
const contentType = headers['content-type'] || headers['Content-Type'] || '';
|
||||
if (contentType.includes('application/json')) {
|
||||
try {
|
||||
const text = new TextDecoder().decode(body);
|
||||
if (text.trim()) {
|
||||
parsedBody = JSON.parse(text);
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[Router] Failed to parse JSON body:', e);
|
||||
// Keep original body if JSON parsing fails
|
||||
parsedBody = body;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Find matching route
|
||||
for (const route of this.routes) {
|
||||
if (route.method !== upperMethod) continue;
|
||||
|
||||
const match = path.match(route.pattern);
|
||||
if (!match) continue;
|
||||
|
||||
// Extract path parameters
|
||||
const params: Record<string, string> = {};
|
||||
for (let i = 0; i < route.paramNames.length; i++) {
|
||||
params[route.paramNames[i]] = decodeURIComponent(match[i + 1]);
|
||||
}
|
||||
|
||||
const request: BrowserRequest = {
|
||||
method: upperMethod,
|
||||
url: urlString,
|
||||
path,
|
||||
params,
|
||||
query,
|
||||
headers: headers ?? {},
|
||||
body: parsedBody
|
||||
};
|
||||
|
||||
try {
|
||||
const result = await getContext().init(async () => await route.handler(request));
|
||||
return this.formatResult(result);
|
||||
} catch (error) {
|
||||
return this.formatError(error, `Error handling ${method} ${path}`);
|
||||
}
|
||||
}
|
||||
|
||||
// No route matched
|
||||
return textResponse(`Not found: ${method} ${path}`, 404);
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a handler result into a response.
|
||||
* Follows the same patterns as the server's apiResultHandler.
|
||||
*/
|
||||
private formatResult(result: unknown): BrowserResponse {
|
||||
// Handle 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();
|
||||
}
|
||||
@@ -1,289 +0,0 @@
|
||||
/**
|
||||
* Browser route definitions.
|
||||
* This integrates with the shared route builder from @triliumnext/core.
|
||||
*/
|
||||
|
||||
import { BootstrapDefinition } from '@triliumnext/commons';
|
||||
import { entity_changes, getContext, 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,
|
||||
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;
|
||||
});
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 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;
|
||||
},
|
||||
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;
|
||||
}
|
||||
};
|
||||
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: createRoute(router),
|
||||
apiRoute,
|
||||
asyncApiRoute: createApiRoute(router, false),
|
||||
apiResultHandler,
|
||||
checkApiAuth: noopMiddleware,
|
||||
checkApiAuthOrElectron: noopMiddleware,
|
||||
checkAppNotInitialized,
|
||||
checkCredentials: noopMiddleware,
|
||||
loginRateLimiter: 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);
|
||||
|
||||
if (!isDbInitialized) {
|
||||
return {
|
||||
...commonItems,
|
||||
isStandalone: true,
|
||||
baseApiUrl: "../api/",
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
...commonItems,
|
||||
appPath: assetPath,
|
||||
device: false, // Let the client detect device type.
|
||||
csrfToken: "dummy-csrf-token",
|
||||
themeCssUrl: false,
|
||||
themeUseNextAsBase: "next",
|
||||
triliumVersion: packageJson.version,
|
||||
baseApiUrl: "../api/",
|
||||
headingStyle: "plain",
|
||||
layoutOrientation: "vertical",
|
||||
platform: "web",
|
||||
isDev: import.meta.env.DEV,
|
||||
isMainWindow: true,
|
||||
isElectron: false,
|
||||
isStandalone: true,
|
||||
hasNativeTitleBar: false,
|
||||
hasBackgroundEffects: false,
|
||||
|
||||
// TODO: Fill properly
|
||||
currentLocale: { id: "en", name: "English", rtl: false },
|
||||
isRtl: false,
|
||||
instanceName: null,
|
||||
appCssNoteIds: [],
|
||||
TRILIUM_SAFE_MODE: false
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create and configure a router with all routes registered.
|
||||
*/
|
||||
export function createConfiguredRouter(): BrowserRouter {
|
||||
const router = new BrowserRouter();
|
||||
registerRoutes(router);
|
||||
return router;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -1,158 +0,0 @@
|
||||
import type { CryptoProvider } from "@triliumnext/core";
|
||||
import { sha1 } from "js-sha1";
|
||||
import { sha256 } from "js-sha256";
|
||||
import { sha512 } from "js-sha512";
|
||||
|
||||
interface Cipher {
|
||||
update(data: Uint8Array): Uint8Array;
|
||||
final(): Uint8Array;
|
||||
}
|
||||
|
||||
const CHARS = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
|
||||
|
||||
/**
|
||||
* Crypto provider for browser environments using the Web Crypto API.
|
||||
*/
|
||||
export default class BrowserCryptoProvider implements CryptoProvider {
|
||||
|
||||
createHash(algorithm: "sha1" | "sha512", content: string | Uint8Array): Uint8Array {
|
||||
const data = typeof content === "string" ? content :
|
||||
new TextDecoder().decode(content);
|
||||
|
||||
const hexHash = algorithm === "sha1" ? sha1(data) : sha512(data);
|
||||
|
||||
// Convert hex string to Uint8Array
|
||||
const bytes = new Uint8Array(hexHash.length / 2);
|
||||
for (let i = 0; i < hexHash.length; i += 2) {
|
||||
bytes[i / 2] = parseInt(hexHash.substr(i, 2), 16);
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
|
||||
createCipheriv(algorithm: "aes-128-cbc", key: Uint8Array, iv: Uint8Array): Cipher {
|
||||
// Web Crypto API doesn't support streaming cipher like Node.js
|
||||
// We need to implement a wrapper that collects data and encrypts on final()
|
||||
return new WebCryptoCipher(algorithm, key, iv, "encrypt");
|
||||
}
|
||||
|
||||
createDecipheriv(algorithm: "aes-128-cbc", key: Uint8Array, iv: Uint8Array): Cipher {
|
||||
return new WebCryptoCipher(algorithm, key, iv, "decrypt");
|
||||
}
|
||||
|
||||
randomBytes(size: number): Uint8Array {
|
||||
const bytes = new Uint8Array(size);
|
||||
crypto.getRandomValues(bytes);
|
||||
return bytes;
|
||||
}
|
||||
|
||||
randomString(length: number): string {
|
||||
const bytes = this.randomBytes(length);
|
||||
let result = "";
|
||||
for (let i = 0; i < length; i++) {
|
||||
result += CHARS[bytes[i] % CHARS.length];
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
hmac(secret: string | Uint8Array, value: string | Uint8Array): string {
|
||||
const secretStr = typeof secret === "string" ? secret : new TextDecoder().decode(secret);
|
||||
const valueStr = typeof value === "string" ? value : new TextDecoder().decode(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));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A cipher implementation that wraps Web Crypto API.
|
||||
* Note: This buffers all data until final() is called, which differs from
|
||||
* Node.js's streaming cipher behavior.
|
||||
*/
|
||||
class WebCryptoCipher implements Cipher {
|
||||
private chunks: Uint8Array[] = [];
|
||||
private algorithm: string;
|
||||
private key: Uint8Array;
|
||||
private iv: Uint8Array;
|
||||
private mode: "encrypt" | "decrypt";
|
||||
private finalized = false;
|
||||
|
||||
constructor(
|
||||
algorithm: "aes-128-cbc",
|
||||
key: Uint8Array,
|
||||
iv: Uint8Array,
|
||||
mode: "encrypt" | "decrypt"
|
||||
) {
|
||||
this.algorithm = algorithm;
|
||||
this.key = key;
|
||||
this.iv = iv;
|
||||
this.mode = mode;
|
||||
}
|
||||
|
||||
update(data: Uint8Array): Uint8Array {
|
||||
if (this.finalized) {
|
||||
throw new Error("Cipher has already been finalized");
|
||||
}
|
||||
// Buffer the data - Web Crypto doesn't support streaming
|
||||
this.chunks.push(data);
|
||||
// Return empty array since we process everything in final()
|
||||
return new Uint8Array(0);
|
||||
}
|
||||
|
||||
final(): Uint8Array {
|
||||
if (this.finalized) {
|
||||
throw new Error("Cipher has already been finalized");
|
||||
}
|
||||
this.finalized = true;
|
||||
|
||||
// Web Crypto API is async, but we need sync behavior
|
||||
// This is a fundamental limitation that requires architectural changes
|
||||
// For now, throw an error directing users to use async methods
|
||||
throw new Error(
|
||||
"Synchronous cipher finalization not available in browser. " +
|
||||
"The Web Crypto API is async-only. Use finalizeAsync() instead."
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Async version that actually performs the encryption/decryption.
|
||||
*/
|
||||
async finalizeAsync(): Promise<Uint8Array> {
|
||||
if (this.finalized) {
|
||||
throw new Error("Cipher has already been finalized");
|
||||
}
|
||||
this.finalized = true;
|
||||
|
||||
// Concatenate all chunks
|
||||
const totalLength = this.chunks.reduce((sum, chunk) => sum + chunk.length, 0);
|
||||
const data = new Uint8Array(totalLength);
|
||||
let offset = 0;
|
||||
for (const chunk of this.chunks) {
|
||||
data.set(chunk, offset);
|
||||
offset += chunk.length;
|
||||
}
|
||||
|
||||
// Copy key and iv to ensure they're plain ArrayBuffer-backed
|
||||
const keyBuffer = new Uint8Array(this.key);
|
||||
const ivBuffer = new Uint8Array(this.iv);
|
||||
|
||||
// Import the key
|
||||
const cryptoKey = await crypto.subtle.importKey(
|
||||
"raw",
|
||||
keyBuffer,
|
||||
{ name: "AES-CBC" },
|
||||
false,
|
||||
[this.mode]
|
||||
);
|
||||
|
||||
// Perform encryption/decryption
|
||||
const result = this.mode === "encrypt"
|
||||
? await crypto.subtle.encrypt({ name: "AES-CBC", iv: ivBuffer }, cryptoKey, data)
|
||||
: await crypto.subtle.decrypt({ name: "AES-CBC", iv: ivBuffer }, cryptoKey, data);
|
||||
|
||||
return new Uint8Array(result);
|
||||
}
|
||||
}
|
||||
@@ -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 = [];
|
||||
}
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
import type { PlatformProvider } from "@triliumnext/core";
|
||||
|
||||
export default class StandalonePlatformProvider implements PlatformProvider {
|
||||
crash(message: string): void {
|
||||
console.error("[Standalone] FATAL:", message);
|
||||
self.postMessage({
|
||||
type: "FATAL_ERROR",
|
||||
message
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -1,613 +0,0 @@
|
||||
import { type BindableValue, 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;
|
||||
|
||||
/**
|
||||
* Get the SQLite WASM module version info.
|
||||
* Returns undefined if the module hasn't been initialized yet.
|
||||
*/
|
||||
get version(): { libVersion: string; sourceId: string } | undefined {
|
||||
return this.sqlite3?.version;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the SQLite WASM module.
|
||||
* This must be called before using any database operations.
|
||||
* Safe to call multiple times - subsequent calls return the same promise.
|
||||
*
|
||||
* @returns A promise that resolves when the module is initialized
|
||||
* @throws Error if initialization fails
|
||||
*/
|
||||
async initWasm(): Promise<void> {
|
||||
// Return existing promise if already initializing/initialized
|
||||
if (this.initPromise) {
|
||||
return this.initPromise;
|
||||
}
|
||||
|
||||
// Fail fast if we already tried and failed
|
||||
if (this.initError) {
|
||||
throw this.initError;
|
||||
}
|
||||
|
||||
this.initPromise = this.doInitWasm();
|
||||
return this.initPromise;
|
||||
}
|
||||
|
||||
private async doInitWasm(): Promise<void> {
|
||||
try {
|
||||
console.log("[BrowserSqlProvider] Initializing SQLite WASM...");
|
||||
const startTime = performance.now();
|
||||
|
||||
this.sqlite3 = await sqlite3InitModule({
|
||||
print: console.log,
|
||||
printErr: console.error,
|
||||
});
|
||||
|
||||
const initTime = performance.now() - startTime;
|
||||
console.log(
|
||||
`[BrowserSqlProvider] SQLite WASM initialized in ${initTime.toFixed(2)}ms:`,
|
||||
this.sqlite3.version.libVersion
|
||||
);
|
||||
} catch (e) {
|
||||
this.initError = e instanceof Error ? e : new Error(String(e));
|
||||
console.error("[BrowserSqlProvider] SQLite WASM initialization failed:", this.initError);
|
||||
throw this.initError;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the SQLite WASM module has been initialized.
|
||||
*/
|
||||
get isInitialized(): boolean {
|
||||
return this.sqlite3 !== undefined;
|
||||
}
|
||||
|
||||
// ==================== OPFS Support ====================
|
||||
|
||||
/**
|
||||
* Check if the OPFS VFS is available.
|
||||
* This requires:
|
||||
* - Running in a Worker context
|
||||
* - Browser support for OPFS APIs
|
||||
* - COOP/COEP headers sent by the server (for SharedArrayBuffer)
|
||||
*
|
||||
* @returns true if OPFS VFS is available for use
|
||||
*/
|
||||
isOpfsAvailable(): boolean {
|
||||
this.ensureSqlite3();
|
||||
// SQLite WASM automatically installs the OPFS VFS if the environment supports it
|
||||
// We can check for its presence via sqlite3_vfs_find or the OpfsDb class
|
||||
return this.sqlite3!.oo1.OpfsDb !== undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load or create a database stored in OPFS for persistent storage.
|
||||
* The database will persist across browser sessions.
|
||||
*
|
||||
* Requires COOP/COEP headers to be set by the server:
|
||||
* - Cross-Origin-Opener-Policy: same-origin
|
||||
* - Cross-Origin-Embedder-Policy: require-corp
|
||||
*
|
||||
* @param path - The path for the database file in OPFS (e.g., "/trilium.db")
|
||||
* Paths without a leading slash are treated as relative to OPFS root.
|
||||
* Leading directories are created automatically.
|
||||
* @param options - Additional options
|
||||
* @throws Error if OPFS VFS is not available
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const provider = new BrowserSqlProvider();
|
||||
* await provider.initWasm();
|
||||
* if (provider.isOpfsAvailable()) {
|
||||
* provider.loadFromOpfs("/my-database.db");
|
||||
* } else {
|
||||
* console.warn("OPFS not available, using in-memory database");
|
||||
* provider.loadFromMemory();
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
loadFromOpfs(path: string, options: { createIfNotExists?: boolean } = {}): void {
|
||||
this.ensureSqlite3();
|
||||
|
||||
if (!this.isOpfsAvailable()) {
|
||||
throw new Error(
|
||||
"OPFS VFS is not available. This requires:\n" +
|
||||
"1. Running in a Worker context\n" +
|
||||
"2. Browser support for OPFS (Chrome 102+, Firefox 111+, Safari 17+)\n" +
|
||||
"3. COOP/COEP headers from the server:\n" +
|
||||
" Cross-Origin-Opener-Policy: same-origin\n" +
|
||||
" Cross-Origin-Embedder-Policy: require-corp"
|
||||
);
|
||||
}
|
||||
|
||||
console.log(`[BrowserSqlProvider] Loading database from OPFS: ${path}`);
|
||||
const startTime = performance.now();
|
||||
|
||||
try {
|
||||
// OpfsDb automatically creates directories in the path
|
||||
// Mode 'c' = create if not exists
|
||||
const mode = options.createIfNotExists !== false ? 'c' : '';
|
||||
this.db = new this.sqlite3!.oo1.OpfsDb(path, mode);
|
||||
this.opfsDbPath = path;
|
||||
|
||||
// Configure the database for OPFS
|
||||
// Note: WAL mode requires exclusive locking in OPFS environment
|
||||
this.db.exec("PRAGMA journal_mode = DELETE");
|
||||
this.db.exec("PRAGMA synchronous = NORMAL");
|
||||
|
||||
const loadTime = performance.now() - startTime;
|
||||
console.log(`[BrowserSqlProvider] OPFS database loaded in ${loadTime.toFixed(2)}ms`);
|
||||
} catch (e) {
|
||||
const error = e instanceof Error ? e : new Error(String(e));
|
||||
console.error(`[BrowserSqlProvider] Failed to load OPFS database: ${error.message}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the currently open database is stored in OPFS.
|
||||
*/
|
||||
get isUsingOpfs(): boolean {
|
||||
return this.opfsDbPath !== undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the OPFS path of the currently open database.
|
||||
* Returns undefined if not using OPFS.
|
||||
*/
|
||||
get currentOpfsPath(): string | undefined {
|
||||
return this.opfsDbPath;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the database has been initialized with a schema.
|
||||
* This is a simple sanity check that looks for the existence of core tables.
|
||||
*
|
||||
* @returns true if the database appears to be initialized
|
||||
*/
|
||||
isDbInitialized(): boolean {
|
||||
this.ensureDb();
|
||||
|
||||
// Check if the 'notes' table exists (a core table that must exist in an initialized DB)
|
||||
const tableExists = this.db!.selectValue(
|
||||
"SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'notes'"
|
||||
);
|
||||
|
||||
return tableExists !== undefined;
|
||||
}
|
||||
|
||||
// ==================== End OPFS Support ====================
|
||||
|
||||
loadFromFile(_path: string, _isReadOnly: boolean): void {
|
||||
// Browser environment doesn't have direct file system access.
|
||||
// Use OPFS for persistent storage.
|
||||
throw new Error(
|
||||
"loadFromFile is not supported in browser environment. " +
|
||||
"Use loadFromMemory() for temporary databases, loadFromBuffer() to load from data, " +
|
||||
"or loadFromOpfs() for persistent storage."
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an empty in-memory database.
|
||||
* Data will be lost when the page is closed.
|
||||
*
|
||||
* For persistent storage, use loadFromOpfs() instead.
|
||||
* To load demo data, call initializeDemoDatabase() after this.
|
||||
*/
|
||||
loadFromMemory(): void {
|
||||
this.ensureSqlite3();
|
||||
console.log("[BrowserSqlProvider] Creating in-memory database...");
|
||||
const startTime = performance.now();
|
||||
|
||||
this.db = new this.sqlite3!.oo1.DB(":memory:", "c");
|
||||
this.opfsDbPath = undefined; // Not using OPFS
|
||||
this.db.exec("PRAGMA journal_mode = WAL");
|
||||
|
||||
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 can deserialize a database from a byte array
|
||||
const p = this.sqlite3!.wasm.allocFromTypedArray(buffer);
|
||||
try {
|
||||
this.db = new this.sqlite3!.oo1.DB({ filename: ":memory:", flags: "c" });
|
||||
this.opfsDbPath = undefined; // Not using OPFS
|
||||
|
||||
const rc = this.sqlite3!.capi.sqlite3_deserialize(
|
||||
this.db.pointer!,
|
||||
"main",
|
||||
p,
|
||||
buffer.byteLength,
|
||||
buffer.byteLength,
|
||||
this.sqlite3!.capi.SQLITE_DESERIALIZE_FREEONCLOSE |
|
||||
this.sqlite3!.capi.SQLITE_DESERIALIZE_RESIZEABLE
|
||||
);
|
||||
if (rc !== 0) {
|
||||
throw new Error(`Failed to deserialize database: ${rc}`);
|
||||
}
|
||||
} catch (e) {
|
||||
this.sqlite3!.wasm.dealloc(p);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
backup(_destinationFile: string): void {
|
||||
// In browser, we can serialize the database to a byte array
|
||||
// For actual file backup, we'd need to use File System Access API or download
|
||||
throw new Error(
|
||||
"backup to file is not supported in browser environment. " +
|
||||
"Use serialize() to get the database as a Uint8Array instead."
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Serialize the database to a byte array.
|
||||
* This can be used to save the database to IndexedDB, download it, etc.
|
||||
*/
|
||||
serialize(): Uint8Array {
|
||||
this.ensureDb();
|
||||
// Use the convenience wrapper which handles all the memory management
|
||||
return this.sqlite3!.capi.sqlite3_js_db_export(this.db!);
|
||||
}
|
||||
|
||||
prepare(query: string): Statement {
|
||||
this.ensureDb();
|
||||
|
||||
// Check if we already have this statement cached
|
||||
if (this.statementCache.has(query)) {
|
||||
return this.statementCache.get(query)!;
|
||||
}
|
||||
|
||||
// Create new statement and cache it
|
||||
const stmt = this.db!.prepare(query);
|
||||
const wasmStatement = new WasmStatement(stmt, this.db!, this.sqlite3!, query);
|
||||
this.statementCache.set(query, wasmStatement);
|
||||
return wasmStatement;
|
||||
}
|
||||
|
||||
transaction<T>(func: (statement: Statement) => T): Transaction {
|
||||
this.ensureDb();
|
||||
|
||||
const self = this;
|
||||
let savepointCounter = 0;
|
||||
|
||||
// Helper function to execute within a transaction
|
||||
const executeTransaction = (beginStatement: string, ...args: unknown[]): T => {
|
||||
// If we're already in a transaction, use SAVEPOINTs for nesting
|
||||
// This mimics better-sqlite3's behavior
|
||||
if (self._inTransaction) {
|
||||
const savepointName = `sp_${++savepointCounter}_${Date.now()}`;
|
||||
self.db!.exec(`SAVEPOINT ${savepointName}`);
|
||||
try {
|
||||
const result = func.apply(null, args as [Statement]);
|
||||
self.db!.exec(`RELEASE SAVEPOINT ${savepointName}`);
|
||||
return result;
|
||||
} catch (e) {
|
||||
self.db!.exec(`ROLLBACK TO SAVEPOINT ${savepointName}`);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
// Not in a transaction, start a new one
|
||||
self._inTransaction = true;
|
||||
self.db!.exec(beginStatement);
|
||||
try {
|
||||
const result = func.apply(null, args as [Statement]);
|
||||
self.db!.exec("COMMIT");
|
||||
return result;
|
||||
} catch (e) {
|
||||
self.db!.exec("ROLLBACK");
|
||||
throw e;
|
||||
} finally {
|
||||
self._inTransaction = false;
|
||||
}
|
||||
};
|
||||
|
||||
// Create the transaction function that acts like better-sqlite3's Transaction interface
|
||||
// In better-sqlite3, the transaction function is callable and has .deferred(), .immediate(), etc.
|
||||
const transactionWrapper = Object.assign(
|
||||
// Default call executes with BEGIN (same as immediate)
|
||||
(...args: unknown[]): T => executeTransaction("BEGIN", ...args),
|
||||
{
|
||||
// Deferred transaction - locks acquired on first data access
|
||||
deferred: (...args: unknown[]): T => executeTransaction("BEGIN DEFERRED", ...args),
|
||||
// Immediate transaction - acquires write lock immediately
|
||||
immediate: (...args: unknown[]): T => executeTransaction("BEGIN IMMEDIATE", ...args),
|
||||
// Exclusive transaction - exclusive lock
|
||||
exclusive: (...args: unknown[]): T => executeTransaction("BEGIN EXCLUSIVE", ...args),
|
||||
// Default is same as calling directly
|
||||
default: (...args: unknown[]): T => executeTransaction("BEGIN", ...args)
|
||||
}
|
||||
);
|
||||
|
||||
return transactionWrapper as unknown as Transaction;
|
||||
}
|
||||
|
||||
get inTransaction(): boolean {
|
||||
return this._inTransaction;
|
||||
}
|
||||
|
||||
exec(query: string): void {
|
||||
this.ensureDb();
|
||||
this.db!.exec(query);
|
||||
}
|
||||
|
||||
close(): void {
|
||||
// Clean up all cached statements first
|
||||
for (const statement of this.statementCache.values()) {
|
||||
try {
|
||||
statement.finalize();
|
||||
} catch (e) {
|
||||
// Ignore errors during cleanup
|
||||
console.warn("Error finalizing statement during cleanup:", e);
|
||||
}
|
||||
}
|
||||
this.statementCache.clear();
|
||||
|
||||
if (this.db) {
|
||||
this.db.close();
|
||||
this.db = undefined;
|
||||
}
|
||||
|
||||
// Reset OPFS state
|
||||
this.opfsDbPath = undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the number of rows changed by the last INSERT, UPDATE, or DELETE statement.
|
||||
*/
|
||||
changes(): number {
|
||||
this.ensureDb();
|
||||
return this.db!.changes();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the database is currently open.
|
||||
*/
|
||||
isOpen(): boolean {
|
||||
return this.db !== undefined && this.db.isOpen();
|
||||
}
|
||||
|
||||
private ensureSqlite3(): void {
|
||||
if (!this.sqlite3) {
|
||||
throw new Error(
|
||||
"SQLite WASM module not initialized. Call initialize() first with the sqlite3 module."
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private ensureDb(): void {
|
||||
this.ensureSqlite3();
|
||||
if (!this.db) {
|
||||
throw new Error("Database not opened. Call loadFromMemory(), loadFromBuffer(), or loadFromOpfs() first.");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
});
|
||||
}
|
||||
@@ -1,109 +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();
|
||||
|
||||
// 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() {
|
||||
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
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -1,265 +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';
|
||||
|
||||
// =============================================================================
|
||||
// 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 FetchRequestProvider: typeof import('./lightweight/request_provider').default;
|
||||
let StandalonePlatformProvider: typeof import('./lightweight/platform_provider').default;
|
||||
let translationProvider: typeof import('./lightweight/translation_provider').default;
|
||||
let createConfiguredRouter: typeof import('./lightweight/browser_routes').createConfiguredRouter;
|
||||
|
||||
// Instance state
|
||||
let sqlProvider: InstanceType<typeof BrowserSqlProvider> | null = null;
|
||||
let messagingProvider: InstanceType<typeof WorkerMessagingProvider> | null = null;
|
||||
|
||||
// Core module, router, and initialization state
|
||||
let coreModule: typeof import("@triliumnext/core") | null = null;
|
||||
let router: BrowserRouter | null = null;
|
||||
let initPromise: Promise<void> | null = null;
|
||||
let initError: Error | null = null;
|
||||
|
||||
/**
|
||||
* Load all required modules using dynamic imports.
|
||||
* This allows errors to be caught by our error handlers.
|
||||
*/
|
||||
async function loadModules(): Promise<void> {
|
||||
console.log("[Worker] Loading lightweight modules...");
|
||||
const [
|
||||
sqlModule,
|
||||
messagingModule,
|
||||
clsModule,
|
||||
cryptoModule,
|
||||
requestModule,
|
||||
platformModule,
|
||||
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/request_provider.js'),
|
||||
import('./lightweight/platform_provider.js'),
|
||||
import('./lightweight/translation_provider.js'),
|
||||
import('./lightweight/browser_routes.js')
|
||||
]);
|
||||
|
||||
BrowserSqlProvider = sqlModule.default;
|
||||
WorkerMessagingProvider = messagingModule.default;
|
||||
BrowserExecutionContext = clsModule.default;
|
||||
BrowserCryptoProvider = cryptoModule.default;
|
||||
FetchRequestProvider = requestModule.default;
|
||||
StandalonePlatformProvider = platformModule.default;
|
||||
translationProvider = translationModule.default;
|
||||
createConfiguredRouter = routesModule.createConfiguredRouter;
|
||||
|
||||
// Create instances
|
||||
sqlProvider = new BrowserSqlProvider();
|
||||
messagingProvider = new WorkerMessagingProvider();
|
||||
|
||||
console.log("[Worker] Lightweight modules loaded successfully");
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize SQLite WASM and load the core module.
|
||||
* This happens once at worker startup.
|
||||
*/
|
||||
async function initialize(): Promise<void> {
|
||||
if (initPromise) {
|
||||
return initPromise; // Already initializing
|
||||
}
|
||||
if (initError) {
|
||||
throw initError; // Failed before, don't retry
|
||||
}
|
||||
|
||||
initPromise = (async () => {
|
||||
try {
|
||||
// First, load all modules dynamically
|
||||
await loadModules();
|
||||
|
||||
console.log("[Worker] Initializing SQLite WASM...");
|
||||
await sqlProvider!.initWasm();
|
||||
|
||||
// Try to use OPFS for persistent storage
|
||||
if (sqlProvider!.isOpfsAvailable()) {
|
||||
console.log("[Worker] OPFS available, loading persistent database...");
|
||||
sqlProvider!.loadFromOpfs("/trilium.db");
|
||||
} else {
|
||||
// Fall back to in-memory database (non-persistent)
|
||||
console.warn("[Worker] OPFS not available, using in-memory database (data will not persist)");
|
||||
console.warn("[Worker] To enable persistence, ensure COOP/COEP headers are set by the server");
|
||||
sqlProvider!.loadFromMemory();
|
||||
}
|
||||
|
||||
console.log("[Worker] Database loaded");
|
||||
|
||||
console.log("[Worker] Loading @triliumnext/core...");
|
||||
const schemaModule = await import("@triliumnext/core/src/assets/schema.sql?raw");
|
||||
coreModule = await import("@triliumnext/core");
|
||||
await coreModule.initializeCore({
|
||||
executionContext: new BrowserExecutionContext(),
|
||||
crypto: new BrowserCryptoProvider(),
|
||||
messaging: messagingProvider!,
|
||||
request: new FetchRequestProvider(),
|
||||
platform: new StandalonePlatformProvider(),
|
||||
translations: translationProvider,
|
||||
schema: schemaModule.default,
|
||||
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;
|
||||
} else {
|
||||
console.log("[Worker] Database not initialized, skipping becca load (will be loaded during DB initialization)");
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
// Start initialization immediately when the worker loads
|
||||
console.log("[Worker] Starting initialization...");
|
||||
initialize().catch(err => {
|
||||
console.error("[Worker] Initialization failed:", err);
|
||||
// Post error to main thread
|
||||
self.postMessage({
|
||||
type: "WORKER_ERROR",
|
||||
error: {
|
||||
message: String(err?.message || err),
|
||||
stack: err?.stack
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
self.onmessage = async (event) => {
|
||||
const msg = event.data;
|
||||
if (!msg || msg.type !== "LOCAL_REQUEST") return;
|
||||
|
||||
const { id, request } = msg;
|
||||
|
||||
try {
|
||||
const response = await dispatch(request);
|
||||
|
||||
// Transfer body back (if any) - use options object for proper typing
|
||||
(self as unknown as Worker).postMessage({
|
||||
type: "LOCAL_RESPONSE",
|
||||
id,
|
||||
response
|
||||
}, { transfer: response.body ? [response.body] : [] });
|
||||
} catch (e) {
|
||||
console.error("[Worker] Dispatch error:", e);
|
||||
(self as unknown as Worker).postMessage({
|
||||
type: "LOCAL_RESPONSE",
|
||||
id,
|
||||
error: String((e as Error)?.message || e)
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -1,84 +0,0 @@
|
||||
import { attachServiceWorkerBridge, startLocalServerWorker } from "./local-bridge.js";
|
||||
|
||||
async function waitForServiceWorkerControl(): Promise<void> {
|
||||
if (!("serviceWorker" in navigator)) {
|
||||
throw new Error("Service Worker not supported in this browser");
|
||||
}
|
||||
|
||||
// If already controlling, we're good
|
||||
if (navigator.serviceWorker.controller) {
|
||||
console.log("[Bootstrap] Service worker already controlling");
|
||||
return;
|
||||
}
|
||||
|
||||
console.log("[Bootstrap] Waiting for service worker to take control...");
|
||||
|
||||
// Register service worker
|
||||
await navigator.serviceWorker.register("./sw.js", { scope: "/" });
|
||||
|
||||
// Wait for it to be ready (installed + activated)
|
||||
await navigator.serviceWorker.ready;
|
||||
|
||||
// Check if we're now controlling
|
||||
if (navigator.serviceWorker.controller) {
|
||||
console.log("[Bootstrap] Service worker now controlling");
|
||||
return;
|
||||
}
|
||||
|
||||
// If not controlling yet, we need to reload the page for SW to take control
|
||||
// This is standard PWA behavior on first install
|
||||
console.log("[Bootstrap] Service worker installed but not controlling yet - reloading page");
|
||||
|
||||
// Wait a tiny bit for SW to fully activate
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
|
||||
// Reload to let SW take control
|
||||
window.location.reload();
|
||||
|
||||
// Throw to stop execution (page will reload)
|
||||
throw new Error("Reloading for service worker activation");
|
||||
}
|
||||
|
||||
async function bootstrap() {
|
||||
/* fixes https://github.com/webpack/webpack/issues/10035 */
|
||||
window.global = globalThis;
|
||||
|
||||
try {
|
||||
// 1) Start local worker ASAP (so /bootstrap is fast)
|
||||
startLocalServerWorker();
|
||||
|
||||
// 2) Bridge SW -> local worker
|
||||
attachServiceWorkerBridge();
|
||||
|
||||
// 3) Wait for service worker to control the page (may reload on first install)
|
||||
await waitForServiceWorkerControl();
|
||||
|
||||
await loadScripts();
|
||||
} catch (err) {
|
||||
// If error is from reload, it will stop here (page reloads)
|
||||
// Otherwise, show error to user
|
||||
if (err instanceof Error && err.message.includes("Reloading")) {
|
||||
// Page is reloading, do nothing
|
||||
return;
|
||||
}
|
||||
|
||||
console.error("[Bootstrap] Fatal error:", err);
|
||||
document.body.innerHTML = `
|
||||
<div style="padding: 40px; max-width: 600px; margin: 0 auto; font-family: system-ui, sans-serif;">
|
||||
<h1 style="color: #d32f2f;">Failed to Initialize</h1>
|
||||
<p>The application failed to start. Please check the browser console for details.</p>
|
||||
<pre style="background: #f5f5f5; padding: 16px; border-radius: 4px; overflow: auto;">${err instanceof Error ? err.message : String(err)}</pre>
|
||||
<button onclick="location.reload()" style="padding: 12px 24px; background: #1976d2; color: white; border: none; border-radius: 4px; cursor: pointer; font-size: 16px;">
|
||||
Reload Page
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
document.body.style.display = "block";
|
||||
}
|
||||
}
|
||||
|
||||
async function loadScripts() {
|
||||
await import("../../client/src/index.js");
|
||||
}
|
||||
|
||||
bootstrap();
|
||||
@@ -1,185 +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 a client to handle the request (prefer the initiating client if available)
|
||||
let client = clientId ? await self.clients.get(clientId) : null;
|
||||
|
||||
if (!client) {
|
||||
const all = await self.clients.matchAll({ type: "window", includeUncontrolled: true });
|
||||
client = all[0] || null;
|
||||
}
|
||||
|
||||
// If no page is available, fall back to network
|
||||
if (!client) return fetch(request);
|
||||
|
||||
const reqUrl = request.url;
|
||||
const headersObj = {};
|
||||
for (const [k, v] of request.headers.entries()) headersObj[k] = v;
|
||||
|
||||
const body = (request.method === "GET" || request.method === "HEAD")
|
||||
? null
|
||||
: await request.arrayBuffer();
|
||||
|
||||
const id = crypto.randomUUID();
|
||||
const channel = new MessageChannel();
|
||||
|
||||
const responsePromise = new Promise((resolve, reject) => {
|
||||
const timeout = setTimeout(() => {
|
||||
reject(new Error("Local server timeout"));
|
||||
}, 30_000);
|
||||
|
||||
channel.port1.onmessage = (event) => {
|
||||
clearTimeout(timeout);
|
||||
resolve(event.data);
|
||||
};
|
||||
channel.port1.onmessageerror = () => {
|
||||
clearTimeout(timeout);
|
||||
reject(new Error("Local server message error"));
|
||||
};
|
||||
});
|
||||
|
||||
// Send to the client with a reply port
|
||||
client.postMessage({
|
||||
type: "LOCAL_FETCH",
|
||||
id,
|
||||
request: {
|
||||
url: reqUrl,
|
||||
method: request.method,
|
||||
headers: headersObj,
|
||||
body // ArrayBuffer or null
|
||||
}
|
||||
}, [channel.port2]);
|
||||
|
||||
const localResp = await responsePromise;
|
||||
|
||||
if (!localResp || localResp.type !== "LOCAL_FETCH_RESPONSE" || localResp.id !== id) {
|
||||
// Protocol mismatch; fall back
|
||||
return fetch(request);
|
||||
}
|
||||
|
||||
// localResp.response: { status, headers, body }
|
||||
const { status, headers, body: respBody } = localResp.response;
|
||||
|
||||
const respHeaders = new Headers();
|
||||
if (headers) {
|
||||
for (const [k, v] of Object.entries(headers)) respHeaders.set(k, String(v));
|
||||
}
|
||||
|
||||
return new Response(respBody ? respBody : null, {
|
||||
status: status || 200,
|
||||
headers: respHeaders
|
||||
});
|
||||
}
|
||||
|
||||
self.addEventListener("fetch", (event) => {
|
||||
const url = new URL(event.request.url);
|
||||
|
||||
// Only handle same-origin
|
||||
if (url.origin !== self.location.origin) return;
|
||||
|
||||
// HTML files: network-first to ensure updates are reflected immediately
|
||||
if (event.request.mode === "navigate" || url.pathname.endsWith(".html")) {
|
||||
event.respondWith(networkFirst(event.request));
|
||||
return;
|
||||
}
|
||||
|
||||
// Static assets: cache-first for performance
|
||||
if (event.request.method === "GET" && !isLocalFirst(url)) {
|
||||
event.respondWith(cacheFirst(event.request));
|
||||
return;
|
||||
}
|
||||
|
||||
// API-ish: local-first via bridge
|
||||
if (isLocalFirst(url)) {
|
||||
event.respondWith(forwardToClientLocalServer(event.request, event.clientId));
|
||||
return;
|
||||
}
|
||||
|
||||
// Default
|
||||
event.respondWith(fetch(event.request));
|
||||
});
|
||||
31
apps/client-standalone/src/vite-env.d.ts
vendored
31
apps/client-standalone/src/vite-env.d.ts
vendored
@@ -1,31 +0,0 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
interface ImportMetaEnv {
|
||||
readonly VITE_APP_TITLE: string
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
readonly env: ImportMetaEnv
|
||||
}
|
||||
|
||||
interface Window {
|
||||
glob: {
|
||||
assetPath: string;
|
||||
themeCssUrl?: string;
|
||||
themeUseNextAsBase?: string;
|
||||
iconPackCss: string;
|
||||
device: string;
|
||||
headingStyle: string;
|
||||
layoutOrientation: string;
|
||||
platform: string;
|
||||
isElectron: boolean;
|
||||
hasNativeTitleBar: boolean;
|
||||
hasBackgroundEffects: boolean;
|
||||
currentLocale: {
|
||||
id: string;
|
||||
rtl: boolean;
|
||||
};
|
||||
activeDialog: any;
|
||||
};
|
||||
global: typeof globalThis;
|
||||
}
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.spec.json" }
|
||||
]
|
||||
}
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
@@ -1,188 +0,0 @@
|
||||
import prefresh from '@prefresh/vite';
|
||||
import { join } from 'path';
|
||||
import { defineConfig } from 'vite';
|
||||
import { viteStaticCopy } from 'vite-plugin-static-copy';
|
||||
|
||||
const clientAssets = ["assets", "stylesheets", "fonts", "translations"];
|
||||
|
||||
const isDev = process.env.NODE_ENV === "development";
|
||||
|
||||
// Watch client files and trigger reload in development
|
||||
const clientWatchPlugin = () => ({
|
||||
name: 'client-watch',
|
||||
configureServer(server: any) {
|
||||
if (isDev) {
|
||||
// Watch client source files (adjusted for new root)
|
||||
server.watcher.add('../../client/src/**/*');
|
||||
server.watcher.on('change', (file: string) => {
|
||||
if (file.includes('../../client/src/')) {
|
||||
server.ws.send({
|
||||
type: 'full-reload'
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Always copy SQLite WASM files so they're available to the module
|
||||
const sqliteWasmPlugin = viteStaticCopy({
|
||||
targets: [
|
||||
{
|
||||
src: "../../../node_modules/@sqlite.org/sqlite-wasm/sqlite-wasm/jswasm/sqlite3.wasm",
|
||||
dest: "assets"
|
||||
},
|
||||
{
|
||||
src: "../../../node_modules/@sqlite.org/sqlite-wasm/sqlite-wasm/jswasm/sqlite3-opfs-async-proxy.js",
|
||||
dest: "assets"
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
let plugins: any = [
|
||||
sqliteWasmPlugin, // Always include SQLite WASM files
|
||||
viteStaticCopy({
|
||||
targets: clientAssets.map((asset) => ({
|
||||
src: `../../client/src/${asset}/*`,
|
||||
dest: asset
|
||||
})),
|
||||
// Enable watching in development
|
||||
...(isDev && {
|
||||
watch: {
|
||||
reloadPageOnChange: true
|
||||
}
|
||||
})
|
||||
}),
|
||||
viteStaticCopy({
|
||||
targets: [
|
||||
{
|
||||
src: "../../server/src/assets/*",
|
||||
dest: "server-assets"
|
||||
}
|
||||
]
|
||||
}),
|
||||
// Watch client files for changes in development
|
||||
...(isDev ? [
|
||||
prefresh(),
|
||||
clientWatchPlugin()
|
||||
] : [])
|
||||
];
|
||||
|
||||
if (!isDev) {
|
||||
plugins = [
|
||||
...plugins,
|
||||
viteStaticCopy({
|
||||
structured: true,
|
||||
targets: [
|
||||
{
|
||||
src: "../../../node_modules/@excalidraw/excalidraw/dist/prod/fonts/*",
|
||||
dest: "",
|
||||
}
|
||||
]
|
||||
})
|
||||
]
|
||||
}
|
||||
|
||||
export default defineConfig(() => ({
|
||||
root: join(__dirname, 'src'), // Set src as root so index.html is served from /
|
||||
envDir: __dirname, // Load .env files from client-standalone directory, not src/
|
||||
cacheDir: '../../../node_modules/.vite/apps/client-standalone',
|
||||
base: "",
|
||||
plugins,
|
||||
esbuild: {
|
||||
jsx: 'automatic',
|
||||
jsxImportSource: 'preact',
|
||||
jsxDev: isDev
|
||||
},
|
||||
css: {
|
||||
transformer: 'lightningcss',
|
||||
devSourcemap: isDev
|
||||
},
|
||||
publicDir: join(__dirname, 'public'),
|
||||
resolve: {
|
||||
alias: [
|
||||
{
|
||||
find: "react",
|
||||
replacement: "preact/compat"
|
||||
},
|
||||
{
|
||||
find: "react-dom",
|
||||
replacement: "preact/compat"
|
||||
},
|
||||
{
|
||||
find: "@client",
|
||||
replacement: join(__dirname, "../client/src")
|
||||
}
|
||||
],
|
||||
dedupe: [
|
||||
"react",
|
||||
"react-dom",
|
||||
"preact",
|
||||
"preact/compat",
|
||||
"preact/hooks"
|
||||
]
|
||||
},
|
||||
server: {
|
||||
watch: {
|
||||
// Watch workspace packages
|
||||
ignored: ['!**/node_modules/@triliumnext/**'],
|
||||
// Also watch client assets for live reload
|
||||
usePolling: false,
|
||||
interval: 100,
|
||||
binaryInterval: 300
|
||||
},
|
||||
// Watch additional directories for changes
|
||||
fs: {
|
||||
allow: [
|
||||
// Allow access to workspace root
|
||||
'../../../',
|
||||
// Explicitly allow client directory
|
||||
'../../client/src/'
|
||||
]
|
||||
},
|
||||
headers: {
|
||||
// Required for SharedArrayBuffer which is needed by SQLite WASM OPFS VFS
|
||||
// See: https://sqlite.org/wasm/doc/trunk/persistence.md#coop-coep
|
||||
"Cross-Origin-Opener-Policy": "same-origin",
|
||||
"Cross-Origin-Embedder-Policy": "require-corp"
|
||||
}
|
||||
},
|
||||
optimizeDeps: {
|
||||
exclude: ['@sqlite.org/sqlite-wasm', '@triliumnext/core']
|
||||
},
|
||||
worker: {
|
||||
format: "es" as const
|
||||
},
|
||||
commonjsOptions: {
|
||||
transformMixedEsModules: true,
|
||||
},
|
||||
build: {
|
||||
target: "esnext",
|
||||
outDir: join(__dirname, 'dist'),
|
||||
emptyOutDir: true,
|
||||
rollupOptions: {
|
||||
input: {
|
||||
main: join(__dirname, 'src', 'index.html'),
|
||||
sw: join(__dirname, 'src', 'sw.ts'),
|
||||
'local-bridge': join(__dirname, 'src', 'local-bridge.ts'),
|
||||
},
|
||||
output: {
|
||||
entryFileNames: (chunkInfo) => {
|
||||
// Service worker and other workers should be at root level
|
||||
if (chunkInfo.name === 'sw') {
|
||||
return '[name].js';
|
||||
}
|
||||
return 'src/[name].js';
|
||||
},
|
||||
chunkFileNames: "src/[name].js",
|
||||
assetFileNames: "src/[name].[ext]"
|
||||
}
|
||||
}
|
||||
},
|
||||
test: {
|
||||
environment: "happy-dom"
|
||||
},
|
||||
define: {
|
||||
"process.env.IS_PREACT": JSON.stringify("true"),
|
||||
}
|
||||
}));
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@triliumnext/client",
|
||||
"version": "0.102.1",
|
||||
"version": "0.102.0",
|
||||
"description": "JQuery-based client for TriliumNext, used for both web and desktop (via Electron)",
|
||||
"private": true,
|
||||
"license": "AGPL-3.0-only",
|
||||
@@ -25,25 +25,19 @@
|
||||
"@fullcalendar/rrule": "6.1.20",
|
||||
"@fullcalendar/timegrid": "6.1.20",
|
||||
"@maplibre/maplibre-gl-leaflet": "0.1.3",
|
||||
"@mermaid-js/layout-elk": "0.2.1",
|
||||
"@mermaid-js/layout-elk": "0.2.0",
|
||||
"@mind-elixir/node-menu": "5.0.1",
|
||||
"@popperjs/core": "2.11.8",
|
||||
"@preact/signals": "2.8.2",
|
||||
"@preact/signals": "2.8.1",
|
||||
"@triliumnext/ckeditor5": "workspace:*",
|
||||
"@triliumnext/codemirror": "workspace:*",
|
||||
"@triliumnext/commons": "workspace:*",
|
||||
"@triliumnext/highlightjs": "workspace:*",
|
||||
"@triliumnext/share-theme": "workspace:*",
|
||||
"@triliumnext/split.js": "workspace:*",
|
||||
"@univerjs/preset-sheets-conditional-formatting": "0.18.0",
|
||||
"@univerjs/preset-sheets-core": "0.18.0",
|
||||
"@univerjs/preset-sheets-data-validation": "0.18.0",
|
||||
"@univerjs/preset-sheets-filter": "0.18.0",
|
||||
"@univerjs/preset-sheets-find-replace": "0.18.0",
|
||||
"@univerjs/preset-sheets-note": "0.18.0",
|
||||
"@univerjs/preset-sheets-sort": "0.18.0",
|
||||
"@univerjs/presets": "0.18.0",
|
||||
"@zumer/snapdom": "2.5.0",
|
||||
"@univerjs/preset-sheets-core": "0.16.1",
|
||||
"@univerjs/presets": "0.16.1",
|
||||
"@zumer/snapdom": "2.0.2",
|
||||
"autocomplete.js": "0.38.1",
|
||||
"bootstrap": "5.3.8",
|
||||
"boxicons": "2.1.4",
|
||||
@@ -51,29 +45,30 @@
|
||||
"color": "5.0.3",
|
||||
"debounce": "3.0.0",
|
||||
"draggabilly": "3.0.0",
|
||||
"force-graph": "1.51.2",
|
||||
"force-graph": "1.51.1",
|
||||
"globals": "17.4.0",
|
||||
"i18next": "25.10.3",
|
||||
"i18next": "25.8.13",
|
||||
"i18next-http-backend": "3.0.2",
|
||||
"jquery": "4.0.0",
|
||||
"jquery.fancytree": "2.38.5",
|
||||
"jsplumb": "2.15.6",
|
||||
"katex": "0.16.40",
|
||||
"katex": "0.16.33",
|
||||
"knockout": "3.5.1",
|
||||
"leaflet": "1.9.4",
|
||||
"leaflet-gpx": "2.2.0",
|
||||
"mark.js": "8.11.1",
|
||||
"marked": "17.0.5",
|
||||
"mermaid": "11.13.0",
|
||||
"mind-elixir": "5.9.3",
|
||||
"marked": "17.0.3",
|
||||
"mermaid": "11.12.3",
|
||||
"mind-elixir": "5.9.1",
|
||||
"normalize.css": "8.0.1",
|
||||
"panzoom": "9.4.3",
|
||||
"preact": "10.29.0",
|
||||
"react-i18next": "16.6.0",
|
||||
"preact": "10.28.4",
|
||||
"react-i18next": "16.5.4",
|
||||
"react-window": "2.2.7",
|
||||
"reveal.js": "6.0.0",
|
||||
"reveal.js": "5.2.1",
|
||||
"rrule": "2.8.1",
|
||||
"svg-pan-zoom": "3.6.2",
|
||||
"tabulator-tables": "6.4.0",
|
||||
"tabulator-tables": "6.3.1",
|
||||
"vanilla-js-wheel-zoom": "9.0.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -84,11 +79,12 @@
|
||||
"@types/leaflet": "1.9.21",
|
||||
"@types/leaflet-gpx": "1.3.8",
|
||||
"@types/mark.js": "8.11.12",
|
||||
"@types/reveal.js": "5.2.2",
|
||||
"@types/tabulator-tables": "6.3.1",
|
||||
"copy-webpack-plugin": "14.0.0",
|
||||
"happy-dom": "20.8.4",
|
||||
"lightningcss": "1.32.0",
|
||||
"happy-dom": "20.8.3",
|
||||
"lightningcss": "1.31.1",
|
||||
"script-loader": "0.7.2",
|
||||
"vite-plugin-static-copy": "3.3.0"
|
||||
"vite-plugin-static-copy": "3.2.0"
|
||||
}
|
||||
}
|
||||
@@ -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 |
@@ -381,10 +381,6 @@ class NoteContext extends Component implements EventListener<"entitiesReloaded">
|
||||
|
||||
// Collections must always display a note list, even if no children.
|
||||
if (note.type === "book") {
|
||||
if (note.isProtected && !protectedSessionHolder.isProtectedSessionAvailable()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const viewType = note.getLabelValue("viewType") ?? "grid";
|
||||
if (!["list", "grid"].includes(viewType)) {
|
||||
return true;
|
||||
|
||||
@@ -36,37 +36,10 @@ 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
|
||||
};
|
||||
}
|
||||
|
||||
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) {
|
||||
@@ -112,8 +85,6 @@ function loadIcons() {
|
||||
}
|
||||
|
||||
function setBodyAttributes() {
|
||||
if (!glob.dbInitialized) return;
|
||||
|
||||
const { device, headingStyle, layoutOrientation, platform, isElectron, hasNativeTitleBar, hasBackgroundEffects, currentLocale } = window.glob;
|
||||
const classesToSet = [
|
||||
device,
|
||||
@@ -134,11 +105,6 @@ function setBodyAttributes() {
|
||||
}
|
||||
|
||||
async function loadScripts() {
|
||||
if (!glob.dbInitialized) {
|
||||
await import("./setup.js");
|
||||
return;
|
||||
}
|
||||
|
||||
switch (glob.device) {
|
||||
case "mobile":
|
||||
await import("./mobile.js");
|
||||
|
||||
@@ -3,55 +3,29 @@ import type { WidgetsByParent } from "../services/bundle.js";
|
||||
import { isExperimentalFeatureEnabled } from "../services/experimental_features.js";
|
||||
import options from "../services/options.js";
|
||||
import utils from "../services/utils.js";
|
||||
import ApiLog from "../widgets/api_log.jsx";
|
||||
import ClosePaneButton from "../widgets/buttons/close_pane_button.js";
|
||||
import CreatePaneButton from "../widgets/buttons/create_pane_button.js";
|
||||
import GlobalMenu from "../widgets/buttons/global_menu.jsx";
|
||||
import LeftPaneToggle from "../widgets/buttons/left_pane_toggle.js";
|
||||
import MovePaneButton from "../widgets/buttons/move_pane_button.js";
|
||||
import RightPaneToggle from "../widgets/buttons/right_pane_toggle.jsx";
|
||||
import CloseZenModeButton from "../widgets/close_zen_button.jsx";
|
||||
import NoteList from "../widgets/collections/NoteList.jsx";
|
||||
import ContentHeader from "../widgets/containers/content_header.js";
|
||||
import FlexContainer from "../widgets/containers/flex_container.js";
|
||||
import LeftPaneContainer from "../widgets/containers/left_pane_container.js";
|
||||
import RightPaneContainer from "../widgets/containers/right_pane_container.js";
|
||||
import RootContainer from "../widgets/containers/root_container.js";
|
||||
import ScrollingContainer from "../widgets/containers/scrolling_container.js";
|
||||
import SplitNoteContainer from "../widgets/containers/split_note_container.js";
|
||||
import PasswordNoteSetDialog from "../widgets/dialogs/password_not_set.js";
|
||||
import UploadAttachmentsDialog from "../widgets/dialogs/upload_attachments.js";
|
||||
import FindWidget from "../widgets/find.js";
|
||||
import FloatingButtons from "../widgets/FloatingButtons.jsx";
|
||||
import { DESKTOP_FLOATING_BUTTONS } from "../widgets/FloatingButtonsDefinitions.jsx";
|
||||
import HighlightsListWidget from "../widgets/highlights_list.js";
|
||||
import LauncherContainer from "../widgets/launch_bar/LauncherContainer.jsx";
|
||||
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";
|
||||
import NoteTreeWidget from "../widgets/note_tree.js";
|
||||
import NoteWrapperWidget from "../widgets/note_wrapper.js";
|
||||
import NoteDetail from "../widgets/NoteDetail.jsx";
|
||||
import PromotedAttributes from "../widgets/PromotedAttributes.jsx";
|
||||
import NoteWrapperWidget from "../widgets/NoteWrapper.js";
|
||||
import QuickSearchWidget from "../widgets/quick_search.js";
|
||||
import ReadOnlyNoteInfoBar from "../widgets/ReadOnlyNoteInfoBar.jsx";
|
||||
import { FixedFormattingToolbar } from "../widgets/ribbon/FormattingToolbar.jsx";
|
||||
import NoteActions from "../widgets/ribbon/NoteActions.jsx";
|
||||
import Ribbon from "../widgets/ribbon/Ribbon.jsx";
|
||||
import ScrollPadding from "../widgets/scroll_padding.js";
|
||||
import SearchResult from "../widgets/search_result.jsx";
|
||||
import SharedInfo from "../widgets/shared_info.jsx";
|
||||
import RightPanelContainer from "../widgets/sidebar/RightPanelContainer.jsx";
|
||||
import TabRowWidget from "../widgets/tab_row.js";
|
||||
import TabHistoryNavigationButtons from "../widgets/TabHistoryNavigationButtons.jsx";
|
||||
import TitleBarButtons from "../widgets/title_bar_buttons.jsx";
|
||||
import TocWidget from "../widgets/toc.js";
|
||||
import WatchedFileUpdateStatusWidget from "../widgets/watched_file_update_status.js";
|
||||
import { applyModals } from "./layout_commons.js";
|
||||
|
||||
export default class DesktopLayout {
|
||||
@@ -134,44 +108,7 @@ export default class DesktopLayout {
|
||||
.filling()
|
||||
.collapsible()
|
||||
.id("center-pane")
|
||||
.child(
|
||||
new SplitNoteContainer(() =>
|
||||
new NoteWrapperWidget()
|
||||
.child(new FlexContainer("row")
|
||||
.class("title-row note-split-title")
|
||||
.cssBlock(".title-row > * { margin: 5px; }")
|
||||
.child(<NoteIconWidget />)
|
||||
.child(<NoteTitleWidget />)
|
||||
.optChild(isNewLayout, <NoteBadges />)
|
||||
.child(<SpacerWidget baseSize={0} growthFactor={1} />)
|
||||
.optChild(!isNewLayout, <MovePaneButton direction="left" />)
|
||||
.optChild(!isNewLayout, <MovePaneButton direction="right" />)
|
||||
.optChild(!isNewLayout, <ClosePaneButton />)
|
||||
.optChild(!isNewLayout, <CreatePaneButton />)
|
||||
.optChild(isNewLayout, <NoteActions />))
|
||||
.optChild(!isNewLayout, <Ribbon />)
|
||||
.child(new WatchedFileUpdateStatusWidget())
|
||||
.optChild(!isNewLayout, <FloatingButtons items={DESKTOP_FLOATING_BUTTONS} />)
|
||||
.child(
|
||||
new ScrollingContainer()
|
||||
.filling()
|
||||
.optChild(isNewLayout, <InlineTitle />)
|
||||
.optChild(isNewLayout, <NoteTitleActions />)
|
||||
.optChild(!isNewLayout, new ContentHeader()
|
||||
.child(<ReadOnlyNoteInfoBar />)
|
||||
.child(<SharedInfo />)
|
||||
)
|
||||
.optChild(!isNewLayout, <PromotedAttributes />)
|
||||
.child(<NoteDetail />)
|
||||
.child(<NoteList media="screen" />)
|
||||
.child(<SearchResult />)
|
||||
.child(<ScrollPadding />)
|
||||
)
|
||||
.child(<ApiLog />)
|
||||
.child(new FindWidget())
|
||||
.child(...this.customWidgets.get("note-detail-pane"))
|
||||
)
|
||||
)
|
||||
.child(new SplitNoteContainer(() => <NoteWrapperWidget />))
|
||||
.child(...this.customWidgets.get("center-pane"))
|
||||
|
||||
)
|
||||
@@ -187,7 +124,6 @@ export default class DesktopLayout {
|
||||
)
|
||||
)
|
||||
.optChild(launcherPaneIsHorizontal && isNewLayout, <StatusBar />)
|
||||
.optChild(glob.isStandalone, <StandaloneWarningBar />)
|
||||
.child(<CloseZenModeButton />)
|
||||
|
||||
// Desktop-specific dialogs.
|
||||
|
||||
@@ -3,30 +3,15 @@ import "./mobile_layout.css";
|
||||
import type AppContext from "../components/app_context.js";
|
||||
import GlobalMenuWidget from "../widgets/buttons/global_menu.js";
|
||||
import CloseZenModeButton from "../widgets/close_zen_button.js";
|
||||
import NoteList from "../widgets/collections/NoteList.jsx";
|
||||
import FlexContainer from "../widgets/containers/flex_container.js";
|
||||
import RootContainer from "../widgets/containers/root_container.js";
|
||||
import ScrollingContainer from "../widgets/containers/scrolling_container.js";
|
||||
import SplitNoteContainer from "../widgets/containers/split_note_container.js";
|
||||
import FindWidget from "../widgets/find.js";
|
||||
import LauncherContainer from "../widgets/launch_bar/LauncherContainer.jsx";
|
||||
import InlineTitle from "../widgets/layout/InlineTitle.jsx";
|
||||
import NoteBadges from "../widgets/layout/NoteBadges.jsx";
|
||||
import NoteTitleActions from "../widgets/layout/NoteTitleActions.jsx";
|
||||
import StandaloneWarningBar from "../widgets/layout/StandaloneWarningBar";
|
||||
import MobileDetailMenu from "../widgets/mobile_widgets/mobile_detail_menu.js";
|
||||
import ScreenContainer from "../widgets/mobile_widgets/screen_container.js";
|
||||
import SidebarContainer from "../widgets/mobile_widgets/sidebar_container.js";
|
||||
import ToggleSidebarButton from "../widgets/mobile_widgets/toggle_sidebar_button.jsx";
|
||||
import NoteIconWidget from "../widgets/note_icon.jsx";
|
||||
import NoteTitleWidget from "../widgets/note_title.js";
|
||||
import NoteTreeWidget from "../widgets/note_tree.js";
|
||||
import NoteWrapperWidget from "../widgets/note_wrapper.js";
|
||||
import NoteDetail from "../widgets/NoteDetail.jsx";
|
||||
import NoteWrapperWidget from "../widgets/NoteWrapper";
|
||||
import QuickSearchWidget from "../widgets/quick_search.js";
|
||||
import ScrollPadding from "../widgets/scroll_padding";
|
||||
import SearchResult from "../widgets/search_result.jsx";
|
||||
import MobileEditorToolbar from "../widgets/type_widgets/text/mobile_editor_toolbar.jsx";
|
||||
import { applyModals } from "./layout_commons.js";
|
||||
|
||||
export default class MobileLayout {
|
||||
@@ -53,36 +38,7 @@ export default class MobileLayout {
|
||||
new ScreenContainer("detail", "row")
|
||||
.id("detail-container")
|
||||
.class("d-sm-flex d-md-flex d-lg-flex d-xl-flex col-12 col-sm-7 col-md-8 col-lg-9")
|
||||
.child(
|
||||
new SplitNoteContainer(() =>
|
||||
new NoteWrapperWidget()
|
||||
.optChild(glob.isStandalone, <StandaloneWarningBar />)
|
||||
.child(
|
||||
new FlexContainer("row")
|
||||
.class("title-row note-split-title")
|
||||
.contentSized()
|
||||
.css("align-items", "center")
|
||||
.child(<ToggleSidebarButton />)
|
||||
.child(<NoteIconWidget />)
|
||||
.child(<NoteTitleWidget />)
|
||||
.child(<NoteBadges />)
|
||||
.child(<MobileDetailMenu />)
|
||||
)
|
||||
.child(
|
||||
new ScrollingContainer()
|
||||
.filling()
|
||||
.contentSized()
|
||||
.child(<InlineTitle />)
|
||||
.child(<NoteTitleActions />)
|
||||
.child(<NoteDetail />)
|
||||
.child(<NoteList media="screen" />)
|
||||
.child(<SearchResult />)
|
||||
.child(<ScrollPadding />)
|
||||
)
|
||||
.child(<MobileEditorToolbar />)
|
||||
.child(new FindWidget())
|
||||
)
|
||||
)
|
||||
.child(new SplitNoteContainer(() => <NoteWrapperWidget />))
|
||||
)
|
||||
)
|
||||
.child(
|
||||
|
||||
@@ -54,7 +54,7 @@ export async function getRenderedContent(this: {} | { ctx: string }, entity: FNo
|
||||
await renderText(entity, $renderedContent, options);
|
||||
} else if (type === "code") {
|
||||
await renderCode(entity, $renderedContent);
|
||||
} else if (["image", "canvas", "mindMap", "spreadsheet"].includes(type)) {
|
||||
} else if (["image", "canvas", "mindMap"].includes(type)) {
|
||||
renderImage(entity, $renderedContent, options);
|
||||
} else if (!options.tooltip && ["file", "pdf", "audio", "video"].includes(type)) {
|
||||
await renderFile(entity, type, $renderedContent);
|
||||
|
||||
@@ -5,7 +5,7 @@ import { formatCodeBlocks } from "./syntax_highlight.js";
|
||||
|
||||
export default function renderDoc(note: FNote) {
|
||||
return new Promise<JQuery<HTMLElement>>((resolve) => {
|
||||
const docName = note.getLabelValue("docName");
|
||||
let docName = note.getLabelValue("docName");
|
||||
const $content = $("<div>");
|
||||
|
||||
if (docName) {
|
||||
@@ -16,7 +16,7 @@ export default function renderDoc(note: FNote) {
|
||||
if (status === "error") {
|
||||
const fallbackUrl = getUrl(docName, "en");
|
||||
$content.load(fallbackUrl, async () => {
|
||||
await processContent(fallbackUrl, $content);
|
||||
await processContent(fallbackUrl, $content)
|
||||
resolve($content);
|
||||
});
|
||||
return;
|
||||
@@ -37,9 +37,9 @@ async function processContent(url: string, $content: JQuery<HTMLElement>) {
|
||||
const dir = url.substring(0, url.lastIndexOf("/"));
|
||||
|
||||
// Images are relative to the docnote but that will not work when rendered in the application since the path breaks.
|
||||
$content.find("img").each((_i, el) => {
|
||||
$content.find("img").each((i, el) => {
|
||||
const $img = $(el);
|
||||
$img.attr("src", `${dir}/${$img.attr("src")}`);
|
||||
$img.attr("src", dir + "/" + $img.attr("src"));
|
||||
});
|
||||
|
||||
formatCodeBlocks($content);
|
||||
@@ -51,17 +51,7 @@ async function processContent(url: string, $content: JQuery<HTMLElement>) {
|
||||
function getUrl(docNameValue: string, language: string) {
|
||||
// Cannot have spaces in the URL due to how JQuery.load works.
|
||||
docNameValue = docNameValue.replaceAll(" ", "%20");
|
||||
// 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`;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -110,12 +110,7 @@ function processNoteChange(loadResults: LoadResults, ec: EntityChange) {
|
||||
}
|
||||
}
|
||||
|
||||
// Only register as a content change if the protection status didn't change.
|
||||
// When isProtected changes, the blobId change is a side effect of re-encryption,
|
||||
// not a content edit. Registering it as content would cause the tree's content-only
|
||||
// filter to incorrectly skip the note update (since both changes share the same
|
||||
// componentId).
|
||||
if (ec.componentId && note.isProtected === (ec.entity as FNoteRow).isProtected) {
|
||||
if (ec.componentId) {
|
||||
loadResults.addNoteContent(note.noteId, ec.componentId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,17 +1,16 @@
|
||||
import { type Locale, LOCALE_IDS, setDayjsLocale } from "@triliumnext/commons";
|
||||
import options from "./options.js";
|
||||
import i18next from "i18next";
|
||||
import i18nextHttpBackend from "i18next-http-backend";
|
||||
import { initReactI18next } from "react-i18next";
|
||||
|
||||
import options from "./options.js";
|
||||
import server from "./server.js";
|
||||
import { LOCALE_IDS, setDayjsLocale, type Locale } from "@triliumnext/commons";
|
||||
import { initReactI18next } from "react-i18next";
|
||||
|
||||
let locales: Locale[] | null;
|
||||
|
||||
/**
|
||||
* A deferred promise that resolves when translations are initialized.
|
||||
*/
|
||||
export const translationsInitializedPromise = $.Deferred();
|
||||
export let translationsInitializedPromise = $.Deferred();
|
||||
|
||||
export async function initLocale() {
|
||||
const locale = ((options.get("locale") as string) || "en") as LOCALE_IDS;
|
||||
@@ -35,7 +34,7 @@ export async function initLocale() {
|
||||
|
||||
export function getAvailableLocales() {
|
||||
if (!locales) {
|
||||
throw new Error("Tried to load list of locales, but localization is not yet initialized.");
|
||||
throw new Error("Tried to load list of locales, but localization is not yet initialized.")
|
||||
}
|
||||
|
||||
return locales;
|
||||
|
||||
@@ -1,17 +1,15 @@
|
||||
import type { CKTextEditor } from "@triliumnext/ckeditor5";
|
||||
import { AttributeRow } from "@triliumnext/commons";
|
||||
|
||||
import appContext from "../components/app_context.js";
|
||||
import type FBranch from "../entities/fbranch.js";
|
||||
import type FNote from "../entities/fnote.js";
|
||||
import type { ChooseNoteTypeResponse } from "../widgets/dialogs/note_type_chooser.js";
|
||||
import froca from "./froca.js";
|
||||
import { t } from "./i18n.js";
|
||||
import protectedSessionHolder from "./protected_session_holder.js";
|
||||
import server from "./server.js";
|
||||
import toastService from "./toast.js";
|
||||
import treeService from "./tree.js";
|
||||
import ws from "./ws.js";
|
||||
import froca from "./froca.js";
|
||||
import treeService from "./tree.js";
|
||||
import toastService from "./toast.js";
|
||||
import { t } from "./i18n.js";
|
||||
import type FNote from "../entities/fnote.js";
|
||||
import type FBranch from "../entities/fbranch.js";
|
||||
import type { ChooseNoteTypeResponse } from "../widgets/dialogs/note_type_chooser.js";
|
||||
import type { CKTextEditor } from "@triliumnext/ckeditor5";
|
||||
|
||||
export interface CreateNoteOpts {
|
||||
isProtected?: boolean;
|
||||
@@ -26,8 +24,6 @@ export interface CreateNoteOpts {
|
||||
target?: string;
|
||||
targetBranchId?: string;
|
||||
textEditor?: CKTextEditor;
|
||||
/** Attributes to be set on the note. These are set atomically on note creation, so entity changes are not sent for attributes defined here. */
|
||||
attributes?: Omit<AttributeRow, "noteId" | "attributeId">[];
|
||||
}
|
||||
|
||||
interface Response {
|
||||
@@ -41,7 +37,7 @@ interface DuplicateResponse {
|
||||
note: FNote;
|
||||
}
|
||||
|
||||
async function createNote(parentNotePath: string | undefined, options: CreateNoteOpts = {}, componentId?: string) {
|
||||
async function createNote(parentNotePath: string | undefined, options: CreateNoteOpts = {}) {
|
||||
options = Object.assign(
|
||||
{
|
||||
activate: true,
|
||||
@@ -67,15 +63,22 @@ async function createNote(parentNotePath: string | undefined, options: CreateNot
|
||||
|
||||
const parentNoteId = treeService.getNoteIdFromUrl(parentNotePath);
|
||||
|
||||
if (options.type === "mermaid" && !options.content && !options.templateNoteId) {
|
||||
options.content = `graph TD;
|
||||
A-->B;
|
||||
A-->C;
|
||||
B-->D;
|
||||
C-->D;`;
|
||||
}
|
||||
|
||||
const { note, branch } = await server.post<Response>(`notes/${parentNoteId}/children?target=${options.target}&targetBranchId=${options.targetBranchId || ""}`, {
|
||||
title: options.title,
|
||||
content: options.content || "",
|
||||
isProtected: options.isProtected,
|
||||
type: options.type,
|
||||
mime: options.mime,
|
||||
templateNoteId: options.templateNoteId,
|
||||
attributes: options.attributes
|
||||
}, componentId);
|
||||
templateNoteId: options.templateNoteId
|
||||
});
|
||||
|
||||
if (options.saveSelection) {
|
||||
// we remove the selection only after it was saved to server to make sure we don't lose anything
|
||||
@@ -137,8 +140,9 @@ function parseSelectedHtml(selectedHtml: string) {
|
||||
const content = selectedHtml.replace(dom[0].outerHTML, "");
|
||||
|
||||
return [title, content];
|
||||
} else {
|
||||
return [null, selectedHtml];
|
||||
}
|
||||
return [null, selectedHtml];
|
||||
}
|
||||
|
||||
async function duplicateSubtree(noteId: string, parentNotePath: string) {
|
||||
|
||||
@@ -1,4 +1,14 @@
|
||||
import { DefinitionObject, LabelType, Multiplicity } from "@triliumnext/commons";
|
||||
export type LabelType = "text" | "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());
|
||||
@@ -7,7 +17,7 @@ function parse(value: string) {
|
||||
for (const token of tokens) {
|
||||
if (token === "promoted") {
|
||||
defObj.isPromoted = true;
|
||||
} else if (["text", "textarea", "number", "boolean", "date", "datetime", "time", "url", "color"].includes(token)) {
|
||||
} else if (["text", "number", "boolean", "date", "datetime", "time", "url", "color"].includes(token)) {
|
||||
defObj.labelType = token as LabelType;
|
||||
} else if (["single", "multi"].includes(token)) {
|
||||
defObj.multiplicity = token as Multiplicity;
|
||||
|
||||
@@ -89,33 +89,21 @@ async function remove<T>(url: string, componentId?: string) {
|
||||
return await call<T>("DELETE", url, componentId);
|
||||
}
|
||||
|
||||
async function upload(url: string, fileToUpload: File, componentId?: string, method = "PUT") {
|
||||
async function upload(url: string, fileToUpload: File, componentId?: string) {
|
||||
const formData = new FormData();
|
||||
formData.append("upload", fileToUpload);
|
||||
|
||||
const doUpload = async () => $.ajax({
|
||||
return await $.ajax({
|
||||
url: window.glob.baseApiUrl + url,
|
||||
headers: await getHeaders(componentId ? {
|
||||
"trilium-component-id": componentId
|
||||
} : undefined),
|
||||
data: formData,
|
||||
type: method,
|
||||
type: "PUT",
|
||||
timeout: 60 * 60 * 1000,
|
||||
contentType: false, // NEEDED, DON'T REMOVE THIS
|
||||
processData: false // NEEDED, DON'T REMOVE THIS
|
||||
});
|
||||
|
||||
try {
|
||||
return await doUpload();
|
||||
} catch (e: unknown) {
|
||||
// jQuery rejects with the jqXHR object
|
||||
const jqXhr = e as JQuery.jqXHR;
|
||||
if (jqXhr?.status && isCsrfError(jqXhr.status, jqXhr.responseText)) {
|
||||
await refreshCsrfToken();
|
||||
return await doUpload();
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
let idCounter = 1;
|
||||
@@ -124,55 +112,12 @@ const idToRequestMap: Record<string, RequestData> = {};
|
||||
|
||||
let maxKnownEntityChangeId = 0;
|
||||
|
||||
let csrfRefreshInProgress: Promise<void> | null = null;
|
||||
|
||||
/**
|
||||
* Re-fetches /bootstrap to obtain a fresh CSRF token. This is needed when the
|
||||
* server session expires (e.g. mobile tab backgrounded for a long time) and the
|
||||
* existing CSRF token is no longer valid.
|
||||
*
|
||||
* Coalesces concurrent calls so only one bootstrap request is in-flight at a time.
|
||||
*/
|
||||
async function refreshCsrfToken(): Promise<void> {
|
||||
if (csrfRefreshInProgress) {
|
||||
return csrfRefreshInProgress;
|
||||
}
|
||||
|
||||
csrfRefreshInProgress = (async () => {
|
||||
try {
|
||||
const response = await fetch(`./bootstrap${window.location.search}`, { cache: "no-store" });
|
||||
if (response.ok) {
|
||||
const json = await response.json();
|
||||
glob.csrfToken = json.csrfToken;
|
||||
}
|
||||
} finally {
|
||||
csrfRefreshInProgress = null;
|
||||
}
|
||||
})();
|
||||
|
||||
return csrfRefreshInProgress;
|
||||
}
|
||||
|
||||
function isCsrfError(status: number, responseText: string): boolean {
|
||||
if (status !== 403) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
const body = JSON.parse(responseText);
|
||||
return body.message === "Invalid CSRF token";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
interface CallOptions {
|
||||
data?: unknown;
|
||||
silentNotFound?: boolean;
|
||||
silentInternalServerError?: boolean;
|
||||
// If `true`, the value will be returned as a string instead of a JavaScript object if JSON, XMLDocument if XML, etc.
|
||||
raw?: boolean;
|
||||
/** Used internally to prevent infinite retry loops on CSRF refresh. */
|
||||
csrfRetried?: boolean;
|
||||
}
|
||||
|
||||
async function call<T>(method: string, url: string, componentId?: string, options: CallOptions = {}) {
|
||||
@@ -222,7 +167,7 @@ function ajax(url: string, method: string, data: unknown, headers: Headers, opts
|
||||
type: method,
|
||||
headers,
|
||||
timeout: 60000,
|
||||
success: (body, _textStatus, jqXhr) => {
|
||||
success: (body, textStatus, jqXhr) => {
|
||||
const respHeaders: Headers = {};
|
||||
|
||||
jqXhr
|
||||
@@ -247,25 +192,7 @@ function ajax(url: string, method: string, data: unknown, headers: Headers, opts
|
||||
// don't report requests that are rejected by the browser, usually when the user is refreshing or going to a different page.
|
||||
rej("rejected by browser");
|
||||
return;
|
||||
}
|
||||
|
||||
// If the CSRF token is stale (e.g. session expired while tab was backgrounded),
|
||||
// refresh it and retry the request once.
|
||||
if (!opts.csrfRetried && isCsrfError(jqXhr.status, jqXhr.responseText)) {
|
||||
try {
|
||||
await refreshCsrfToken();
|
||||
// Rebuild headers so the fresh glob.csrfToken is picked up
|
||||
const retryHeaders = await getHeaders({ "trilium-component-id": headers["trilium-component-id"] });
|
||||
const retryResult = await ajax(url, method, data, retryHeaders, { ...opts, csrfRetried: true });
|
||||
res(retryResult);
|
||||
return;
|
||||
} catch (retryErr) {
|
||||
rej(retryErr);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (opts.silentNotFound && jqXhr.status === 404) {
|
||||
} else if (opts.silentNotFound && jqXhr.status === 404) {
|
||||
// report nothing
|
||||
} else if (opts.silentInternalServerError && jqXhr.status === 500) {
|
||||
// report nothing
|
||||
|
||||
@@ -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`.
|
||||
*/
|
||||
@@ -818,7 +816,7 @@ function compareVersions(v1: string, v2: string): number {
|
||||
/**
|
||||
* Compares two semantic version strings and returns `true` if the latest version is greater than the current version.
|
||||
*/
|
||||
export function isUpdateAvailable(latestVersion: string | null | undefined, currentVersion: string): boolean {
|
||||
function isUpdateAvailable(latestVersion: string | null | undefined, currentVersion: string): boolean {
|
||||
if (!latestVersion) {
|
||||
return false;
|
||||
}
|
||||
@@ -905,10 +903,6 @@ export function getErrorMessage(e: unknown) {
|
||||
|
||||
}
|
||||
|
||||
export function replaceHtmlEscapedSlashes(str: string) {
|
||||
return str.replace(///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.
|
||||
|
||||
@@ -1,14 +1,13 @@
|
||||
import { WebSocketMessage } from "@triliumnext/commons";
|
||||
|
||||
import appContext from "../components/app_context.js";
|
||||
import type { EntityChange } from "../server_types.js";
|
||||
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";
|
||||
import toastService from "./toast.js";
|
||||
import server from "./server.js";
|
||||
import options from "./options.js";
|
||||
import frocaUpdater from "./froca_updater.js";
|
||||
import appContext from "../components/app_context.js";
|
||||
import { t } from "./i18n.js";
|
||||
import type { EntityChange } from "../server_types.js";
|
||||
import { WebSocketMessage } from "@triliumnext/commons";
|
||||
import toast from "./toast.js";
|
||||
|
||||
type MessageHandler = (message: WebSocketMessage) => void;
|
||||
let messageHandlers: MessageHandler[] = [];
|
||||
@@ -58,49 +57,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);
|
||||
} else if (messageType === "execute-script") {
|
||||
// TODO: Remove after porting the file
|
||||
// @ts-ignore
|
||||
const bundleService = (await import("./bundle.js")).default as any;
|
||||
// TODO: Remove after porting the file
|
||||
// @ts-ignore
|
||||
const froca = (await import("./froca.js")).default as any;
|
||||
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;
|
||||
|
||||
@@ -156,13 +112,38 @@ 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") {
|
||||
toastService.showError(t("ws.sync-check-failed"), 60000);
|
||||
} else if (message.type === "consistency-checks-failed") {
|
||||
toastService.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") {
|
||||
toastService.showMessage(message.message);
|
||||
} else if (message.type === "execute-script") {
|
||||
// TODO: Remove after porting the file
|
||||
// @ts-ignore
|
||||
const bundleService = (await import("./bundle.js")).default as any;
|
||||
// TODO: Remove after porting the file
|
||||
// @ts-ignore
|
||||
const froca = (await import("./froca.js")).default as any;
|
||||
const originEntity = message.originEntityId ? await froca.getNote(message.originEntityId) : null;
|
||||
|
||||
bundleService.getAndExecuteBundle(message.currentNoteId, originEntity, message.script, message.params);
|
||||
}
|
||||
}
|
||||
|
||||
let entityChangeIdReachedListeners: {
|
||||
@@ -180,7 +161,7 @@ function waitForEntityChangeId(desiredEntityChangeId: number) {
|
||||
|
||||
return new Promise<void>((res, rej) => {
|
||||
entityChangeIdReachedListeners.push({
|
||||
desiredEntityChangeId,
|
||||
desiredEntityChangeId: desiredEntityChangeId,
|
||||
resolvePromise: res,
|
||||
start: Date.now()
|
||||
});
|
||||
@@ -247,18 +228,13 @@ 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");
|
||||
toast.showPersistent({
|
||||
@@ -286,18 +262,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();
|
||||
|
||||
@@ -1,420 +0,0 @@
|
||||
html,
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
body.setup {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
|
||||
&>.setup-outer-wrapper {
|
||||
width: 100dvw;
|
||||
height: 100dvh;
|
||||
|
||||
body:not(.electron) & {
|
||||
@media (min-width: 700px) {
|
||||
background:
|
||||
radial-gradient(ellipse at 20% 50%, rgba(99, 102, 241, 0.3) 0%, transparent 50%),
|
||||
radial-gradient(ellipse at 80% 20%, rgba(168, 85, 247, 0.25) 0%, transparent 50%),
|
||||
radial-gradient(ellipse at 60% 80%, rgba(59, 130, 246, 0.25) 0%, transparent 50%),
|
||||
var(--left-pane-background-color);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding: 2em;
|
||||
}
|
||||
}
|
||||
|
||||
.setup-container {
|
||||
background-color: var(--main-background-color);
|
||||
border-radius: 16px;
|
||||
padding: 2em;
|
||||
flex-direction: column;
|
||||
gap: 2rem;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1);
|
||||
position: relative;
|
||||
height: 100%;
|
||||
|
||||
@media (min-width: 700px) {
|
||||
display: flex;
|
||||
width: 750px;
|
||||
height: 650px;
|
||||
top: unset;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.setup-options {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
gap: 1rem;
|
||||
|
||||
.setup-option-card {
|
||||
padding: 1.5em;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
|
||||
&.disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.5;
|
||||
background-color: transparent;
|
||||
border-color: var(--main-border-color)
|
||||
}
|
||||
|
||||
&:not(.disabled):hover {
|
||||
background-color: var(--card-background-hover-color);
|
||||
filter: contrast(105%);
|
||||
transition: background-color .2s ease-out;
|
||||
}
|
||||
|
||||
.tn-icon {
|
||||
font-size: 2.5em;
|
||||
color: var(--muted-text-color);
|
||||
}
|
||||
|
||||
h3 {
|
||||
font-size: 1.5em;
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
p:last-of-type {
|
||||
margin-bottom: 0;
|
||||
color: var(--muted-text-color);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
padding: 2em;
|
||||
overflow: auto;
|
||||
|
||||
>.back-button {
|
||||
position: absolute;
|
||||
top: 2em;
|
||||
left: 2em;
|
||||
color: var(--muted-text-color);
|
||||
|
||||
.tn-icon {
|
||||
margin-right: 0.4em;
|
||||
}
|
||||
}
|
||||
|
||||
>main {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding-top: 1em;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
&.contentless {
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
>footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 0.5rem;
|
||||
border-top: 1px solid var(--main-border-color);
|
||||
padding-top: 1rem;
|
||||
margin-inline: -2em;
|
||||
padding-inline: 2em;
|
||||
}
|
||||
|
||||
>.page-error {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
background: var(--admonition-caution-accent-color);
|
||||
z-index: 1;
|
||||
margin: 0;
|
||||
border-radius: 0;
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.1);
|
||||
padding-right: 2.5em;
|
||||
|
||||
button {
|
||||
position: absolute;
|
||||
top: 0.5em;
|
||||
right: 0.5em;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
width: 80%;
|
||||
margin-inline: auto;
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.admonition {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.form-item-with-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
|
||||
.tn-icon {
|
||||
font-size: 1.5em;
|
||||
color: var(--muted-text-color);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.sync-illustration {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin-top: 1.5em;
|
||||
margin-bottom: 1.5rem;
|
||||
|
||||
.tn-icon {
|
||||
font-size: 3em;
|
||||
color: var(--muted-text-color);
|
||||
}
|
||||
|
||||
>div {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
text-align: center;
|
||||
gap: 0.5rem;
|
||||
line-height: 1;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.sync-illustration-arrows {
|
||||
width: 60px;
|
||||
height: 3em;
|
||||
position: relative;
|
||||
|
||||
&::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
border: 2px dashed var(--main-border-color);
|
||||
top: 1.5em;
|
||||
left: 0;
|
||||
right: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.illustration-icon {
|
||||
font-size: 4em;
|
||||
text-align: center;
|
||||
color: var(--muted-text-color);
|
||||
opacity: 0.6;
|
||||
margin-block: 1rem;
|
||||
}
|
||||
|
||||
.illustration-logo {
|
||||
width: 96px;
|
||||
height: 96px;
|
||||
margin: auto;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 1.4em;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
h1 + p {
|
||||
text-align: center;
|
||||
color: var(--muted-text-color);
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.tooltip {
|
||||
z-index: 15 !important;
|
||||
}
|
||||
}
|
||||
|
||||
body.setup.background-effects,
|
||||
body.setup.background-effects .setup-container {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
/* macOS: draggable title bar region and traffic light buttons */
|
||||
body.setup.platform-darwin {
|
||||
.drag-region {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 40px;
|
||||
-webkit-app-region: drag;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.back-button {
|
||||
-webkit-app-region: no-drag;
|
||||
z-index: 11;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes lds-ring {
|
||||
0% {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
100% {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
/* Slide transitions */
|
||||
.slide-page {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
}
|
||||
|
||||
.slide-out-forward,
|
||||
.slide-out-backward,
|
||||
.slide-in-forward,
|
||||
.slide-in-backward {
|
||||
animation-duration: 0.35s;
|
||||
animation-timing-function: ease-in-out;
|
||||
animation-fill-mode: forwards;
|
||||
}
|
||||
|
||||
.slide-out-forward {
|
||||
animation-name: slide-out-left;
|
||||
}
|
||||
|
||||
.slide-out-backward {
|
||||
animation-name: slide-out-right;
|
||||
}
|
||||
|
||||
.slide-in-forward {
|
||||
animation-name: slide-in-right;
|
||||
}
|
||||
|
||||
.slide-in-backward {
|
||||
animation-name: slide-in-left;
|
||||
}
|
||||
|
||||
.page.select-language {
|
||||
.dropdownWrapper {
|
||||
padding-bottom: 2em;
|
||||
width: 80%;
|
||||
margin: auto;
|
||||
}
|
||||
|
||||
.dropdownWrapper,
|
||||
.dropdown,
|
||||
.dropdown-menu {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.dropdown-menu {
|
||||
box-sizing: border-box;
|
||||
overflow: auto;
|
||||
}
|
||||
}
|
||||
|
||||
.page.sync-from-desktop {
|
||||
.card-columns {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.sync-from-desktop-waiting {
|
||||
margin-top: 2rem;
|
||||
text-align: center;
|
||||
|
||||
.main {
|
||||
font-size: 1.35em;
|
||||
}
|
||||
|
||||
.subtle {
|
||||
color: var(--muted-text-color);
|
||||
}
|
||||
}
|
||||
|
||||
.ip-addresses {
|
||||
min-width: 250px;
|
||||
user-select: text;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
font-family: var(--monospace-font-family);
|
||||
font-size: 0.9em;
|
||||
|
||||
.tn-card-body {
|
||||
overflow: auto;
|
||||
padding-bottom: 0.5em;
|
||||
|
||||
> :first-child {
|
||||
font-weight: bold;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.page.sync-in-progress {
|
||||
.sync-progress {
|
||||
margin-top: 0.5rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
|
||||
progress {
|
||||
width: 100%;
|
||||
height: 1rem;
|
||||
border-radius: 0.5rem;
|
||||
overflow: hidden;
|
||||
appearance: none;
|
||||
|
||||
&::-webkit-progress-bar {
|
||||
background-color: var(--main-border-color);
|
||||
}
|
||||
|
||||
&::-webkit-progress-value {
|
||||
background-color: var(--main-text-color);
|
||||
transition: width 0.2s ease-out;
|
||||
}
|
||||
}
|
||||
|
||||
span {
|
||||
font-size: 0.85rem;
|
||||
color: var(--muted-text-color);
|
||||
min-width: 2.5em;
|
||||
text-align: right;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slide-out-left {
|
||||
from { transform: translateX(0); opacity: 1; }
|
||||
to { transform: translateX(-100%); opacity: 0; }
|
||||
}
|
||||
|
||||
@keyframes slide-out-right {
|
||||
from { transform: translateX(0); opacity: 1; }
|
||||
to { transform: translateX(100%); opacity: 0; }
|
||||
}
|
||||
|
||||
@keyframes slide-in-right {
|
||||
from { transform: translateX(100%); opacity: 0; }
|
||||
to { transform: translateX(0); opacity: 1; }
|
||||
}
|
||||
|
||||
@keyframes slide-in-left {
|
||||
from { transform: translateX(-100%); opacity: 0; }
|
||||
to { transform: translateX(0); opacity: 1; }
|
||||
}
|
||||
128
apps/client/src/setup.ts
Normal file
128
apps/client/src/setup.ts
Normal file
@@ -0,0 +1,128 @@
|
||||
import "jquery";
|
||||
import utils from "./services/utils.js";
|
||||
import ko from "knockout";
|
||||
|
||||
// TriliumNextTODO: properly make use of below types
|
||||
// type SetupModelSetupType = "new-document" | "sync-from-desktop" | "sync-from-server" | "";
|
||||
// type SetupModelStep = "sync-in-progress" | "setup-type" | "new-document-in-progress" | "sync-from-desktop";
|
||||
|
||||
class SetupModel {
|
||||
syncInProgress: boolean;
|
||||
step: ko.Observable<string>;
|
||||
setupType: ko.Observable<string>;
|
||||
setupNewDocument: ko.Observable<boolean>;
|
||||
setupSyncFromDesktop: ko.Observable<boolean>;
|
||||
setupSyncFromServer: ko.Observable<boolean>;
|
||||
syncServerHost: ko.Observable<string | undefined>;
|
||||
syncProxy: ko.Observable<string | undefined>;
|
||||
password: ko.Observable<string | undefined>;
|
||||
|
||||
constructor(syncInProgress: boolean) {
|
||||
this.syncInProgress = syncInProgress;
|
||||
this.step = ko.observable(syncInProgress ? "sync-in-progress" : "setup-type");
|
||||
this.setupType = ko.observable("");
|
||||
this.setupNewDocument = ko.observable(false);
|
||||
this.setupSyncFromDesktop = ko.observable(false);
|
||||
this.setupSyncFromServer = ko.observable(false);
|
||||
this.syncServerHost = ko.observable();
|
||||
this.syncProxy = ko.observable();
|
||||
this.password = ko.observable();
|
||||
|
||||
if (this.syncInProgress) {
|
||||
setInterval(checkOutstandingSyncs, 1000);
|
||||
}
|
||||
const serverAddress = `${location.protocol}//${location.host}`;
|
||||
$("#current-host").html(serverAddress);
|
||||
}
|
||||
|
||||
// this is called in setup.ejs
|
||||
setupTypeSelected() {
|
||||
return !!this.setupType();
|
||||
}
|
||||
|
||||
selectSetupType() {
|
||||
if (this.setupType() === "new-document") {
|
||||
this.step("new-document-in-progress");
|
||||
|
||||
$.post("api/setup/new-document").then(() => {
|
||||
window.location.replace("./setup");
|
||||
});
|
||||
} else {
|
||||
this.step(this.setupType());
|
||||
}
|
||||
}
|
||||
|
||||
back() {
|
||||
this.step("setup-type");
|
||||
this.setupType("");
|
||||
}
|
||||
|
||||
async finish() {
|
||||
const syncServerHost = this.syncServerHost();
|
||||
const syncProxy = this.syncProxy();
|
||||
const password = this.password();
|
||||
|
||||
if (!syncServerHost) {
|
||||
showAlert("Trilium server address can't be empty");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!password) {
|
||||
showAlert("Password can't be empty");
|
||||
return;
|
||||
}
|
||||
|
||||
// not using server.js because it loads too many dependencies
|
||||
const resp = await $.post("api/setup/sync-from-server", {
|
||||
syncServerHost: syncServerHost,
|
||||
syncProxy: syncProxy,
|
||||
password: password
|
||||
});
|
||||
|
||||
if (resp.result === "success") {
|
||||
this.step("sync-in-progress");
|
||||
|
||||
setInterval(checkOutstandingSyncs, 1000);
|
||||
|
||||
hideAlert();
|
||||
} else {
|
||||
showAlert(`Sync setup failed: ${resp.error}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function checkOutstandingSyncs() {
|
||||
const { outstandingPullCount, initialized } = await $.get("api/sync/stats");
|
||||
|
||||
if (initialized) {
|
||||
if (utils.isElectron()) {
|
||||
const remote = utils.dynamicRequire("@electron/remote");
|
||||
remote.app.relaunch();
|
||||
remote.app.exit(0);
|
||||
} else {
|
||||
utils.reloadFrontendApp();
|
||||
}
|
||||
} else {
|
||||
$("#outstanding-syncs").html(outstandingPullCount);
|
||||
}
|
||||
}
|
||||
|
||||
function showAlert(message: string) {
|
||||
$("#alert").text(message);
|
||||
$("#alert").show();
|
||||
}
|
||||
|
||||
function hideAlert() {
|
||||
$("#alert").hide();
|
||||
}
|
||||
|
||||
function getSyncInProgress() {
|
||||
const el = document.getElementById("syncInProgress");
|
||||
if (!el || !(el instanceof HTMLMetaElement)) return false;
|
||||
return !!parseInt(el.content);
|
||||
}
|
||||
|
||||
addEventListener("DOMContentLoaded", (event) => {
|
||||
ko.applyBindings(new SetupModel(getSyncInProgress()), document.getElementById("setup-dialog"));
|
||||
$("#setup-dialog").show();
|
||||
});
|
||||
@@ -1,524 +0,0 @@
|
||||
import "./setup.css";
|
||||
|
||||
import { LOCALES, SetupSyncFromServerResponse } from "@triliumnext/commons";
|
||||
import clsx from "clsx";
|
||||
import { ComponentChildren, render } from "preact";
|
||||
import { useEffect, useMemo, useRef, useState } from "preact/hooks";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import logo from "./assets/icon-color.svg?url";
|
||||
import { initLocale, t } from "./services/i18n";
|
||||
import server from "./services/server";
|
||||
import { isElectron, replaceHtmlEscapedSlashes } from "./services/utils";
|
||||
import ActionButton from "./widgets/react/ActionButton";
|
||||
import Admonition from "./widgets/react/Admonition";
|
||||
import Button from "./widgets/react/Button";
|
||||
import { Card, CardFrame, CardSection } from "./widgets/react/Card";
|
||||
import FormGroup from "./widgets/react/FormGroup";
|
||||
import FormList, { FormListItem } from "./widgets/react/FormList";
|
||||
import FormTextBox from "./widgets/react/FormTextBox";
|
||||
import Icon from "./widgets/react/Icon";
|
||||
|
||||
async function main() {
|
||||
await initLocale();
|
||||
|
||||
const bodyWrapper = document.createElement("div");
|
||||
bodyWrapper.classList.add("setup-outer-wrapper");
|
||||
document.body.classList.add("setup");
|
||||
if (isElectron()) {
|
||||
document.body.classList.add("electron", `platform-${window.process.platform}`, "background-effects");
|
||||
}
|
||||
render(<App />, bodyWrapper);
|
||||
document.body.replaceChildren(bodyWrapper);
|
||||
}
|
||||
|
||||
type State = "selectLanguage" | "firstOptions" | "createNewDocumentOptions" | "createNewDocumentWithDemo" | "createNewDocumentEmpty" | "syncFromDesktop" | "syncFromServer" | "syncFromServerInProgress" | "syncFromDesktopInProgress" | "syncFailed";
|
||||
|
||||
const STATE_ORDER: State[] = ["selectLanguage", "firstOptions", "createNewDocumentOptions", "createNewDocumentWithDemo", "createNewDocumentEmpty", "syncFromDesktop", "syncFromServer", "syncFromServerInProgress", "syncFromDesktopInProgress", "syncFailed"];
|
||||
|
||||
function renderState(state: State, setState: (state: State) => void) {
|
||||
switch (state) {
|
||||
case "selectLanguage": return <SelectLanguage setState={setState} />;
|
||||
case "firstOptions": return <SetupOptions setState={setState} />;
|
||||
case "createNewDocumentOptions": return <CreateNewDocumentOptions setState={setState} />;
|
||||
case "createNewDocumentWithDemo": return <CreateNewDocumentInProgress withDemo />;
|
||||
case "createNewDocumentEmpty": return <CreateNewDocumentInProgress />;
|
||||
case "syncFromServer": return <SyncFromServer setState={setState} />;
|
||||
case "syncFromDesktop": return <SyncFromDesktop setState={setState} />;
|
||||
case "syncFromServerInProgress": return <SyncInProgress device="server" />;
|
||||
case "syncFromDesktopInProgress": return <SyncInProgress device="desktop" />;
|
||||
default: return null;
|
||||
}
|
||||
}
|
||||
|
||||
function App() {
|
||||
const [state, setState] = useState<State>("selectLanguage");
|
||||
const [prevState, setPrevState] = useState<State | null>(null);
|
||||
const [transitioning, setTransitioning] = useState(false);
|
||||
const prevStateRef = useRef<State>(state);
|
||||
|
||||
function handleSetState(newState: State) {
|
||||
setPrevState(prevStateRef.current);
|
||||
prevStateRef.current = newState;
|
||||
setTransitioning(true);
|
||||
setState(newState);
|
||||
}
|
||||
|
||||
const direction = prevState !== null
|
||||
? STATE_ORDER.indexOf(state) > STATE_ORDER.indexOf(prevState) ? "forward" : "backward"
|
||||
: "forward";
|
||||
|
||||
return (
|
||||
<div class="setup-container">
|
||||
<div class="drag-region" />
|
||||
{transitioning && prevState !== null && (
|
||||
<div
|
||||
class={`slide-page slide-out-${direction}`}
|
||||
onAnimationEnd={() => {
|
||||
setTransitioning(false);
|
||||
setPrevState(null);
|
||||
}}
|
||||
>
|
||||
{renderState(prevState, handleSetState)}
|
||||
</div>
|
||||
)}
|
||||
<div class={`slide-page ${transitioning ? `slide-in-${direction}` : "slide-current"}`} key={state}>
|
||||
{renderState(state, handleSetState)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SelectLanguage({ setState }: { setState: (state: State) => void }) {
|
||||
const { t, i18n } = useTranslation();
|
||||
const [ currentLocale, setCurrentLocale ] = useState(i18n.language);
|
||||
const filteredLocales = useMemo(() => LOCALES.filter(l => !l.contentOnly), []);
|
||||
|
||||
return (
|
||||
<SetupPage
|
||||
title={t("setup.language")}
|
||||
className="select-language"
|
||||
illustration={<Icon icon="bx bx-globe" className="illustration-icon" />}
|
||||
footer={<Button text={t("setup.continue")} kind="primary" onClick={() => setState("firstOptions")} />}
|
||||
>
|
||||
<FormList onSelect={async (id) => {
|
||||
await i18n.changeLanguage(id);
|
||||
setCurrentLocale(id);
|
||||
}}>
|
||||
{filteredLocales.map(locale => (
|
||||
<FormListItem key={locale.id} value={locale.id} active={locale.id === currentLocale}>{locale.name}</FormListItem>
|
||||
))}
|
||||
</FormList>
|
||||
</SetupPage>
|
||||
);
|
||||
}
|
||||
|
||||
function SetupOptions({ setState }: { setState: (state: State) => void }) {
|
||||
return (
|
||||
<SetupPage
|
||||
title={t("setup.heading")}
|
||||
className="setup-options-container"
|
||||
illustration={<img src={logo} alt="Setup illustration" className="illustration-logo" />}
|
||||
onBack={() => setState("selectLanguage")}
|
||||
>
|
||||
<div class="setup-options">
|
||||
<SetupOptionCard
|
||||
icon="bx bx-file-blank"
|
||||
title={t("setup.new-document")}
|
||||
description={t("setup.new-document-description")}
|
||||
onClick={() => setState("createNewDocumentOptions")}
|
||||
/>
|
||||
|
||||
<SetupOptionCard
|
||||
icon="bx bx-server"
|
||||
title={t("setup.sync-from-server")}
|
||||
description={t("setup.sync-from-server-description")}
|
||||
onClick={() => setState("syncFromServer")}
|
||||
/>
|
||||
|
||||
<SetupOptionCard
|
||||
icon="bx bx-desktop"
|
||||
title={t("setup.sync-from-desktop")}
|
||||
description={t("setup.sync-from-desktop-description")}
|
||||
disabled={glob.isStandalone}
|
||||
onClick={() => setState("syncFromDesktop")}
|
||||
/>
|
||||
</div>
|
||||
</SetupPage>
|
||||
);
|
||||
}
|
||||
|
||||
type SyncStep = "connecting" | "syncing" | "finalizing";
|
||||
|
||||
function getSyncStep(stats: { outstandingPullCount: number; totalPullCount: number | null; initialized: boolean }): SyncStep {
|
||||
if (stats.initialized) {
|
||||
return "finalizing"; // will reload momentarily
|
||||
}
|
||||
if (stats.totalPullCount !== null && stats.outstandingPullCount > 0) {
|
||||
return "syncing";
|
||||
}
|
||||
if (stats.totalPullCount !== null && stats.outstandingPullCount === 0) {
|
||||
return "finalizing";
|
||||
}
|
||||
return "connecting";
|
||||
}
|
||||
|
||||
function SyncInProgress({ device }: { device: "server" | "desktop" }) {
|
||||
const stats = useOutstandingSyncInfo();
|
||||
const step = getSyncStep(stats);
|
||||
|
||||
useEffect(() => {
|
||||
if (stats.initialized) {
|
||||
onSetupFinished();
|
||||
}
|
||||
}, [stats.initialized]);
|
||||
|
||||
const steps: { key: SyncStep; label: string }[] = [
|
||||
{ key: "connecting", label: t("setup.sync-step-connecting") },
|
||||
{ key: "syncing", label: t("setup.sync-step-syncing") },
|
||||
{ key: "finalizing", label: t("setup.sync-step-finalizing") }
|
||||
];
|
||||
|
||||
const currentIndex = steps.findIndex((s) => s.key === step);
|
||||
|
||||
const syncingDone = currentIndex > steps.findIndex((s) => s.key === "syncing");
|
||||
let progress = 0;
|
||||
if (syncingDone) {
|
||||
progress = 100;
|
||||
} else if (stats.totalPullCount) {
|
||||
progress = Math.round(((stats.totalPullCount - stats.outstandingPullCount) / stats.totalPullCount) * 100);
|
||||
}
|
||||
|
||||
return (
|
||||
<SetupPage
|
||||
className="sync-in-progress"
|
||||
illustration={<SyncIllustration targetDevice={device} />}
|
||||
title={t("setup.sync-in-progress-title")}
|
||||
>
|
||||
<Card className="sync-steps">
|
||||
{steps.map((s, i) => (
|
||||
<CardSection className={i < currentIndex ? "completed" : i === currentIndex ? "active" : ""} key={s.key}>
|
||||
<Icon icon={i < currentIndex ? "bx bx-check-circle" : i === currentIndex ? "bx bx-loader-circle bx-spin" : "bx bx-circle"} />{" "}
|
||||
{s.label}
|
||||
{s.key === "syncing" && (
|
||||
<div class="sync-progress">
|
||||
<progress value={syncingDone ? 1 : stats.totalPullCount! - stats.outstandingPullCount} max={syncingDone ? 1 : stats.totalPullCount!} />
|
||||
<span>{progress}%</span>
|
||||
</div>
|
||||
)}
|
||||
</CardSection>
|
||||
))}
|
||||
</Card>
|
||||
</SetupPage>
|
||||
);
|
||||
}
|
||||
|
||||
function useOutstandingSyncInfo() {
|
||||
const [ outstandingPullCount, setOutstandingPullCount ] = useState(0);
|
||||
const [ totalPullCount, setTotalPullCount ] = useState<number | null>(null);
|
||||
const [ initialized, setInitialized ] = useState(false);
|
||||
|
||||
async function refresh() {
|
||||
const resp = await server.get<{ outstandingPullCount: number; totalPullCount: number | null; initialized: boolean }>("sync/stats");
|
||||
setOutstandingPullCount(resp.outstandingPullCount);
|
||||
setTotalPullCount(resp.totalPullCount);
|
||||
setInitialized(resp.initialized);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const interval = setInterval(refresh, 1000);
|
||||
refresh();
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
return { outstandingPullCount, totalPullCount, initialized };
|
||||
}
|
||||
|
||||
function CreateNewDocumentOptions({ setState }: { setState: (state: State) => void }) {
|
||||
return (
|
||||
<SetupPage
|
||||
className="create-new-document-options"
|
||||
title={t("setup.create-new-document-options-title")}
|
||||
illustration={<Icon icon="bx bx-star" className="illustration-icon" />}
|
||||
onBack={() => setState("firstOptions")}
|
||||
>
|
||||
<div class="setup-options">
|
||||
<SetupOptionCard icon="bx bx-book-open" title={t("setup.create-new-document-options-with-demo")} description={t("setup.create-new-document-options-with-demo-description")} onClick={() => setState("createNewDocumentWithDemo")} />
|
||||
<SetupOptionCard icon="bx bx-file-blank" title={t("setup.create-new-document-options-empty")} description={t("setup.create-new-document-options-empty-description")} onClick={() => setState("createNewDocumentEmpty")} />
|
||||
</div>
|
||||
</SetupPage>
|
||||
);
|
||||
}
|
||||
|
||||
function CreateNewDocumentInProgress({ withDemo = false }: { withDemo?: boolean }) {
|
||||
useEffect(() => {
|
||||
server.post(`setup/new-document${withDemo ? "" : "?skipDemoDb"}`).then(onSetupFinished);
|
||||
}, [ withDemo ]);
|
||||
|
||||
return (
|
||||
<SetupPage
|
||||
className="create-new-document"
|
||||
title={t("setup.create-new-document-title")}
|
||||
description={t("setup.create-new-document-description")}
|
||||
illustration={<Icon icon="bx bx-loader-circle bx-spin" className="illustration-icon" />}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SyncFromServer({ setState }: { setState: (state: State) => void }) {
|
||||
const [ syncServerHost, setSyncServerHost ] = useState("");
|
||||
const [ password, setPassword ] = useState("");
|
||||
const [ syncProxy, setSyncProxy ] = useState("");
|
||||
const [ error, setError ] = useState<string | null>(null);
|
||||
const [ errorId, setErrorId ] = useState(0);
|
||||
const [ isWrongPassword, setIsWrongPassword ] = useState(false);
|
||||
const isValid = syncServerHost.trim() !== "" && password !== "";
|
||||
|
||||
function raiseError(message: string) {
|
||||
setError(message);
|
||||
setErrorId(id => id + 1);
|
||||
}
|
||||
|
||||
async function handleFinishSetup() {
|
||||
try {
|
||||
const resp = await server.post<SetupSyncFromServerResponse>("setup/sync-from-server", {
|
||||
syncServerHost: syncServerHost.trim(),
|
||||
syncProxy: syncProxy.trim(),
|
||||
password
|
||||
});
|
||||
|
||||
if (resp.result === "success") {
|
||||
setState("syncFromServerInProgress");
|
||||
} else if (resp.error.includes("Incorrect password")) {
|
||||
setIsWrongPassword(true);
|
||||
} else {
|
||||
raiseError(t("setup.sync-failed", { message: resp.error }));
|
||||
}
|
||||
} catch (e) {
|
||||
raiseError(e instanceof Error ? e.message : String(e));
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<SetupPage
|
||||
className="sync-from-server top-aligned"
|
||||
title={t("setup.sync-from-server")}
|
||||
description={t("setup.sync-from-server-page-description")}
|
||||
illustration={<SyncIllustration targetDevice="server" />}
|
||||
error={error}
|
||||
errorId={errorId}
|
||||
onBack={() => setState("firstOptions")}
|
||||
footer={<Button text={t("setup.button-finish-setup")} kind="primary" onClick={handleFinishSetup} disabled={!isValid} />}
|
||||
>
|
||||
<form>
|
||||
<Card>
|
||||
<CardSection>
|
||||
<FormGroup label={t("setup.server-host")} name="serverHost">
|
||||
<FormTextBox
|
||||
placeholder={t("setup.server-host-placeholder")}
|
||||
currentValue={syncServerHost} onChange={setSyncServerHost}
|
||||
autocomplete="trilium-sync-server-host"
|
||||
required
|
||||
/>
|
||||
</FormGroup>
|
||||
</CardSection>
|
||||
|
||||
<CardSection>
|
||||
<FormGroup
|
||||
label={t("setup.server-password")} name="serverPassword"
|
||||
error={isWrongPassword ? t("setup.wrong-password") : undefined}
|
||||
>
|
||||
<FormTextBox
|
||||
type="password"
|
||||
currentValue={password} onChange={setPassword}
|
||||
autocomplete="trilium-sync-server-password"
|
||||
required
|
||||
/>
|
||||
</FormGroup>
|
||||
</CardSection>
|
||||
</Card>
|
||||
|
||||
<Card heading={t("setup.advanced-options")}>
|
||||
<CardSection>
|
||||
<FormGroup
|
||||
name="proxyServer"
|
||||
label={t("setup.proxy-server")}
|
||||
description={isElectron() ? t("setup.proxy-instruction") : undefined}
|
||||
>
|
||||
<FormTextBox placeholder={t("setup.proxy-server-placeholder")} currentValue={syncProxy} onChange={setSyncProxy} />
|
||||
</FormGroup>
|
||||
</CardSection>
|
||||
</Card>
|
||||
</form>
|
||||
</SetupPage>
|
||||
);
|
||||
}
|
||||
|
||||
function SyncFromDesktop({ setState }: { setState: (state: State) => void }) {
|
||||
const networkAddresses = getNetworkAddresses();
|
||||
|
||||
useEffect(() => {
|
||||
const interval = setInterval(async () => {
|
||||
const status = await server.get<{ schemaExists: boolean }>("setup/status");
|
||||
if (status.schemaExists) {
|
||||
setState("syncFromDesktopInProgress");
|
||||
}
|
||||
}, 1000);
|
||||
return () => clearInterval(interval);
|
||||
}, [setState]);
|
||||
|
||||
return (
|
||||
<SetupPage
|
||||
className="sync-from-desktop"
|
||||
title={t("setup.sync-from-desktop")}
|
||||
illustration={<SyncIllustration targetDevice="desktop" />}
|
||||
onBack={() => setState("firstOptions")}
|
||||
>
|
||||
<div class="card-columns">
|
||||
<Card heading="On the other device">
|
||||
<CardSection>1. {t("setup.sync-from-desktop-step1")}</CardSection>
|
||||
<CardSection>2. {t("setup.sync-from-desktop-step2")}</CardSection>
|
||||
<CardSection>3. {t("setup.sync-from-desktop-step3")}</CardSection>
|
||||
<CardSection>4. {t("setup.sync-from-desktop-step4")}</CardSection>
|
||||
<CardSection>5. {t("setup.sync-from-desktop-step5")}</CardSection>
|
||||
</Card>
|
||||
|
||||
{networkAddresses.length > 0 && (
|
||||
<Card heading={t("setup.your-ip-addresses")} className="ip-addresses">
|
||||
{networkAddresses.map((addr) => (
|
||||
<CardSection key={addr}>{addr}</CardSection>
|
||||
))}
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div class="sync-from-desktop-waiting">
|
||||
<div class="main"><Icon icon="bx bx-loader-circle bx-spin" />{" "} {t("setup.sync-from-desktop-waiting")}</div>
|
||||
<div class="subtle">{t("setup.sync-from-desktop-warning")}</div>
|
||||
</div>
|
||||
</SetupPage>
|
||||
);
|
||||
}
|
||||
|
||||
function SyncIllustration({ targetDevice }: { targetDevice: "desktop" | "server" }) {
|
||||
return (
|
||||
<div class="sync-illustration">
|
||||
<div>
|
||||
<Icon icon={isElectron() ? "bx bx-desktop" : "bx bx-globe"} />
|
||||
{t("setup.sync-illustration-this-device")}
|
||||
</div>
|
||||
<div class="sync-illustration-arrows" />
|
||||
<div>
|
||||
<Icon icon={targetDevice === "desktop" ? "bx bx-desktop" : "bx bx-server"} />
|
||||
{targetDevice === "desktop" ? t("setup.sync-illustration-desktop-app") : t("setup.sync-illustration-server")}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SetupOptionCard({ title, description, icon, onClick, disabled }: { title: string; description: string, icon: string, onClick?: () => void, disabled?: boolean }) {
|
||||
return (
|
||||
<CardFrame
|
||||
className={clsx("setup-option-card", { disabled })}
|
||||
onClick={disabled ? undefined : onClick}
|
||||
>
|
||||
<Icon icon={icon} />
|
||||
|
||||
<div>
|
||||
<h3>{title}</h3>
|
||||
<p>{description}</p>
|
||||
</div>
|
||||
</CardFrame>
|
||||
);
|
||||
}
|
||||
|
||||
function SetupPage({ title, description, className, illustration, children, footer, error, errorId, onBack }: {
|
||||
title: string;
|
||||
description?: string;
|
||||
error?: string | null;
|
||||
errorId?: number;
|
||||
className?: string;
|
||||
illustration?: ComponentChildren;
|
||||
children?: ComponentChildren;
|
||||
footer?: ComponentChildren;
|
||||
onBack?: () => void;
|
||||
}) {
|
||||
const [ showError, setShowError ] = useState(!!error);
|
||||
useEffect(() => {
|
||||
if (error) {
|
||||
setShowError(true);
|
||||
}
|
||||
}, [ error, errorId ]);
|
||||
|
||||
return (
|
||||
<div className={clsx("page", className, { "contentless": !children })}>
|
||||
{onBack && (
|
||||
<Button
|
||||
className="back-button"
|
||||
icon="bx bx-arrow-back"
|
||||
text={t("setup.button-back")}
|
||||
onClick={onBack}
|
||||
kind="lowProfile"
|
||||
/>
|
||||
)}
|
||||
{error && showError && (
|
||||
<Admonition className="page-error" type="caution">
|
||||
<ActionButton icon="bx bx-x" text={t("setup.dismiss-error")} onClick={() => setShowError(false)} />
|
||||
{replaceHtmlEscapedSlashes(error)}
|
||||
</Admonition>
|
||||
)}
|
||||
|
||||
{illustration}
|
||||
<h1>{title}</h1>
|
||||
{description && <p class="page-description">{description}</p>}
|
||||
{children && <main>
|
||||
{children}
|
||||
</main>}
|
||||
{footer && <footer>{footer}</footer>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function getNetworkAddresses(): string[] {
|
||||
if (!isElectron()) {
|
||||
return [`${location.protocol}//${location.host}`];
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
const os = require("os") as typeof import("os");
|
||||
const interfaces = os.networkInterfaces();
|
||||
const addresses: string[] = [];
|
||||
|
||||
for (const nets of Object.values(interfaces)) {
|
||||
if (!nets) continue;
|
||||
for (const net of nets) {
|
||||
if (net.internal) continue;
|
||||
if (net.family === "IPv6" && net.scopeid !== 0) continue;
|
||||
addresses.push(net.address);
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by likelihood of being the local network address.
|
||||
addresses.sort((a, b) => networkScore(a) - networkScore(b));
|
||||
|
||||
return addresses.map((addr) => `${location.protocol}//${addr}:${location.port}`);
|
||||
}
|
||||
|
||||
function networkScore(addr: string): number {
|
||||
if (addr.startsWith("192.168.")) return 0;
|
||||
if (addr.startsWith("10.")) return 1;
|
||||
if (/^172\.(1[6-9]|2\d|3[01])\./.test(addr)) return 2;
|
||||
if (addr.includes(":")) return 4; // IPv6
|
||||
return 3;
|
||||
}
|
||||
|
||||
function onSetupFinished() {
|
||||
if (isElectron()) {
|
||||
// On Electron we need to use the setup route because it handles the closing of the setup window and opening the main app window.
|
||||
location.href = "setup";
|
||||
} else {
|
||||
location.reload();
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -1612,7 +1612,11 @@ body:not(.mobile) #launcher-pane.horizontal .dropdown-submenu > .dropdown-menu {
|
||||
}
|
||||
|
||||
body.mobile #launcher-container {
|
||||
justify-content: space-evenly;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
body.mobile #launcher-container button {
|
||||
margin: 0 16px;
|
||||
}
|
||||
|
||||
body.mobile .modal.show {
|
||||
|
||||
@@ -675,11 +675,10 @@ li.dropdown-item a.dropdown-item-button:focus-visible {
|
||||
div.alert {
|
||||
margin-bottom: 8px;
|
||||
background: var(--alert-bar-background) !important;
|
||||
color: var(--main-text-color);
|
||||
border-radius: 8px;
|
||||
font-size: .85em;
|
||||
}
|
||||
|
||||
div.alert p + p {
|
||||
margin-block: 1em 0;
|
||||
}
|
||||
}
|
||||
@@ -803,13 +803,12 @@
|
||||
"web-view": "عرض الويب",
|
||||
"mind-map": "خريطة ذهنية",
|
||||
"geo-map": "خريطة جغرافية",
|
||||
"task-list": "قائمة المهام",
|
||||
"spreadsheet": "جدول البيانات"
|
||||
"task-list": "قائمة المهام"
|
||||
},
|
||||
"shared_switch": {
|
||||
"shared": "مشترك",
|
||||
"toggle-on-title": "مشاركة الملاحظة",
|
||||
"toggle-off-title": "إلغاء مشاركة الملاحظة"
|
||||
"toggle-off-title": "الغاء مشاركة الملاحظة"
|
||||
},
|
||||
"template_switch": {
|
||||
"template": "قالب"
|
||||
@@ -1069,6 +1068,7 @@
|
||||
"rename_note": "اعادة تسمية الملاحظة",
|
||||
"remove_relation": "حذف العلاقة",
|
||||
"default_new_note_title": "ملاحظة جديدة",
|
||||
"open_in_new_tab": "فتح في تبويب جديد",
|
||||
"enter_new_title": "ادخل عنوان ملاحظة جديدة:",
|
||||
"note_not_found": "الملاحظة {{noteId}} غير موجودة!",
|
||||
"cannot_match_transform": "تعذر مطابقة التحويل: {{transform}}"
|
||||
@@ -1286,10 +1286,8 @@
|
||||
"search-for": "بحث ل \"{{term}}\""
|
||||
},
|
||||
"protect_note": {
|
||||
"toggle-off": "إزالة الحماية عن الملاحظة",
|
||||
"toggle-on": "حماية الملاحظة",
|
||||
"toggle-on-hint": "الملاحظة غير محمة، انقر لحمايتها",
|
||||
"toggle-off-hint": "الملاحظة محمية، انقر لإزالة الحماية منها"
|
||||
"toggle-off": "ازالة الحماية عن الملاحظة",
|
||||
"toggle-on": "حماية الملاحظة"
|
||||
},
|
||||
"open-help-page": "فتح صفحة المساعدة",
|
||||
"empty": {
|
||||
|
||||
@@ -1047,6 +1047,7 @@
|
||||
"unprotecting-title": "解除保护状态"
|
||||
},
|
||||
"relation_map": {
|
||||
"open_in_new_tab": "在新标签页中打开",
|
||||
"remove_note": "删除笔记",
|
||||
"edit_title": "编辑标题",
|
||||
"rename_note": "重命名笔记",
|
||||
@@ -1534,8 +1535,7 @@
|
||||
"new-feature": "新建",
|
||||
"collections": "集合",
|
||||
"book": "集合",
|
||||
"ai-chat": "AI聊天",
|
||||
"spreadsheet": "电子表格"
|
||||
"ai-chat": "AI聊天"
|
||||
},
|
||||
"protect_note": {
|
||||
"toggle-on": "保护笔记",
|
||||
|
||||
@@ -1046,6 +1046,7 @@
|
||||
"unprotecting-title": "Ungeschützt-Status"
|
||||
},
|
||||
"relation_map": {
|
||||
"open_in_new_tab": "In neuem Tab öffnen",
|
||||
"remove_note": "Notiz entfernen",
|
||||
"edit_title": "Titel bearbeiten",
|
||||
"rename_note": "Notiz umbenennen",
|
||||
@@ -1487,21 +1488,20 @@
|
||||
"mermaid-diagram": "Mermaid Diagramm",
|
||||
"canvas": "Leinwand",
|
||||
"web-view": "Webansicht",
|
||||
"mind-map": "Mindmap",
|
||||
"mind-map": "Mind Map",
|
||||
"file": "Datei",
|
||||
"image": "Bild",
|
||||
"launcher": "Starter",
|
||||
"doc": "Dokument",
|
||||
"widget": "Widget",
|
||||
"confirm-change": "Es ist nicht empfehlenswert den Notiz-Typ zu ändern, wenn der Inhalt der Notiz nicht leer ist. Möchtest du dennoch fortfahren?",
|
||||
"confirm-change": "Es is nicht empfehlenswert den Notiz-Typ zu ändern, wenn der Inhalt der Notiz nicht leer ist. Möchtest du dennoch fortfahren?",
|
||||
"geo-map": "Geo-Karte",
|
||||
"beta-feature": "Beta",
|
||||
"book": "Sammlung",
|
||||
"ai-chat": "KI-Chat",
|
||||
"ai-chat": "KI Chat",
|
||||
"task-list": "Aufgabenliste",
|
||||
"new-feature": "Neu",
|
||||
"collections": "Sammlungen",
|
||||
"spreadsheet": "Tabelle"
|
||||
"collections": "Sammlungen"
|
||||
},
|
||||
"protect_note": {
|
||||
"toggle-on": "Notiz schützen",
|
||||
@@ -2182,52 +2182,5 @@
|
||||
},
|
||||
"setup_form": {
|
||||
"more_info": "Mehr erfahren"
|
||||
},
|
||||
"media": {
|
||||
"play": "Abspielen (Arbeitsbereich)",
|
||||
"pause": "Pausieren (Arbeitsbereich)",
|
||||
"back-10s": "10 s zurück (Linke Pfeiltaste)",
|
||||
"forward-30s": "30 s vorwärts",
|
||||
"mute": "Stumm (M)",
|
||||
"unmute": "Stummschaltung aufheben (M)",
|
||||
"playback-speed": "Wiedergabegeschwindigkeit",
|
||||
"loop": "Schleife",
|
||||
"disable-loop": "Schleife deaktivieren",
|
||||
"rotate": "Rotieren",
|
||||
"picture-in-picture": "Bild-in-Bild",
|
||||
"exit-picture-in-picture": "Bild-in-Bild verlassen",
|
||||
"fullscreen": "Vollbild (F)",
|
||||
"exit-fullscreen": "Vollbild verlassen",
|
||||
"unsupported-format": "Medienvorschau ist für dieses Format nicht verfügbar:\n{{mime}}",
|
||||
"zoom-to-fit": "Zoomen um auszufüllen",
|
||||
"zoom-reset": "Zoomen um auszufüllen zurücksetzen"
|
||||
},
|
||||
"mermaid": {
|
||||
"placeholder": "Geben den Inhalt des Mermaid-Diagramms ein oder verwenden eine der folgenden Beispieldiagramme.",
|
||||
"sample_diagrams": "Beispieldiagramme:",
|
||||
"sample_flowchart": "Flussdiagramm",
|
||||
"sample_class": "Klasse",
|
||||
"sample_sequence": "Abfolge",
|
||||
"sample_entity_relationship": "Entität Beziehung",
|
||||
"sample_state": "Zustandsübergangsdiagramm",
|
||||
"sample_mindmap": "Mindmap",
|
||||
"sample_architecture": "Architektur",
|
||||
"sample_block": "Block",
|
||||
"sample_c4": "C4",
|
||||
"sample_gantt": "Gantt",
|
||||
"sample_git": "GitGraph",
|
||||
"sample_kanban": "Kanban",
|
||||
"sample_packet": "Paket",
|
||||
"sample_pie": "Kuchen",
|
||||
"sample_quadrant": "Quadrant",
|
||||
"sample_radar": "Radar",
|
||||
"sample_requirement": "Anforderung",
|
||||
"sample_sankey": "Sankey",
|
||||
"sample_timeline": "Zeitstrahl",
|
||||
"sample_treemap": "Kachel",
|
||||
"sample_user_journey": "Benutzererfahrung",
|
||||
"sample_xy": "XY",
|
||||
"sample_venn": "Mengen",
|
||||
"sample_ishikawa": "Ursache-Wirkung"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"about": {
|
||||
"title": "Σχετικά με το Trilium Notes",
|
||||
"title": "Πληροφορίες για το Trilium Notes",
|
||||
"homepage": "Αρχική Σελίδα:",
|
||||
"app_version": "Έκδοση εφαρμογής:",
|
||||
"db_version": "Έκδοση βάσης δεδομένων:",
|
||||
|
||||
@@ -343,7 +343,6 @@
|
||||
"label_type_title": "Type of the label will help Trilium to choose suitable interface to enter the label value.",
|
||||
"label_type": "Type",
|
||||
"text": "Text",
|
||||
"textarea": "Multi-line Text",
|
||||
"number": "Number",
|
||||
"boolean": "Boolean",
|
||||
"date": "Date",
|
||||
@@ -1037,25 +1036,6 @@
|
||||
"file_preview_not_available": "File preview is not available for this file format.",
|
||||
"too_big": "The preview only shows the first {{maxNumChars}} characters of the file for performance reasons. Download the file and open it externally to be able to see the entire content."
|
||||
},
|
||||
"media": {
|
||||
"play": "Play (Space)",
|
||||
"pause": "Pause (Space)",
|
||||
"back-10s": "Back 10s (Left arrow key)",
|
||||
"forward-30s": "Forward 30s",
|
||||
"mute": "Mute (M)",
|
||||
"unmute": "Unmute (M)",
|
||||
"playback-speed": "Playback speed",
|
||||
"loop": "Loop",
|
||||
"disable-loop": "Disable loop",
|
||||
"rotate": "Rotate",
|
||||
"picture-in-picture": "Picture-in-picture",
|
||||
"exit-picture-in-picture": "Exit picture-in-picture",
|
||||
"fullscreen": "Fullscreen (F)",
|
||||
"exit-fullscreen": "Exit fullscreen",
|
||||
"unsupported-format": "Media preview is not available for this file format:\n{{mime}}",
|
||||
"zoom-to-fit": "Zoom to fill",
|
||||
"zoom-reset": "Reset zoom to fill"
|
||||
},
|
||||
"protected_session": {
|
||||
"enter_password_instruction": "Showing protected note requires entering your password:",
|
||||
"start_session_button": "Start protected session",
|
||||
@@ -1069,6 +1049,7 @@
|
||||
"unprotecting-title": "Unprotecting status"
|
||||
},
|
||||
"relation_map": {
|
||||
"open_in_new_tab": "Open in new tab",
|
||||
"remove_note": "Remove note",
|
||||
"edit_title": "Edit title",
|
||||
"rename_note": "Rename note",
|
||||
@@ -2202,84 +2183,5 @@
|
||||
},
|
||||
"setup_form": {
|
||||
"more_info": "Learn more"
|
||||
},
|
||||
"mermaid": {
|
||||
"placeholder": "Type the content of your Mermaid diagram or use one of the sample diagrams below.",
|
||||
"sample_diagrams": "Sample diagrams:",
|
||||
"sample_flowchart": "Flowchart",
|
||||
"sample_class": "Class",
|
||||
"sample_sequence": "Sequence",
|
||||
"sample_entity_relationship": "Entity Relationship",
|
||||
"sample_state": "State",
|
||||
"sample_mindmap": "Mindmap",
|
||||
"sample_architecture": "Architecture",
|
||||
"sample_block": "Block",
|
||||
"sample_c4": "C4",
|
||||
"sample_gantt": "Gantt",
|
||||
"sample_git": "Git",
|
||||
"sample_kanban": "Kanban",
|
||||
"sample_packet": "Packet",
|
||||
"sample_pie": "Pie",
|
||||
"sample_quadrant": "Quadrant",
|
||||
"sample_radar": "Radar",
|
||||
"sample_requirement": "Requirement",
|
||||
"sample_sankey": "Sankey",
|
||||
"sample_timeline": "Timeline",
|
||||
"sample_treemap": "Treemap",
|
||||
"sample_user_journey": "User Journey",
|
||||
"sample_xy": "XY",
|
||||
"sample_venn": "Venn",
|
||||
"sample_ishikawa": "Ishikawa"
|
||||
},
|
||||
"setup": {
|
||||
"heading": "Get started with Trilium",
|
||||
"new-document": "New knowledge base",
|
||||
"new-document-description": "Start with a clean knowledge base and begin right away.",
|
||||
"sync-from-desktop": "Connect a desktop app",
|
||||
"sync-from-desktop-description": "You only have a Trilium desktop app running on another device. This device will sync its data from that desktop app.",
|
||||
"sync-from-server": "Connect to an existing server",
|
||||
"sync-from-server-description": "You have a Trilium server running elsewhere (either self-hosted or in the cloud). This device will sync its data from that server.",
|
||||
"next": "Next",
|
||||
"init-in-progress": "Document initialization in progress",
|
||||
"redirecting": "You will be shortly redirected to the application.",
|
||||
"title": "Setup",
|
||||
"sync-from-server-page-description": "Enter your server details below to connect your existing workspace.",
|
||||
"sync-in-progress-title": "Sync in progress",
|
||||
"sync-in-progress-description": "Your device is now connected and items are being synchronized.",
|
||||
"button-back": "Back",
|
||||
"button-finish-setup": "Finish setup",
|
||||
"sync-step-connecting": "Connecting to server",
|
||||
"sync-step-syncing": "Syncing data",
|
||||
"sync-step-finalizing": "Setting up options",
|
||||
"create-new-document-options-title": "How would you like to start?",
|
||||
"create-new-document-options-with-demo": "With demo content",
|
||||
"create-new-document-options-with-demo-description": "Explore Trilium with example content.",
|
||||
"create-new-document-options-empty": "Empty",
|
||||
"create-new-document-options-empty-description": "Start with a blank knowledge base. You can import demo notes later.",
|
||||
"create-new-document-title": "Preparing your knowledge base",
|
||||
"create-new-document-description": "This will only take a moment.",
|
||||
"sync-illustration-this-device": "This device",
|
||||
"sync-illustration-desktop-app": "Your desktop app",
|
||||
"sync-illustration-server": "Your server",
|
||||
"sync-from-desktop-step1": "Open your desktop instance of Trilium Notes.",
|
||||
"sync-from-desktop-step2": "From the Trilium Menu, click Options.",
|
||||
"sync-from-desktop-step3": "Click on Sync category in the note tree.",
|
||||
"sync-from-desktop-step4": "Change server instance address to point to one of the addresses on the right and click Save.",
|
||||
"sync-from-desktop-step5": "Click the \"Test sync\" button to verify connection is successful.",
|
||||
"sync-from-desktop-warning": "Make sure both devices are on the same network.",
|
||||
"sync-from-desktop-waiting": "Waiting for connection...",
|
||||
"advanced-options": "Advanced options",
|
||||
"sync-failed": "Failed to sync: {{message}}",
|
||||
"server-host": "Trilium server address",
|
||||
"server-host-placeholder": "https://<hostname>:<port>",
|
||||
"server-password": "Password",
|
||||
"proxy-server": "Proxy server (optional)",
|
||||
"proxy-server-placeholder": "https://<hostname>:<port>",
|
||||
"proxy-instruction": "If you leave proxy setting blank, system proxy will be used.",
|
||||
"dismiss-error": "Dismiss error",
|
||||
"wrong-password": "Incorrect password. Please try again.",
|
||||
"language": "Language",
|
||||
"continue": "Continue",
|
||||
"your-ip-addresses": "Addresses for this device"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1051,6 +1051,7 @@
|
||||
"unprotecting-title": "Estado de desprotección"
|
||||
},
|
||||
"relation_map": {
|
||||
"open_in_new_tab": "Abrir en nueva pestaña",
|
||||
"remove_note": "Quitar nota",
|
||||
"edit_title": "Editar título",
|
||||
"rename_note": "Cambiar nombre de nota",
|
||||
@@ -1547,8 +1548,7 @@
|
||||
"task-list": "Lista de tareas",
|
||||
"book": "Colección",
|
||||
"new-feature": "Nuevo",
|
||||
"collections": "Colecciones",
|
||||
"spreadsheet": "Hoja de cálculo"
|
||||
"collections": "Colecciones"
|
||||
},
|
||||
"protect_note": {
|
||||
"toggle-on": "Proteger la nota",
|
||||
@@ -1650,8 +1650,7 @@
|
||||
},
|
||||
"search_result": {
|
||||
"no_notes_found": "No se han encontrado notas para los parámetros de búsqueda dados.",
|
||||
"search_not_executed": "La búsqueda aún no se ha ejecutado.",
|
||||
"search_now": "Buscar ahora"
|
||||
"search_not_executed": "La búsqueda aún no se ha ejecutado. Dé clic en el botón «Buscar» para ver los resultados."
|
||||
},
|
||||
"spacer": {
|
||||
"configure_launchbar": "Configurar barra de lanzamiento"
|
||||
@@ -2197,52 +2196,5 @@
|
||||
},
|
||||
"setup_form": {
|
||||
"more_info": "Para saber más"
|
||||
},
|
||||
"media": {
|
||||
"play": "Reproducir (Espacio)",
|
||||
"pause": "Pausa (Espacio)",
|
||||
"back-10s": "Retroceder 10s (tecla de flecha izquierda)",
|
||||
"forward-30s": "Adelantar 30s",
|
||||
"mute": "Silenciar (M)",
|
||||
"unmute": "Activar sonido (M)",
|
||||
"playback-speed": "Velocidad de reproducción",
|
||||
"loop": "Bucle",
|
||||
"disable-loop": "Deshabilitar bucle",
|
||||
"rotate": "Rotar",
|
||||
"picture-in-picture": "Imagen en imagen",
|
||||
"exit-picture-in-picture": "Salir del modo imagen en imagen",
|
||||
"fullscreen": "Pantalla completa (F)",
|
||||
"exit-fullscreen": "Salir de la pantalla completa",
|
||||
"unsupported-format": "La vista previa del medio no está disponible para este formato de archivo:\n{{mime}}",
|
||||
"zoom-to-fit": "Acercamiento para llenar",
|
||||
"zoom-reset": "Reiniciar acercamiento para llenar"
|
||||
},
|
||||
"mermaid": {
|
||||
"placeholder": "Ingrese el contenido de su diagrama Mermaid o utilice uno de los diagramas de muestra a continuación.",
|
||||
"sample_diagrams": "Diagramas de muestra:",
|
||||
"sample_flowchart": "Diagrama de flujo",
|
||||
"sample_class": "Clase",
|
||||
"sample_sequence": "Secuencia",
|
||||
"sample_entity_relationship": "Relación entre entidades",
|
||||
"sample_state": "Estado",
|
||||
"sample_mindmap": "Mapa mental",
|
||||
"sample_architecture": "Arquitectura",
|
||||
"sample_block": "Bloque",
|
||||
"sample_c4": "C4",
|
||||
"sample_gantt": "Gantt",
|
||||
"sample_git": "Git",
|
||||
"sample_kanban": "Kanban",
|
||||
"sample_packet": "Paquete",
|
||||
"sample_pie": "Pastel",
|
||||
"sample_quadrant": "Cuadrante",
|
||||
"sample_radar": "Radar",
|
||||
"sample_requirement": "Requerimiento",
|
||||
"sample_sankey": "Sankey",
|
||||
"sample_timeline": "Línea de tiempo",
|
||||
"sample_user_journey": "Jornada de usuario",
|
||||
"sample_xy": "XY",
|
||||
"sample_venn": "Venn",
|
||||
"sample_ishikawa": "Ishikawa",
|
||||
"sample_treemap": "Mapa de árbol"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1036,6 +1036,7 @@
|
||||
"unprotecting-title": "Statut de la non-protection"
|
||||
},
|
||||
"relation_map": {
|
||||
"open_in_new_tab": "Ouvrir dans un nouvel onglet",
|
||||
"remove_note": "Supprimer la note",
|
||||
"edit_title": "Modifier le titre",
|
||||
"rename_note": "Renommer la note",
|
||||
|
||||
@@ -1055,6 +1055,7 @@
|
||||
"unprotecting-title": "Stádas díchosanta"
|
||||
},
|
||||
"relation_map": {
|
||||
"open_in_new_tab": "Oscail i gcluaisín nua",
|
||||
"remove_note": "Bain nóta",
|
||||
"edit_title": "Cuir an teideal in eagar",
|
||||
"rename_note": "Athainmnigh an nóta",
|
||||
@@ -1570,8 +1571,7 @@
|
||||
"ai-chat": "Comhrá AI",
|
||||
"task-list": "Liosta Tascanna",
|
||||
"new-feature": "Nua",
|
||||
"collections": "Bailiúcháin",
|
||||
"spreadsheet": "Scarbhileog"
|
||||
"collections": "Bailiúcháin"
|
||||
},
|
||||
"protect_note": {
|
||||
"toggle-on": "Cosain an nóta",
|
||||
@@ -2227,52 +2227,5 @@
|
||||
},
|
||||
"setup_form": {
|
||||
"more_info": "Foghlaim níos mó"
|
||||
},
|
||||
"media": {
|
||||
"play": "Seinn (Spás)",
|
||||
"pause": "Sos (Spás)",
|
||||
"back-10s": "10 soicind ar ais (eochair saighead chlé)",
|
||||
"forward-30s": "Ar aghaidh 30s",
|
||||
"mute": "Balbhaigh (M)",
|
||||
"unmute": "Díbhalbhaigh (M)",
|
||||
"playback-speed": "Luas athsheinm",
|
||||
"loop": "Lúb",
|
||||
"disable-loop": "Díchumasaigh an lúb",
|
||||
"rotate": "Rothlaigh",
|
||||
"picture-in-picture": "Pictiúr i bpictiúr",
|
||||
"exit-picture-in-picture": "Scoir pictiúr-i-bpictiúr",
|
||||
"fullscreen": "Lánscáileán (F)",
|
||||
"exit-fullscreen": "Scoir lánscáileáin",
|
||||
"unsupported-format": "Níl réamhamharc meán ar fáil don fhormáid comhaid seo:\n{{mime}}",
|
||||
"zoom-to-fit": "Zúmáil chun líonadh",
|
||||
"zoom-reset": "Athshocraigh súmáil chun líonadh"
|
||||
},
|
||||
"mermaid": {
|
||||
"placeholder": "Clóscríobh ábhar do léaráid Maighdean Mhara nó bain úsáid as ceann de na léaráidí samplacha thíos.",
|
||||
"sample_diagrams": "Léaráidí samplacha:",
|
||||
"sample_flowchart": "Cairt Sreabhadh",
|
||||
"sample_class": "Rang",
|
||||
"sample_sequence": "Seicheamh",
|
||||
"sample_entity_relationship": "Gaol Eintitis",
|
||||
"sample_state": "Stát",
|
||||
"sample_mindmap": "Léarscáil intinne",
|
||||
"sample_architecture": "Ailtireacht",
|
||||
"sample_block": "Bloc",
|
||||
"sample_c4": "C4",
|
||||
"sample_gantt": "Gantt",
|
||||
"sample_git": "Git",
|
||||
"sample_kanban": "Kanban",
|
||||
"sample_packet": "Paicéad",
|
||||
"sample_pie": "Pióg",
|
||||
"sample_quadrant": "Ceathrú",
|
||||
"sample_radar": "Radar",
|
||||
"sample_requirement": "Riachtanas",
|
||||
"sample_sankey": "Sankey",
|
||||
"sample_timeline": "Amlíne",
|
||||
"sample_treemap": "Léarscáil Crann",
|
||||
"sample_user_journey": "Turas Úsáideora",
|
||||
"sample_xy": "XY",
|
||||
"sample_venn": "Venn",
|
||||
"sample_ishikawa": "Ishikawa"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1049,6 +1049,7 @@
|
||||
"unprotecting-title": "अन-प्रोटेक्ट स्टेटस"
|
||||
},
|
||||
"relation_map": {
|
||||
"open_in_new_tab": "नए टैब में खोलें",
|
||||
"remove_note": "नोट हटाएं",
|
||||
"edit_title": "टाइटल एडिट करें",
|
||||
"rename_note": "नोट का नाम बदलें",
|
||||
|
||||
@@ -520,7 +520,7 @@
|
||||
"custom_name_label": "Nome del motore di ricerca personalizzato",
|
||||
"custom_name_placeholder": "Personalizza il nome del motore di ricerca",
|
||||
"custom_url_label": "L'URL del motore di ricerca personalizzato deve includere {keyword} come segnaposto per il termine di ricerca.",
|
||||
"custom_url_placeholder": "Personalizza l'URL del motore di ricerca"
|
||||
"custom_url_placeholder": "Personalizza l'URL del motore di ricerca"
|
||||
},
|
||||
"sql_table_schemas": {
|
||||
"tables": "Tabelle"
|
||||
@@ -1424,6 +1424,7 @@
|
||||
"unprotecting-title": "Stato non protetto"
|
||||
},
|
||||
"relation_map": {
|
||||
"open_in_new_tab": "Apri in una nuova scheda",
|
||||
"remove_note": "Rimuovi nota",
|
||||
"edit_title": "Modifica titolo",
|
||||
"rename_note": "Rinomina nota",
|
||||
@@ -1716,8 +1717,7 @@
|
||||
"task-list": "Elenco delle attività",
|
||||
"new-feature": "Nuovo",
|
||||
"collections": "Collezioni",
|
||||
"ai-chat": "Chat con IA",
|
||||
"spreadsheet": "Foglio di calcolo"
|
||||
"ai-chat": "Chat con IA"
|
||||
},
|
||||
"protect_note": {
|
||||
"toggle-on": "Proteggi la nota",
|
||||
|
||||
@@ -588,7 +588,7 @@
|
||||
"note-map": "ノートマップ",
|
||||
"render-note": "レンダリングノート",
|
||||
"book": "コレクション",
|
||||
"mermaid-diagram": "マーメイド図",
|
||||
"mermaid-diagram": "Mermaidダイアグラム",
|
||||
"canvas": "キャンバス",
|
||||
"web-view": "Web ビュー",
|
||||
"mind-map": "マインドマップ",
|
||||
@@ -600,8 +600,7 @@
|
||||
"task-list": "タスクリスト",
|
||||
"new-feature": "New",
|
||||
"collections": "コレクション",
|
||||
"ai-chat": "AI チャット",
|
||||
"spreadsheet": "スプレッドシート"
|
||||
"ai-chat": "AI チャット"
|
||||
},
|
||||
"edited_notes": {
|
||||
"no_edited_notes_found": "この日の編集されたノートはまだありません...",
|
||||
@@ -1180,8 +1179,7 @@
|
||||
"is_owned_by_note": "ノートによって所有されています",
|
||||
"and_more": "...その他 {{count}} 件。",
|
||||
"print_landscape": "PDF にエクスポートするときに、ページの向きを縦向きではなく横向きに変更します。",
|
||||
"print_page_size": "PDF にエクスポートするときに、ページのサイズを変更します。サポートされる値: <code>A0</code>, <code>A1</code>, <code>A2</code>, <code>A3</code>, <code>A4</code>, <code>A5</code>, <code>A6</code>, <code>Legal</code>, <code>Letter</code>, <code>Tabloid</code>, <code>Ledger</code>。",
|
||||
"textarea": "複数行テキスト"
|
||||
"print_page_size": "PDF にエクスポートするときに、ページのサイズを変更します。サポートされる値: <code>A0</code>, <code>A1</code>, <code>A2</code>, <code>A3</code>, <code>A4</code>, <code>A5</code>, <code>A6</code>, <code>Legal</code>, <code>Letter</code>, <code>Tabloid</code>, <code>Ledger</code>。"
|
||||
},
|
||||
"link_context_menu": {
|
||||
"open_note_in_popup": "クイック編集",
|
||||
@@ -1538,6 +1536,7 @@
|
||||
"url_placeholder": "http://web サイト..."
|
||||
},
|
||||
"relation_map": {
|
||||
"open_in_new_tab": "新しいタブで開く",
|
||||
"remove_note": "ノートを削除",
|
||||
"edit_title": "タイトルを編集",
|
||||
"rename_note": "ノート名を変更",
|
||||
@@ -2168,52 +2167,5 @@
|
||||
},
|
||||
"setup_form": {
|
||||
"more_info": "さらに詳しく"
|
||||
},
|
||||
"media": {
|
||||
"play": "再生 (スペース)",
|
||||
"pause": "一時停止 (スペース)",
|
||||
"back-10s": "10 秒戻る (左矢印キー)",
|
||||
"forward-30s": "30 秒進む",
|
||||
"mute": "ミュート (M)",
|
||||
"unmute": "ミュート解除 (M)",
|
||||
"playback-speed": "再生速度",
|
||||
"loop": "ループ",
|
||||
"disable-loop": "ループを解除",
|
||||
"rotate": "回転",
|
||||
"picture-in-picture": "ピクチャーインピクチャー",
|
||||
"exit-picture-in-picture": "ピクチャーインピクチャーを終了",
|
||||
"fullscreen": "全画面表示 (F)",
|
||||
"exit-fullscreen": "全画面表示を終了",
|
||||
"unsupported-format": "このファイル形式ではメディアプレビューはご利用いただけません:\n{{mime}}",
|
||||
"zoom-to-fit": "ズームして全体を表示",
|
||||
"zoom-reset": "ズーム設定をリセット"
|
||||
},
|
||||
"mermaid": {
|
||||
"placeholder": "マーメイド図の内容を入力するか、以下のサンプル図のいずれかを使用してください。",
|
||||
"sample_diagrams": "サンプル図:",
|
||||
"sample_flowchart": "フローチャート",
|
||||
"sample_class": "クラス図",
|
||||
"sample_sequence": "シーケンス図",
|
||||
"sample_entity_relationship": "ER 図",
|
||||
"sample_state": "状態遷移図",
|
||||
"sample_mindmap": "マインドマップ",
|
||||
"sample_architecture": "アーキテクチャ図",
|
||||
"sample_block": "ブロック図",
|
||||
"sample_c4": "C4 図",
|
||||
"sample_gantt": "ガントチャート",
|
||||
"sample_git": "Git グラフ",
|
||||
"sample_kanban": "カンバン",
|
||||
"sample_packet": "パケット図",
|
||||
"sample_pie": "円グラフ",
|
||||
"sample_quadrant": "4象限図",
|
||||
"sample_radar": "レーダーチャート",
|
||||
"sample_requirement": "要件図",
|
||||
"sample_sankey": "サンキー図",
|
||||
"sample_timeline": "タイムライン",
|
||||
"sample_treemap": "ツリーマップ",
|
||||
"sample_user_journey": "ユーザージャーニー図",
|
||||
"sample_xy": "XY チャート",
|
||||
"sample_venn": "ベン図",
|
||||
"sample_ishikawa": "石川図"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -51,7 +51,7 @@
|
||||
"branch_prefix_saved": "브랜치 접두사가 저장되었습니다.",
|
||||
"edit_branch_prefix_multiple": "{{count}}개의 지점 접두사 편집",
|
||||
"branch_prefix_saved_multiple": "{{count}}개의 지점에 대해 지점 접두사가 저장되었습니다.",
|
||||
"affected_branches": "영향을 받은 분기 수({{count}}):"
|
||||
"affected_branches": "영향을 받는 브랜치 수 ({{count}}):"
|
||||
},
|
||||
"bulk_actions": {
|
||||
"bulk_actions": "대량 작업",
|
||||
@@ -134,27 +134,6 @@
|
||||
"notSet": "미설정",
|
||||
"goBackForwards": "히스토리에서 뒤로/앞으로 이동",
|
||||
"showJumpToNoteDialog": "<a class=\"external\" href=\"https://triliumnext.github.io/Docs/Wiki/note-navigation.html#jump-to-note\">\"노트로 이동\" 대화 상자</a> 표시",
|
||||
"scrollToActiveNote": "활성화된 노트로 스크롤 이동",
|
||||
"collapseWholeTree": "모든 노트 트리를 접기",
|
||||
"collapseSubTree": "하위 트리 접기",
|
||||
"tabShortcuts": "탭 단축키",
|
||||
"onlyInDesktop": "데스크톱에서만(일렉트론 빌드)",
|
||||
"openEmptyTab": "빈 탭 열기",
|
||||
"closeActiveTab": "활성 탭 닫기",
|
||||
"jumpToParentNote": "부모 노트로 이동하기",
|
||||
"activateNextTab": "다음 탭 활성화",
|
||||
"activatePreviousTab": "이전 탭 활성화",
|
||||
"creatingNotes": "노트 만들기",
|
||||
"createNoteInto": "활성 노트에 새로운 하위 노트 추가",
|
||||
"movingCloningNotes": "노트 이동/복제",
|
||||
"moveNoteUpDown": "노트 목록에서 노트 위/아래 이동",
|
||||
"selectAllNotes": "현재 레벨의 모든 노트 선택",
|
||||
"selectNote": "노트 선택",
|
||||
"deleteNotes": "노트/하위트리 삭제",
|
||||
"editingNotes": "노트 편집",
|
||||
"createEditLink": "외부 링크 생성/편집",
|
||||
"createInternalLink": "내부 링크 생성",
|
||||
"followLink": "커서아래 링크 따라가기",
|
||||
"insertDateTime": "커서위치에 현재 날짜와 시간 삽입"
|
||||
"scrollToActiveNote": "활성화된 노트로 스크롤 이동"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,7 +29,7 @@
|
||||
"widget-render-error": {
|
||||
"title": "Nie udało się wyrenderować niestandardowego widżetu React"
|
||||
},
|
||||
"widget-missing-parent": "Niestandardowy widżet nie ma zdefiniowanej obowiązkowej właściwości „{{property}}”.\n\nJeśli skrypt ma działać bez interfejsu użytkownika (UI) wyłącz go: '#run=frontendStartup'.",
|
||||
"widget-missing-parent": "Niestandardowy widżet nie ma zdefiniowanej obowiązkowej właściwości „{{property}}”.\nJeśli skrypt ma działać bez interfejsu użytkownika (UI) wyłącz go: '#run=frontendStartup'.",
|
||||
"open-script-note": "Otwórz notatkę ze skryptem",
|
||||
"scripting-error": "Błąd skryptu użytkownika: {{title}}"
|
||||
},
|
||||
@@ -1275,6 +1275,7 @@
|
||||
"unprotecting-title": "Status zdejmowania ochrony"
|
||||
},
|
||||
"relation_map": {
|
||||
"open_in_new_tab": "Otwórz w nowej karcie",
|
||||
"remove_note": "Usuń notatkę",
|
||||
"edit_title": "Edytuj tytuł",
|
||||
"rename_note": "Zmień nazwę notatki",
|
||||
@@ -1486,7 +1487,7 @@
|
||||
"custom_name_label": "Nazwa niestandardowej wyszukiwarki",
|
||||
"custom_name_placeholder": "Dostosuj nazwę wyszukiwarki",
|
||||
"custom_url_label": "URL niestandardowej wyszukiwarki powinien zawierać {keyword} jako symbol zastępczy dla wyszukiwanej frazy.",
|
||||
"custom_url_placeholder": "Dostosuj url wyszukiwarki",
|
||||
"custom_url_placeholder": "Dostosuj URL wyszukiwarki",
|
||||
"save_button": "Zapisz"
|
||||
},
|
||||
"tray": {
|
||||
@@ -1779,8 +1780,7 @@
|
||||
"ai-chat": "Czat AI",
|
||||
"task-list": "Lista zadań",
|
||||
"new-feature": "Nowość",
|
||||
"collections": "Kolekcje",
|
||||
"spreadsheet": "Arkusz"
|
||||
"collections": "Kolekcje"
|
||||
},
|
||||
"protect_note": {
|
||||
"toggle-on": "Chroń notatkę",
|
||||
@@ -2197,52 +2197,5 @@
|
||||
},
|
||||
"setup_form": {
|
||||
"more_info": "Dowiedz się więcej"
|
||||
},
|
||||
"media": {
|
||||
"fullscreen": "Pełny ekran (F)",
|
||||
"mute": "Wycisz (M)",
|
||||
"unmute": "Wyłącz wyciszenie (M)",
|
||||
"exit-fullscreen": "Wyłącz pełny ekran",
|
||||
"loop": "Pętla",
|
||||
"disable-loop": "Wyłącz pętle",
|
||||
"rotate": "Obróć",
|
||||
"picture-in-picture": "Obraz w obrazie",
|
||||
"pause": "Zatrzymaj (Space)",
|
||||
"back-10s": "Cofnij 10s (Lewa strzałka)",
|
||||
"forward-30s": "Do przodu 30s",
|
||||
"playback-speed": "Szybkość odtwarzania",
|
||||
"exit-picture-in-picture": "Wyjdź z obrazu w obrazie",
|
||||
"zoom-to-fit": "Powiększ aby wypełnić",
|
||||
"unsupported-format": "Podgląd multimediów nie jest dostępny dla tego formatu pliku\n{{mime}}",
|
||||
"play": "Odtwórz (Space)",
|
||||
"zoom-reset": "Zresetuj powiększenie"
|
||||
},
|
||||
"mermaid": {
|
||||
"sample_architecture": "Architektura",
|
||||
"sample_diagrams": "Przykład diagramu:",
|
||||
"sample_flowchart": "Schemat blokowy",
|
||||
"sample_class": "Klasa",
|
||||
"sample_sequence": "Sekwencja",
|
||||
"sample_timeline": "Oś czasu",
|
||||
"sample_treemap": "Mapa drzewa",
|
||||
"sample_xy": "XY",
|
||||
"sample_venn": "Diagram Venna",
|
||||
"sample_ishikawa": "Diagram Ishikawa",
|
||||
"placeholder": "Wpisz treść swojego diagramu lub skorzystaj z jednego z przykładowych diagramów poniżej.",
|
||||
"sample_entity_relationship": "Diagram związków encji",
|
||||
"sample_state": "Diagram stanów",
|
||||
"sample_mindmap": "Mapa myśli",
|
||||
"sample_block": "Diagram blokowy",
|
||||
"sample_c4": "C4",
|
||||
"sample_gantt": "Wykres Gantta",
|
||||
"sample_git": "Diagram Git",
|
||||
"sample_kanban": "Kanban",
|
||||
"sample_packet": "Diagram pakietów",
|
||||
"sample_pie": "Wykres kołowy",
|
||||
"sample_quadrant": "Diagram kwadrantowy",
|
||||
"sample_radar": "Wykres radarowy",
|
||||
"sample_requirement": "Diagram wymagań",
|
||||
"sample_sankey": "Wykres Sankeya",
|
||||
"sample_user_journey": "Mapa Podróży Użytkownika"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1047,6 +1047,7 @@
|
||||
"unprotecting-title": "Estado da remoção de proteção"
|
||||
},
|
||||
"relation_map": {
|
||||
"open_in_new_tab": "Abrir em nova guia",
|
||||
"remove_note": "Remover nota",
|
||||
"edit_title": "Editar título",
|
||||
"rename_note": "Renomear nota",
|
||||
|
||||
@@ -1111,6 +1111,7 @@
|
||||
"start_session_button": "Iniciar sessão protegida"
|
||||
},
|
||||
"relation_map": {
|
||||
"open_in_new_tab": "Abrir em nova aba",
|
||||
"remove_note": "Remover nota",
|
||||
"edit_title": "Editar título",
|
||||
"rename_note": "Renomear nota",
|
||||
|
||||
@@ -1054,6 +1054,7 @@
|
||||
"enter_title_of_new_note": "Introduceți titlul noii notițe",
|
||||
"note_already_in_diagram": "Notița „{{title}}” deja se află pe diagramă.",
|
||||
"note_not_found": "Notița „{{noteId}}” nu a putut fi găsită!",
|
||||
"open_in_new_tab": "Deschide într-un tab nou",
|
||||
"remove_note": "Șterge notița",
|
||||
"remove_relation": "Șterge relația",
|
||||
"rename_note": "Redenumește notița",
|
||||
@@ -2137,55 +2138,5 @@
|
||||
"title_few": "{{count}} taburi",
|
||||
"title_other": "{{count}} de taburi",
|
||||
"more_options": "Mai multe opțiuni"
|
||||
},
|
||||
"setup": {
|
||||
"heading": "Începeți cu Trilium",
|
||||
"new-document": "Nouă bază de cunoștințe",
|
||||
"new-document-description": "Începeți cu o bază de cunoștințe curată și începeți imediat.",
|
||||
"sync-from-desktop": "Conectează o aplicație desktop",
|
||||
"sync-from-desktop-description": "Aveți doar o aplicație Trilium desktop rulând pe un alt dispozitiv. Acest dispozitiv va sincroniza datele de la acea aplicație desktop.",
|
||||
"sync-from-server": "Conectează-te la un server existent",
|
||||
"sync-from-server-description": "Aveți un server Trilium care rulează în altă parte (fie auto-găzduit, fie în cloud). Acest dispozitiv va sincroniza datele de la acel server.",
|
||||
"next": "Următorul",
|
||||
"init-in-progress": "Inițializarea documentului în curs",
|
||||
"redirecting": "Veți fi redirecționat în scurt timp către aplicație.",
|
||||
"title": "Configurare",
|
||||
"sync-from-server-page-description": "Introduceți detaliile serverului dvs. mai jos pentru a vă conecta la spațiul de lucru existent.",
|
||||
"sync-in-progress-title": "Sincronizare în curs",
|
||||
"sync-in-progress-description": "Dispozitivul dvs. este acum conectat și elementele sunt sincronizate.",
|
||||
"button-back": "Înapoi",
|
||||
"button-finish-setup": "Finalizează configurarea",
|
||||
"sync-step-connecting": "Conectare la server",
|
||||
"sync-step-syncing": "Sincronizare date",
|
||||
"sync-step-finalizing": "Setarea opțiunilor",
|
||||
"create-new-document-options-title": "Cum doriți să începeți?",
|
||||
"create-new-document-options-with-demo": "Cu conținut demonstrativ",
|
||||
"create-new-document-options-with-demo-description": "Explorează Trilium cu conținut exemplu.",
|
||||
"create-new-document-options-empty": "Gol",
|
||||
"create-new-document-options-empty-description": "Începeți cu o bază de cunoștințe goală. Puteți importa notițe demo mai târziu.",
|
||||
"create-new-document-title": "Pregătirea bazei de cunoștințe",
|
||||
"create-new-document-description": "Acest proces va dura doar câteva momente.",
|
||||
"sync-illustration-this-device": "Acest dispozitiv",
|
||||
"sync-illustration-desktop-app": "Aplicație desktop",
|
||||
"sync-illustration-server": "Server de sincronizare",
|
||||
"sync-from-desktop-step1": "Deschideți aplicația Trilium Notes pentru desktop.",
|
||||
"sync-from-desktop-step2": "Din meniul Trilium, dați clic pe Opțiuni.",
|
||||
"sync-from-desktop-step3": "Clic pe categoria „Sincronizare”.",
|
||||
"sync-from-desktop-step4": "Schimbați adresa server-ului către: {{- host}} și apăsați „Salvează”.",
|
||||
"sync-from-desktop-step5": "Clic pe butonul „Testează sincronizarea” pentru a verifica dacă conexiunea a fost făcută cu succes.",
|
||||
"sync-from-desktop-final": "După ce ați finalizat acești pași, puteți trece la pasul următor.",
|
||||
"sync-from-desktop-waiting": "Așteptare pentru conexiune...",
|
||||
"advanced-options": "Opțiuni avansate",
|
||||
"sync-failed": "Sincronizare eșuată: {{message}}",
|
||||
"server-host": "Adresa serverului Trilium",
|
||||
"server-host-placeholder": "https://<hostname>:<port>",
|
||||
"server-password": "Parolă",
|
||||
"proxy-server": "Server proxy (opțional)",
|
||||
"proxy-server-placeholder": "https://<hostname>:<port>",
|
||||
"proxy-instruction": " Dacă lăsați setarea proxy necompletată, va fi utilizat proxy-ul sistemului.",
|
||||
"dismiss-error": "Închide mesajul de eroare",
|
||||
"wrong-password": "Parolă greșită. Vă rugăm să încercați din nou.",
|
||||
"language": "Limbă",
|
||||
"continue": "Continuă"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -257,7 +257,7 @@
|
||||
"collapseExpand": "свернуть/развернуть узел",
|
||||
"notSet": "не установлено",
|
||||
"goBackForwards": "назад / вперед в истории",
|
||||
"showJumpToNoteDialog": "Перейти к <a class=\"external\" href=\"https://triliumnext.github.io/Docs/Wiki/note-navigation.html#jump-to-note\">\"Перейти к\" окно</a>",
|
||||
"showJumpToNoteDialog": "показать <a class=\"external\" href=\"https://triliumnext.github.io/Docs/Wiki/note-navigation.html#jump-to-note\">окно \"Перейти к\"</a>",
|
||||
"scrollToActiveNote": "прокрутка к активной заметке",
|
||||
"jumpToParentNote": "переход к родительской заметке",
|
||||
"collapseWholeTree": "свернуть все дерево заметок",
|
||||
@@ -471,7 +471,7 @@
|
||||
"calendar_root": "отмечает заметку, которая должна использоваться в качестве корневой для заметок дня. Только одна должна быть отмечена как таковая.",
|
||||
"archived": "заметки с этой меткой не будут отображаться в результатах поиска по умолчанию (а также в диалоговых окнах «Перейти к», «Добавить ссылку» и т. д.).",
|
||||
"exclude_from_export": "заметки (с их поддеревьями) не будут включены ни в один экспорт заметок",
|
||||
"run": "определяет, при каких событиях должен запускаться скрипт. Возможные значения:<ul>\n<li>frontendStartup — при запуске (или обновлении) фронтенда Trilium, но не на мобильном устройстве.</li>\n<li>mobileStartup — при запуске (или обновлении) фронтенда Trilium на мобильном устройстве.</li>\n<li>backendStartup — при запуске бэкенда Trilium.</li>\n<li>hourly — запускать каждый час. Для указания времени можно использовать дополнительную метку <code>runAtHour</code>.</li>\n<li>daily — запускать раз в день.</li></ul>",
|
||||
"run": "определяет, при каких событиях должен запускаться скрипт. Возможные значения:\n<ul>\n<li>frontendStartup — при запуске (или обновлении) фронтенда Trilium, но не на мобильном устройстве.</li>\n<li>mobileStartup — при запуске (или обновлении) фронтенда Trilium на мобильном устройстве.</li>\n<li>backendStartup — при запуске бэкенда Trilium.</li>\n<li>hourly — запускать каждый час. Для указания времени можно использовать дополнительную метку <code>runAtHour</code>.</li>\n<li>daily — запускать раз в день.</li>\n</ul>",
|
||||
"run_on_instance": "Определить, на каком экземпляре Trilium это должно выполняться. По умолчанию — для всех экземпляров.",
|
||||
"run_at_hour": "В какой час это должно выполняться? Следует использовать вместе с <code>#run=hourly</code>. Можно задать несколько раз для большего количества запусков в течение дня.",
|
||||
"disable_inclusion": "скрипты с этой меткой не будут включены в выполнение родительского скрипта.",
|
||||
@@ -594,8 +594,7 @@
|
||||
"display-week-numbers": "Отображать номера недель",
|
||||
"hide-weekends": "Скрыть выходные",
|
||||
"raster": "Растр",
|
||||
"show-scale": "Показать масштаб",
|
||||
"show-labels": "Показать названия маркеров"
|
||||
"show-scale": "Показать масштаб"
|
||||
},
|
||||
"editorfeatures": {
|
||||
"note_completion_enabled": "Включить автодополнение",
|
||||
@@ -783,13 +782,7 @@
|
||||
"shared-indicator-tooltip": "Эта заметка опубликована",
|
||||
"shared-indicator-tooltip-with-url": "Эта заметка доступно публично по адресу: {{- url}}",
|
||||
"subtree-hidden-moved-description-other": "В дереве, к которому относится эта заметка, скрыты дочерние заметки.",
|
||||
"subtree-hidden-moved-description-collection": "Эта коллекция скрывает свои дочерние заметки в дереве.",
|
||||
"clone-indicator-tooltip": "У этой заметки {{- count}} родителей: {{- parents}}",
|
||||
"clone-indicator-tooltip-single": "Эта заметка клонирована (1 дополнительный родитель: {{- parent}})",
|
||||
"subtree-hidden-moved-title": "Добавлено в {{title}}",
|
||||
"subtree-hidden-tooltip_one": "{{count}} дочерняя заметка скрыта",
|
||||
"subtree-hidden-tooltip_few": "Скрыто {{count}} дочерних заметок",
|
||||
"subtree-hidden-tooltip_many": "Скрыто {{count}} дочерних заметок"
|
||||
"subtree-hidden-moved-description-collection": "Эта коллекция скрывает свои дочерние заметки в дереве."
|
||||
},
|
||||
"quick-search": {
|
||||
"no-results": "Результаты не найдены",
|
||||
@@ -833,9 +826,7 @@
|
||||
"mind-map": "Mind Map",
|
||||
"geo-map": "Географическая карта",
|
||||
"task-list": "Список задач",
|
||||
"confirm-change": "Не рекомендуется менять тип заметки, если её содержимое не пустое. Вы всё равно хотите продолжить?",
|
||||
"ai-chat": "Чат с ИИ",
|
||||
"spreadsheet": "Электронная таблица"
|
||||
"confirm-change": "Не рекомендуется менять тип заметки, если её содержимое не пустое. Вы всё равно хотите продолжить?"
|
||||
},
|
||||
"tree-context-menu": {
|
||||
"open-in-popup": "Быстрое редактирование",
|
||||
@@ -1162,8 +1153,7 @@
|
||||
"search_note_saved": "Заметка с настройкой поиска сохранена в {{- notePathTitle}}",
|
||||
"unknown_search_option": "Неизвестный параметр поиска {{searchOptionName}}",
|
||||
"actions_executed": "Действия выполнены.",
|
||||
"view_options": "Просмотреть опции:",
|
||||
"option": "опция"
|
||||
"view_options": "Просмотреть опции:"
|
||||
},
|
||||
"ancestor": {
|
||||
"depth_label": "глубина",
|
||||
@@ -1413,8 +1403,7 @@
|
||||
"type_text_to_filter": "Введите текст для фильтрации сочетаний клавиш...",
|
||||
"reload_app": "Перезагрузить приложение, чтобы применить изменения",
|
||||
"confirm_reset": "Вы действительно хотите сбросить все сочетания клавиш до значений по умолчанию?",
|
||||
"set_all_to_default": "Установить все сочетания клавиш по умолчанию",
|
||||
"no_results": "Не найдено ярлыков, соответствующих '{{filter}}'"
|
||||
"set_all_to_default": "Установить все сочетания клавиш по умолчанию"
|
||||
},
|
||||
"sync_2": {
|
||||
"timeout_unit": "миллисекунд",
|
||||
@@ -1625,6 +1614,7 @@
|
||||
"rename_note": "Переименовать заметку",
|
||||
"remove_relation": "Удалить отношение",
|
||||
"default_new_note_title": "новая заметка",
|
||||
"open_in_new_tab": "Открыть в новой вкладке",
|
||||
"confirm_remove_relation": "Вы уверены, что хотите удалить связь?",
|
||||
"enter_new_title": "Введите новое название заметки:",
|
||||
"note_not_found": "Заметка {{noteId}} не найдена!",
|
||||
@@ -1723,8 +1713,7 @@
|
||||
"delete_this_note": "Удалить эту заметку",
|
||||
"insert_child_note": "Вставить дочернюю заметку",
|
||||
"note_revisions": "История изменений",
|
||||
"content_language_switcher": "Язык содержимого: {{language}}",
|
||||
"backlinks": "Ссылки"
|
||||
"content_language_switcher": "Язык содержимого: {{language}}"
|
||||
},
|
||||
"svg_export_button": {
|
||||
"button_title": "Экспортировать диаграмму как SVG"
|
||||
@@ -1801,8 +1790,7 @@
|
||||
},
|
||||
"search_result": {
|
||||
"no_notes_found": "По заданным параметрам поиска заметки не найдены.",
|
||||
"search_not_executed": "Поиск ещё не выполнен.",
|
||||
"search_now": "Искать сейчас"
|
||||
"search_not_executed": "Поиск ещё не выполнен. Нажмите кнопку «Поиск» выше, чтобы увидеть результаты."
|
||||
},
|
||||
"empty": {
|
||||
"search_placeholder": "поиск заметки по ее названию",
|
||||
@@ -2000,12 +1988,10 @@
|
||||
"print_report_collection_content_few": "{{count}} заметки в коллекции не удалось распечатать, поскольку они не поддерживаются или защищены.",
|
||||
"print_report_collection_content_many": "{{count}} заметок в коллекции не удалось распечатать, поскольку они не поддерживаются или защищены.",
|
||||
"print_report_collection_details_button": "Подробнее",
|
||||
"print_report_collection_details_ignored_notes": "Пропущенные заметки",
|
||||
"print_report_error_title": "Не удалось напечатать",
|
||||
"print_report_stack_trace": "Трассировка стека"
|
||||
"print_report_collection_details_ignored_notes": "Пропущенные заметки"
|
||||
},
|
||||
"book": {
|
||||
"no_children_help": "В этой коллекции нет дочерних заметок, поэтому отображать нечего.",
|
||||
"no_children_help": "В этой коллекции нет дочерних заметок, поэтому отображать нечего. Подробности см. в <a href=\"https://triliumnext.github.io/Docs/Wiki/book-note.html\">wiki</a>.",
|
||||
"drag_locked_title": "Защищено от изменения",
|
||||
"drag_locked_message": "Перетаскивание не допускается, так как коллекция защищена от редактирования."
|
||||
},
|
||||
@@ -2021,9 +2007,7 @@
|
||||
"rendering_error": "Невозможно отобразить содержимое из-за ошибки."
|
||||
},
|
||||
"pagination": {
|
||||
"total_notes": "{{count}} заметок",
|
||||
"prev_page": "Предыдущая страница",
|
||||
"next_page": "Следующая страница"
|
||||
"total_notes": "{{count}} заметок"
|
||||
},
|
||||
"status_bar": {
|
||||
"attributes_one": "{{count}} атрибут",
|
||||
@@ -2153,49 +2137,5 @@
|
||||
},
|
||||
"platform_indicator": {
|
||||
"available_on": "Доступно для {{platform}}"
|
||||
},
|
||||
"render": {
|
||||
"setup_title": "Отобразить настраиваемый HTML или Preact JSX в этой заметке",
|
||||
"setup_create_sample_preact": "Создать образец заметки с помощью Preact",
|
||||
"setup_create_sample_html": "Создать образец заметки с помощью HTML",
|
||||
"setup_sample_created": "Образец заметки был создан в качестве дочерней записи.",
|
||||
"disabled_description": "Эти заметки для рендера поступают из внешнего источника. Чтобы защитить вас от вредоносного содержимого, они не включены по умолчанию. Убедитесь, что вы доверяете источнику до его включения.",
|
||||
"disabled_button_enable": "Включить заметки для рендера"
|
||||
},
|
||||
"web_view_setup": {
|
||||
"title": "Создайте живой просмотр веб-страницы прямо в Trilium",
|
||||
"url_placeholder": "Введите или вставьте адрес сайта, например https://triliumnotes.org",
|
||||
"create_button": "Создать веб-просмотр",
|
||||
"invalid_url_title": "Неверный адрес",
|
||||
"invalid_url_message": "Введите корректный веб-адрес, например https://triliumnotes.org.",
|
||||
"disabled_description": "Этот веб-просмотр был импортирован из внешнего источника. Чтобы защитить вас от фишинга или вредоносного контента, он не загружается автоматически. Вы можете включить его, если доверяете источнику.",
|
||||
"disabled_button_enable": "Включить просмотр веб-страниц"
|
||||
},
|
||||
"active_content_badges": {
|
||||
"type_icon_pack": "Набор иконок",
|
||||
"type_backend_script": "Бэкенд скрипт",
|
||||
"type_frontend_script": "Фронтенд скрипт",
|
||||
"type_widget": "Виджет",
|
||||
"type_app_css": "Пользовательский CSS",
|
||||
"type_render_note": "Заметка для рендера",
|
||||
"type_web_view": "Просмотр веб-страницы",
|
||||
"type_app_theme": "Пользовательская тема",
|
||||
"toggle_tooltip_enable_tooltip": "Нажмите, чтобы включить этот {{type}}.",
|
||||
"toggle_tooltip_disable_tooltip": "Нажмите, чтобы выключить этот {{type}}.",
|
||||
"menu_docs": "Открытая документация",
|
||||
"menu_execute_now": "Выполнить скрипт сейчас",
|
||||
"menu_run": "Выполнять автоматически",
|
||||
"menu_run_disabled": "Вручную",
|
||||
"menu_run_backend_startup": "При запуске бэкенда",
|
||||
"menu_run_hourly": "Ежечасно",
|
||||
"menu_run_daily": "Ежедневно",
|
||||
"menu_run_frontend_startup": "Когда запускается интерфейс ПК",
|
||||
"menu_run_mobile_startup": "При запуске мобильного интерфейса",
|
||||
"menu_change_to_widget": "Изменить виджет",
|
||||
"menu_change_to_frontend_script": "Перейти к фронтенд скрипту",
|
||||
"menu_theme_base": "Базовая тема"
|
||||
},
|
||||
"setup_form": {
|
||||
"more_info": "Узнать больше"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,38 +3,6 @@
|
||||
"title": "Om Trilium Notes",
|
||||
"homepage": "Hemsida:",
|
||||
"app_version": "App version:",
|
||||
"db_version": "DB version:",
|
||||
"sync_version": "Sync version:",
|
||||
"build_date": "Bygg datum:",
|
||||
"build_revision": "Bygg version:",
|
||||
"data_directory": "Data sökväg:"
|
||||
},
|
||||
"toast": {
|
||||
"critical-error": {
|
||||
"title": "Kritiskt fel",
|
||||
"message": "Ett kritiskt fel har inträffat som förhindrar klientprogrammet från att starta:\n\n{{message}}\n\nDetta beror troligen på att ett skript har misslyckats på ett oväntat sätt. Försök att starta programmet i felsäkert läge och åtgärda problemet."
|
||||
},
|
||||
"widget-error": {
|
||||
"title": "Misslyckades att starta widget",
|
||||
"message-custom": "Anpassad widget från anteckning med ID \"{{id}}\", med rubrik \"{{title}}\" kunde inte startas på grund av:\n\n{{message}}",
|
||||
"message-unknown": "Okänd widget kunde inte startas på grund av:\n\n{{message}}"
|
||||
},
|
||||
"bundle-error": {
|
||||
"title": "Misslyckades att starta ett anpassat skript",
|
||||
"message": "Skript kunde inte startas på grund av:\n\n{{message}}"
|
||||
},
|
||||
"widget-list-error": {
|
||||
"title": "Misslyckades att hämta widget-listan från servern"
|
||||
},
|
||||
"widget-render-error": {
|
||||
"title": "Misslyckades att renderera en anpassad React-widget"
|
||||
},
|
||||
"widget-missing-parent": "Anpassad widget saknar '{{property}}', som måste vara definierad.\n\nOm skriptet är avsett att köras utan gränssnitt, använd '#run-frontendStartup' istället.",
|
||||
"open-script-note": "Öppna skriptanteckning",
|
||||
"scripting-error": "Fel i anpassat skript: {{title}}"
|
||||
},
|
||||
"add_link": {
|
||||
"add_link": "Infoga länk",
|
||||
"help_on_links": "Hjälp om länkar"
|
||||
"db_version": "DB version:"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -446,8 +446,7 @@
|
||||
"app_theme_base": "設定為 \"next\"、\"next-light \" 或 \"next-dark\",以使用相應的 TriliumNext 主題(自動、淺色或深色)作為自訂主題的基礎,而非傳統主題。",
|
||||
"print_landscape": "匯出為 PDF 時,將頁面方向更改為橫向而非縱向。",
|
||||
"print_page_size": "在匯出 PDF 時更改頁面大小。支援的值:<code>A0</code>、<code>A1</code>、<code>A2</code>、<code>A3</code>、<code>A4</code>、<code>A5</code>、<code>A6</code>、<code>Legal</code>、<code>Letter</code>、<code>Tabloid</code>、<code>Ledger</code>。",
|
||||
"color_type": "顏色",
|
||||
"textarea": "多行文字"
|
||||
"color_type": "顏色"
|
||||
},
|
||||
"attribute_editor": {
|
||||
"help_text_body1": "要新增標籤,只需輸入例如 <code>#rock</code> 或者如果您還想新增值,則例如 <code>#year = 2020</code>",
|
||||
@@ -1047,6 +1046,7 @@
|
||||
"unprotecting-title": "解除保護狀態"
|
||||
},
|
||||
"relation_map": {
|
||||
"open_in_new_tab": "在新分頁中打開",
|
||||
"remove_note": "刪除筆記",
|
||||
"edit_title": "編輯標題",
|
||||
"rename_note": "重新命名筆記",
|
||||
@@ -1496,8 +1496,7 @@
|
||||
"task-list": "任務列表",
|
||||
"new-feature": "新增",
|
||||
"collections": "集合",
|
||||
"ai-chat": "AI 聊天",
|
||||
"spreadsheet": "試算表"
|
||||
"ai-chat": "AI 聊天"
|
||||
},
|
||||
"protect_note": {
|
||||
"toggle-on": "保護筆記",
|
||||
@@ -2183,52 +2182,5 @@
|
||||
},
|
||||
"setup_form": {
|
||||
"more_info": "了解更多"
|
||||
},
|
||||
"media": {
|
||||
"play": "播放 (空白鍵)",
|
||||
"pause": "暫停 (空白鍵)",
|
||||
"back-10s": "往前 10 秒 (左方向鍵)",
|
||||
"forward-30s": "往後 30 秒",
|
||||
"mute": "靜音 (M)",
|
||||
"unmute": "解除靜音 (M)",
|
||||
"playback-speed": "播放速度",
|
||||
"loop": "循環",
|
||||
"disable-loop": "解除循環",
|
||||
"rotate": "旋轉",
|
||||
"picture-in-picture": "畫中畫",
|
||||
"exit-picture-in-picture": "退出畫中畫",
|
||||
"fullscreen": "全螢幕 (F)",
|
||||
"exit-fullscreen": "退出全螢幕",
|
||||
"unsupported-format": "此檔案格式不支援媒體預覽:\n{{mime}}",
|
||||
"zoom-to-fit": "放大至填滿畫面",
|
||||
"zoom-reset": "重設放大至填滿畫面"
|
||||
},
|
||||
"mermaid": {
|
||||
"placeholder": "請輸入您的美人魚圖表內容,或選用下方其中一個範例圖表。",
|
||||
"sample_diagrams": "範例圖表:",
|
||||
"sample_flowchart": "流程圖",
|
||||
"sample_class": "階層圖",
|
||||
"sample_sequence": "時序圖",
|
||||
"sample_entity_relationship": "實體關係圖",
|
||||
"sample_state": "狀態圖",
|
||||
"sample_mindmap": "心智圖",
|
||||
"sample_architecture": "架構圖",
|
||||
"sample_block": "區塊圖",
|
||||
"sample_c4": "C4 圖",
|
||||
"sample_gantt": "甘特圖",
|
||||
"sample_git": "Git 分支圖",
|
||||
"sample_kanban": "看板圖",
|
||||
"sample_packet": "數據包圖",
|
||||
"sample_pie": "圓餅圖",
|
||||
"sample_quadrant": "象限圖",
|
||||
"sample_radar": "雷達圖",
|
||||
"sample_requirement": "需求圖",
|
||||
"sample_sankey": "桑基圖",
|
||||
"sample_timeline": "時間軸",
|
||||
"sample_treemap": "樹狀圖",
|
||||
"sample_user_journey": "用戶旅程",
|
||||
"sample_xy": "XY 圖表",
|
||||
"sample_venn": "韋恩圖",
|
||||
"sample_ishikawa": "魚骨圖"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1151,6 +1151,7 @@
|
||||
"unprotecting-title": "Статус зняття захисту"
|
||||
},
|
||||
"relation_map": {
|
||||
"open_in_new_tab": "Відкрити в новій вкладці",
|
||||
"remove_note": "Видалити нотатку",
|
||||
"edit_title": "Редагувати заголовок",
|
||||
"rename_note": "Перейменувати нотатку",
|
||||
|
||||
30
apps/client/src/types.d.ts
vendored
30
apps/client/src/types.d.ts
vendored
@@ -1,4 +1,4 @@
|
||||
import { BootstrapDefinition } from "@triliumnext/commons";
|
||||
import { IconRegistry, Locale } from "@triliumnext/commons";
|
||||
|
||||
import appContext, { AppContext } from "./components/app_context";
|
||||
import type FNote from "./entities/fnote";
|
||||
@@ -15,9 +15,10 @@ interface ElectronProcess {
|
||||
platform: string;
|
||||
}
|
||||
|
||||
interface CustomGlobals extends BootstrapDefinition {
|
||||
interface CustomGlobals {
|
||||
isDesktop: typeof utils.isDesktop;
|
||||
isMobile: typeof utils.isMobile;
|
||||
device: "mobile" | "desktop" | "print";
|
||||
getComponentByEl: typeof appContext.getComponentByEl;
|
||||
getHeaders: typeof server.getHeaders;
|
||||
getReferenceLinkTitle: (href: string) => Promise<string>;
|
||||
@@ -30,7 +31,32 @@ interface CustomGlobals extends BootstrapDefinition {
|
||||
SEARCH_HELP_TEXT: string;
|
||||
activeDialog: JQuery<HTMLElement> | null;
|
||||
componentId: string;
|
||||
csrfToken: string;
|
||||
baseApiUrl: string;
|
||||
isProtectedSessionAvailable: boolean;
|
||||
isDev: boolean;
|
||||
isMainWindow: boolean;
|
||||
maxEntityChangeIdAtLoad: number;
|
||||
maxEntityChangeSyncIdAtLoad: number;
|
||||
assetPath: string;
|
||||
appPath: string;
|
||||
instanceName: string;
|
||||
appCssNoteIds: string[];
|
||||
triliumVersion: string;
|
||||
TRILIUM_SAFE_MODE: boolean;
|
||||
platform?: typeof process.platform;
|
||||
linter: typeof lint;
|
||||
hasNativeTitleBar: boolean;
|
||||
hasBackgroundEffects: boolean;
|
||||
isElectron: boolean;
|
||||
isRtl: boolean;
|
||||
iconRegistry: IconRegistry;
|
||||
themeCssUrl: string;
|
||||
themeUseNextAsBase?: "next" | "next-light" | "next-dark";
|
||||
iconPackCss: string;
|
||||
headingStyle: "plain" | "underline" | "markdown";
|
||||
layoutOrientation: "vertical" | "horizontal";
|
||||
currentLocale: Locale;
|
||||
}
|
||||
|
||||
type RequireMethod = (moduleName: string) => any;
|
||||
|
||||
@@ -40,21 +40,6 @@ export default function NoteDetail() {
|
||||
const widgetRequestId = useRef(0);
|
||||
const hasFixedTree = note && noteContext?.hoistedNoteId === "_lbMobileRoot" && isMobile() && note.noteId.startsWith("_lbMobile");
|
||||
|
||||
// Defer loading for tabs that haven't been active yet (e.g. on app refresh).
|
||||
// Special contexts (ntxId starting with "_", e.g. popup editor) are always considered active.
|
||||
const isSpecialContext = ntxId?.startsWith("_") ?? false;
|
||||
const [ hasTabBeenActive, setHasTabBeenActive ] = useState(() => isSpecialContext || (noteContext?.isActive() ?? false));
|
||||
useEffect(() => {
|
||||
if (!hasTabBeenActive && noteContext?.isActive()) {
|
||||
setHasTabBeenActive(true);
|
||||
}
|
||||
}, [ noteContext, hasTabBeenActive ]);
|
||||
useTriliumEvent("activeNoteChanged", ({ ntxId: eventNtxId }) => {
|
||||
if (eventNtxId === ntxId && !hasTabBeenActive) {
|
||||
setHasTabBeenActive(true);
|
||||
}
|
||||
});
|
||||
|
||||
const props: TypeWidgetProps = {
|
||||
note: note!,
|
||||
viewScope,
|
||||
@@ -64,7 +49,7 @@ export default function NoteDetail() {
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!type || !hasTabBeenActive) return;
|
||||
if (!type) return;
|
||||
const requestId = ++widgetRequestId.current;
|
||||
|
||||
if (!noteTypesToRender[type]) {
|
||||
@@ -83,7 +68,7 @@ export default function NoteDetail() {
|
||||
} else {
|
||||
setActiveNoteType(type);
|
||||
}
|
||||
}, [ note, viewScope, type, noteTypesToRender, hasTabBeenActive ]);
|
||||
}, [ note, viewScope, type, noteTypesToRender ]);
|
||||
|
||||
// Detect note type changes.
|
||||
useTriliumEvent("entitiesReloaded", async ({ loadResults }) => {
|
||||
@@ -262,8 +247,9 @@ function NoteDetailWrapper({ Element, type, isVisible, isFullHeight, props }: {
|
||||
useEffect(() => {
|
||||
if (isVisible) {
|
||||
setCachedProps(props);
|
||||
} else {
|
||||
// Do nothing, keep the old props.
|
||||
}
|
||||
// When not visible, keep the old props to avoid re-rendering in the background.
|
||||
}, [ props, isVisible ]);
|
||||
|
||||
const typeMapping = TYPE_MAPPINGS[type];
|
||||
@@ -274,7 +260,7 @@ function NoteDetailWrapper({ Element, type, isVisible, isFullHeight, props }: {
|
||||
height: isFullHeight ? "100%" : ""
|
||||
}}
|
||||
>
|
||||
<Element {...cachedProps} />
|
||||
{ <Element {...cachedProps} /> }
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -11,12 +11,6 @@ export default class NoteWrapperWidget extends FlexContainer<BasicWidget> {
|
||||
|
||||
private noteContext?: NoteContext;
|
||||
|
||||
constructor() {
|
||||
super("column");
|
||||
|
||||
this.css("flex-grow", "1").collapsible();
|
||||
}
|
||||
|
||||
setNoteContextEvent({ noteContext }: EventData<"setNoteContext">) {
|
||||
this.noteContext = noteContext;
|
||||
|
||||
@@ -53,8 +47,6 @@ export default class NoteWrapperWidget extends FlexContainer<BasicWidget> {
|
||||
this.$widget.addClass("active");
|
||||
}
|
||||
|
||||
this.$widget.addClass("component note-split");
|
||||
|
||||
const note = this.noteContext?.note;
|
||||
if (!note) {
|
||||
this.$widget.addClass("bgfx empty-note");
|
||||
@@ -83,7 +75,7 @@ export default class NoteWrapperWidget extends FlexContainer<BasicWidget> {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (note.type === "file" && (note.mime === "application/pdf" || note.mime.startsWith("video/") || note.mime.startsWith("audio/"))) {
|
||||
if (note.type === "file" && (note.mime === "application/pdf" || note.mime.startsWith("video/"))) {
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -108,7 +100,7 @@ export default class NoteWrapperWidget extends FlexContainer<BasicWidget> {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (note.type === "file" && (MIME_TYPES_WITH_BACKGROUND_EFFECTS.includes(note.mime) || note.mime.startsWith("audio/"))) {
|
||||
if (note.type === "file" && MIME_TYPES_WITH_BACKGROUND_EFFECTS.includes(note.mime)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
16
apps/client/src/widgets/NoteWrapper.css
Normal file
16
apps/client/src/widgets/NoteWrapper.css
Normal file
@@ -0,0 +1,16 @@
|
||||
.component.note-split {
|
||||
display: flex;
|
||||
flex-grow: 1;
|
||||
flex-direction: column;
|
||||
contain: none;
|
||||
|
||||
> .title-row {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
contain: none;
|
||||
}
|
||||
}
|
||||
|
||||
body.desktop .component.note-split > .title-row > * {
|
||||
margin: 5px;
|
||||
}
|
||||
96
apps/client/src/widgets/NoteWrapper.tsx
Normal file
96
apps/client/src/widgets/NoteWrapper.tsx
Normal file
@@ -0,0 +1,96 @@
|
||||
import "./NoteWrapper.css";
|
||||
|
||||
import { isExperimentalFeatureEnabled } from "../services/experimental_features";
|
||||
import { isDesktop } from "../services/utils";
|
||||
import ApiLog from "./api_log";
|
||||
import ClosePaneButton from "./buttons/close_pane_button";
|
||||
import CreatePaneButton from "./buttons/create_pane_button";
|
||||
import MovePaneButton from "./buttons/move_pane_button";
|
||||
import NoteList from "./collections/NoteList";
|
||||
import ScrollingContainer from "./containers/ScrollingContainer";
|
||||
import FindWidget from "./find";
|
||||
import FloatingButtons from "./FloatingButtons";
|
||||
import { DESKTOP_FLOATING_BUTTONS } from "./FloatingButtonsDefinitions";
|
||||
import { LegacyWidgetRenderer } from "./launch_bar/LauncherDefinitions";
|
||||
import SpacerWidget from "./launch_bar/SpacerWidget";
|
||||
import InlineTitle from "./layout/InlineTitle";
|
||||
import NoteBadges from "./layout/NoteBadges";
|
||||
import NoteTitleActions from "./layout/NoteTitleActions";
|
||||
import MobileDetailMenu from "./mobile_widgets/mobile_detail_menu";
|
||||
import ToggleSidebarButton from "./mobile_widgets/toggle_sidebar_button";
|
||||
import NoteIcon from "./note_icon";
|
||||
import NoteTitleWidget from "./note_title";
|
||||
import NoteDetail from "./NoteDetail";
|
||||
import PromotedAttributes from "./PromotedAttributes";
|
||||
import ReadOnlyNoteInfoBar from "./ReadOnlyNoteInfoBar";
|
||||
import NoteActions from "./ribbon/NoteActions";
|
||||
import Ribbon from "./ribbon/Ribbon";
|
||||
import ScrollPadding from "./scroll_padding";
|
||||
import SearchResult from "./search_result";
|
||||
import SharedInfo from "./shared_info";
|
||||
import MobileEditorToolbar from "./type_widgets/text/mobile_editor_toolbar";
|
||||
import WatchedFileUpdateStatusWidget from "./watched_file_update_status";
|
||||
|
||||
const isNewLayout = isExperimentalFeatureEnabled("new-layout");
|
||||
const cachedIsDesktop = isDesktop();
|
||||
const cachedIsMobile = !cachedIsDesktop;
|
||||
|
||||
export default function NoteWrapper() {
|
||||
return (
|
||||
<div className="component note-split">
|
||||
<TitleRow />
|
||||
{!isNewLayout && <Ribbon />}
|
||||
{cachedIsDesktop && <LegacyWidgetRenderer widget={new WatchedFileUpdateStatusWidget()} />}
|
||||
{!isNewLayout && <FloatingButtons items={DESKTOP_FLOATING_BUTTONS} />}
|
||||
<ScrollingContainer>
|
||||
{isNewLayout ? (
|
||||
<>
|
||||
<InlineTitle />
|
||||
<NoteTitleActions />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<ReadOnlyNoteInfoBar />
|
||||
<SharedInfo />
|
||||
</>
|
||||
)}
|
||||
{!isNewLayout && <PromotedAttributes />}
|
||||
<NoteDetail />
|
||||
<NoteList media="screen" />
|
||||
<SearchResult />
|
||||
<ScrollPadding />
|
||||
</ScrollingContainer>
|
||||
<ApiLog />
|
||||
{cachedIsMobile && <MobileEditorToolbar />}
|
||||
<LegacyWidgetRenderer widget={new FindWidget()} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TitleRow() {
|
||||
return (
|
||||
<div className="component title-row note-split-title">
|
||||
{cachedIsMobile && <ToggleSidebarButton />}
|
||||
<NoteIcon />
|
||||
<NoteTitleWidget />
|
||||
{isNewLayout && <NoteBadges />}
|
||||
{cachedIsDesktop ? (
|
||||
<>
|
||||
<SpacerWidget baseSize={0} growthFactor={1} />
|
||||
{!isNewLayout ? (
|
||||
<>
|
||||
<MovePaneButton direction="left" />
|
||||
<MovePaneButton direction="right" />
|
||||
<ClosePaneButton />
|
||||
<CreatePaneButton />
|
||||
</>
|
||||
) : (
|
||||
<NoteActions />
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<MobileDetailMenu />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2,7 +2,7 @@ import "./PromotedAttributes.css";
|
||||
|
||||
import { UpdateAttributeResponse } from "@triliumnext/commons";
|
||||
import clsx from "clsx";
|
||||
import { ComponentChild, createElement, HTMLInputTypeAttribute, InputHTMLAttributes, MouseEventHandler, TargetedEvent, TargetedInputEvent } from "preact";
|
||||
import { ComponentChild, HTMLInputTypeAttribute, InputHTMLAttributes, MouseEventHandler, TargetedEvent, TargetedInputEvent } from "preact";
|
||||
import { Dispatch, StateUpdater, useCallback, useEffect, useRef, useState } from "preact/hooks";
|
||||
|
||||
import NoteContext from "../components/note_context";
|
||||
@@ -36,7 +36,7 @@ interface CellProps {
|
||||
setCellToFocus(cell: Cell): void;
|
||||
}
|
||||
|
||||
type OnChangeEventData = TargetedEvent<HTMLInputElement | HTMLTextAreaElement, Event> | InputEvent | JQuery.TriggeredEvent<HTMLInputElement, undefined, HTMLInputElement, HTMLInputElement>;
|
||||
type OnChangeEventData = TargetedEvent<HTMLInputElement, Event> | InputEvent | JQuery.TriggeredEvent<HTMLInputElement, undefined, HTMLInputElement, HTMLInputElement>;
|
||||
type OnChangeListener = (e: OnChangeEventData) => void | Promise<void>;
|
||||
|
||||
export default function PromotedAttributes() {
|
||||
@@ -171,9 +171,8 @@ function PromotedAttributeCell(props: CellProps) {
|
||||
);
|
||||
}
|
||||
|
||||
const LABEL_MAPPINGS: Record<LabelType, HTMLInputTypeAttribute | undefined> = {
|
||||
const LABEL_MAPPINGS: Record<LabelType, HTMLInputTypeAttribute> = {
|
||||
text: "text",
|
||||
textarea: undefined,
|
||||
number: "number",
|
||||
boolean: "checkbox",
|
||||
date: "date",
|
||||
@@ -227,21 +226,20 @@ function LabelInput(props: CellProps & { inputId: string }) {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
const inputNode = createElement(definition.labelType === "textarea" ? "textarea" : "input", {
|
||||
className: "form-control promoted-attribute-input",
|
||||
tabIndex: 200 + definitionAttr.position,
|
||||
id: inputId,
|
||||
type: LABEL_MAPPINGS[definition.labelType ?? "text"],
|
||||
value: valueDraft,
|
||||
checked: definition.labelType === "boolean" ? valueAttr.value === "true" : undefined,
|
||||
placeholder: t("promoted_attributes.unset-field-placeholder"),
|
||||
"data-attribute-id": valueAttr.attributeId,
|
||||
"data-attribute-type": valueAttr.type,
|
||||
"data-attribute-name": valueAttr.name,
|
||||
onBlur: onChangeListener,
|
||||
...extraInputProps
|
||||
});
|
||||
const inputNode = <input
|
||||
className="form-control promoted-attribute-input"
|
||||
tabIndex={200 + definitionAttr.position}
|
||||
id={inputId}
|
||||
type={LABEL_MAPPINGS[definition.labelType ?? "text"]}
|
||||
value={valueDraft}
|
||||
checked={definition.labelType === "boolean" ? valueAttr.value === "true" : undefined}
|
||||
placeholder={t("promoted_attributes.unset-field-placeholder")}
|
||||
data-attribute-id={valueAttr.attributeId}
|
||||
data-attribute-type={valueAttr.type}
|
||||
data-attribute-name={valueAttr.name}
|
||||
onBlur={onChangeListener}
|
||||
{...extraInputProps}
|
||||
/>;
|
||||
|
||||
if (definition.labelType === "boolean") {
|
||||
return <>
|
||||
|
||||
@@ -1,16 +1,14 @@
|
||||
import "./UserAttributesList.css";
|
||||
|
||||
import type { DefinitionObject } from "@triliumnext/commons";
|
||||
import { ComponentChildren, CSSProperties } from "preact";
|
||||
import { useState } from "preact/hooks";
|
||||
|
||||
import FNote from "../../entities/fnote";
|
||||
import attributes from "../../services/attributes";
|
||||
import { getReadableTextColor } from "../../services/css_class_manager";
|
||||
import { formatDateTime } from "../../utils/formatters";
|
||||
import "./UserAttributesList.css";
|
||||
import { useTriliumEvent } from "../react/hooks";
|
||||
import attributes from "../../services/attributes";
|
||||
import { DefinitionObject } from "../../services/promoted_attribute_definition_parser";
|
||||
import { formatDateTime } from "../../utils/formatters";
|
||||
import { ComponentChildren, CSSProperties } from "preact";
|
||||
import Icon from "../react/Icon";
|
||||
import NoteLink from "../react/NoteLink";
|
||||
import { getReadableTextColor } from "../../services/css_class_manager";
|
||||
|
||||
interface UserAttributesListProps {
|
||||
note: FNote;
|
||||
@@ -31,7 +29,7 @@ export default function UserAttributesDisplay({ note, ignoredAttributes }: UserA
|
||||
<div className="user-attributes">
|
||||
{userAttributes?.map(attr => buildUserAttribute(attr))}
|
||||
</div>
|
||||
);
|
||||
)
|
||||
|
||||
}
|
||||
|
||||
@@ -48,13 +46,13 @@ function useNoteAttributesWithDefinitions(note: FNote, attributesToIgnore: stri
|
||||
}
|
||||
|
||||
function UserAttribute({ attr, children, style }: { attr: AttributeWithDefinitions, children: ComponentChildren, style?: CSSProperties }) {
|
||||
const className = attr.type === "label" ? `label ${attr.def.labelType}` : "relation";
|
||||
const className = `${attr.type === "label" ? "label" + " " + attr.def.labelType : "relation"}`;
|
||||
|
||||
return (
|
||||
<span key={attr.friendlyName} className={`user-attribute type-${className}`} style={style}>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
function buildUserAttribute(attr: AttributeWithDefinitions): ComponentChildren {
|
||||
@@ -63,7 +61,7 @@ function buildUserAttribute(attr: AttributeWithDefinitions): ComponentChildren {
|
||||
let style: CSSProperties | undefined;
|
||||
|
||||
if (attr.type === "label") {
|
||||
const value = attr.value;
|
||||
let value = attr.value;
|
||||
switch (attr.def.labelType) {
|
||||
case "number":
|
||||
let formattedValue = value;
|
||||
@@ -104,7 +102,7 @@ function buildUserAttribute(attr: AttributeWithDefinitions): ComponentChildren {
|
||||
content = <>{defaultLabel}<NoteLink notePath={attr.value} showNoteIcon /></>;
|
||||
}
|
||||
|
||||
return <UserAttribute attr={attr} style={style}>{content}</UserAttribute>;
|
||||
return <UserAttribute attr={attr} style={style}>{content}</UserAttribute>
|
||||
}
|
||||
|
||||
function getAttributesWithDefinitions(note: FNote, attributesToIgnore: string[] = []): AttributeWithDefinitions[] {
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
import appContext from "../../components/app_context.js";
|
||||
import attributeAutocompleteService from "../../services/attribute_autocomplete.js";
|
||||
import type { Attribute } from "../../services/attribute_parser.js";
|
||||
import { isExperimentalFeatureEnabled } from "../../services/experimental_features.js";
|
||||
import { focusSavedElement, saveFocusedElement } from "../../services/focus.js";
|
||||
import froca from "../../services/froca.js";
|
||||
import { t } from "../../services/i18n.js";
|
||||
import server from "../../services/server.js";
|
||||
import froca from "../../services/froca.js";
|
||||
import linkService from "../../services/link.js";
|
||||
import attributeAutocompleteService from "../../services/attribute_autocomplete.js";
|
||||
import noteAutocompleteService from "../../services/note_autocomplete.js";
|
||||
import promotedAttributeDefinitionParser from "../../services/promoted_attribute_definition_parser.js";
|
||||
import server from "../../services/server.js";
|
||||
import shortcutService from "../../services/shortcuts.js";
|
||||
import NoteContextAwareWidget from "../note_context_aware_widget.js";
|
||||
import SpacedUpdate from "../../services/spaced_update.js";
|
||||
import utils from "../../services/utils.js";
|
||||
import NoteContextAwareWidget from "../note_context_aware_widget.js";
|
||||
import shortcutService from "../../services/shortcuts.js";
|
||||
import appContext from "../../components/app_context.js";
|
||||
import type { Attribute } from "../../services/attribute_parser.js";
|
||||
import { focusSavedElement, saveFocusedElement } from "../../services/focus.js";
|
||||
import { isExperimentalFeatureEnabled } from "../../services/experimental_features.js";
|
||||
|
||||
const TPL = /*html*/`
|
||||
<div class="attr-detail tn-tool-dialog">
|
||||
@@ -29,7 +29,6 @@ const TPL = /*html*/`
|
||||
max-height: 600px;
|
||||
overflow: auto;
|
||||
box-shadow: 10px 10px 93px -25px black;
|
||||
contain: none;
|
||||
}
|
||||
|
||||
.attr-help td {
|
||||
@@ -138,7 +137,6 @@ const TPL = /*html*/`
|
||||
<td>
|
||||
<select class="attr-input-label-type form-control">
|
||||
<option value="text">${t("attribute_detail.text")}</option>
|
||||
<option value="textarea">${t("attribute_detail.textarea")}</option>
|
||||
<option value="number">${t("attribute_detail.number")}</option>
|
||||
<option value="boolean">${t("attribute_detail.boolean")}</option>
|
||||
<option value="date">${t("attribute_detail.date")}</option>
|
||||
@@ -344,7 +342,6 @@ export default class AttributeDetailWidget extends NoteContextAwareWidget {
|
||||
private $relatedNotesList!: JQuery<HTMLElement>;
|
||||
private $relatedNotesMoreNotes!: JQuery<HTMLElement>;
|
||||
private $attrHelp!: JQuery<HTMLElement>;
|
||||
private $statusBar?: JQuery<HTMLElement>;
|
||||
|
||||
private relatedNotesSpacedUpdate!: SpacedUpdate;
|
||||
private attribute!: Attribute;
|
||||
@@ -579,24 +576,17 @@ export default class AttributeDetailWidget extends NoteContextAwareWidget {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isNewLayout) {
|
||||
if (!this.$statusBar) {
|
||||
this.$statusBar = $(document.body).find(".component.status-bar");
|
||||
}
|
||||
this.$widget
|
||||
.css("left", detPosition.left)
|
||||
.css("right", detPosition.right)
|
||||
.css("top", y - offset.top + 70)
|
||||
.css("max-height", outerHeight + y > height - 50 ? height - y - 50 : 10000);
|
||||
|
||||
const statusBarHeight = this.$statusBar.outerHeight() ?? 0;
|
||||
const maxHeight = document.body.clientHeight - statusBarHeight;
|
||||
if (isNewLayout) {
|
||||
this.$widget
|
||||
.css("left", offset.left + (typeof detPosition.left === "number" ? detPosition.left : 0))
|
||||
.css("top", "unset")
|
||||
.css("bottom", statusBarHeight ?? 0)
|
||||
.css("max-height", maxHeight);
|
||||
} else {
|
||||
this.$widget
|
||||
.css("left", detPosition.left)
|
||||
.css("right", detPosition.right)
|
||||
.css("top", y - offset.top + 70)
|
||||
.css("max-height", outerHeight + y > height - 50 ? height - y - 50 : 10000);
|
||||
.css("bottom", 70)
|
||||
.css("max-height", "80vh");
|
||||
}
|
||||
|
||||
if (focus === "name") {
|
||||
@@ -704,14 +694,14 @@ export default class AttributeDetailWidget extends NoteContextAwareWidget {
|
||||
return "label-definition";
|
||||
} else if (attribute.name.startsWith("relation:")) {
|
||||
return "relation-definition";
|
||||
} else {
|
||||
return "label";
|
||||
}
|
||||
return "label";
|
||||
|
||||
} else if (attribute.type === "relation") {
|
||||
return "relation";
|
||||
} else {
|
||||
this.$title.text("");
|
||||
}
|
||||
this.$title.text("");
|
||||
|
||||
}
|
||||
|
||||
updateAttributeInEditor() {
|
||||
|
||||
@@ -8,7 +8,7 @@ import { CommandNames } from "../../components/app_context";
|
||||
import Component from "../../components/component";
|
||||
import { ExperimentalFeature, ExperimentalFeatureId, experimentalFeatures, isExperimentalFeatureEnabled, toggleExperimentalFeature } from "../../services/experimental_features";
|
||||
import { t } from "../../services/i18n";
|
||||
import utils, { dynamicRequire, isElectron, isMobile, isStandalone, reloadFrontendApp } from "../../services/utils";
|
||||
import utils, { dynamicRequire, isElectron, isMobile, reloadFrontendApp } from "../../services/utils";
|
||||
import Dropdown from "../react/Dropdown";
|
||||
import { FormDropdownDivider, FormDropdownSubmenu, FormListHeader, FormListItem } from "../react/FormList";
|
||||
import { useStaticTooltip, useStaticTooltipWithKeyboardShortcut, useTriliumOption, useTriliumOptionBool, useTriliumOptionInt } from "../react/hooks";
|
||||
@@ -251,7 +251,7 @@ function ToggleWindowOnTop() {
|
||||
function useTriliumUpdateStatus() {
|
||||
const [ latestVersion, setLatestVersion ] = useState<string>();
|
||||
const [ checkForUpdates ] = useTriliumOptionBool("checkForUpdates");
|
||||
const isUpdateAvailable = utils.isUpdateAvailable(latestVersion, window.glob.triliumVersion);
|
||||
const isUpdateAvailable = utils.isUpdateAvailable(latestVersion, glob.triliumVersion);
|
||||
|
||||
async function updateVersionStatus() {
|
||||
const RELEASES_API_URL = "https://api.github.com/repos/TriliumNext/Trilium/releases/latest";
|
||||
@@ -269,7 +269,7 @@ function useTriliumUpdateStatus() {
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!checkForUpdates || !isStandalone) {
|
||||
if (!checkForUpdates) {
|
||||
setLatestVersion(undefined);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { BulkAction } from "@triliumnext/commons";
|
||||
|
||||
import { BoardViewData } from ".";
|
||||
import appContext from "../../../components/app_context";
|
||||
import FNote from "../../../entities/fnote";
|
||||
import attributes from "../../../services/attributes";
|
||||
@@ -9,7 +9,6 @@ import froca from "../../../services/froca";
|
||||
import { t } from "../../../services/i18n";
|
||||
import note_create from "../../../services/note_create";
|
||||
import server from "../../../services/server";
|
||||
import { BoardViewData } from ".";
|
||||
import { ColumnMap } from "./data";
|
||||
|
||||
export default class BoardApi {
|
||||
@@ -36,11 +35,13 @@ export default class BoardApi {
|
||||
|
||||
async createNewItem(column: string, title: string) {
|
||||
try {
|
||||
// Get the parent note path
|
||||
const parentNotePath = this.parentNote.noteId;
|
||||
|
||||
// Create a new note as a child of the parent note
|
||||
const { note: newNote, branch: newBranch } = await note_create.createNote(this.parentNote.noteId, {
|
||||
const { note: newNote, branch: newBranch } = await note_create.createNote(parentNotePath, {
|
||||
activate: false,
|
||||
title,
|
||||
isProtected: this.parentNote.isProtected
|
||||
title
|
||||
});
|
||||
|
||||
if (newNote && newBranch) {
|
||||
@@ -86,7 +87,7 @@ export default class BoardApi {
|
||||
|
||||
const action: BulkAction = this.isRelationMode
|
||||
? { name: "deleteRelation", relationName: this.statusAttribute }
|
||||
: { name: "deleteLabel", labelName: this.statusAttribute };
|
||||
: { name: "deleteLabel", labelName: this.statusAttribute }
|
||||
await executeBulkActions(noteIds, [ action ]);
|
||||
this.viewConfig.columns = (this.viewConfig.columns ?? []).filter(col => col.value !== column);
|
||||
this.saveConfig(this.viewConfig);
|
||||
@@ -98,7 +99,7 @@ export default class BoardApi {
|
||||
// Change the value in the notes.
|
||||
const action: BulkAction = this.isRelationMode
|
||||
? { name: "updateRelationTarget", relationName: this.statusAttribute, targetNoteId: newValue }
|
||||
: { name: "updateLabelValue", labelName: this.statusAttribute, labelValue: newValue };
|
||||
: { name: "updateLabelValue", labelName: this.statusAttribute, labelValue: newValue }
|
||||
await executeBulkActions(noteIds, [ action ]);
|
||||
|
||||
// Rename the column in the persisted data.
|
||||
@@ -136,9 +137,9 @@ export default class BoardApi {
|
||||
}
|
||||
|
||||
async insertRowAtPosition(
|
||||
column: string,
|
||||
relativeToBranchId: string,
|
||||
direction: "before" | "after") {
|
||||
column: string,
|
||||
relativeToBranchId: string,
|
||||
direction: "before" | "after") {
|
||||
const { note, branch } = await note_create.createNote(this.parentNote.noteId, {
|
||||
activate: false,
|
||||
targetBranchId: relativeToBranchId,
|
||||
@@ -178,8 +179,9 @@ export default class BoardApi {
|
||||
if (!note) return;
|
||||
if (this.isRelationMode) {
|
||||
return attributes.removeOwnedRelationByName(note, this.statusAttribute);
|
||||
} else {
|
||||
return attributes.removeOwnedLabelByName(note, this.statusAttribute);
|
||||
}
|
||||
return attributes.removeOwnedLabelByName(note, this.statusAttribute);
|
||||
}
|
||||
|
||||
async moveWithinBoard(noteId: string, sourceBranchId: string, sourceIndex: number, targetIndex: number, sourceColumn: string, targetColumn: string) {
|
||||
|
||||
@@ -14,7 +14,8 @@
|
||||
height: 100%;
|
||||
display: flex;
|
||||
gap: 1em;
|
||||
padding: 4px var(--content-margin-inline);
|
||||
margin-inline: var(--content-margin-inline);
|
||||
padding-block: 4px;
|
||||
align-items: flex-start;
|
||||
overflow-x: auto;
|
||||
}
|
||||
@@ -41,11 +42,7 @@ body.mobile .board-view-container {
|
||||
body.mobile .board-view-container .board-column {
|
||||
width: 75vw;
|
||||
max-width: 300px;
|
||||
}
|
||||
|
||||
body.mobile .board-view-container .board-column,
|
||||
body.mobile .board-view-container .board-add-column {
|
||||
scroll-snap-align: center;
|
||||
scroll-snap-align: center;
|
||||
}
|
||||
|
||||
.board-view-container .board-column.drag-over {
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { AttributeRow } from "@triliumnext/commons";
|
||||
import { AttributeRow, CreateChildrenResponse } from "@triliumnext/commons";
|
||||
|
||||
import FNote from "../../../entities/fnote";
|
||||
import { setAttribute, setLabel } from "../../../services/attributes";
|
||||
import note_create from "../../../services/note_create";
|
||||
import server from "../../../services/server";
|
||||
|
||||
interface NewEventOpts {
|
||||
title: string;
|
||||
@@ -51,13 +51,11 @@ export async function newEvent(parentNote: FNote, { title, startDate, endDate, s
|
||||
}
|
||||
|
||||
// Create the note.
|
||||
await note_create.createNote(parentNote.noteId, {
|
||||
await server.post<CreateChildrenResponse>(`notes/${parentNote.noteId}/children?target=into`, {
|
||||
title,
|
||||
isProtected: parentNote.isProtected,
|
||||
content: "",
|
||||
type: "text",
|
||||
attributes,
|
||||
activate: false
|
||||
attributes
|
||||
}, componentId);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import type { LatLng, LeafletMouseEvent } from "leaflet";
|
||||
|
||||
import FNote from "../../../entities/fnote";
|
||||
import { LOCATION_ATTRIBUTE } from ".";
|
||||
import attributes from "../../../services/attributes";
|
||||
import { prompt } from "../../../services/dialog";
|
||||
import server from "../../../services/server";
|
||||
import { t } from "../../../services/i18n";
|
||||
import note_create from "../../../services/note_create";
|
||||
import { LOCATION_ATTRIBUTE } from ".";
|
||||
import { CreateChildrenResponse } from "@triliumnext/commons";
|
||||
|
||||
const CHILD_NOTE_ICON = "bx bx-pin";
|
||||
|
||||
@@ -14,20 +13,16 @@ export async function moveMarker(noteId: string, latLng: LatLng | null) {
|
||||
await attributes.setLabel(noteId, LOCATION_ATTRIBUTE, value);
|
||||
}
|
||||
|
||||
export async function createNewNote(parentNote: FNote, e: LeafletMouseEvent) {
|
||||
export async function createNewNote(noteId: string, e: LeafletMouseEvent) {
|
||||
const title = await prompt({ message: t("relation_map.enter_title_of_new_note"), defaultValue: t("relation_map.default_new_note_title") });
|
||||
|
||||
if (title?.trim()) {
|
||||
await note_create.createNote(parentNote.noteId, {
|
||||
const { note } = await server.post<CreateChildrenResponse>(`notes/${noteId}/children?target=into`, {
|
||||
title,
|
||||
content: "",
|
||||
type: "text",
|
||||
activate: false,
|
||||
isProtected: parentNote.isProtected,
|
||||
attributes: [
|
||||
{ type: "label", name: LOCATION_ATTRIBUTE, value: [e.latlng.lat, e.latlng.lng].join(",") },
|
||||
{ type: "label", name: "iconClass", value: CHILD_NOTE_ICON }
|
||||
]
|
||||
type: "text"
|
||||
});
|
||||
attributes.setLabel(note.noteId, "iconClass", CHILD_NOTE_ICON);
|
||||
moveMarker(note.noteId, e.latlng);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,14 +1,12 @@
|
||||
import type { LatLng, LeafletMouseEvent } from "leaflet";
|
||||
|
||||
import appContext, { type CommandMappings } from "../../../components/app_context.js";
|
||||
import FNote from "../../../entities/fnote.js";
|
||||
import contextMenu, { type MenuItem } from "../../../menus/context_menu.js";
|
||||
import NoteColorPicker from "../../../menus/custom-items/NoteColorPicker.jsx";
|
||||
import linkContextMenu from "../../../menus/link_context_menu.js";
|
||||
import { copyTextWithToast } from "../../../services/clipboard_ext.js";
|
||||
import NoteColorPicker from "../../../menus/custom-items/NoteColorPicker.jsx";
|
||||
import { t } from "../../../services/i18n.js";
|
||||
import link from "../../../services/link.js";
|
||||
import { createNewNote } from "./api.js";
|
||||
import { copyTextWithToast } from "../../../services/clipboard_ext.js";
|
||||
import link from "../../../services/link.js";
|
||||
|
||||
export default function openContextMenu(noteId: string, e: LeafletMouseEvent, isEditable: boolean) {
|
||||
let items: MenuItem<keyof CommandMappings>[] = [
|
||||
@@ -46,7 +44,7 @@ export default function openContextMenu(noteId: string, e: LeafletMouseEvent, is
|
||||
});
|
||||
}
|
||||
|
||||
export function openMapContextMenu(note: FNote, e: LeafletMouseEvent, isEditable: boolean) {
|
||||
export function openMapContextMenu(noteId: string, e: LeafletMouseEvent, isEditable: boolean) {
|
||||
let items: MenuItem<keyof CommandMappings>[] = [
|
||||
...buildGeoLocationItem(e)
|
||||
];
|
||||
@@ -57,10 +55,10 @@ export function openMapContextMenu(note: FNote, e: LeafletMouseEvent, isEditable
|
||||
{ kind: "separator" },
|
||||
{
|
||||
title: t("geo-map-context.add-note"),
|
||||
handler: () => createNewNote(note, e),
|
||||
handler: () => createNewNote(noteId, e),
|
||||
uiIcon: "bx bx-plus"
|
||||
}
|
||||
];
|
||||
]
|
||||
}
|
||||
|
||||
contextMenu.show({
|
||||
|
||||
@@ -93,14 +93,14 @@ export default function GeoView({ note, noteIds, viewConfig, saveConfig }: ViewM
|
||||
const onClick = useCallback(async (e: LeafletMouseEvent) => {
|
||||
if (state === State.NewNote) {
|
||||
toast.closePersistent("geo-new-note");
|
||||
await createNewNote(note, e);
|
||||
await createNewNote(note.noteId, e);
|
||||
setState(State.Normal);
|
||||
}
|
||||
}, [ note, state ]);
|
||||
}, [ state ]);
|
||||
|
||||
const onContextMenu = useCallback((e: LeafletMouseEvent) => {
|
||||
openMapContextMenu(note, e, !isReadOnly);
|
||||
}, [ note, isReadOnly ]);
|
||||
openMapContextMenu(note.noteId, e, !isReadOnly);
|
||||
}, [ note.noteId, isReadOnly ]);
|
||||
|
||||
// Dragging
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
@@ -364,19 +364,23 @@
|
||||
mask-repeat: no-repeat;
|
||||
mask-size: 100% 100%;
|
||||
}
|
||||
|
||||
|
||||
.ck-content p {
|
||||
margin-bottom: 0.5em;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.ck-content figure.image {
|
||||
width: 25%;
|
||||
}
|
||||
|
||||
.ck-content .table {
|
||||
display: flex;
|
||||
flex-direction: column-reverse;
|
||||
overflow-x: scroll;
|
||||
--scrollbar-thickness: 0;
|
||||
scrollbar-width: none;
|
||||
|
||||
|
||||
table {
|
||||
width: max-content;
|
||||
table-layout: auto;
|
||||
@@ -431,4 +435,4 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* #endregion */
|
||||
/* #endregion */
|
||||
@@ -2,8 +2,8 @@ import "./index.css";
|
||||
|
||||
import { RefObject } from "preact";
|
||||
import { useEffect, useLayoutEffect, useRef, useState } from "preact/hooks";
|
||||
import Reveal, { RevealApi } from "reveal.js";
|
||||
import slideBaseStylesheet from "reveal.js/reveal.css?raw";
|
||||
import Reveal from "reveal.js";
|
||||
import slideBaseStylesheet from "reveal.js/dist/reveal.css?raw";
|
||||
|
||||
import { openInCurrentNoteContext } from "../../../components/note_context";
|
||||
import FNote from "../../../entities/fnote";
|
||||
@@ -20,7 +20,7 @@ import { DEFAULT_THEME, loadPresentationTheme } from "./themes";
|
||||
export default function PresentationView({ note, noteIds, media, onReady, onProgressChanged }: ViewModeProps<{}>) {
|
||||
const [ presentation, setPresentation ] = useState<PresentationModel>();
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [ api, setApi ] = useState<RevealApi>();
|
||||
const [ api, setApi ] = useState<Reveal.Api>();
|
||||
const stylesheets = usePresentationStylesheets(note, media);
|
||||
|
||||
function refresh() {
|
||||
@@ -98,7 +98,7 @@ function usePresentationStylesheets(note: FNote, media: ViewModeMedia) {
|
||||
return stylesheets;
|
||||
}
|
||||
|
||||
function ButtonOverlay({ containerRef, api }: { containerRef: RefObject<HTMLDivElement>, api: RevealApi | undefined }) {
|
||||
function ButtonOverlay({ containerRef, api }: { containerRef: RefObject<HTMLDivElement>, api: Reveal.Api | undefined }) {
|
||||
const [ isOverviewActive, setIsOverviewActive ] = useState(false);
|
||||
useEffect(() => {
|
||||
if (!api) return;
|
||||
@@ -144,9 +144,9 @@ function ButtonOverlay({ containerRef, api }: { containerRef: RefObject<HTMLDivE
|
||||
);
|
||||
}
|
||||
|
||||
function Presentation({ presentation, setApi } : { presentation: PresentationModel, setApi: (api: RevealApi | undefined) => void }) {
|
||||
function Presentation({ presentation, setApi } : { presentation: PresentationModel, setApi: (api: Reveal.Api | undefined) => void }) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [revealApi, setRevealApi] = useState<RevealApi>();
|
||||
const [revealApi, setRevealApi] = useState<Reveal.Api>();
|
||||
|
||||
useEffect(() => {
|
||||
if (!containerRef.current) return;
|
||||
@@ -222,7 +222,7 @@ function getNoteIdFromSlide(slide: HTMLElement | undefined) {
|
||||
return slide.dataset.noteId;
|
||||
}
|
||||
|
||||
function rewireLinks(container: HTMLElement, api: RevealApi) {
|
||||
function rewireLinks(container: HTMLElement, api: Reveal.Api) {
|
||||
const links = container.querySelectorAll<HTMLLinkElement>("a.reference-link");
|
||||
for (const link of links) {
|
||||
link.addEventListener("click", () => {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user