mirror of
https://github.com/zadam/trilium.git
synced 2026-03-26 22:00:12 +01:00
Compare commits
349 Commits
renovate/n
...
feature/st
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
97443c0682 | ||
|
|
53c0b920e2 | ||
|
|
79b2bc8b93 | ||
|
|
360d9d5202 | ||
|
|
bf7af98739 | ||
|
|
b574237dfb | ||
|
|
afe597c811 | ||
|
|
48219f54fc | ||
|
|
d171409301 | ||
|
|
e508a4cd43 | ||
|
|
a5da35b7ae | ||
|
|
2016c97a12 | ||
|
|
9595f52a9c | ||
|
|
9ee17445a5 | ||
|
|
cd97e2c861 | ||
|
|
db6f034cb5 | ||
|
|
46b478ec17 | ||
|
|
de57a39df6 | ||
|
|
8eb45e2814 | ||
|
|
5bb0887d8b | ||
|
|
b5f7f89c27 | ||
|
|
fa7d1d3f80 | ||
|
|
2eef2f801f | ||
|
|
6ebf9f59a0 | ||
|
|
eddb47c9c4 | ||
|
|
8d38b818c0 | ||
|
|
af462ab0f9 | ||
|
|
07753a6253 | ||
|
|
54b12cf560 | ||
|
|
f97f5da837 | ||
|
|
19e315dc1a | ||
|
|
96d01d6379 | ||
|
|
ee156f1183 | ||
|
|
f83e184fcd | ||
|
|
a2ead45c83 | ||
|
|
b295f1e957 | ||
|
|
cbd4fd3820 | ||
|
|
b27fa2a555 | ||
|
|
2afd9b474c | ||
|
|
680ac80526 | ||
|
|
4b08a33307 | ||
|
|
04db52145d | ||
|
|
ae996e8847 | ||
|
|
06cb568fbd | ||
|
|
39a1aa360d | ||
|
|
51ed4dece2 | ||
|
|
1620b0be62 | ||
|
|
4c7c8a19c5 | ||
|
|
93f825e970 | ||
|
|
310035be1b | ||
|
|
4ec90e5575 | ||
|
|
5ba5aee160 | ||
|
|
aecca66972 | ||
|
|
a872664789 | ||
|
|
7b639f2718 | ||
|
|
7dcc1496ec | ||
|
|
0dc7d71d1b | ||
|
|
dd67710b12 | ||
|
|
6d376731e3 | ||
|
|
5157fd9ecd | ||
|
|
4226827b5d | ||
|
|
cb3b362bad | ||
|
|
4dcb08745b | ||
|
|
28c57813db | ||
|
|
49868362cd | ||
|
|
c2b965c24b | ||
|
|
6c3e16db20 | ||
|
|
b880d81104 | ||
|
|
ef8db52ebe | ||
|
|
185a88e655 | ||
|
|
3eef1a1c59 | ||
|
|
78451b9721 | ||
|
|
26973681ec | ||
|
|
f48b67f872 | ||
|
|
8d5ccb5ba8 | ||
|
|
619751a8aa | ||
|
|
be9c55acae | ||
|
|
ffd37755a3 | ||
|
|
9991b8f1e2 | ||
|
|
13eb8152e0 | ||
|
|
7bf6db7817 | ||
|
|
a1eb79fcb0 | ||
|
|
3f5cdc533e | ||
|
|
697ea995cb | ||
|
|
a2002b8e9c | ||
|
|
c1d8637fec | ||
|
|
b6ea29ffc9 | ||
|
|
6aa0c573fb | ||
|
|
fcc575c508 | ||
|
|
62d6ce08a0 | ||
|
|
b50127b0d3 | ||
|
|
669a58cc0e | ||
|
|
bf4b5dad5a | ||
|
|
39972a9bd7 | ||
|
|
44f519c1d6 | ||
|
|
dd6c5bbf12 | ||
|
|
20d4db2608 | ||
|
|
3151e86665 | ||
|
|
96a0d483f5 | ||
|
|
3faefdbc85 | ||
|
|
12347d5c4a | ||
|
|
4dbaadf9cc | ||
|
|
2a1c165a54 | ||
|
|
939f931809 | ||
|
|
4fd09bf1f8 | ||
|
|
3231db3c3f | ||
|
|
c07ea1bfa7 | ||
|
|
79db638bf4 | ||
|
|
794dab2894 | ||
|
|
97b303aea6 | ||
|
|
a259b65085 | ||
|
|
5ea014cc37 | ||
|
|
3210dbb6d8 | ||
|
|
64cbb2c7d2 | ||
|
|
3b35dc50c5 | ||
|
|
a768d2f7a7 | ||
|
|
156ac3be6d | ||
|
|
ccc0038d4e | ||
|
|
3684f4727c | ||
|
|
efd294d53b | ||
|
|
f9eb4bf574 | ||
|
|
b49912bf71 | ||
|
|
f5f11de58e | ||
|
|
a8ea40b2e1 | ||
|
|
308bab8a3c | ||
|
|
ef8c4cef8a | ||
|
|
63198a03ab | ||
|
|
ed808abd22 | ||
|
|
9fe23442f5 | ||
|
|
0e2e86e7d3 | ||
|
|
ea0e3fd248 | ||
|
|
2ac85a1d1c | ||
|
|
cb71dc4202 | ||
|
|
6637542e7c | ||
|
|
971ce09811 | ||
|
|
04826074f4 | ||
|
|
bcd4baff3d | ||
|
|
3bcf7b22be | ||
|
|
ee8c54bdd3 | ||
|
|
1af8699fc0 | ||
|
|
5bc1fc71ef | ||
|
|
0b5ce95093 | ||
|
|
77971a10d1 | ||
|
|
28a56ff7bf | ||
|
|
d7d28bcf58 | ||
|
|
682e1549f8 | ||
|
|
d7d2b21935 | ||
|
|
1b7d2da6cb | ||
|
|
9350c43e5b | ||
|
|
0fae11d54c | ||
|
|
1ed3999639 | ||
|
|
7d30771f05 | ||
|
|
08f1d44d90 | ||
|
|
969860c344 | ||
|
|
ed905c9d64 | ||
|
|
c5518b64b7 | ||
|
|
a7b2b631c5 | ||
|
|
dcfc1119eb | ||
|
|
88add55ebc | ||
|
|
ad41a58904 | ||
|
|
49ce312ab2 | ||
|
|
223d69206c | ||
|
|
d68ada1026 | ||
|
|
e0a23f6b63 | ||
|
|
bd147ea72e | ||
|
|
4494aed1cf | ||
|
|
788eaad61c | ||
|
|
0cfd6bae0e | ||
|
|
82c435b916 | ||
|
|
bc5b9708c7 | ||
|
|
7e87e6f832 | ||
|
|
e5a7a32439 | ||
|
|
e9214d84b7 | ||
|
|
da7a61a8b6 | ||
|
|
458e858b24 | ||
|
|
ec84e72b4c | ||
|
|
64a8c3b005 | ||
|
|
0b5cf2e6c8 | ||
|
|
7ed4e1c284 | ||
|
|
9dd7616f7d | ||
|
|
ab29caff7b | ||
|
|
7633e3d48e | ||
|
|
411fdf3114 | ||
|
|
5c52917459 | ||
|
|
51753ad82a | ||
|
|
7e00634f3d | ||
|
|
daf41804d4 | ||
|
|
43d087f886 | ||
|
|
503a6e520d | ||
|
|
52610a7410 | ||
|
|
c7edb71fed | ||
|
|
83db37ed31 | ||
|
|
0d1c8ae01e | ||
|
|
92f71e100f | ||
|
|
659573b864 | ||
|
|
e1c798561b | ||
|
|
0c52b56e02 | ||
|
|
f9731d9cfc | ||
|
|
7547371ba0 | ||
|
|
84e1d45d2a | ||
|
|
364c9cda27 | ||
|
|
af944c29a8 | ||
|
|
45577f1585 | ||
|
|
1648c67467 | ||
|
|
882793e794 | ||
|
|
4a4a7d79c2 | ||
|
|
a955eb80da | ||
|
|
cd64a1ee18 | ||
|
|
9894d4256c | ||
|
|
3b5f1dabd6 | ||
|
|
750fa2e647 | ||
|
|
4f0021e44e | ||
|
|
2546e4c0dc | ||
|
|
eac5dbb210 | ||
|
|
8b6da981f7 | ||
|
|
7433ca069f | ||
|
|
128049b672 | ||
|
|
0eb3cb1118 | ||
|
|
8fc28716a7 | ||
|
|
af346f455a | ||
|
|
3e5a6c1e51 | ||
|
|
9e3b4435cd | ||
|
|
3a793a3549 | ||
|
|
4f139552f4 | ||
|
|
13f25e9fed | ||
|
|
91db73703b | ||
|
|
d690985b58 | ||
|
|
b5bcf73531 | ||
|
|
2e905c8292 | ||
|
|
4374c92032 | ||
|
|
edde0d0f90 | ||
|
|
32c39384ff | ||
|
|
807ab4be8c | ||
|
|
4da20f4829 | ||
|
|
cb5b491633 | ||
|
|
e76c33c37a | ||
|
|
89fc89603e | ||
|
|
c0bf294457 | ||
|
|
24e076cacf | ||
|
|
1e381b13ca | ||
|
|
f83121ce1d | ||
|
|
b32480f1d3 | ||
|
|
d4468bd97b | ||
|
|
e8711d7cd5 | ||
|
|
35f4d2aaad | ||
|
|
b1f3fe5345 | ||
|
|
9f1b0ac449 | ||
|
|
a84e804fc3 | ||
|
|
3371a31c70 | ||
|
|
724af8e103 | ||
|
|
c5803a2650 | ||
|
|
baf18835be | ||
|
|
3d1c93e58c | ||
|
|
ab0800a9f3 | ||
|
|
dd58eac4b0 | ||
|
|
c6d1457ad7 | ||
|
|
f05fda871c | ||
|
|
22590596da | ||
|
|
8274f9a220 | ||
|
|
b19bf62d7e | ||
|
|
7b436bdf70 | ||
|
|
a1c4a17d64 | ||
|
|
7966cfd09c | ||
|
|
0fe299250e | ||
|
|
adfe490480 | ||
|
|
872ab0864b | ||
|
|
6633b4233d | ||
|
|
a2d873d16f | ||
|
|
a6f52fff3e | ||
|
|
7832f20c89 | ||
|
|
405db7cedb | ||
|
|
ccf4df8e86 | ||
|
|
1beda05e6c | ||
|
|
18a3d9d71a | ||
|
|
25dc9201bf | ||
|
|
b60501dd3f | ||
|
|
cbd2fc3966 | ||
|
|
9bce12a85b | ||
|
|
8523c369e1 | ||
|
|
7c16aeca4a | ||
|
|
8399600e79 | ||
|
|
edac58f3fa | ||
|
|
51d0d848c5 | ||
|
|
1edab8e8da | ||
|
|
e1e294914a | ||
|
|
4668fdc15c | ||
|
|
f1e0d5558c | ||
|
|
c94c54c641 | ||
|
|
18416eb89a | ||
|
|
263c9028e2 | ||
|
|
0b528e9937 | ||
|
|
e905c1ec11 | ||
|
|
ecb27fe9f7 | ||
|
|
a8f6db4b20 | ||
|
|
78262e55ec | ||
|
|
c6197e520d | ||
|
|
299c06c1a6 | ||
|
|
674593b38c | ||
|
|
f5535657ad | ||
|
|
de4d07e904 | ||
|
|
5508b505c8 | ||
|
|
8cdfc108ba | ||
|
|
6a0f6fab83 | ||
|
|
ad3be73e1b | ||
|
|
64b212b93e | ||
|
|
60cb8d950e | ||
|
|
61f6f94295 | ||
|
|
ebe7276f40 | ||
|
|
26d299aa44 | ||
|
|
bd45c32251 | ||
|
|
321558a01f | ||
|
|
f5a77477aa | ||
|
|
20c90d1296 | ||
|
|
bbfef0315f | ||
|
|
321fcf34f2 | ||
|
|
b9a59fe0c4 | ||
|
|
01f3c32d92 | ||
|
|
05b9e2ec2a | ||
|
|
c8d3b091fd | ||
|
|
d717a89163 | ||
|
|
8149460547 | ||
|
|
b7ad76827a | ||
|
|
e19e9b3830 | ||
|
|
40b07c3e8a | ||
|
|
a15b84b4e5 | ||
|
|
544c52931c | ||
|
|
9391159413 | ||
|
|
f9e22a9ba9 | ||
|
|
f88ac5dfae | ||
|
|
3459d2906e | ||
|
|
4506b717d5 | ||
|
|
af8744ef2a | ||
|
|
320d8e3b45 | ||
|
|
c20da77f83 | ||
|
|
14e2e85da7 | ||
|
|
c7f0d541c2 | ||
|
|
5d474150da | ||
|
|
d3941752f1 | ||
|
|
56b305b1de | ||
|
|
bde472d649 | ||
|
|
c1548b0f54 | ||
|
|
6f04738629 | ||
|
|
f79af7b045 | ||
|
|
527f502083 | ||
|
|
d61e2c6f2c | ||
|
|
ea31d2f446 | ||
|
|
62803a1817 | ||
|
|
a67464b4a0 | ||
|
|
00e7482968 |
67
.github/workflows/deploy-app.yml
vendored
Normal file
67
.github/workflows/deploy-app.yml
vendored
Normal file
@@ -0,0 +1,67 @@
|
||||
name: Deploy Standalone App
|
||||
|
||||
on:
|
||||
# Trigger on push to main branch
|
||||
push:
|
||||
branches:
|
||||
- standalone
|
||||
# Only run when app files change
|
||||
paths:
|
||||
- 'apps/client/**'
|
||||
- 'apps/client-standalone/**'
|
||||
- 'packages/trilium-core/**'
|
||||
- '.github/workflows/deploy-app.yml'
|
||||
|
||||
# Allow manual triggering from Actions tab
|
||||
workflow_dispatch:
|
||||
|
||||
# Run on pull requests for preview deployments
|
||||
pull_request:
|
||||
paths:
|
||||
- 'apps/client/**'
|
||||
- 'apps/client-standalone/**'
|
||||
- 'packages/trilium-core/**'
|
||||
- '.github/workflows/deploy-app.yml'
|
||||
jobs:
|
||||
build-and-deploy:
|
||||
name: Build and Deploy App
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
|
||||
# Required permissions for deployment
|
||||
permissions:
|
||||
contents: read
|
||||
deployments: write
|
||||
pull-requests: write # For PR preview comments
|
||||
id-token: write # For OIDC authentication (if needed)
|
||||
|
||||
steps:
|
||||
- name: Checkout Repository
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: '24'
|
||||
cache: 'pnpm'
|
||||
|
||||
- name: Install Dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Trigger build of app
|
||||
run: pnpm --filter=client-standalone build
|
||||
|
||||
- name: Deploy
|
||||
uses: ./.github/actions/deploy-to-cloudflare-pages
|
||||
if: github.repository == vars.REPO_MAIN
|
||||
with:
|
||||
project_name: "trilium-app"
|
||||
comment_body: "🖥️ App preview is ready"
|
||||
production_url: "https://app.triliumnotes.org"
|
||||
deploy_dir: "apps/client-standalone/dist"
|
||||
cloudflare_api_token: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
cloudflare_account_id: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
||||
github_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
4
.github/workflows/dev.yml
vendored
4
.github/workflows/dev.yml
vendored
@@ -1,9 +1,9 @@
|
||||
name: Dev
|
||||
on:
|
||||
push:
|
||||
branches: [ main ]
|
||||
branches: [ main, standalone ]
|
||||
pull_request:
|
||||
branches: [ main ]
|
||||
branches: [ main, standalone ]
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -51,3 +51,6 @@ upload
|
||||
site/
|
||||
apps/*/coverage
|
||||
scripts/translation/.language*.json
|
||||
|
||||
# AI
|
||||
.claude/settings.local.json
|
||||
261
CLAUDE.md
261
CLAUDE.md
@@ -4,149 +4,184 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
||||
|
||||
## Overview
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
## Development Commands
|
||||
|
||||
### Setup
|
||||
- `pnpm install` - Install all dependencies
|
||||
- `corepack enable` - Enable pnpm if not available
|
||||
```bash
|
||||
# Setup
|
||||
corepack enable && pnpm install
|
||||
|
||||
### Running Applications
|
||||
- `pnpm run server:start` - Start development server (http://localhost:8080)
|
||||
- `pnpm run server:start-prod` - Run server in production mode
|
||||
# Run
|
||||
pnpm server:start # Dev server at http://localhost:8080
|
||||
pnpm desktop:start # Electron dev app
|
||||
pnpm standalone:start # Standalone client dev
|
||||
|
||||
### Building
|
||||
- `pnpm run client:build` - Build client application
|
||||
- `pnpm run server:build` - Build server application
|
||||
- `pnpm run electron:build` - Build desktop application
|
||||
# Build
|
||||
pnpm client:build # Frontend
|
||||
pnpm server:build # Backend
|
||||
pnpm desktop:build # Electron
|
||||
|
||||
### 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
|
||||
# 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
|
||||
|
||||
## Architecture Overview
|
||||
# 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
|
||||
```
|
||||
|
||||
### 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`
|
||||
**Running a single test file**: `pnpm --filter server test spec/etapi/search.spec.ts`
|
||||
|
||||
- **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`
|
||||
## Main Applications
|
||||
|
||||
### Core Architecture Patterns
|
||||
The four main apps share `packages/trilium-core/` for business logic but differ in 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/`)
|
||||
- **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.
|
||||
|
||||
#### 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
|
||||
## Monorepo Structure
|
||||
|
||||
#### 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
|
||||
```
|
||||
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
|
||||
- Type-specific widgets in `type_widgets/` directory
|
||||
|
||||
#### 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`)
|
||||
**Widget lifecycle**: `doRenderBody()` for initial render, `refreshWithNote()` for note changes, `entitiesReloadedEvent({loadResults})` for entity updates. Uses jQuery — don't mix React patterns.
|
||||
|
||||
### Key Files for Understanding Architecture
|
||||
Fluent builder pattern: `.child()`, `.class()`, `.css()` chaining with position-based ordering.
|
||||
|
||||
1. **Application Entry Points**:
|
||||
- `apps/server/src/main.ts` - Server startup
|
||||
- `apps/client/src/desktop.ts` - Client initialization
|
||||
### API Architecture
|
||||
|
||||
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
|
||||
- **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
|
||||
|
||||
3. **Database Schema**:
|
||||
- `apps/server/src/assets/db/schema.sql` - Core database structure
|
||||
### Platform Abstraction
|
||||
|
||||
4. **Configuration**:
|
||||
- `package.json` - Project dependencies and scripts
|
||||
`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()`.
|
||||
|
||||
## Note Types and Features
|
||||
### Database
|
||||
|
||||
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
|
||||
SQLite via `better-sqlite3`. SQL abstraction in `packages/trilium-core/src/services/sql/` with `DatabaseProvider` interface, prepared statement caching, and transaction support.
|
||||
|
||||
## Development Guidelines
|
||||
- Schema: `apps/server/src/assets/db/schema.sql`
|
||||
- Migrations: `apps/server/src/migrations/YYMMDD_HHMM__description.sql`
|
||||
|
||||
### 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
|
||||
### Attribute Inheritance
|
||||
|
||||
### 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/`
|
||||
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
|
||||
|
||||
### Internationalization
|
||||
- Translation files in `apps/client/src/translations/`
|
||||
- Supported languages: English, German, Spanish, French, Romanian, Chinese
|
||||
Use `note.getOwnedAttribute()` for direct, `note.getAttribute()` for inherited.
|
||||
|
||||
### Security Considerations
|
||||
- Per-note encryption with granular protected sessions
|
||||
- CSRF protection for API endpoints
|
||||
- OpenID and TOTP authentication support
|
||||
- Sanitization of user-generated content
|
||||
## Important Patterns
|
||||
|
||||
## Common Development Tasks
|
||||
- **Protected notes**: Check `note.isContentAvailable()` before accessing content; use `note.getTitleOrProtected()` for safe title access
|
||||
- **Long operations**: Use `TaskContext` for progress reporting via WebSocket
|
||||
- **Event system** (`packages/trilium-core/src/services/events.ts`): Events emitted in order (notes → branches → attributes) during load for referential integrity
|
||||
- **Search**: Expression-based, scoring happens in-memory — cannot add SQL-level LIMIT/OFFSET without losing scoring
|
||||
- **Widget cleanup**: Unsubscribe from events in `cleanup()`/`doDestroy()` to prevent memory leaks
|
||||
|
||||
### Adding New Note Types
|
||||
1. Create widget in `apps/client/src/widgets/type_widgets/`
|
||||
2. Register in `apps/client/src/services/note_types.ts`
|
||||
3. Add backend handling in `apps/server/src/services/notes.ts`
|
||||
## Code Style
|
||||
|
||||
### Extending Search
|
||||
- Search expressions handled in `apps/server/src/services/search/`
|
||||
- Add new search operators in search context files
|
||||
- 4-space indentation, semicolons always required
|
||||
- Double quotes (enforced by format config)
|
||||
- Max line length: 100 characters
|
||||
- Unix line endings
|
||||
- Import sorting via `eslint-plugin-simple-import-sort`
|
||||
|
||||
### Custom CKEditor Plugins
|
||||
- Create new package in `packages/` following existing plugin structure
|
||||
- Register in `packages/ckeditor5/src/plugins.ts`
|
||||
## Testing
|
||||
|
||||
### Database Migrations
|
||||
- Add migration scripts in `apps/server/src/migrations/`
|
||||
- Update schema in `apps/server/src/assets/db/schema.sql`
|
||||
- **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
|
||||
|
||||
## 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
|
||||
## 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
|
||||
|
||||
4
apps/client-standalone/.env
Normal file
4
apps/client-standalone/.env
Normal file
@@ -0,0 +1,4 @@
|
||||
# The development license key for premium CKEditor features.
|
||||
# Note: This key must only be used for the Trilium Notes project.
|
||||
VITE_CKEDITOR_KEY=eyJhbGciOiJFUzI1NiJ9.eyJleHAiOjE3ODcyNzA0MDAsImp0aSI6IjkyMWE1MWNlLTliNDMtNGRlMC1iOTQwLTc5ZjM2MDBkYjg1NyIsImRpc3RyaWJ1dGlvbkNoYW5uZWwiOiJ0cmlsaXVtIiwiZmVhdHVyZXMiOlsiVFJJTElVTSJdLCJ2YyI6ImU4YzRhMjBkIn0.hny77p-U4-jTkoqbwPytrEar5ylGCWBN7Ez3SlB8i6_mJCBIeCSTOlVQk_JMiOEq3AGykUMHzWXzjdMFwgniOw
|
||||
VITE_CKEDITOR_ENABLE_INSPECTOR=false
|
||||
1
apps/client-standalone/.env.production
Normal file
1
apps/client-standalone/.env.production
Normal file
@@ -0,0 +1 @@
|
||||
VITE_CKEDITOR_ENABLE_INSPECTOR=false
|
||||
87
apps/client-standalone/package.json
Normal file
87
apps/client-standalone/package.json
Normal file
@@ -0,0 +1,87 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
}
|
||||
3
apps/client-standalone/public/_headers
Normal file
3
apps/client-standalone/public/_headers
Normal file
@@ -0,0 +1,3 @@
|
||||
/*
|
||||
Cross-Origin-Opener-Policy: same-origin
|
||||
Cross-Origin-Embedder-Policy: require-corp
|
||||
BIN
apps/client-standalone/public/favicon.ico
Normal file
BIN
apps/client-standalone/public/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 112 KiB |
20
apps/client-standalone/public/manifest.webmanifest
Normal file
20
apps/client-standalone/public/manifest.webmanifest
Normal file
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"name": "Trilium Notes",
|
||||
"short_name": "Trilium",
|
||||
"description": "Trilium Notes is a hierarchical note taking application with focus on building large personal knowledge bases.",
|
||||
"theme_color": "#333333",
|
||||
"background_color": "#1F1F1F",
|
||||
"display": "standalone",
|
||||
"scope": "/",
|
||||
"start_url": "/",
|
||||
"display_override": [
|
||||
"window-controls-overlay"
|
||||
],
|
||||
"icons": [
|
||||
{
|
||||
"src": "assets/icon.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png"
|
||||
}
|
||||
]
|
||||
}
|
||||
2
apps/client-standalone/src/desktop.ts
Normal file
2
apps/client-standalone/src/desktop.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
// Re-export desktop from client
|
||||
export * from "../../client/src/desktop";
|
||||
31
apps/client-standalone/src/index.html
Normal file
31
apps/client-standalone/src/index.html
Normal file
@@ -0,0 +1,31 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<link rel="shortcut icon" href="favicon.ico">
|
||||
<meta name="mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, viewport-fit=cover" />
|
||||
<link rel="manifest" crossorigin="use-credentials" href="manifest.webmanifest">
|
||||
<title>Trilium Notes</title>
|
||||
</head>
|
||||
|
||||
<body id="trilium-app">
|
||||
<noscript>Trilium requires JavaScript to be enabled.</noscript>
|
||||
|
||||
<div id="context-menu-cover"></div>
|
||||
<div class="dropdown-menu dropdown-menu-sm" id="context-menu-container" style="display: none"></div>
|
||||
|
||||
<!-- Required for match the PWA's top bar color with the theme -->
|
||||
<!-- This works even when the user directly changes --root-background in CSS -->
|
||||
<div id="background-color-tracker" style="position: absolute; visibility: hidden; color: var(--root-background); transition: color 1ms;"></div>
|
||||
|
||||
<!-- Bootstrap (request server for required information) -->
|
||||
<script src="./main.ts" type="module"></script>
|
||||
|
||||
<!-- Required for correct loading of scripts in Electron -->
|
||||
<script>
|
||||
if (typeof module === 'object') {window.module = module; module = undefined;}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
283
apps/client-standalone/src/lightweight/browser_router.ts
Normal file
283
apps/client-standalone/src/lightweight/browser_router.ts
Normal file
@@ -0,0 +1,283 @@
|
||||
/**
|
||||
* 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();
|
||||
}
|
||||
289
apps/client-standalone/src/lightweight/browser_routes.ts
Normal file
289
apps/client-standalone/src/lightweight/browser_routes.ts
Normal file
@@ -0,0 +1,289 @@
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
77
apps/client-standalone/src/lightweight/cls_provider.ts
Normal file
77
apps/client-standalone/src/lightweight/cls_provider.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import { ExecutionContext } from "@triliumnext/core";
|
||||
|
||||
/**
|
||||
* Browser execution context implementation.
|
||||
*
|
||||
* Handles per-request context isolation with support for fire-and-forget async operations
|
||||
* using a context stack and grace-period cleanup to allow unawaited promises to complete.
|
||||
*/
|
||||
export default class BrowserExecutionContext implements ExecutionContext {
|
||||
private contextStack: Map<string, any>[] = [];
|
||||
private cleanupTimers = new WeakMap<Map<string, any>, ReturnType<typeof setTimeout>>();
|
||||
private readonly CLEANUP_GRACE_PERIOD = 1000; // 1 second for fire-and-forget operations
|
||||
|
||||
private getCurrentContext(): Map<string, any> {
|
||||
if (this.contextStack.length === 0) {
|
||||
throw new Error("ExecutionContext not initialized");
|
||||
}
|
||||
return this.contextStack[this.contextStack.length - 1];
|
||||
}
|
||||
|
||||
get<T = any>(key: string): T {
|
||||
return this.getCurrentContext().get(key);
|
||||
}
|
||||
|
||||
set(key: string, value: any): void {
|
||||
this.getCurrentContext().set(key, value);
|
||||
}
|
||||
|
||||
reset(): void {
|
||||
this.contextStack = [];
|
||||
}
|
||||
|
||||
init<T>(callback: () => T): T {
|
||||
const context = new Map<string, any>();
|
||||
this.contextStack.push(context);
|
||||
|
||||
// Cancel any pending cleanup timer for this context
|
||||
const existingTimer = this.cleanupTimers.get(context);
|
||||
if (existingTimer) {
|
||||
clearTimeout(existingTimer);
|
||||
this.cleanupTimers.delete(context);
|
||||
}
|
||||
|
||||
try {
|
||||
const result = callback();
|
||||
|
||||
// If the result is a Promise
|
||||
if (result && typeof result === 'object' && 'then' in result && 'catch' in result) {
|
||||
const promise = result as unknown as Promise<any>;
|
||||
return promise.finally(() => {
|
||||
this.scheduleContextCleanup(context);
|
||||
}) as T;
|
||||
} else {
|
||||
// For synchronous results, schedule delayed cleanup to allow fire-and-forget operations
|
||||
this.scheduleContextCleanup(context);
|
||||
return result;
|
||||
}
|
||||
} catch (error) {
|
||||
// Always clean up on error with grace period
|
||||
this.scheduleContextCleanup(context);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private scheduleContextCleanup(context: Map<string, any>): void {
|
||||
const timer = setTimeout(() => {
|
||||
// Remove from stack if still present
|
||||
const index = this.contextStack.indexOf(context);
|
||||
if (index !== -1) {
|
||||
this.contextStack.splice(index, 1);
|
||||
}
|
||||
this.cleanupTimers.delete(context);
|
||||
}, this.CLEANUP_GRACE_PERIOD);
|
||||
|
||||
this.cleanupTimers.set(context, timer);
|
||||
}
|
||||
}
|
||||
158
apps/client-standalone/src/lightweight/crypto_provider.ts
Normal file
158
apps/client-standalone/src/lightweight/crypto_provider.ts
Normal file
@@ -0,0 +1,158 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
120
apps/client-standalone/src/lightweight/messaging_provider.ts
Normal file
120
apps/client-standalone/src/lightweight/messaging_provider.ts
Normal file
@@ -0,0 +1,120 @@
|
||||
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 = [];
|
||||
}
|
||||
}
|
||||
11
apps/client-standalone/src/lightweight/platform_provider.ts
Normal file
11
apps/client-standalone/src/lightweight/platform_provider.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
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
|
||||
});
|
||||
}
|
||||
}
|
||||
93
apps/client-standalone/src/lightweight/request_provider.ts
Normal file
93
apps/client-standalone/src/lightweight/request_provider.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
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();
|
||||
}
|
||||
}
|
||||
613
apps/client-standalone/src/lightweight/sql_provider.ts
Normal file
613
apps/client-standalone/src/lightweight/sql_provider.ts
Normal file
@@ -0,0 +1,613 @@
|
||||
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.");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { LOCALE_IDS } from "@triliumnext/commons";
|
||||
import type i18next from "i18next";
|
||||
import I18NextHttpBackend from "i18next-http-backend";
|
||||
|
||||
export default async function translationProvider(i18nextInstance: typeof i18next, locale: LOCALE_IDS) {
|
||||
await i18nextInstance.use(I18NextHttpBackend).init({
|
||||
lng: locale,
|
||||
fallbackLng: "en",
|
||||
ns: "server",
|
||||
backend: {
|
||||
loadPath: `${import.meta.resolve("../server-assets/translations")}/{{lng}}/{{ns}}.json`
|
||||
},
|
||||
returnEmptyString: false,
|
||||
debug: true
|
||||
});
|
||||
}
|
||||
109
apps/client-standalone/src/local-bridge.ts
Normal file
109
apps/client-standalone/src/local-bridge.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
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
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
265
apps/client-standalone/src/local-server-worker.ts
Normal file
265
apps/client-standalone/src/local-server-worker.ts
Normal file
@@ -0,0 +1,265 @@
|
||||
// =============================================================================
|
||||
// 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)
|
||||
});
|
||||
}
|
||||
};
|
||||
84
apps/client-standalone/src/main.ts
Normal file
84
apps/client-standalone/src/main.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
import { attachServiceWorkerBridge, startLocalServerWorker } from "./local-bridge.js";
|
||||
|
||||
async function waitForServiceWorkerControl(): Promise<void> {
|
||||
if (!("serviceWorker" in navigator)) {
|
||||
throw new Error("Service Worker not supported in this browser");
|
||||
}
|
||||
|
||||
// If already controlling, we're good
|
||||
if (navigator.serviceWorker.controller) {
|
||||
console.log("[Bootstrap] Service worker already controlling");
|
||||
return;
|
||||
}
|
||||
|
||||
console.log("[Bootstrap] Waiting for service worker to take control...");
|
||||
|
||||
// Register service worker
|
||||
await navigator.serviceWorker.register("./sw.js", { scope: "/" });
|
||||
|
||||
// Wait for it to be ready (installed + activated)
|
||||
await navigator.serviceWorker.ready;
|
||||
|
||||
// Check if we're now controlling
|
||||
if (navigator.serviceWorker.controller) {
|
||||
console.log("[Bootstrap] Service worker now controlling");
|
||||
return;
|
||||
}
|
||||
|
||||
// If not controlling yet, we need to reload the page for SW to take control
|
||||
// This is standard PWA behavior on first install
|
||||
console.log("[Bootstrap] Service worker installed but not controlling yet - reloading page");
|
||||
|
||||
// Wait a tiny bit for SW to fully activate
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
|
||||
// Reload to let SW take control
|
||||
window.location.reload();
|
||||
|
||||
// Throw to stop execution (page will reload)
|
||||
throw new Error("Reloading for service worker activation");
|
||||
}
|
||||
|
||||
async function bootstrap() {
|
||||
/* fixes https://github.com/webpack/webpack/issues/10035 */
|
||||
window.global = globalThis;
|
||||
|
||||
try {
|
||||
// 1) Start local worker ASAP (so /bootstrap is fast)
|
||||
startLocalServerWorker();
|
||||
|
||||
// 2) Bridge SW -> local worker
|
||||
attachServiceWorkerBridge();
|
||||
|
||||
// 3) Wait for service worker to control the page (may reload on first install)
|
||||
await waitForServiceWorkerControl();
|
||||
|
||||
await loadScripts();
|
||||
} catch (err) {
|
||||
// If error is from reload, it will stop here (page reloads)
|
||||
// Otherwise, show error to user
|
||||
if (err instanceof Error && err.message.includes("Reloading")) {
|
||||
// Page is reloading, do nothing
|
||||
return;
|
||||
}
|
||||
|
||||
console.error("[Bootstrap] Fatal error:", err);
|
||||
document.body.innerHTML = `
|
||||
<div style="padding: 40px; max-width: 600px; margin: 0 auto; font-family: system-ui, sans-serif;">
|
||||
<h1 style="color: #d32f2f;">Failed to Initialize</h1>
|
||||
<p>The application failed to start. Please check the browser console for details.</p>
|
||||
<pre style="background: #f5f5f5; padding: 16px; border-radius: 4px; overflow: auto;">${err instanceof Error ? err.message : String(err)}</pre>
|
||||
<button onclick="location.reload()" style="padding: 12px 24px; background: #1976d2; color: white; border: none; border-radius: 4px; cursor: pointer; font-size: 16px;">
|
||||
Reload Page
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
document.body.style.display = "block";
|
||||
}
|
||||
}
|
||||
|
||||
async function loadScripts() {
|
||||
await import("../../client/src/index.js");
|
||||
}
|
||||
|
||||
bootstrap();
|
||||
185
apps/client-standalone/src/sw.ts
Normal file
185
apps/client-standalone/src/sw.ts
Normal file
@@ -0,0 +1,185 @@
|
||||
// public/sw.js
|
||||
const VERSION = "localserver-v1.4";
|
||||
const STATIC_CACHE = `static-${VERSION}`;
|
||||
|
||||
// Check if running in dev mode (passed via URL parameter)
|
||||
const isDev = true;
|
||||
|
||||
if (isDev) {
|
||||
console.log('[Service Worker] Running in DEV mode - caching disabled');
|
||||
}
|
||||
|
||||
// Adjust these to your routes:
|
||||
const LOCAL_FIRST_PREFIXES = [
|
||||
"/bootstrap",
|
||||
"/api/",
|
||||
"/sync/",
|
||||
"/search/"
|
||||
];
|
||||
|
||||
// Optional: basic precache list (keep small; you can expand later)
|
||||
const PRECACHE_URLS = [
|
||||
// "/",
|
||||
// "/index.html",
|
||||
// "/manifest.webmanifest",
|
||||
// "/favicon.ico",
|
||||
];
|
||||
|
||||
self.addEventListener("install", (event) => {
|
||||
event.waitUntil((async () => {
|
||||
// Skip precaching in dev mode
|
||||
if (!isDev) {
|
||||
const cache = await caches.open(STATIC_CACHE);
|
||||
await cache.addAll(PRECACHE_URLS);
|
||||
}
|
||||
self.skipWaiting();
|
||||
})());
|
||||
});
|
||||
|
||||
self.addEventListener("activate", (event) => {
|
||||
event.waitUntil((async () => {
|
||||
// Cleanup old caches
|
||||
const keys = await caches.keys();
|
||||
await Promise.all(keys.map((k) => (k === STATIC_CACHE ? Promise.resolve() : caches.delete(k))));
|
||||
await self.clients.claim();
|
||||
})());
|
||||
});
|
||||
|
||||
function isLocalFirst(url) {
|
||||
return LOCAL_FIRST_PREFIXES.some((p) => url.pathname.startsWith(p));
|
||||
}
|
||||
|
||||
async function cacheFirst(request) {
|
||||
// In dev mode, always bypass cache
|
||||
if (isDev) {
|
||||
return fetch(request);
|
||||
}
|
||||
|
||||
const cache = await caches.open(STATIC_CACHE);
|
||||
const cached = await cache.match(request);
|
||||
if (cached) return cached;
|
||||
|
||||
const fresh = await fetch(request);
|
||||
// Cache only successful GETs
|
||||
if (request.method === "GET" && fresh.ok) cache.put(request, fresh.clone());
|
||||
return fresh;
|
||||
}
|
||||
|
||||
async function networkFirst(request) {
|
||||
// In dev mode, always bypass cache
|
||||
if (isDev) {
|
||||
return fetch(request);
|
||||
}
|
||||
|
||||
const cache = await caches.open(STATIC_CACHE);
|
||||
try {
|
||||
const fresh = await fetch(request);
|
||||
// Cache only successful GETs
|
||||
if (request.method === "GET" && fresh.ok) cache.put(request, fresh.clone());
|
||||
return fresh;
|
||||
} catch (error) {
|
||||
// Fallback to cache if network fails
|
||||
const cached = await cache.match(request);
|
||||
if (cached) return cached;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function forwardToClientLocalServer(request, clientId) {
|
||||
// Find a client to handle the request (prefer the initiating client if available)
|
||||
let client = clientId ? await self.clients.get(clientId) : null;
|
||||
|
||||
if (!client) {
|
||||
const all = await self.clients.matchAll({ type: "window", includeUncontrolled: true });
|
||||
client = all[0] || null;
|
||||
}
|
||||
|
||||
// If no page is available, fall back to network
|
||||
if (!client) return fetch(request);
|
||||
|
||||
const reqUrl = request.url;
|
||||
const headersObj = {};
|
||||
for (const [k, v] of request.headers.entries()) headersObj[k] = v;
|
||||
|
||||
const body = (request.method === "GET" || request.method === "HEAD")
|
||||
? null
|
||||
: await request.arrayBuffer();
|
||||
|
||||
const id = crypto.randomUUID();
|
||||
const channel = new MessageChannel();
|
||||
|
||||
const responsePromise = new Promise((resolve, reject) => {
|
||||
const timeout = setTimeout(() => {
|
||||
reject(new Error("Local server timeout"));
|
||||
}, 30_000);
|
||||
|
||||
channel.port1.onmessage = (event) => {
|
||||
clearTimeout(timeout);
|
||||
resolve(event.data);
|
||||
};
|
||||
channel.port1.onmessageerror = () => {
|
||||
clearTimeout(timeout);
|
||||
reject(new Error("Local server message error"));
|
||||
};
|
||||
});
|
||||
|
||||
// Send to the client with a reply port
|
||||
client.postMessage({
|
||||
type: "LOCAL_FETCH",
|
||||
id,
|
||||
request: {
|
||||
url: reqUrl,
|
||||
method: request.method,
|
||||
headers: headersObj,
|
||||
body // ArrayBuffer or null
|
||||
}
|
||||
}, [channel.port2]);
|
||||
|
||||
const localResp = await responsePromise;
|
||||
|
||||
if (!localResp || localResp.type !== "LOCAL_FETCH_RESPONSE" || localResp.id !== id) {
|
||||
// Protocol mismatch; fall back
|
||||
return fetch(request);
|
||||
}
|
||||
|
||||
// localResp.response: { status, headers, body }
|
||||
const { status, headers, body: respBody } = localResp.response;
|
||||
|
||||
const respHeaders = new Headers();
|
||||
if (headers) {
|
||||
for (const [k, v] of Object.entries(headers)) respHeaders.set(k, String(v));
|
||||
}
|
||||
|
||||
return new Response(respBody ? respBody : null, {
|
||||
status: status || 200,
|
||||
headers: respHeaders
|
||||
});
|
||||
}
|
||||
|
||||
self.addEventListener("fetch", (event) => {
|
||||
const url = new URL(event.request.url);
|
||||
|
||||
// Only handle same-origin
|
||||
if (url.origin !== self.location.origin) return;
|
||||
|
||||
// HTML files: network-first to ensure updates are reflected immediately
|
||||
if (event.request.mode === "navigate" || url.pathname.endsWith(".html")) {
|
||||
event.respondWith(networkFirst(event.request));
|
||||
return;
|
||||
}
|
||||
|
||||
// Static assets: cache-first for performance
|
||||
if (event.request.method === "GET" && !isLocalFirst(url)) {
|
||||
event.respondWith(cacheFirst(event.request));
|
||||
return;
|
||||
}
|
||||
|
||||
// API-ish: local-first via bridge
|
||||
if (isLocalFirst(url)) {
|
||||
event.respondWith(forwardToClientLocalServer(event.request, event.clientId));
|
||||
return;
|
||||
}
|
||||
|
||||
// Default
|
||||
event.respondWith(fetch(event.request));
|
||||
});
|
||||
31
apps/client-standalone/src/vite-env.d.ts
vendored
Normal file
31
apps/client-standalone/src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1,31 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
interface ImportMetaEnv {
|
||||
readonly VITE_APP_TITLE: string
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
readonly env: ImportMetaEnv
|
||||
}
|
||||
|
||||
interface Window {
|
||||
glob: {
|
||||
assetPath: string;
|
||||
themeCssUrl?: string;
|
||||
themeUseNextAsBase?: string;
|
||||
iconPackCss: string;
|
||||
device: string;
|
||||
headingStyle: string;
|
||||
layoutOrientation: string;
|
||||
platform: string;
|
||||
isElectron: boolean;
|
||||
hasNativeTitleBar: boolean;
|
||||
hasBackgroundEffects: boolean;
|
||||
currentLocale: {
|
||||
id: string;
|
||||
rtl: boolean;
|
||||
};
|
||||
activeDialog: any;
|
||||
};
|
||||
global: typeof globalThis;
|
||||
}
|
||||
26
apps/client-standalone/tsconfig.app.json
Normal file
26
apps/client-standalone/tsconfig.app.json
Normal file
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"lib": [
|
||||
"ES2022",
|
||||
"dom",
|
||||
"dom.iterable"
|
||||
],
|
||||
"skipLibCheck": true,
|
||||
"types": [
|
||||
"vite/client"
|
||||
],
|
||||
"jsx": "react-jsx",
|
||||
"jsxImportSource": "preact"
|
||||
},
|
||||
"include": [
|
||||
"src/**/*",
|
||||
"../client/src/**/*"
|
||||
],
|
||||
"exclude": [
|
||||
"src/**/*.spec.ts",
|
||||
"src/**/*.test.ts",
|
||||
"../client/src/**/*.spec.ts",
|
||||
"../client/src/**/*.test.ts"
|
||||
]
|
||||
}
|
||||
7
apps/client-standalone/tsconfig.json
Normal file
7
apps/client-standalone/tsconfig.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.spec.json" }
|
||||
]
|
||||
}
|
||||
18
apps/client-standalone/tsconfig.spec.json
Normal file
18
apps/client-standalone/tsconfig.spec.json
Normal file
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"lib": [
|
||||
"ES2022",
|
||||
"dom",
|
||||
"dom.iterable"
|
||||
],
|
||||
"types": [
|
||||
"vitest/globals",
|
||||
"happy-dom"
|
||||
]
|
||||
},
|
||||
"include": [
|
||||
"src/**/*.spec.ts",
|
||||
"src/**/*.test.ts"
|
||||
]
|
||||
}
|
||||
188
apps/client-standalone/vite.config.mts
Normal file
188
apps/client-standalone/vite.config.mts
Normal file
@@ -0,0 +1,188 @@
|
||||
import prefresh from '@prefresh/vite';
|
||||
import { join } from 'path';
|
||||
import { defineConfig } from 'vite';
|
||||
import { viteStaticCopy } from 'vite-plugin-static-copy';
|
||||
|
||||
const clientAssets = ["assets", "stylesheets", "fonts", "translations"];
|
||||
|
||||
const isDev = process.env.NODE_ENV === "development";
|
||||
|
||||
// Watch client files and trigger reload in development
|
||||
const clientWatchPlugin = () => ({
|
||||
name: 'client-watch',
|
||||
configureServer(server: any) {
|
||||
if (isDev) {
|
||||
// Watch client source files (adjusted for new root)
|
||||
server.watcher.add('../../client/src/**/*');
|
||||
server.watcher.on('change', (file: string) => {
|
||||
if (file.includes('../../client/src/')) {
|
||||
server.ws.send({
|
||||
type: 'full-reload'
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Always copy SQLite WASM files so they're available to the module
|
||||
const sqliteWasmPlugin = viteStaticCopy({
|
||||
targets: [
|
||||
{
|
||||
src: "../../../node_modules/@sqlite.org/sqlite-wasm/sqlite-wasm/jswasm/sqlite3.wasm",
|
||||
dest: "assets"
|
||||
},
|
||||
{
|
||||
src: "../../../node_modules/@sqlite.org/sqlite-wasm/sqlite-wasm/jswasm/sqlite3-opfs-async-proxy.js",
|
||||
dest: "assets"
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
let plugins: any = [
|
||||
sqliteWasmPlugin, // Always include SQLite WASM files
|
||||
viteStaticCopy({
|
||||
targets: clientAssets.map((asset) => ({
|
||||
src: `../../client/src/${asset}/*`,
|
||||
dest: asset
|
||||
})),
|
||||
// Enable watching in development
|
||||
...(isDev && {
|
||||
watch: {
|
||||
reloadPageOnChange: true
|
||||
}
|
||||
})
|
||||
}),
|
||||
viteStaticCopy({
|
||||
targets: [
|
||||
{
|
||||
src: "../../server/src/assets/*",
|
||||
dest: "server-assets"
|
||||
}
|
||||
]
|
||||
}),
|
||||
// Watch client files for changes in development
|
||||
...(isDev ? [
|
||||
prefresh(),
|
||||
clientWatchPlugin()
|
||||
] : [])
|
||||
];
|
||||
|
||||
if (!isDev) {
|
||||
plugins = [
|
||||
...plugins,
|
||||
viteStaticCopy({
|
||||
structured: true,
|
||||
targets: [
|
||||
{
|
||||
src: "../../../node_modules/@excalidraw/excalidraw/dist/prod/fonts/*",
|
||||
dest: "",
|
||||
}
|
||||
]
|
||||
})
|
||||
]
|
||||
}
|
||||
|
||||
export default defineConfig(() => ({
|
||||
root: join(__dirname, 'src'), // Set src as root so index.html is served from /
|
||||
envDir: __dirname, // Load .env files from client-standalone directory, not src/
|
||||
cacheDir: '../../../node_modules/.vite/apps/client-standalone',
|
||||
base: "",
|
||||
plugins,
|
||||
esbuild: {
|
||||
jsx: 'automatic',
|
||||
jsxImportSource: 'preact',
|
||||
jsxDev: isDev
|
||||
},
|
||||
css: {
|
||||
transformer: 'lightningcss',
|
||||
devSourcemap: isDev
|
||||
},
|
||||
publicDir: join(__dirname, 'public'),
|
||||
resolve: {
|
||||
alias: [
|
||||
{
|
||||
find: "react",
|
||||
replacement: "preact/compat"
|
||||
},
|
||||
{
|
||||
find: "react-dom",
|
||||
replacement: "preact/compat"
|
||||
},
|
||||
{
|
||||
find: "@client",
|
||||
replacement: join(__dirname, "../client/src")
|
||||
}
|
||||
],
|
||||
dedupe: [
|
||||
"react",
|
||||
"react-dom",
|
||||
"preact",
|
||||
"preact/compat",
|
||||
"preact/hooks"
|
||||
]
|
||||
},
|
||||
server: {
|
||||
watch: {
|
||||
// Watch workspace packages
|
||||
ignored: ['!**/node_modules/@triliumnext/**'],
|
||||
// Also watch client assets for live reload
|
||||
usePolling: false,
|
||||
interval: 100,
|
||||
binaryInterval: 300
|
||||
},
|
||||
// Watch additional directories for changes
|
||||
fs: {
|
||||
allow: [
|
||||
// Allow access to workspace root
|
||||
'../../../',
|
||||
// Explicitly allow client directory
|
||||
'../../client/src/'
|
||||
]
|
||||
},
|
||||
headers: {
|
||||
// Required for SharedArrayBuffer which is needed by SQLite WASM OPFS VFS
|
||||
// See: https://sqlite.org/wasm/doc/trunk/persistence.md#coop-coep
|
||||
"Cross-Origin-Opener-Policy": "same-origin",
|
||||
"Cross-Origin-Embedder-Policy": "require-corp"
|
||||
}
|
||||
},
|
||||
optimizeDeps: {
|
||||
exclude: ['@sqlite.org/sqlite-wasm', '@triliumnext/core']
|
||||
},
|
||||
worker: {
|
||||
format: "es" as const
|
||||
},
|
||||
commonjsOptions: {
|
||||
transformMixedEsModules: true,
|
||||
},
|
||||
build: {
|
||||
target: "esnext",
|
||||
outDir: join(__dirname, 'dist'),
|
||||
emptyOutDir: true,
|
||||
rollupOptions: {
|
||||
input: {
|
||||
main: join(__dirname, 'src', 'index.html'),
|
||||
sw: join(__dirname, 'src', 'sw.ts'),
|
||||
'local-bridge': join(__dirname, 'src', 'local-bridge.ts'),
|
||||
},
|
||||
output: {
|
||||
entryFileNames: (chunkInfo) => {
|
||||
// Service worker and other workers should be at root level
|
||||
if (chunkInfo.name === 'sw') {
|
||||
return '[name].js';
|
||||
}
|
||||
return 'src/[name].js';
|
||||
},
|
||||
chunkFileNames: "src/[name].js",
|
||||
assetFileNames: "src/[name].[ext]"
|
||||
}
|
||||
}
|
||||
},
|
||||
test: {
|
||||
environment: "happy-dom"
|
||||
},
|
||||
define: {
|
||||
"process.env.IS_PREACT": JSON.stringify("true"),
|
||||
}
|
||||
}));
|
||||
@@ -91,4 +91,4 @@
|
||||
"script-loader": "0.7.2",
|
||||
"vite-plugin-static-copy": "3.3.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
28
apps/client/src/assets/icon-color.svg
Normal file
28
apps/client/src/assets/icon-color.svg
Normal file
@@ -0,0 +1,28 @@
|
||||
<?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>
|
||||
|
After Width: | Height: | Size: 2.5 KiB |
@@ -36,10 +36,37 @@ async function setupGlob() {
|
||||
window.global = globalThis; /* fixes https://github.com/webpack/webpack/issues/10035 */
|
||||
window.glob = {
|
||||
...json,
|
||||
activeDialog: null
|
||||
activeDialog: null,
|
||||
device: json.device || getDevice()
|
||||
};
|
||||
}
|
||||
|
||||
function getDevice() {
|
||||
// Respect user's manual override via URL.
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
if (urlParams.has("print")) {
|
||||
return "print";
|
||||
} else if (urlParams.has("desktop")) {
|
||||
return "desktop";
|
||||
} else if (urlParams.has("mobile")) {
|
||||
return "mobile";
|
||||
}
|
||||
|
||||
const deviceCookie = document.cookie.split("; ").find(row => row.startsWith("trilium-device="))?.split("=")[1];
|
||||
if (deviceCookie === "desktop" || deviceCookie === "mobile") return deviceCookie;
|
||||
return isMobile() ? "mobile" : "desktop";
|
||||
}
|
||||
|
||||
// https://stackoverflow.com/a/73731646/944162
|
||||
function isMobile() {
|
||||
const mQ = matchMedia?.("(pointer:coarse)");
|
||||
if (mQ?.media === "(pointer:coarse)") return !!mQ.matches;
|
||||
|
||||
if ("orientation" in window) return true;
|
||||
const userAgentsRegEx = /\b(Android|iPhone|iPad|iPod|Windows Phone|BlackBerry|webOS|IEMobile)\b/i;
|
||||
return userAgentsRegEx.test(navigator.userAgent);
|
||||
}
|
||||
|
||||
async function loadBootstrapCss() {
|
||||
// We have to selectively import Bootstrap CSS based on text direction.
|
||||
if (glob.isRtl) {
|
||||
@@ -85,6 +112,8 @@ function loadIcons() {
|
||||
}
|
||||
|
||||
function setBodyAttributes() {
|
||||
if (!glob.dbInitialized) return;
|
||||
|
||||
const { device, headingStyle, layoutOrientation, platform, isElectron, hasNativeTitleBar, hasBackgroundEffects, currentLocale } = window.glob;
|
||||
const classesToSet = [
|
||||
device,
|
||||
@@ -105,6 +134,11 @@ function setBodyAttributes() {
|
||||
}
|
||||
|
||||
async function loadScripts() {
|
||||
if (!glob.dbInitialized) {
|
||||
await import("./setup.js");
|
||||
return;
|
||||
}
|
||||
|
||||
switch (glob.device) {
|
||||
case "mobile":
|
||||
await import("./mobile.js");
|
||||
|
||||
@@ -30,6 +30,7 @@ import SpacerWidget from "../widgets/launch_bar/SpacerWidget.jsx";
|
||||
import InlineTitle from "../widgets/layout/InlineTitle.jsx";
|
||||
import NoteBadges from "../widgets/layout/NoteBadges.jsx";
|
||||
import NoteTitleActions from "../widgets/layout/NoteTitleActions.jsx";
|
||||
import StandaloneWarningBar from "../widgets/layout/StandaloneWarningBar.jsx";
|
||||
import StatusBar from "../widgets/layout/StatusBar.jsx";
|
||||
import NoteIconWidget from "../widgets/note_icon.jsx";
|
||||
import NoteTitleWidget from "../widgets/note_title.jsx";
|
||||
@@ -186,6 +187,7 @@ export default class DesktopLayout {
|
||||
)
|
||||
)
|
||||
.optChild(launcherPaneIsHorizontal && isNewLayout, <StatusBar />)
|
||||
.optChild(glob.isStandalone, <StandaloneWarningBar />)
|
||||
.child(<CloseZenModeButton />)
|
||||
|
||||
// Desktop-specific dialogs.
|
||||
|
||||
@@ -13,6 +13,7 @@ import LauncherContainer from "../widgets/launch_bar/LauncherContainer.jsx";
|
||||
import InlineTitle from "../widgets/layout/InlineTitle.jsx";
|
||||
import NoteBadges from "../widgets/layout/NoteBadges.jsx";
|
||||
import NoteTitleActions from "../widgets/layout/NoteTitleActions.jsx";
|
||||
import StandaloneWarningBar from "../widgets/layout/StandaloneWarningBar";
|
||||
import MobileDetailMenu from "../widgets/mobile_widgets/mobile_detail_menu.js";
|
||||
import ScreenContainer from "../widgets/mobile_widgets/screen_container.js";
|
||||
import SidebarContainer from "../widgets/mobile_widgets/sidebar_container.js";
|
||||
@@ -55,6 +56,7 @@ export default class MobileLayout {
|
||||
.child(
|
||||
new SplitNoteContainer(() =>
|
||||
new NoteWrapperWidget()
|
||||
.optChild(glob.isStandalone, <StandaloneWarningBar />)
|
||||
.child(
|
||||
new FlexContainer("row")
|
||||
.class("title-row note-split-title")
|
||||
|
||||
@@ -5,7 +5,7 @@ import { formatCodeBlocks } from "./syntax_highlight.js";
|
||||
|
||||
export default function renderDoc(note: FNote) {
|
||||
return new Promise<JQuery<HTMLElement>>((resolve) => {
|
||||
let docName = note.getLabelValue("docName");
|
||||
const docName = note.getLabelValue("docName");
|
||||
const $content = $("<div>");
|
||||
|
||||
if (docName) {
|
||||
@@ -16,7 +16,7 @@ export default function renderDoc(note: FNote) {
|
||||
if (status === "error") {
|
||||
const fallbackUrl = getUrl(docName, "en");
|
||||
$content.load(fallbackUrl, async () => {
|
||||
await processContent(fallbackUrl, $content)
|
||||
await processContent(fallbackUrl, $content);
|
||||
resolve($content);
|
||||
});
|
||||
return;
|
||||
@@ -37,9 +37,9 @@ async function processContent(url: string, $content: JQuery<HTMLElement>) {
|
||||
const dir = url.substring(0, url.lastIndexOf("/"));
|
||||
|
||||
// Images are relative to the docnote but that will not work when rendered in the application since the path breaks.
|
||||
$content.find("img").each((i, el) => {
|
||||
$content.find("img").each((_i, el) => {
|
||||
const $img = $(el);
|
||||
$img.attr("src", dir + "/" + $img.attr("src"));
|
||||
$img.attr("src", `${dir}/${$img.attr("src")}`);
|
||||
});
|
||||
|
||||
formatCodeBlocks($content);
|
||||
@@ -51,7 +51,17 @@ async function processContent(url: string, $content: JQuery<HTMLElement>) {
|
||||
function getUrl(docNameValue: string, language: string) {
|
||||
// Cannot have spaces in the URL due to how JQuery.load works.
|
||||
docNameValue = docNameValue.replaceAll(" ", "%20");
|
||||
|
||||
const basePath = window.glob.isDev ? window.glob.assetPath + "/.." : window.glob.assetPath;
|
||||
return `${basePath}/doc_notes/${language}/${docNameValue}.html`;
|
||||
// The user guide is available only in English, so make sure we are requesting correctly since 404s in standalone client are treated differently.
|
||||
if (docNameValue.includes("User%20Guide")) language = "en";
|
||||
return `${getBasePath()}/doc_notes/${language}/${docNameValue}.html`;
|
||||
}
|
||||
|
||||
function getBasePath() {
|
||||
if (window.glob.isStandalone) {
|
||||
return `server-assets`;
|
||||
}
|
||||
if (window.glob.isDev) {
|
||||
return `${window.glob.assetPath }/..`;
|
||||
}
|
||||
return window.glob.assetPath;
|
||||
}
|
||||
|
||||
@@ -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 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";
|
||||
import server from "./server.js";
|
||||
|
||||
interface SubtreeResponse {
|
||||
notes: FNoteRow[];
|
||||
@@ -44,8 +44,9 @@ class FrocaImpl implements Froca {
|
||||
}
|
||||
|
||||
async loadInitialTree() {
|
||||
const resp = await server.get<SubtreeResponse>("tree");
|
||||
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);
|
||||
@@ -77,7 +78,7 @@ class FrocaImpl implements Froca {
|
||||
for (const noteRow of noteRows) {
|
||||
const { noteId } = noteRow;
|
||||
|
||||
let note = this.notes[noteId];
|
||||
const note = this.notes[noteId];
|
||||
|
||||
if (note) {
|
||||
note.update(noteRow);
|
||||
@@ -240,9 +241,8 @@ 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,9 +263,8 @@ 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[];
|
||||
}
|
||||
@@ -338,11 +337,10 @@ 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);
|
||||
|
||||
@@ -206,7 +206,7 @@ export interface Api {
|
||||
* Instance name identifies particular Trilium instance. It can be useful for scripts
|
||||
* if some action needs to happen on only one specific instance.
|
||||
*/
|
||||
getInstanceName(): string;
|
||||
getInstanceName(): string | null;
|
||||
|
||||
/**
|
||||
* @returns date in YYYY-MM-DD format
|
||||
|
||||
@@ -1,16 +1,17 @@
|
||||
import options from "./options.js";
|
||||
import { type Locale, LOCALE_IDS, setDayjsLocale } from "@triliumnext/commons";
|
||||
import i18next from "i18next";
|
||||
import i18nextHttpBackend from "i18next-http-backend";
|
||||
import server from "./server.js";
|
||||
import { LOCALE_IDS, setDayjsLocale, type Locale } from "@triliumnext/commons";
|
||||
import { initReactI18next } from "react-i18next";
|
||||
|
||||
import options from "./options.js";
|
||||
import server from "./server.js";
|
||||
|
||||
let locales: Locale[] | null;
|
||||
|
||||
/**
|
||||
* A deferred promise that resolves when translations are initialized.
|
||||
*/
|
||||
export let translationsInitializedPromise = $.Deferred();
|
||||
export const translationsInitializedPromise = $.Deferred();
|
||||
|
||||
export async function initLocale() {
|
||||
const locale = ((options.get("locale") as string) || "en") as LOCALE_IDS;
|
||||
@@ -34,7 +35,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,14 +1,4 @@
|
||||
export type LabelType = "text" | "textarea" | "number" | "boolean" | "date" | "datetime" | "time" | "url" | "color";
|
||||
type Multiplicity = "single" | "multi";
|
||||
|
||||
export interface DefinitionObject {
|
||||
isPromoted?: boolean;
|
||||
labelType?: LabelType;
|
||||
multiplicity?: Multiplicity;
|
||||
numberPrecision?: number;
|
||||
promotedAlias?: string;
|
||||
inverseRelation?: string;
|
||||
}
|
||||
import { DefinitionObject, LabelType, Multiplicity } from "@triliumnext/commons";
|
||||
|
||||
function parse(value: string) {
|
||||
const tokens = value.split(",").map((t) => t.trim());
|
||||
|
||||
@@ -135,6 +135,8 @@ export function isElectron() {
|
||||
return !!(window && window.process && window.process.type);
|
||||
}
|
||||
|
||||
export const isStandalone = window.glob.isStandalone;
|
||||
|
||||
/**
|
||||
* Returns `true` if the client is running as a PWA, otherwise `false`.
|
||||
*/
|
||||
@@ -816,7 +818,7 @@ function compareVersions(v1: string, v2: string): number {
|
||||
/**
|
||||
* Compares two semantic version strings and returns `true` if the latest version is greater than the current version.
|
||||
*/
|
||||
function isUpdateAvailable(latestVersion: string | null | undefined, currentVersion: string): boolean {
|
||||
export function isUpdateAvailable(latestVersion: string | null | undefined, currentVersion: string): boolean {
|
||||
if (!latestVersion) {
|
||||
return false;
|
||||
}
|
||||
@@ -903,6 +905,10 @@ 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,13 +1,14 @@
|
||||
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 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";
|
||||
|
||||
type MessageHandler = (message: WebSocketMessage) => void;
|
||||
let messageHandlers: MessageHandler[] = [];
|
||||
@@ -57,6 +58,49 @@ 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;
|
||||
|
||||
@@ -112,38 +156,13 @@ async function executeFrontendUpdate(entityChanges: EntityChange[]) {
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
/**
|
||||
* 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);
|
||||
}
|
||||
|
||||
let entityChangeIdReachedListeners: {
|
||||
@@ -161,7 +180,7 @@ function waitForEntityChangeId(desiredEntityChangeId: number) {
|
||||
|
||||
return new Promise<void>((res, rej) => {
|
||||
entityChangeIdReachedListeners.push({
|
||||
desiredEntityChangeId: desiredEntityChangeId,
|
||||
desiredEntityChangeId,
|
||||
resolvePromise: res,
|
||||
start: Date.now()
|
||||
});
|
||||
@@ -228,13 +247,18 @@ 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 = handleMessage;
|
||||
ws.onmessage = handleWebSocketMessage;
|
||||
// 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({
|
||||
@@ -262,7 +286,18 @@ 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();
|
||||
|
||||
420
apps/client/src/setup.css
Normal file
420
apps/client/src/setup.css
Normal file
@@ -0,0 +1,420 @@
|
||||
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; }
|
||||
}
|
||||
@@ -1,204 +0,0 @@
|
||||
import "jquery";
|
||||
|
||||
import utils from "./services/utils.js";
|
||||
|
||||
type SetupStep = "sync-in-progress" | "setup-type" | "new-document-in-progress" | "sync-from-desktop" | "sync-from-server";
|
||||
type SetupType = "new-document" | "sync-from-desktop" | "sync-from-server" | "";
|
||||
|
||||
class SetupController {
|
||||
private step: SetupStep;
|
||||
private setupType: SetupType = "";
|
||||
private syncPollIntervalId: number | null = null;
|
||||
private rootNode: HTMLElement;
|
||||
private setupTypeForm: HTMLFormElement;
|
||||
private syncFromServerForm: HTMLFormElement;
|
||||
private setupTypeNextButton: HTMLButtonElement;
|
||||
private setupTypeInputs: HTMLInputElement[];
|
||||
private syncServerHostInput: HTMLInputElement;
|
||||
private syncProxyInput: HTMLInputElement;
|
||||
private passwordInput: HTMLInputElement;
|
||||
private sections: Record<SetupStep, HTMLElement>;
|
||||
|
||||
constructor(rootNode: HTMLElement, syncInProgress: boolean) {
|
||||
this.rootNode = rootNode;
|
||||
this.step = syncInProgress ? "sync-in-progress" : "setup-type";
|
||||
this.setupTypeForm = mustGetElement("setup-type-form", HTMLFormElement);
|
||||
this.syncFromServerForm = mustGetElement("sync-from-server-form", HTMLFormElement);
|
||||
this.setupTypeNextButton = mustGetElement("setup-type-next", HTMLButtonElement);
|
||||
this.setupTypeInputs = Array.from(document.querySelectorAll<HTMLInputElement>("input[name='setup-type']"));
|
||||
this.syncServerHostInput = mustGetElement("sync-server-host", HTMLInputElement);
|
||||
this.syncProxyInput = mustGetElement("sync-proxy", HTMLInputElement);
|
||||
this.passwordInput = mustGetElement("password", HTMLInputElement);
|
||||
this.sections = {
|
||||
"setup-type": mustGetElement("setup-type-section", HTMLElement),
|
||||
"new-document-in-progress": mustGetElement("new-document-in-progress-section", HTMLElement),
|
||||
"sync-from-desktop": mustGetElement("sync-from-desktop-section", HTMLElement),
|
||||
"sync-from-server": mustGetElement("sync-from-server-section", HTMLElement),
|
||||
"sync-in-progress": mustGetElement("sync-in-progress-section", HTMLElement)
|
||||
};
|
||||
}
|
||||
|
||||
init() {
|
||||
this.setupTypeForm.addEventListener("submit", (event) => {
|
||||
event.preventDefault();
|
||||
void this.selectSetupType();
|
||||
});
|
||||
|
||||
this.syncFromServerForm.addEventListener("submit", (event) => {
|
||||
event.preventDefault();
|
||||
void this.finish();
|
||||
});
|
||||
|
||||
for (const input of this.setupTypeInputs) {
|
||||
input.addEventListener("change", () => {
|
||||
this.setupType = input.value as SetupType;
|
||||
this.render();
|
||||
});
|
||||
}
|
||||
|
||||
for (const backButton of document.querySelectorAll<HTMLElement>("[data-action='back']")) {
|
||||
backButton.addEventListener("click", () => {
|
||||
this.back();
|
||||
});
|
||||
}
|
||||
|
||||
const serverAddress = `${location.protocol}//${location.host}`;
|
||||
$("#current-host").html(serverAddress);
|
||||
|
||||
if (this.step === "sync-in-progress") {
|
||||
this.startSyncPolling();
|
||||
}
|
||||
|
||||
this.render();
|
||||
this.rootNode.style.display = "";
|
||||
}
|
||||
|
||||
private async selectSetupType() {
|
||||
if (this.setupType === "new-document") {
|
||||
this.setStep("new-document-in-progress");
|
||||
|
||||
await $.post("api/setup/new-document");
|
||||
window.location.replace("./setup");
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.setupType) {
|
||||
this.setStep(this.setupType);
|
||||
}
|
||||
}
|
||||
|
||||
private back() {
|
||||
this.setStep("setup-type");
|
||||
this.setupType = "";
|
||||
|
||||
for (const input of this.setupTypeInputs) {
|
||||
input.checked = false;
|
||||
}
|
||||
|
||||
this.render();
|
||||
}
|
||||
|
||||
private async finish() {
|
||||
const syncServerHost = this.syncServerHostInput.value.trim();
|
||||
const syncProxy = this.syncProxyInput.value.trim();
|
||||
const password = this.passwordInput.value;
|
||||
|
||||
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,
|
||||
syncProxy,
|
||||
password
|
||||
});
|
||||
|
||||
if (resp.result === "success") {
|
||||
hideAlert();
|
||||
this.setStep("sync-in-progress");
|
||||
this.startSyncPolling();
|
||||
} else {
|
||||
showAlert(`Sync setup failed: ${resp.error}`);
|
||||
}
|
||||
}
|
||||
|
||||
private setStep(step: SetupStep) {
|
||||
this.step = step;
|
||||
this.render();
|
||||
}
|
||||
|
||||
private render() {
|
||||
for (const [step, section] of Object.entries(this.sections) as [SetupStep, HTMLElement][]) {
|
||||
section.style.display = step === this.step ? "" : "none";
|
||||
}
|
||||
|
||||
this.setupTypeNextButton.disabled = !this.setupType;
|
||||
}
|
||||
|
||||
private getSelectedSetupType(): SetupType {
|
||||
return (this.setupTypeInputs.find((input) => input.checked)?.value ?? "") as SetupType;
|
||||
}
|
||||
|
||||
private startSyncPolling() {
|
||||
if (this.syncPollIntervalId !== null) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.syncPollIntervalId = window.setInterval(checkOutstandingSyncs, 1000);
|
||||
}
|
||||
}
|
||||
|
||||
async function checkOutstandingSyncs() {
|
||||
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);
|
||||
}
|
||||
|
||||
function mustGetElement<T extends typeof HTMLElement>(id: string, ctor: T): InstanceType<T> {
|
||||
const element = document.getElementById(id);
|
||||
|
||||
if (!element || !(element instanceof ctor)) {
|
||||
throw new Error(`Expected element #${id}`);
|
||||
}
|
||||
|
||||
return element as InstanceType<T>;
|
||||
}
|
||||
|
||||
addEventListener("DOMContentLoaded", (event) => {
|
||||
const rootNode = document.getElementById("setup-dialog");
|
||||
if (!rootNode || !(rootNode instanceof HTMLElement)) return;
|
||||
|
||||
new SetupController(rootNode, getSyncInProgress()).init();
|
||||
});
|
||||
524
apps/client/src/setup.tsx
Normal file
524
apps/client/src/setup.tsx
Normal file
@@ -0,0 +1,524 @@
|
||||
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();
|
||||
@@ -2230,5 +2230,56 @@
|
||||
"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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2137,5 +2137,55 @@
|
||||
"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ă"
|
||||
}
|
||||
}
|
||||
|
||||
30
apps/client/src/types.d.ts
vendored
30
apps/client/src/types.d.ts
vendored
@@ -1,4 +1,4 @@
|
||||
import { IconRegistry, Locale } from "@triliumnext/commons";
|
||||
import { BootstrapDefinition } from "@triliumnext/commons";
|
||||
|
||||
import appContext, { AppContext } from "./components/app_context";
|
||||
import type FNote from "./entities/fnote";
|
||||
@@ -15,10 +15,9 @@ interface ElectronProcess {
|
||||
platform: string;
|
||||
}
|
||||
|
||||
interface CustomGlobals {
|
||||
interface CustomGlobals extends BootstrapDefinition {
|
||||
isDesktop: typeof utils.isDesktop;
|
||||
isMobile: typeof utils.isMobile;
|
||||
device: "mobile" | "desktop" | "print";
|
||||
getComponentByEl: typeof appContext.getComponentByEl;
|
||||
getHeaders: typeof server.getHeaders;
|
||||
getReferenceLinkTitle: (href: string) => Promise<string>;
|
||||
@@ -31,32 +30,7 @@ interface CustomGlobals {
|
||||
SEARCH_HELP_TEXT: string;
|
||||
activeDialog: JQuery<HTMLElement> | null;
|
||||
componentId: string;
|
||||
csrfToken: string;
|
||||
baseApiUrl: string;
|
||||
isProtectedSessionAvailable: boolean;
|
||||
isDev: boolean;
|
||||
isMainWindow: boolean;
|
||||
maxEntityChangeIdAtLoad: number;
|
||||
maxEntityChangeSyncIdAtLoad: number;
|
||||
assetPath: string;
|
||||
appPath: string;
|
||||
instanceName: string;
|
||||
appCssNoteIds: string[];
|
||||
triliumVersion: string;
|
||||
TRILIUM_SAFE_MODE: boolean;
|
||||
platform?: typeof process.platform;
|
||||
linter: typeof lint;
|
||||
hasNativeTitleBar: boolean;
|
||||
hasBackgroundEffects: boolean;
|
||||
isElectron: boolean;
|
||||
isRtl: boolean;
|
||||
iconRegistry: IconRegistry;
|
||||
themeCssUrl: string;
|
||||
themeUseNextAsBase?: "next" | "next-light" | "next-dark";
|
||||
iconPackCss: string;
|
||||
headingStyle: "plain" | "underline" | "markdown";
|
||||
layoutOrientation: "vertical" | "horizontal";
|
||||
currentLocale: Locale;
|
||||
}
|
||||
|
||||
type RequireMethod = (moduleName: string) => any;
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
import { useState } from "preact/hooks";
|
||||
import FNote from "../../entities/fnote";
|
||||
import "./UserAttributesList.css";
|
||||
import { useTriliumEvent } from "../react/hooks";
|
||||
import attributes from "../../services/attributes";
|
||||
import { DefinitionObject } from "../../services/promoted_attribute_definition_parser";
|
||||
import { formatDateTime } from "../../utils/formatters";
|
||||
|
||||
import type { DefinitionObject } from "@triliumnext/commons";
|
||||
import { ComponentChildren, CSSProperties } from "preact";
|
||||
import { useState } from "preact/hooks";
|
||||
|
||||
import FNote from "../../entities/fnote";
|
||||
import attributes from "../../services/attributes";
|
||||
import { getReadableTextColor } from "../../services/css_class_manager";
|
||||
import { formatDateTime } from "../../utils/formatters";
|
||||
import { useTriliumEvent } from "../react/hooks";
|
||||
import Icon from "../react/Icon";
|
||||
import NoteLink from "../react/NoteLink";
|
||||
import { getReadableTextColor } from "../../services/css_class_manager";
|
||||
|
||||
interface UserAttributesListProps {
|
||||
note: FNote;
|
||||
@@ -29,7 +31,7 @@ export default function UserAttributesDisplay({ note, ignoredAttributes }: UserA
|
||||
<div className="user-attributes">
|
||||
{userAttributes?.map(attr => buildUserAttribute(attr))}
|
||||
</div>
|
||||
)
|
||||
);
|
||||
|
||||
}
|
||||
|
||||
@@ -46,13 +48,13 @@ function useNoteAttributesWithDefinitions(note: FNote, attributesToIgnore: stri
|
||||
}
|
||||
|
||||
function UserAttribute({ attr, children, style }: { attr: AttributeWithDefinitions, children: ComponentChildren, style?: CSSProperties }) {
|
||||
const className = `${attr.type === "label" ? "label" + " " + attr.def.labelType : "relation"}`;
|
||||
const className = attr.type === "label" ? `label ${attr.def.labelType}` : "relation";
|
||||
|
||||
return (
|
||||
<span key={attr.friendlyName} className={`user-attribute type-${className}`} style={style}>
|
||||
{children}
|
||||
</span>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function buildUserAttribute(attr: AttributeWithDefinitions): ComponentChildren {
|
||||
@@ -61,7 +63,7 @@ function buildUserAttribute(attr: AttributeWithDefinitions): ComponentChildren {
|
||||
let style: CSSProperties | undefined;
|
||||
|
||||
if (attr.type === "label") {
|
||||
let value = attr.value;
|
||||
const value = attr.value;
|
||||
switch (attr.def.labelType) {
|
||||
case "number":
|
||||
let formattedValue = value;
|
||||
@@ -102,7 +104,7 @@ function buildUserAttribute(attr: AttributeWithDefinitions): ComponentChildren {
|
||||
content = <>{defaultLabel}<NoteLink notePath={attr.value} showNoteIcon /></>;
|
||||
}
|
||||
|
||||
return <UserAttribute attr={attr} style={style}>{content}</UserAttribute>
|
||||
return <UserAttribute attr={attr} style={style}>{content}</UserAttribute>;
|
||||
}
|
||||
|
||||
function getAttributesWithDefinitions(note: FNote, attributesToIgnore: string[] = []): AttributeWithDefinitions[] {
|
||||
|
||||
@@ -8,7 +8,7 @@ import { CommandNames } from "../../components/app_context";
|
||||
import Component from "../../components/component";
|
||||
import { ExperimentalFeature, ExperimentalFeatureId, experimentalFeatures, isExperimentalFeatureEnabled, toggleExperimentalFeature } from "../../services/experimental_features";
|
||||
import { t } from "../../services/i18n";
|
||||
import utils, { dynamicRequire, isElectron, isMobile, reloadFrontendApp } from "../../services/utils";
|
||||
import utils, { dynamicRequire, isElectron, isMobile, isStandalone, reloadFrontendApp } from "../../services/utils";
|
||||
import Dropdown from "../react/Dropdown";
|
||||
import { FormDropdownDivider, FormDropdownSubmenu, FormListHeader, FormListItem } from "../react/FormList";
|
||||
import { useStaticTooltip, useStaticTooltipWithKeyboardShortcut, useTriliumOption, useTriliumOptionBool, useTriliumOptionInt } from "../react/hooks";
|
||||
@@ -251,7 +251,7 @@ function ToggleWindowOnTop() {
|
||||
function useTriliumUpdateStatus() {
|
||||
const [ latestVersion, setLatestVersion ] = useState<string>();
|
||||
const [ checkForUpdates ] = useTriliumOptionBool("checkForUpdates");
|
||||
const isUpdateAvailable = utils.isUpdateAvailable(latestVersion, glob.triliumVersion);
|
||||
const isUpdateAvailable = utils.isUpdateAvailable(latestVersion, window.glob.triliumVersion);
|
||||
|
||||
async function updateVersionStatus() {
|
||||
const RELEASES_API_URL = "https://api.github.com/repos/TriliumNext/Trilium/releases/latest";
|
||||
@@ -269,7 +269,7 @@ function useTriliumUpdateStatus() {
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!checkForUpdates) {
|
||||
if (!checkForUpdates || !isStandalone) {
|
||||
setLatestVersion(undefined);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import type { CellComponent, ColumnDefinition, EmptyCallback, FormatterParams, ValueBooleanCallback, ValueVoidCallback } from "tabulator-tables";
|
||||
import { LabelType } from "../../../services/promoted_attribute_definition_parser.js";
|
||||
import { LabelType } from "@triliumnext/commons";
|
||||
import { JSX } from "preact";
|
||||
import { renderReactWidget } from "../../react/react_utils.jsx";
|
||||
import Icon from "../../react/Icon.jsx";
|
||||
import { useEffect, useRef, useState } from "preact/hooks";
|
||||
import type { CellComponent, ColumnDefinition, EmptyCallback, FormatterParams, ValueBooleanCallback, ValueVoidCallback } from "tabulator-tables";
|
||||
|
||||
import froca from "../../../services/froca.js";
|
||||
import Icon from "../../react/Icon.jsx";
|
||||
import NoteAutocomplete from "../../react/NoteAutocomplete.jsx";
|
||||
import { renderReactWidget } from "../../react/react_utils.jsx";
|
||||
|
||||
type ColumnType = LabelType | "relation";
|
||||
|
||||
@@ -85,7 +86,7 @@ export function buildColumnDefinitions({ info, movableRows, existingColumnData,
|
||||
rowHandle: movableRows,
|
||||
width: calculateIndexColumnWidth(rowNumberHint, movableRows),
|
||||
formatter: wrapFormatter(({ cell, formatterParams }) => <div>
|
||||
{(formatterParams as RowNumberFormatterParams).movableRows && <><span class="bx bx-dots-vertical-rounded"></span>{" "}</>}
|
||||
{(formatterParams as RowNumberFormatterParams).movableRows && <><span class="bx bx-dots-vertical-rounded" />{" "}</>}
|
||||
{cell.getRow().getPosition(true)}
|
||||
</div>),
|
||||
formatterParams: { movableRows } satisfies RowNumberFormatterParams
|
||||
@@ -207,14 +208,14 @@ function wrapEditor(Component: (opts: EditorOpts) => JSX.Element): ((
|
||||
editorParams: {},
|
||||
) => HTMLElement | false) {
|
||||
return (cell, _, success, cancel, editorParams) => {
|
||||
const elWithParams = <Component cell={cell} success={success} cancel={cancel} editorParams={editorParams} />
|
||||
const elWithParams = <Component cell={cell} success={success} cancel={cancel} editorParams={editorParams} />;
|
||||
return renderReactWidget(null, elWithParams)[0];
|
||||
};
|
||||
}
|
||||
|
||||
function NoteFormatter({ cell }: FormatterOpts) {
|
||||
const noteId = cell.getValue();
|
||||
const [ note, setNote ] = useState(noteId ? froca.getNoteFromCache(noteId) : null)
|
||||
const [ note, setNote ] = useState(noteId ? froca.getNoteFromCache(noteId) : null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!noteId || note?.noteId === noteId) return;
|
||||
@@ -238,5 +239,5 @@ function RelationEditor({ cell, success }: EditorOpts) {
|
||||
hideAllButtons: true
|
||||
}}
|
||||
noteIdChanged={success}
|
||||
/>
|
||||
/>;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { LabelType } from "@triliumnext/commons";
|
||||
|
||||
import FNote from "../../../entities/fnote.js";
|
||||
import { extractAttributeDefinitionTypeAndName, type LabelType } from "../../../services/promoted_attribute_definition_parser.js";
|
||||
import { extractAttributeDefinitionTypeAndName } from "../../../services/promoted_attribute_definition_parser.js";
|
||||
import type { AttributeDefinitionInformation } from "./columns.js";
|
||||
|
||||
export type TableData = {
|
||||
@@ -49,7 +51,7 @@ export async function buildRowDefinitions(parentNote: FNote, infos: AttributeDef
|
||||
isArchived: note.isArchived,
|
||||
branchId: branch.branchId,
|
||||
colorClass: note.getColorClass()
|
||||
}
|
||||
};
|
||||
|
||||
if (note.hasChildren() && (maxDepth < 0 || currentDepth < maxDepth)) {
|
||||
const { definitions, rowNumber: subRowNumber } = (await buildRowDefinitions(note, infos, includeArchived, maxDepth, currentDepth + 1));
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
import Modal from "../react/Modal.js";
|
||||
import type { AppInfo } from "@triliumnext/commons";
|
||||
import type { CSSProperties } from "preact/compat";
|
||||
import { useState } from "preact/hooks";
|
||||
|
||||
import { t } from "../../services/i18n.js";
|
||||
import { formatDateTime } from "../../utils/formatters.js";
|
||||
import openService from "../../services/open.js";
|
||||
import server from "../../services/server.js";
|
||||
import utils from "../../services/utils.js";
|
||||
import openService from "../../services/open.js";
|
||||
import { useState } from "preact/hooks";
|
||||
import type { CSSProperties } from "preact/compat";
|
||||
import type { AppInfo } from "@triliumnext/commons";
|
||||
import { formatDateTime } from "../../utils/formatters.js";
|
||||
import { useTriliumEvent } from "../react/hooks.jsx";
|
||||
import Modal from "../react/Modal.js";
|
||||
|
||||
export default function AboutDialog() {
|
||||
const [appInfo, setAppInfo] = useState<AppInfo | null>(null);
|
||||
@@ -54,15 +55,15 @@ export default function AboutDialog() {
|
||||
<tr>
|
||||
<th>{t("about.build_revision")}</th>
|
||||
<td className="selectable-text">
|
||||
{appInfo?.buildRevision && <a className="tn-link build-revision external" href={`https://github.com/TriliumNext/Trilium/commit/${appInfo.buildRevision}`} target="_blank" style={forceWordBreak}>{appInfo.buildRevision}</a>}
|
||||
{appInfo?.buildRevision && <a className="tn-link build-revision external" href={`https://github.com/TriliumNext/Trilium/commit/${appInfo.buildRevision}`} target="_blank" style={forceWordBreak} rel="noreferrer">{appInfo.buildRevision}</a>}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
{ appInfo?.dataDirectory && <tr>
|
||||
<th>{t("about.data_directory")}</th>
|
||||
<td className="data-directory">
|
||||
{appInfo?.dataDirectory && (<DirectoryLink directory={appInfo.dataDirectory} style={forceWordBreak} />)}
|
||||
</td>
|
||||
</tr>
|
||||
</tr>}
|
||||
</tbody>
|
||||
</table>
|
||||
</Modal>
|
||||
@@ -76,8 +77,8 @@ function DirectoryLink({ directory, style }: { directory: string, style?: CSSPro
|
||||
openService.openDirectory(directory);
|
||||
};
|
||||
|
||||
return <a className="tn-link selectable-text" href="#" onClick={onClick} style={style}>{directory}</a>
|
||||
} else {
|
||||
return <span className="selectable-text" style={style}>{directory}</span>;
|
||||
}
|
||||
return <a className="tn-link selectable-text" href="#" onClick={onClick} style={style}>{directory}</a>;
|
||||
}
|
||||
return <span className="selectable-text" style={style}>{directory}</span>;
|
||||
|
||||
}
|
||||
|
||||
@@ -27,6 +27,7 @@ export default function RecentChangesDialog() {
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!ancestorNoteId) return;
|
||||
server.get<RecentChangeRow[]>(`recent-changes/${ancestorNoteId}`)
|
||||
.then(async (recentChanges) => {
|
||||
// preload all notes into cache
|
||||
|
||||
26
apps/client/src/widgets/layout/StandaloneWarningBar.tsx
Normal file
26
apps/client/src/widgets/layout/StandaloneWarningBar.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
import { isMobile } from "../../services/utils";
|
||||
import Admonition from "../react/Admonition";
|
||||
|
||||
export default function StandaloneWarningBar() {
|
||||
return (
|
||||
<div
|
||||
className="standalone-warning-bar"
|
||||
style={{
|
||||
contain: "none"
|
||||
}}
|
||||
>
|
||||
<Admonition
|
||||
type="caution"
|
||||
style={{
|
||||
margin: 0,
|
||||
fontSize: "0.8em"
|
||||
}}
|
||||
>
|
||||
{isMobile()
|
||||
? "Running Trilium standalone. Beware of data loss and other issues."
|
||||
: "You are running Trilium in standalone mode. Some features are not available, and you may experience issues or data loss. Use the desktop application or self-hosted server for the best experience."
|
||||
}
|
||||
</Admonition>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,15 +1,16 @@
|
||||
import { ComponentChildren } from "preact";
|
||||
import { HTML } from "mermaid/dist/diagram-api/types.js";
|
||||
import { ComponentChildren, HTMLAttributes } from "preact";
|
||||
|
||||
interface AdmonitionProps {
|
||||
interface AdmonitionProps extends Pick<HTMLAttributes<HTMLDivElement>, "style"> {
|
||||
type: "warning" | "note" | "caution";
|
||||
children: ComponentChildren;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export default function Admonition({ type, children, className }: AdmonitionProps) {
|
||||
export default function Admonition({ type, children, className, ...props }: AdmonitionProps) {
|
||||
return (
|
||||
<div className={`admonition ${type} ${className}`} role="alert">
|
||||
<div className={`admonition ${type} ${className}`} role="alert" {...props}>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -43,13 +43,13 @@ export function ExternallyControlledCollapsible({ title, children, className, ex
|
||||
setFullyExpanded(true);
|
||||
}, 250);
|
||||
return () => clearTimeout(timeout);
|
||||
} else {
|
||||
setFullyExpanded(true);
|
||||
}
|
||||
}
|
||||
setFullyExpanded(true);
|
||||
|
||||
} else {
|
||||
setFullyExpanded(false);
|
||||
}
|
||||
}, [expanded, transitionEnabled])
|
||||
}, [expanded, transitionEnabled]);
|
||||
|
||||
return (
|
||||
<div className={clsx("collapsible", className, {
|
||||
@@ -58,7 +58,10 @@ export function ExternallyControlledCollapsible({ title, children, className, ex
|
||||
})}>
|
||||
<button
|
||||
className="collapsible-title tn-low-profile"
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
setExpanded(!expanded);
|
||||
}}
|
||||
aria-expanded={expanded}
|
||||
aria-controls={contentId}
|
||||
>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { cloneElement, ComponentChildren, RefObject, VNode } from "preact";
|
||||
import { CSSProperties } from "preact/compat";
|
||||
|
||||
import { useUniqueName } from "./hooks";
|
||||
|
||||
interface FormGroupProps {
|
||||
@@ -8,6 +9,7 @@ interface FormGroupProps {
|
||||
label?: string;
|
||||
title?: string;
|
||||
className?: string;
|
||||
error?: string;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
children: VNode<any>;
|
||||
description?: string | ComponentChildren;
|
||||
@@ -15,7 +17,7 @@ interface FormGroupProps {
|
||||
style?: CSSProperties;
|
||||
}
|
||||
|
||||
export default function FormGroup({ name, label, title, className, children, description, labelRef, disabled, style }: FormGroupProps) {
|
||||
export default function FormGroup({ name, label, title, className, children, description, labelRef, disabled, style, error }: FormGroupProps) {
|
||||
const id = useUniqueName(name);
|
||||
const childWithId = cloneElement(children, { id });
|
||||
|
||||
@@ -26,6 +28,7 @@ export default function FormGroup({ name, label, title, className, children, des
|
||||
|
||||
{childWithId}
|
||||
|
||||
{error && <div><small className="form-text text-danger">{error}</small></div>}
|
||||
{description && <div><small className="form-text">{description}</small></div>}
|
||||
</div>
|
||||
);
|
||||
@@ -41,4 +44,4 @@ export function FormMultiGroup({ label, children }: { label: string, children: C
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@ import froca from "../../../services/froca.js";
|
||||
import type LoadResults from "../../../services/load_results.js";
|
||||
import search from "../../../services/search.js";
|
||||
import type { TemplateDefinition } from "@triliumnext/ckeditor5";
|
||||
import appContext from "../../../components/app_context.js";
|
||||
import type FNote from "../../../entities/fnote.js";
|
||||
|
||||
interface TemplateData {
|
||||
@@ -21,20 +20,25 @@ const debouncedHandleContentUpdate = debounce(handleContentUpdate, 1000);
|
||||
* @returns the list of templates.
|
||||
*/
|
||||
export default async function getTemplates() {
|
||||
// Build the definitions and populate the cache.
|
||||
const snippets = await search.searchForNotes("#textSnippet");
|
||||
const definitions: TemplateDefinition[] = [];
|
||||
for (const snippet of snippets) {
|
||||
const { description } = await invalidateCacheFor(snippet);
|
||||
try {
|
||||
// Build the definitions and populate the cache.
|
||||
const snippets = await search.searchForNotes("#textSnippet");
|
||||
const definitions: TemplateDefinition[] = [];
|
||||
for (const snippet of snippets) {
|
||||
const { description } = await invalidateCacheFor(snippet);
|
||||
|
||||
definitions.push({
|
||||
title: snippet.title,
|
||||
data: () => templateCache.get(snippet.noteId)?.content ?? "",
|
||||
icon: buildIcon(snippet),
|
||||
description
|
||||
});
|
||||
definitions.push({
|
||||
title: snippet.title,
|
||||
data: () => templateCache.get(snippet.noteId)?.content ?? "",
|
||||
icon: buildIcon(snippet),
|
||||
description
|
||||
});
|
||||
}
|
||||
return definitions;
|
||||
} catch (e) {
|
||||
logError("Error while building text snippet templates: ", e);
|
||||
return [];
|
||||
}
|
||||
return definitions;
|
||||
}
|
||||
|
||||
async function invalidateCacheFor(snippet: FNote) {
|
||||
|
||||
@@ -87,7 +87,6 @@ export default defineConfig(() => ({
|
||||
input: {
|
||||
index: join(__dirname, "index.html"),
|
||||
login: join(__dirname, "src", "login.ts"),
|
||||
setup: join(__dirname, "src", "setup.ts"),
|
||||
set_password: join(__dirname, "src", "set_password.ts"),
|
||||
runtime: join(__dirname, "src", "runtime.ts"),
|
||||
print: join(__dirname, "src", "print.tsx")
|
||||
|
||||
@@ -33,6 +33,7 @@
|
||||
"devDependencies": {
|
||||
"@types/electron-squirrel-startup": "1.0.2",
|
||||
"@triliumnext/commons": "workspace:*",
|
||||
"@triliumnext/core": "workspace:*",
|
||||
"@triliumnext/server": "workspace:*",
|
||||
"copy-webpack-plugin": "14.0.0",
|
||||
"electron": "41.0.3",
|
||||
|
||||
@@ -1,17 +1,24 @@
|
||||
import { initializeTranslations } from "@triliumnext/server/src/services/i18n.js";
|
||||
import { t } from "i18next";
|
||||
|
||||
import { app, globalShortcut, BrowserWindow } from "electron";
|
||||
import sqlInit from "@triliumnext/server/src/services/sql_init.js";
|
||||
import windowService from "@triliumnext/server/src/services/window.js";
|
||||
import tray from "@triliumnext/server/src/services/tray.js";
|
||||
import { getLog, initializeCore, sql_init } from "@triliumnext/core";
|
||||
import ClsHookedExecutionContext from "@triliumnext/server/src/cls_provider.js";
|
||||
import NodejsCryptoProvider from "@triliumnext/server/src/crypto_provider.js";
|
||||
import dataDirs from "@triliumnext/server/src/services/data_dir.js";
|
||||
import options from "@triliumnext/server/src/services/options.js";
|
||||
import port from "@triliumnext/server/src/services/port.js";
|
||||
import NodeRequestProvider from "@triliumnext/server/src/services/request.js";
|
||||
import tray from "@triliumnext/server/src/services/tray.js";
|
||||
import windowService from "@triliumnext/server/src/services/window.js";
|
||||
import WebSocketMessagingProvider from "@triliumnext/server/src/services/ws_messaging_provider.js";
|
||||
import BetterSqlite3Provider from "@triliumnext/server/src/sql_provider.js";
|
||||
import { app, BrowserWindow,globalShortcut } from "electron";
|
||||
import electronDebug from "electron-debug";
|
||||
import electronDl from "electron-dl";
|
||||
import { PRODUCT_NAME } from "./app-info";
|
||||
import port from "@triliumnext/server/src/services/port.js";
|
||||
import { join } from "path";
|
||||
import fs from "fs";
|
||||
import { t } from "i18next";
|
||||
import path, { join } from "path";
|
||||
|
||||
import { deferred, LOCALES } from "../../../packages/commons/src";
|
||||
import { PRODUCT_NAME } from "./app-info";
|
||||
import DesktopPlatformProvider from "./platform_provider";
|
||||
|
||||
async function main() {
|
||||
const userDataPath = getUserData();
|
||||
@@ -82,7 +89,7 @@ async function main() {
|
||||
}
|
||||
});
|
||||
|
||||
await initializeTranslations();
|
||||
// await initializeTranslations();
|
||||
|
||||
const isPrimaryInstance = (await import("electron")).app.requestSingleInstanceLock();
|
||||
if (!isPrimaryInstance) {
|
||||
@@ -93,9 +100,55 @@ async function main() {
|
||||
// this is to disable electron warning spam in the dev console (local development only)
|
||||
process.env["ELECTRON_DISABLE_SECURITY_WARNINGS"] = "true";
|
||||
|
||||
const { DOCUMENT_PATH } = (await import("@triliumnext/server/src/services/data_dir.js")).default;
|
||||
const config = (await import("@triliumnext/server/src/services/config.js")).default;
|
||||
|
||||
const dbProvider = new BetterSqlite3Provider();
|
||||
dbProvider.loadFromFile(DOCUMENT_PATH, config.General.readOnly);
|
||||
|
||||
await initializeCore({
|
||||
dbConfig: {
|
||||
provider: dbProvider,
|
||||
isReadOnly: config.General.readOnly,
|
||||
async onTransactionCommit() {
|
||||
const ws = (await import("@triliumnext/server/src/services/ws.js")).default;
|
||||
ws.sendTransactionEntityChangesToAllClients();
|
||||
},
|
||||
async onTransactionRollback() {
|
||||
const cls = (await import("@triliumnext/server/src/services/cls.js")).default;
|
||||
const becca_loader = (await import("@triliumnext/core")).becca_loader;
|
||||
const entity_changes = (await import("@triliumnext/server/src/services/entity_changes.js")).default;
|
||||
const log = (await import("@triliumnext/server/src/services/log")).default;
|
||||
|
||||
const entityChangeIds = cls.getAndClearEntityChangeIds();
|
||||
|
||||
if (entityChangeIds.length > 0) {
|
||||
log.info("Transaction rollback dirtied the becca, forcing reload.");
|
||||
|
||||
becca_loader.load();
|
||||
}
|
||||
|
||||
// the maxEntityChangeId has been incremented during failed transaction, need to recalculate
|
||||
entity_changes.recalculateMaxEntityChangeId();
|
||||
}
|
||||
},
|
||||
crypto: new NodejsCryptoProvider(),
|
||||
request: new NodeRequestProvider(),
|
||||
executionContext: new ClsHookedExecutionContext(),
|
||||
messaging: new WebSocketMessagingProvider(),
|
||||
schema: fs.readFileSync(require.resolve("@triliumnext/core/src/assets/schema.sql"), "utf-8"),
|
||||
platform: new DesktopPlatformProvider(),
|
||||
translations: (await import("@triliumnext/server/src/services/i18n.js")).initializeTranslations,
|
||||
extraAppInfo: {
|
||||
nodeVersion: process.version,
|
||||
dataDirectory: path.resolve(dataDirs.TRILIUM_DATA_DIR)
|
||||
}
|
||||
});
|
||||
|
||||
const startTriliumServer = (await import("@triliumnext/server/src/www.js")).default;
|
||||
await startTriliumServer();
|
||||
console.log("Server loaded");
|
||||
|
||||
serverInitializedPromise.resolve();
|
||||
}
|
||||
|
||||
@@ -112,8 +165,8 @@ async function onReady() {
|
||||
|
||||
// if db is not initialized -> setup process
|
||||
// if db is initialized, then we need to wait until the migration process is finished
|
||||
if (sqlInit.isDbInitialized()) {
|
||||
await sqlInit.dbReady;
|
||||
if (sql_init.isDbInitialized()) {
|
||||
await sql_init.dbReady;
|
||||
|
||||
await windowService.createMainWindow(app);
|
||||
|
||||
@@ -127,6 +180,7 @@ async function onReady() {
|
||||
|
||||
tray.createTray();
|
||||
} else {
|
||||
getLog().banner(t("sql_init.db_not_initialized_desktop"));
|
||||
await windowService.createSetupWindow();
|
||||
}
|
||||
|
||||
@@ -141,7 +195,7 @@ function getElectronLocale() {
|
||||
// For RTL, we have to force the UI locale to align the window buttons properly.
|
||||
if (formattingLocale && !correspondingLocale?.rtl) return formattingLocale;
|
||||
|
||||
return uiLocale || "en"
|
||||
return uiLocale || "en";
|
||||
}
|
||||
|
||||
main();
|
||||
|
||||
9
apps/desktop/src/platform_provider.ts
Normal file
9
apps/desktop/src/platform_provider.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { PlatformProvider, t } from "@triliumnext/core";
|
||||
import electron from "electron";
|
||||
|
||||
export default class DesktopPlatformProvider implements PlatformProvider {
|
||||
crash(message: string): void {
|
||||
electron.dialog.showErrorBox(t("modals.error_title"), message);
|
||||
electron.app.exit(1);
|
||||
}
|
||||
}
|
||||
@@ -39,6 +39,7 @@
|
||||
"@braintree/sanitize-url": "7.1.2",
|
||||
"@electron/remote": "2.1.3",
|
||||
"@triliumnext/commons": "workspace:*",
|
||||
"@triliumnext/core": "workspace:*",
|
||||
"@triliumnext/express-partial-content": "workspace:*",
|
||||
"@triliumnext/highlightjs": "workspace:*",
|
||||
"@triliumnext/turndown-plugin-gfm": "workspace:*",
|
||||
@@ -49,17 +50,13 @@
|
||||
"@types/cookie-parser": "1.4.10",
|
||||
"@types/debounce": "1.2.4",
|
||||
"@types/ejs": "3.1.5",
|
||||
"@types/escape-html": "1.0.4",
|
||||
"@types/express-http-proxy": "1.6.7",
|
||||
"@types/express-session": "1.18.2",
|
||||
"@types/fs-extra": "11.0.4",
|
||||
"@types/html": "1.0.4",
|
||||
"@types/ini": "4.1.1",
|
||||
"@types/mime-types": "3.0.1",
|
||||
"@types/multer": "2.1.0",
|
||||
"@types/safe-compare": "1.1.2",
|
||||
"@types/sanitize-html": "2.16.1",
|
||||
"@types/sax": "1.2.7",
|
||||
"@types/serve-favicon": "2.5.7",
|
||||
"@types/serve-static": "2.2.0",
|
||||
"@types/stream-throttle": "0.1.4",
|
||||
@@ -69,7 +66,6 @@
|
||||
"@types/ws": "8.18.1",
|
||||
"@types/xml2js": "0.4.14",
|
||||
"archiver": "7.0.1",
|
||||
"async-mutex": "0.5.0",
|
||||
"axios": "1.13.6",
|
||||
"bindings": "1.5.0",
|
||||
"bootstrap": "5.3.8",
|
||||
@@ -86,7 +82,6 @@
|
||||
"electron": "41.0.3",
|
||||
"electron-debug": "4.1.0",
|
||||
"electron-window-state": "5.0.3",
|
||||
"escape-html": "1.0.3",
|
||||
"express": "5.2.1",
|
||||
"express-http-proxy": "2.1.2",
|
||||
"express-openid-connect": "2.19.4",
|
||||
@@ -108,13 +103,10 @@
|
||||
"jimp": "1.6.0",
|
||||
"lorem-ipsum": "2.0.8",
|
||||
"marked": "17.0.5",
|
||||
"mime-types": "3.0.2",
|
||||
"multer": "2.1.1",
|
||||
"normalize-strings": "1.1.1",
|
||||
"rand-token": "1.0.1",
|
||||
"safe-compare": "1.1.4",
|
||||
"sanitize-filename": "1.6.4",
|
||||
"sanitize-html": "2.17.2",
|
||||
"sax": "1.6.0",
|
||||
"serve-favicon": "2.5.1",
|
||||
"stream-throttle": "0.1.3",
|
||||
@@ -125,7 +117,6 @@
|
||||
"time2fa": "1.4.2",
|
||||
"tmp": "0.2.5",
|
||||
"turnish": "1.8.0",
|
||||
"unescape": "1.0.1",
|
||||
"vite": "8.0.1",
|
||||
"ws": "8.20.0",
|
||||
"xml2js": "0.6.2",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import anonymizationService from "./services/anonymization.js";
|
||||
import sqlInit from "./services/sql_init.js";
|
||||
await import("./becca/entity_constructor.js");
|
||||
await import("@triliumnext/core");
|
||||
|
||||
sqlInit.dbReady.then(async () => {
|
||||
try {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import "./services/handlers.js";
|
||||
import "./becca/becca_loader.js";
|
||||
import("@triliumnext/core");
|
||||
|
||||
import { erase } from "@triliumnext/core";
|
||||
import compression from "compression";
|
||||
import cookieParser from "cookie-parser";
|
||||
import ejs from "ejs";
|
||||
@@ -16,19 +16,14 @@ import custom from "./routes/custom.js";
|
||||
import error_handlers from "./routes/error_handlers.js";
|
||||
import routes from "./routes/routes.js";
|
||||
import config from "./services/config.js";
|
||||
import { startScheduledCleanup } from "./services/erase.js";
|
||||
import log from "./services/log.js";
|
||||
import openID from "./services/open_id.js";
|
||||
import { RESOURCE_DIR } from "./services/resource_dir.js";
|
||||
import sql_init from "./services/sql_init.js";
|
||||
import utils, { getResourceDir, isDev } from "./services/utils.js";
|
||||
|
||||
export default async function buildApp() {
|
||||
const app = express();
|
||||
|
||||
// Initialize DB
|
||||
sql_init.initializeDb();
|
||||
|
||||
const publicDir = isDev ? path.join(getResourceDir(), "../dist/public") : path.join(getResourceDir(), "public");
|
||||
const publicAssetsDir = path.join(publicDir, "assets");
|
||||
const assetsDir = RESOURCE_DIR;
|
||||
@@ -39,9 +34,10 @@ export default async function buildApp() {
|
||||
app.set("view engine", "ejs");
|
||||
|
||||
app.use((req, res, next) => {
|
||||
// set CORS header
|
||||
// set CORS headers
|
||||
if (config["Network"]["corsAllowOrigin"]) {
|
||||
res.header("Access-Control-Allow-Origin", config["Network"]["corsAllowOrigin"]);
|
||||
res.header("Access-Control-Allow-Credentials", "true");
|
||||
}
|
||||
if (config["Network"]["corsAllowMethods"]) {
|
||||
res.header("Access-Control-Allow-Methods", config["Network"]["corsAllowMethods"]);
|
||||
@@ -50,6 +46,12 @@ export default async function buildApp() {
|
||||
res.header("Access-Control-Allow-Headers", config["Network"]["corsAllowHeaders"]);
|
||||
}
|
||||
|
||||
// Handle preflight OPTIONS requests
|
||||
if (req.method === "OPTIONS" && config["Network"]["corsAllowOrigin"]) {
|
||||
res.sendStatus(204);
|
||||
return;
|
||||
}
|
||||
|
||||
res.locals.t = t;
|
||||
return next();
|
||||
});
|
||||
@@ -99,18 +101,17 @@ export default async function buildApp() {
|
||||
custom.register(app);
|
||||
error_handlers.register(app);
|
||||
|
||||
const { startSyncTimer } = await import("./services/sync.js");
|
||||
startSyncTimer();
|
||||
const { sync, consistency_checks } = await import("@triliumnext/core");
|
||||
sync.startSyncTimer();
|
||||
|
||||
await import("./services/backup.js");
|
||||
|
||||
const { startConsistencyChecks } = await import("./services/consistency_checks.js");
|
||||
startConsistencyChecks();
|
||||
consistency_checks.startConsistencyChecks();
|
||||
|
||||
const { startScheduler } = await import("./services/scheduler.js");
|
||||
startScheduler();
|
||||
|
||||
startScheduledCleanup();
|
||||
erase.startScheduledCleanup();
|
||||
|
||||
if (utils.isElectron) {
|
||||
(await import("@electron/remote/main/index.js")).initialize();
|
||||
|
||||
@@ -241,22 +241,6 @@
|
||||
"password-confirmation": "تاكيد كلمة المرور",
|
||||
"button": "تعين كلمة المرور"
|
||||
},
|
||||
"setup": {
|
||||
"next": "التالي",
|
||||
"title": "تثبيت",
|
||||
"heading": "تثبيت تريليوم للملاحظات",
|
||||
"init-in-progress": "جار تهيئة المستند"
|
||||
},
|
||||
"setup_sync-from-desktop": {
|
||||
"step6-here": "هنا",
|
||||
"heading": "مزامنة من سطح المكتب",
|
||||
"step3": "انقر على صنف المزامنة."
|
||||
},
|
||||
"setup_sync-in-progress": {
|
||||
"outstanding-items-default": "غير متوفر",
|
||||
"heading": "المزامنة جارية",
|
||||
"outstanding-items": "عناصر المزامنة المعلقة:"
|
||||
},
|
||||
"share_page": {
|
||||
"parent": "الأصل:",
|
||||
"child-notes": "الملاحظات الفرعية:",
|
||||
|
||||
@@ -15,24 +15,6 @@
|
||||
"set_password": {
|
||||
"password": "Contrasenya"
|
||||
},
|
||||
"setup": {
|
||||
"next": "Següent",
|
||||
"title": "Configuració"
|
||||
},
|
||||
"setup_sync-from-desktop": {
|
||||
"step6-here": "aquí"
|
||||
},
|
||||
"setup_sync-from-server": {
|
||||
"server-host-placeholder": "https://<hostname>:<port>",
|
||||
"proxy-server-placeholder": "https://<hostname>:<port>",
|
||||
"note": "Nota:",
|
||||
"password": "Contrasenya",
|
||||
"password-placeholder": "Contrasenya",
|
||||
"back": "Torna"
|
||||
},
|
||||
"setup_sync-in-progress": {
|
||||
"outstanding-items-default": "N/A"
|
||||
},
|
||||
"share_page": {
|
||||
"parent": "pare:"
|
||||
},
|
||||
|
||||
@@ -123,47 +123,6 @@
|
||||
"password-confirmation": "密码确认",
|
||||
"button": "设置密码"
|
||||
},
|
||||
"setup": {
|
||||
"heading": "TriliumNext笔记设置",
|
||||
"new-document": "我是新用户,我想为我的笔记创建一个新的Trilium文档",
|
||||
"sync-from-desktop": "我已经有一个桌面实例,我想设置与它的同步",
|
||||
"sync-from-server": "我已经有一个服务器实例,我想设置与它的同步",
|
||||
"next": "下一步",
|
||||
"init-in-progress": "文档初始化进行中",
|
||||
"redirecting": "您将很快被重定向到应用程序。",
|
||||
"title": "设置"
|
||||
},
|
||||
"setup_sync-from-desktop": {
|
||||
"heading": "从桌面同步",
|
||||
"description": "此设置需要从桌面实例开始:",
|
||||
"step1": "打开您的TriliumNext笔记桌面实例。",
|
||||
"step2": "从Trilium菜单中,点击“选项”。",
|
||||
"step3": "点击“同步”类别。",
|
||||
"step4": "将服务器实例地址更改为:{{- host}} 并点击保存。",
|
||||
"step5": "点击“测试同步”按钮以验证连接是否成功。",
|
||||
"step6": "完成这些步骤后,点击{{- link}}。",
|
||||
"step6-here": "这里"
|
||||
},
|
||||
"setup_sync-from-server": {
|
||||
"heading": "从服务器同步",
|
||||
"instructions": "请在下面输入Trilium服务器地址和凭据。这将从服务器下载整个Trilium文档并设置与它的同步。根据文档大小和您的连接的速度,这可能需要一段时间。",
|
||||
"server-host": "Trilium服务器地址",
|
||||
"server-host-placeholder": "https://<主机名称>:<端口>",
|
||||
"proxy-server": "代理服务器(可选)",
|
||||
"proxy-server-placeholder": "https://<主机名称>:<端口>",
|
||||
"note": "注意:",
|
||||
"proxy-instruction": "如果您将代理设置留空,将使用系统代理(仅适用于桌面应用)",
|
||||
"password": "密码",
|
||||
"password-placeholder": "密码",
|
||||
"back": "返回",
|
||||
"finish-setup": "完成设置"
|
||||
},
|
||||
"setup_sync-in-progress": {
|
||||
"heading": "同步中",
|
||||
"successful": "同步已被正确设置。初始同步完成可能需要一些时间。完成后,您将被重定向到登录页面。",
|
||||
"outstanding-items": "未完成的同步项目:",
|
||||
"outstanding-items-default": "无"
|
||||
},
|
||||
"share_404": {
|
||||
"title": "未找到",
|
||||
"heading": "未找到"
|
||||
|
||||
@@ -123,47 +123,6 @@
|
||||
"password-confirmation": "Passwortbestätigung",
|
||||
"button": "Passwort festlegen"
|
||||
},
|
||||
"setup": {
|
||||
"heading": "Trilium Notes Setup",
|
||||
"new-document": "Ich bin ein neuer Benutzer und möchte ein neues Trilium-Dokument für meine Notizen erstellen",
|
||||
"sync-from-desktop": "Ich habe bereits eine Desktop-Instanz und möchte die Synchronisierung damit einrichten",
|
||||
"sync-from-server": "Ich habe bereits eine Server-Instanz und möchte die Synchronisierung damit einrichten",
|
||||
"next": "Weiter",
|
||||
"init-in-progress": "Dokumenteninitialisierung läuft",
|
||||
"redirecting": "Du wirst in Kürze zur Anwendung weitergeleitet.",
|
||||
"title": "Setup"
|
||||
},
|
||||
"setup_sync-from-desktop": {
|
||||
"heading": "Synchronisation vom Desktop",
|
||||
"description": "Dieses Setup muss von der Desktop-Instanz aus initiiert werden:",
|
||||
"step1": "Öffne deine Trilium Notes Desktop-Instanz.",
|
||||
"step2": "Klicke im Trilium-Menü auf Optionen.",
|
||||
"step3": "Klicke auf die Kategorie Synchronisation.",
|
||||
"step4": "Ändere die Server-Instanzadresse auf: {{- host}} und klicke auf Speichern.",
|
||||
"step5": "Klicke auf den Button \"Test-Synchronisation\", um zu überprüfen, ob die Verbindung erfolgreich ist.",
|
||||
"step6": "Sobald du diese Schritte abgeschlossen hast, klicke auf {{- link}}.",
|
||||
"step6-here": "hier"
|
||||
},
|
||||
"setup_sync-from-server": {
|
||||
"heading": "Synchronisation vom Server",
|
||||
"instructions": "Bitte gib unten die Trilium-Server-Adresse und die Zugangsdaten ein. Dies wird das gesamte Trilium-Dokument vom Server herunterladen und die Synchronisation einrichten. Je nach Dokumentgröße und Verbindungsgeschwindigkeit kann dies eine Weile dauern.",
|
||||
"server-host": "Trilium Server-Adresse",
|
||||
"server-host-placeholder": "https://<hostname>:<port>",
|
||||
"proxy-server": "Proxy-Server (optional)",
|
||||
"proxy-server-placeholder": "https://<hostname>:<port>",
|
||||
"note": "Hinweis:",
|
||||
"proxy-instruction": "Wenn du die Proxy-Einstellung leer lässt, wird der System-Proxy verwendet (gilt nur für die Desktop-Anwendung)",
|
||||
"password": "Passwort",
|
||||
"password-placeholder": "Passwort",
|
||||
"back": "Zurück",
|
||||
"finish-setup": "Setup abschließen"
|
||||
},
|
||||
"setup_sync-in-progress": {
|
||||
"heading": "Synchronisation läuft",
|
||||
"successful": "Die Synchronisation wurde erfolgreich eingerichtet. Es wird eine Weile dauern, bis die erste Synchronisation abgeschlossen ist. Sobald dies erledigt ist, wirst du zur Anmeldeseite weitergeleitet.",
|
||||
"outstanding-items": "Ausstehende Synchronisationselemente:",
|
||||
"outstanding-items-default": "N/A"
|
||||
},
|
||||
"share_404": {
|
||||
"title": "Nicht gefunden",
|
||||
"heading": "Nicht gefunden"
|
||||
|
||||
@@ -220,47 +220,6 @@
|
||||
"password-confirmation": "Password confirmation",
|
||||
"button": "Set password"
|
||||
},
|
||||
"setup": {
|
||||
"heading": "Trilium Notes setup",
|
||||
"new-document": "I'm a new user, and I want to create a new Trilium document for my notes",
|
||||
"sync-from-desktop": "I have a desktop instance already, and I want to set up sync with it",
|
||||
"sync-from-server": "I have a server instance already, and I want to set up sync with it",
|
||||
"next": "Next",
|
||||
"init-in-progress": "Document initialization in progress",
|
||||
"redirecting": "You will be shortly redirected to the application.",
|
||||
"title": "Setup"
|
||||
},
|
||||
"setup_sync-from-desktop": {
|
||||
"heading": "Sync from Desktop",
|
||||
"description": "This setup needs to be initiated from the desktop instance:",
|
||||
"step1": "Open your desktop instance of Trilium Notes.",
|
||||
"step2": "From the Trilium Menu, click Options.",
|
||||
"step3": "Click on Sync category.",
|
||||
"step4": "Change server instance address to: {{- host}} and click Save.",
|
||||
"step5": "Click \"Test sync\" button to verify connection is successful.",
|
||||
"step6": "Once you've completed these steps, click {{- link}}.",
|
||||
"step6-here": "here"
|
||||
},
|
||||
"setup_sync-from-server": {
|
||||
"heading": "Sync from Server",
|
||||
"instructions": "Please enter Trilium server address and credentials below. This will download the whole Trilium document from server and setup sync to it. Depending on the document size and your connection speed, this may take a while.",
|
||||
"server-host": "Trilium server address",
|
||||
"server-host-placeholder": "https://<hostname>:<port>",
|
||||
"proxy-server": "Proxy server (optional)",
|
||||
"proxy-server-placeholder": "https://<hostname>:<port>",
|
||||
"note": "Note:",
|
||||
"proxy-instruction": "If you leave proxy setting blank, system proxy will be used (applies to the desktop application only)",
|
||||
"password": "Password",
|
||||
"password-placeholder": "Password",
|
||||
"back": "Back",
|
||||
"finish-setup": "Finish setup"
|
||||
},
|
||||
"setup_sync-in-progress": {
|
||||
"heading": "Sync in progress",
|
||||
"successful": "Sync has been correctly set up. It will take some time for the initial sync to finish. Once it's done, you'll be redirected to the login page.",
|
||||
"outstanding-items": "Outstanding sync items:",
|
||||
"outstanding-items-default": "N/A"
|
||||
},
|
||||
"share_404": {
|
||||
"title": "Not found",
|
||||
"heading": "Not found"
|
||||
|
||||
@@ -123,47 +123,6 @@
|
||||
"password-confirmation": "Confirmación de contraseña",
|
||||
"button": "Establecer contraseña"
|
||||
},
|
||||
"setup": {
|
||||
"heading": "Configuración de Trilium Notes",
|
||||
"new-document": "Soy un usuario nuevo y quiero crear un nuevo documento de Trilium para mis notas",
|
||||
"sync-from-desktop": "Ya tengo una instancia de escritorio y quiero configurar la sincronización con ella",
|
||||
"sync-from-server": "Ya tengo una instancia de servidor y quiero configurar la sincronización con ella",
|
||||
"next": "Siguiente",
|
||||
"init-in-progress": "Inicialización del documento en curso",
|
||||
"redirecting": "En breve será redirigido a la aplicación.",
|
||||
"title": "Configuración"
|
||||
},
|
||||
"setup_sync-from-desktop": {
|
||||
"heading": "Sincronizar desde el escritorio",
|
||||
"description": "Esta configuración debe iniciarse desde la instancia de escritorio:",
|
||||
"step1": "Abra su instancia de escritorio de Trilium Notes.",
|
||||
"step2": "En el menú Trilium, dé clic en Opciones.",
|
||||
"step3": "Dé clic en la categoría Sincronizar.",
|
||||
"step4": "Cambie la dirección de la instancia del servidor a: {{- host}} y dé clic en Guardar.",
|
||||
"step5": "Dé clic en el botón \"Probar sincronización\" para verificar que la conexión fue exitosa.",
|
||||
"step6": "Una vez que haya completado estos pasos, dé clic en {{- link}}.",
|
||||
"step6-here": "aquí"
|
||||
},
|
||||
"setup_sync-from-server": {
|
||||
"heading": "Sincronización desde el servidor",
|
||||
"instructions": "Por favor, ingrese la dirección y las credenciales del servidor Trilium a continuación. Esto descargará todo el documento de Trilium desde el servidor y configurará la sincronización. Dependiendo del tamaño del documento y de la velocidad de su conexión, esto puede tardar un poco.",
|
||||
"server-host": "Dirección del servidor Trilium",
|
||||
"server-host-placeholder": "https://<hostname>:<port>",
|
||||
"proxy-server": "Servidor proxy (opcional)",
|
||||
"proxy-server-placeholder": "https://<hostname>:<port>",
|
||||
"note": "Nota:",
|
||||
"proxy-instruction": "Si deja la configuración de proxy en blanco, se utilizará el proxy del sistema (aplica únicamente a la aplicación de escritorio)",
|
||||
"password": "Contraseña",
|
||||
"password-placeholder": "Contraseña",
|
||||
"back": "Atrás",
|
||||
"finish-setup": "Finalizar la configuración"
|
||||
},
|
||||
"setup_sync-in-progress": {
|
||||
"heading": "Sincronización en progreso",
|
||||
"successful": "La sincronización se ha configurado correctamente. La sincronización inicial tardará algún tiempo en finalizar. Una vez hecho esto, será redirigido a la página de inicio de sesión.",
|
||||
"outstanding-items": "Elementos de sincronización destacados:",
|
||||
"outstanding-items-default": "N/A"
|
||||
},
|
||||
"share_404": {
|
||||
"title": "No encontrado",
|
||||
"heading": "No encontrado"
|
||||
|
||||
@@ -123,47 +123,6 @@
|
||||
"password-confirmation": "Confirmation du mot de passe",
|
||||
"button": "Définir le mot de passe"
|
||||
},
|
||||
"setup": {
|
||||
"heading": "Configuration de Trilium Notes",
|
||||
"new-document": "Je suis un nouvel utilisateur et je souhaite créer un nouveau document Trilium pour mes notes",
|
||||
"sync-from-desktop": "J'ai déjà l'application de bureau et je souhaite configurer la synchronisation avec celle-ci",
|
||||
"sync-from-server": "J'ai déjà un serveur et je souhaite configurer la synchronisation avec celui-ci",
|
||||
"next": "Suivant",
|
||||
"init-in-progress": "Initialisation du document en cours",
|
||||
"redirecting": "Vous serez bientôt redirigé vers l'application.",
|
||||
"title": "Configuration"
|
||||
},
|
||||
"setup_sync-from-desktop": {
|
||||
"heading": "Synchroniser depuis une application de bureau",
|
||||
"description": "Cette procédure doit être réalisée depuis l'application de bureau :",
|
||||
"step1": "Ouvrez l'application Trilium Notes.",
|
||||
"step2": "Dans le menu Trilium, cliquez sur Options.",
|
||||
"step3": "Cliquez sur la catégorie Synchroniser.",
|
||||
"step4": "Remplacez l'adresse de l'instance de serveur par : {{- host}} et cliquez sur Enregistrer.",
|
||||
"step5": "Cliquez sur le bouton 'Tester la synchronisation' pour vérifier que la connexion fonctionne.",
|
||||
"step6": "Une fois que vous avez terminé ces étapes, cliquez sur {{- link}}.",
|
||||
"step6-here": "ici"
|
||||
},
|
||||
"setup_sync-from-server": {
|
||||
"heading": "Synchroniser depuis le serveur",
|
||||
"instructions": "Veuillez saisir l'adresse du serveur Trilium et les informations d'identification ci-dessous. Cela téléchargera l'intégralité du document Trilium à partir du serveur et configurera la synchronisation avec celui-ci. En fonction de la taille du document et de votre vitesse de connexion, cela peut prendre un plusieurs minutes.",
|
||||
"server-host": "Adresse du serveur Trilium",
|
||||
"server-host-placeholder": "https://<nom d'hôte>:<port>",
|
||||
"proxy-server": "Serveur proxy (facultatif)",
|
||||
"proxy-server-placeholder": "https://<nom d'hôte>:<port>",
|
||||
"note": "Note :",
|
||||
"proxy-instruction": "Si vous laissez le paramètre de proxy vide, le proxy du système sera utilisé (s'applique uniquement à l'application de bureau)",
|
||||
"password": "Mot de passe",
|
||||
"password-placeholder": "Mot de passe",
|
||||
"back": "Retour",
|
||||
"finish-setup": "Terminer"
|
||||
},
|
||||
"setup_sync-in-progress": {
|
||||
"heading": "Synchronisation en cours",
|
||||
"successful": "La synchronisation a été correctement configurée. La synchronisation initiale prendra un certain temps. Une fois terminée, vous serez redirigé vers la page de connexion.",
|
||||
"outstanding-items": "Éléments de synchronisation exceptionnels :",
|
||||
"outstanding-items-default": "N/A"
|
||||
},
|
||||
"share_404": {
|
||||
"title": "Page non trouvée",
|
||||
"heading": "Page non trouvée"
|
||||
|
||||
@@ -220,47 +220,6 @@
|
||||
"password-confirmation": "Deimhniú pasfhocail",
|
||||
"button": "Socraigh pasfhocal"
|
||||
},
|
||||
"setup": {
|
||||
"heading": "Socrú Trilium Notes",
|
||||
"new-document": "Is úsáideoir nua mé, agus ba mhaith liom doiciméad Trilium nua a chruthú do mo nótaí",
|
||||
"sync-from-desktop": "Tá cás deisce agam cheana féin, agus ba mhaith liom sioncrónú a shocrú leis",
|
||||
"sync-from-server": "Tá sampla freastalaí agam cheana féin, agus ba mhaith liom sioncrónú a shocrú leis",
|
||||
"next": "Ar Aghaidh",
|
||||
"init-in-progress": "Túsú doiciméad ar siúl",
|
||||
"redirecting": "Atreorófar chuig an bhfeidhmchlár thú go luath.",
|
||||
"title": "Socrú"
|
||||
},
|
||||
"setup_sync-from-desktop": {
|
||||
"heading": "Sioncrónaigh ón Deasc",
|
||||
"description": "Ní mór an socrú seo a thionscnamh ón deasc:",
|
||||
"step1": "Oscail sampla de Trilium Notes ar do dheasc.",
|
||||
"step2": "Ón Roghchlár Trilium, cliceáil Roghanna.",
|
||||
"step3": "Cliceáil ar an gcatagóir Sioncrónaigh.",
|
||||
"step4": "Athraigh seoladh an fhreastalaí go: {{- host}} agus cliceáil Sábháil.",
|
||||
"step5": "Cliceáil an cnaipe \"Tástáil sioncrónaithe\" chun a fhíorú go bhfuil an nasc rathúil.",
|
||||
"step6": "Nuair a bheidh na céimeanna seo críochnaithe agat, cliceáil {{- link}}.",
|
||||
"step6-here": "anseo"
|
||||
},
|
||||
"setup_sync-from-server": {
|
||||
"heading": "Sioncrónaigh ón bhFreastalaí",
|
||||
"instructions": "Cuir isteach seoladh agus dintiúir freastalaí Trilium thíos le do thoil. Íoslódálfaidh sé seo an doiciméad Trilium iomlán ón bhfreastalaí agus socróidh sé sioncrónú leis. Ag brath ar mhéid an doiciméid agus luas do nasc, d'fhéadfadh sé seo tamall a thógáil.",
|
||||
"server-host": "Seoladh freastalaí Trilium",
|
||||
"server-host-placeholder": "https://<ainm óstach>:<port>",
|
||||
"proxy-server": "Freastalaí seachfhreastalaí (roghnach)",
|
||||
"proxy-server-placeholder": "https://<ainm óstach>:<port>",
|
||||
"note": "Nóta:",
|
||||
"proxy-instruction": "Má fhágann tú an socrú seachfhreastalaí bán, úsáidfear seachfhreastalaí an chórais (baineann sé leis an bhfeidhmchlár deisce amháin)",
|
||||
"password": "Pasfhocal",
|
||||
"password-placeholder": "Pasfhocal",
|
||||
"back": "Ar ais",
|
||||
"finish-setup": "Críochnaigh an socrú"
|
||||
},
|
||||
"setup_sync-in-progress": {
|
||||
"heading": "Sioncrónú ar siúl",
|
||||
"successful": "Tá an sioncrónú socraithe i gceart. Tógfaidh sé tamall go mbeidh an sioncrónú tosaigh críochnaithe. Nuair a bheidh sé déanta, atreorófar chuig an leathanach logála isteach thú.",
|
||||
"outstanding-items": "Míreanna sioncrónaithe gan réiteach:",
|
||||
"outstanding-items-default": "N/B"
|
||||
},
|
||||
"share_404": {
|
||||
"title": "Níor aimsíodh",
|
||||
"heading": "Níor aimsíodh"
|
||||
|
||||
@@ -323,47 +323,6 @@
|
||||
"password-confirmation": "Conferma della password",
|
||||
"button": "Imposta password"
|
||||
},
|
||||
"setup": {
|
||||
"heading": "Configurazione di Trilium Notes",
|
||||
"new-document": "Sono un nuovo utente, e desidero creare un nuovo documento Trilium per le mie note",
|
||||
"sync-from-desktop": "Ho già una istanza desktop, e desidero configurare la sincronizzazione con quest'ultima",
|
||||
"sync-from-server": "Ho già una istanza server, e desidero configurare la sincronizzazione con quest'ultima",
|
||||
"next": "Avanti",
|
||||
"init-in-progress": "Inizializzazione del documento in corso",
|
||||
"redirecting": "Sarai reindirizzato a breve all'applicazione.",
|
||||
"title": "Configurazione"
|
||||
},
|
||||
"setup_sync-from-desktop": {
|
||||
"heading": "Sincronizza dal desktop",
|
||||
"description": "Questa configurazione deve essere iniziata dalla istanza desktop:",
|
||||
"step1": "Apri la tua istanza desktop di Trilium Notes.",
|
||||
"step2": "Dal menù di Trilium, seleziona \"Opzioni\".",
|
||||
"step3": "Clicca sulla categoria \"Sincronizzazione\".",
|
||||
"step4": "Imposta \"{{- host}}\" come l'indirizzo dell'istanza server e clicca \"Salva\".",
|
||||
"step5": "Clicca \"Prova sincronizzazione\" per verificare la connessione.",
|
||||
"step6": "Dopo aver completato questi passaggi, clicca {{- link}}.",
|
||||
"step6-here": "qui"
|
||||
},
|
||||
"setup_sync-from-server": {
|
||||
"heading": "Sincronizza dal server",
|
||||
"instructions": "Inserire l'indirizzo e le credenziali del server Trilium. L'intero documento Trilium verrà scaricato dal server, e sarà configurata la sincronizzazione allo stesso. L'operazione potrebbe impiegare un po' di tempo, in base alla velocità della connessione e alla dimensione del documento.",
|
||||
"server-host": "Indirizzo del server Trilium",
|
||||
"server-host-placeholder": "https://<nome host>:<porta>",
|
||||
"proxy-server": "Server proxy (facoltativo)",
|
||||
"proxy-server-placeholder": "https://<nome host>:<porta>",
|
||||
"note": "Note:",
|
||||
"proxy-instruction": "Se il campo del proxy viene lasciato vuoto, il proxy di sistema verrà utilizzato (si applica solo all'applicazione desktop)",
|
||||
"password": "Password",
|
||||
"password-placeholder": "Password",
|
||||
"back": "Indietro",
|
||||
"finish-setup": "Termina configurazione"
|
||||
},
|
||||
"setup_sync-in-progress": {
|
||||
"heading": "Sincronizzazione in corso",
|
||||
"successful": "La sincronizzazione è stata configurata correttamente. Ci potrebbe volere un po' di tempo prima che la sincronizzazione iniziale termini. Appena sarà terminata, sarai ri-indirizzato alla pagina di accesso.",
|
||||
"outstanding-items": "Elementi eccezionali in sincronizzazione:",
|
||||
"outstanding-items-default": "N/A"
|
||||
},
|
||||
"share_404": {
|
||||
"title": "Pagina non trovata",
|
||||
"heading": "Pagina non trovata"
|
||||
|
||||
@@ -220,47 +220,6 @@
|
||||
"button": "パスワードの設定",
|
||||
"password-confirmation": "パスワードの再入力"
|
||||
},
|
||||
"setup": {
|
||||
"heading": "Trilium Notes セットアップ",
|
||||
"new-document": "私は新しいユーザーで、ノートを取るために新しいTriliumドキュメントを作成したい",
|
||||
"sync-from-desktop": "すでにデスクトップ版のインスタンスがあり、同期を設定したい",
|
||||
"sync-from-server": "すでにサーバー版のインスタンスがあり、同期を設定したい",
|
||||
"init-in-progress": "ドキュメントの初期化処理を実行中",
|
||||
"redirecting": "まもなくアプリケーションにリダイレクトされます。",
|
||||
"next": "次へ",
|
||||
"title": "セットアップ"
|
||||
},
|
||||
"setup_sync-from-desktop": {
|
||||
"heading": "デスクトップから同期",
|
||||
"description": "このセットアップはデスクトップインスタンスから開始する必要があります:",
|
||||
"step1": "Trilium Notes のデスクトップインスタンスを開きます。",
|
||||
"step2": "Triliumメニューから、設定をクリックします。",
|
||||
"step3": "同期をクリックします。",
|
||||
"step4": "サーバーインスタンスアドレスを {{- host}} に変更し、保存をクリックします。",
|
||||
"step5": "「同期テスト」をクリックして、接続が成功したか確認してください。",
|
||||
"step6": "これらのステップを完了したら、{{- link}} をクリックしてください。",
|
||||
"step6-here": "ここ"
|
||||
},
|
||||
"setup_sync-from-server": {
|
||||
"heading": "サーバーから同期",
|
||||
"instructions": "Triliumサーバーのアドレスと認証情報を下記に入力してください。これにより、Triliumドキュメント全体がサーバーからダウンロードされ、同期が設定されます。ドキュメントのサイズと接続速度によっては、時間がかかる場合があります。",
|
||||
"server-host": "Triliumサーバーのアドレス",
|
||||
"server-host-placeholder": "https://<hostname>:<port>",
|
||||
"proxy-server": "プロキシサーバー(オプション)",
|
||||
"proxy-server-placeholder": "https://<hostname>:<port>",
|
||||
"proxy-instruction": "プロキシ設定を空欄にすると、システムプロキシが使用されます(デスクトップアプリケーションにのみ適用されます)",
|
||||
"password": "パスワード",
|
||||
"password-placeholder": "パスワード",
|
||||
"finish-setup": "セットアップ完了",
|
||||
"back": "戻る",
|
||||
"note": "ノート:"
|
||||
},
|
||||
"setup_sync-in-progress": {
|
||||
"heading": "同期中",
|
||||
"successful": "同期が正しく設定されました。最初の同期が完了するまでしばらく時間がかかります。完了すると、ログインページにリダイレクトされます。",
|
||||
"outstanding-items": "同期が未完了のアイテム:",
|
||||
"outstanding-items-default": "N/A"
|
||||
},
|
||||
"weekdays": {
|
||||
"monday": "月曜日",
|
||||
"tuesday": "火曜日",
|
||||
|
||||
@@ -202,14 +202,5 @@
|
||||
"password": "암호",
|
||||
"password-confirmation": "암호 확인",
|
||||
"button": "암호 설정"
|
||||
},
|
||||
"setup": {
|
||||
"heading": "Trilium 노트 셋업",
|
||||
"next": "다음",
|
||||
"init-in-progress": "문서 초기화 진행 중",
|
||||
"title": "셋업"
|
||||
},
|
||||
"setup_sync-from-desktop": {
|
||||
"step5": "연결 설정이 성공적인지 확인을 위해 \"Test sync\" 버튼을 클릭하세요."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,10 +18,6 @@
|
||||
"set_password": {
|
||||
"password": "Passord"
|
||||
},
|
||||
"setup": {
|
||||
"next": "Neste",
|
||||
"title": "Konfigurasjon"
|
||||
},
|
||||
"login": {
|
||||
"title": "Logg inn",
|
||||
"password": "Passord",
|
||||
|
||||
@@ -331,27 +331,6 @@
|
||||
"password-confirmation": "Potwierdź hasło",
|
||||
"button": "Ustaw hasło"
|
||||
},
|
||||
"setup": {
|
||||
"heading": "Instalacja Trilium Notes",
|
||||
"new-document": "Jestem nowym użytkownikiem i chcę stworzyć nową bazę danych dla moich notatek",
|
||||
"sync-from-desktop": "Mam już aplikację desktopową i chcę skonfigurować z nią synchronizację",
|
||||
"sync-from-server": "Mam już serwer i chcę skonfigurować z nim synchronizację",
|
||||
"next": "Dalej",
|
||||
"init-in-progress": "Trwa inicjalizacja dokumentu",
|
||||
"redirecting": "Zaraz zostaniesz przekierowany do aplikacji.",
|
||||
"title": "Instalacja"
|
||||
},
|
||||
"setup_sync-from-desktop": {
|
||||
"heading": "Synchronizacja z aplikacji desktopowej",
|
||||
"description": "Tę konfigurację należy rozpocząć w aplikacji desktopowej:",
|
||||
"step1": "Otwórz aplikację desktopową Trilium Notes.",
|
||||
"step2": "Z menu Trilium wybierz Opcje.",
|
||||
"step3": "Przejdź do kategorii Synchronizacja.",
|
||||
"step4": "Zmień adres serwera na: {{- host}} i kliknij Zapisz.",
|
||||
"step5": "Kliknij przycisk \"Testuj synchronizację\", aby zweryfikować połączenie.",
|
||||
"step6": "Po wykonaniu tych kroków kliknij {{- link}}.",
|
||||
"step6-here": "tutaj"
|
||||
},
|
||||
"share_page": {
|
||||
"no-content": "Ta notatka nie posiada treści.",
|
||||
"parent": "nadrzędna:",
|
||||
|
||||
@@ -220,47 +220,6 @@
|
||||
"password-confirmation": "Confirmar Palavra-passe",
|
||||
"button": "Definir palavra-passe"
|
||||
},
|
||||
"setup": {
|
||||
"heading": "Trilium Notes setup",
|
||||
"new-document": "Sou um novo utilizador e quero criar um documento Trilium para as minhas notas",
|
||||
"sync-from-desktop": "Já tenho uma instância no desktop e quero configurar a sincronização com ela",
|
||||
"sync-from-server": "Já tenho uma instância no servidor e quero configurar a sincronização com ela",
|
||||
"next": "Avançar",
|
||||
"init-in-progress": "Inicialização do documento em andamento",
|
||||
"redirecting": "Será redirecionado para a aplicação brevemente.",
|
||||
"title": "Configuração"
|
||||
},
|
||||
"setup_sync-from-desktop": {
|
||||
"heading": "Sincronizar com Desktop",
|
||||
"description": "Esta configuração deve ser iniciada a partir da instância do desktop:",
|
||||
"step1": "Abra a sua instância do Trilium Notes no desktop.",
|
||||
"step2": "No menu do Trilium, clique em Opções.",
|
||||
"step3": "Clique na categoria Sincronização.",
|
||||
"step4": "Altere o endereço da instância do servidor para: {{- host}} e clique em Gravar.",
|
||||
"step5": "Clique no botão \"Testar sincronização\" para verificar se a conexão foi bem-sucedida.",
|
||||
"step6": "Depois de concluir estas etapas, clique em {{- link}}.",
|
||||
"step6-here": "aqui"
|
||||
},
|
||||
"setup_sync-from-server": {
|
||||
"heading": "Sincronizar do Servidor",
|
||||
"instructions": "Por favor, insira abaixo o endereço e as credenciais do servidor Trilium. Isto descarragará de todo o documento Trilium do servidor e configurará a sincronização com ele. Dependendo do tamanho do documento e da velocidade da conexão, isto pode levar algum tempo.",
|
||||
"server-host": "Endereço do servidor Trilium",
|
||||
"server-host-placeholder": "https://<hostname>:<port>",
|
||||
"proxy-server": "Servidor proxy (opcional)",
|
||||
"proxy-server-placeholder": "https://<hostname>:<port>",
|
||||
"note": "Nota:",
|
||||
"proxy-instruction": "Se deixar o campo de proxy em branco, o proxy do sistema será usado (aplicável apenas à aplicação desktop)",
|
||||
"password": "Palavra-passe",
|
||||
"password-placeholder": "Palavra-passe",
|
||||
"back": "Voltar",
|
||||
"finish-setup": "Terminar configuração"
|
||||
},
|
||||
"setup_sync-in-progress": {
|
||||
"heading": "Sincronização em andamento",
|
||||
"successful": "A sincronização foi configurada corretamente. Levará algum tempo para que a sincronização inicial seja concluída. Quando terminar, será redirecionado para a página de login.",
|
||||
"outstanding-items": "Elementos de sincronização pendentes:",
|
||||
"outstanding-items-default": "N/A"
|
||||
},
|
||||
"share_404": {
|
||||
"title": "Não encontrado",
|
||||
"heading": "Não encontrado"
|
||||
|
||||
@@ -123,47 +123,6 @@
|
||||
"password-confirmation": "Confirmar Senha",
|
||||
"button": "Definir senha"
|
||||
},
|
||||
"setup": {
|
||||
"heading": "Trilium Notes setup",
|
||||
"new-document": "Sou um novo usuário e quero criar um novo documento Trilium para minhas notas",
|
||||
"sync-from-desktop": "Já tenho uma instância no desktop e quero configurar a sincronização com ela",
|
||||
"sync-from-server": "Já tenho uma instância no servidor e quero configurar a sincronização com ela",
|
||||
"next": "Avançar",
|
||||
"init-in-progress": "Inicialização do documento em andamento",
|
||||
"redirecting": "Você será redirecionado para o aplicativo em breve.",
|
||||
"title": "Setup"
|
||||
},
|
||||
"setup_sync-from-desktop": {
|
||||
"heading": "Sincronizar com Desktop",
|
||||
"description": "Esta configuração deve ser iniciada a partir da instância do desktop:",
|
||||
"step1": "Abra sua instância do Trilium Notes no desktop.",
|
||||
"step2": "No menu do Trilium, clique em Opções.",
|
||||
"step3": "Clique na categoria Sincronização.",
|
||||
"step4": "Altere o endereço da instância do servidor para: {{- host}} e clique em Salvar.",
|
||||
"step5": "Clique no botão \"Testar sincronização\" para verificar se a conexão foi bem-sucedida.",
|
||||
"step6": "Depois de concluir essas etapas, clique em {{- link}}.",
|
||||
"step6-here": "Aqui"
|
||||
},
|
||||
"setup_sync-from-server": {
|
||||
"heading": "Sincronizar do Servidor",
|
||||
"instructions": "Por favor, insira abaixo o endereço e as credenciais do servidor Trilium. Isso fará o download de todo o documento Trilium do servidor e configurará a sincronização com ele. Dependendo do tamanho do documento e da velocidade da conexão, isso pode levar algum tempo.",
|
||||
"server-host": "Endereço do servidor Trilium",
|
||||
"server-host-placeholder": "https://<hostname>:<port>",
|
||||
"proxy-server": "Servidor proxy (opcional)",
|
||||
"proxy-server-placeholder": "https://<hostname>:<port>",
|
||||
"note": "Nota:",
|
||||
"proxy-instruction": "Se você deixar o campo de proxy em branco, o proxy do sistema será usado (aplicável apenas ao aplicativo desktop)",
|
||||
"password": "Senha",
|
||||
"password-placeholder": "Senha",
|
||||
"back": "Voltar",
|
||||
"finish-setup": "Terminar configuração"
|
||||
},
|
||||
"setup_sync-in-progress": {
|
||||
"heading": "Sincronização em andamento",
|
||||
"successful": "A sincronização foi configurada corretamente. Levará algum tempo para que a sincronização inicial seja concluída. Quando terminar, você será redirecionado para a página de login.",
|
||||
"outstanding-items": "Itens de sincronização pendentes:",
|
||||
"outstanding-items-default": "N/A"
|
||||
},
|
||||
"share_404": {
|
||||
"title": "Não encontrado",
|
||||
"heading": "Não encontrado"
|
||||
|
||||
@@ -123,47 +123,6 @@
|
||||
"password": "Parolă",
|
||||
"password-confirmation": "Confirmarea parolei"
|
||||
},
|
||||
"setup": {
|
||||
"heading": "Instalarea Trilium Notes",
|
||||
"init-in-progress": "Se inițializează documentul",
|
||||
"new-document": "Sunt un utilizator nou și doresc să creez un document Trilium pentru notițele mele",
|
||||
"next": "Mai departe",
|
||||
"redirecting": "În scurt timp veți fi redirecționat la aplicație.",
|
||||
"sync-from-desktop": "Am deja o instanță de desktop și aș dori o sincronizare cu aceasta",
|
||||
"sync-from-server": "Am deja o instanță de server și doresc o sincronizare cu aceasta",
|
||||
"title": "Inițializare"
|
||||
},
|
||||
"setup_sync-from-desktop": {
|
||||
"description": "Acești pași trebuie urmați de pe aplicația de desktop:",
|
||||
"heading": "Sincronizare cu aplicația desktop",
|
||||
"step1": "Deschideți aplicația Trilium Notes pentru desktop.",
|
||||
"step2": "Din meniul Trilium, dați clic pe Opțiuni.",
|
||||
"step3": "Clic pe categoria „Sincronizare”.",
|
||||
"step4": "Schimbați adresa server-ului către: {{- host}} și apăsați „Salvează”.",
|
||||
"step5": "Clic pe butonul „Testează sincronizarea” pentru a verifica dacă conexiunea a fost făcută cu succes.",
|
||||
"step6": "După ce ați completat pașii, dați click {{- link}}.",
|
||||
"step6-here": "aici"
|
||||
},
|
||||
"setup_sync-from-server": {
|
||||
"back": "Înapoi",
|
||||
"finish-setup": "Finalizează inițializarea",
|
||||
"heading": "Sincronizare cu server-ul",
|
||||
"instructions": "Introduceți adresa server-ului Trilium și credențialele în secțiunea de jos. Astfel se va descărca întregul document Trilium de pe server și se va configura sincronizarea cu acesta. În funcție de dimensiunea documentului și viteza rețelei, acest proces poate dura.",
|
||||
"note": "De remarcat:",
|
||||
"password": "Parolă",
|
||||
"proxy-instruction": "Dacă lăsați câmpul de proxy nesetat, proxy-ul de sistem va fi folosit (valabil doar pentru aplicația de desktop)",
|
||||
"proxy-server": "Server-ul proxy (opțional)",
|
||||
"proxy-server-placeholder": "https://<sistem>:<port>",
|
||||
"server-host": "Adresa server-ului Trilium",
|
||||
"server-host-placeholder": "https://<sistem>:<port>",
|
||||
"password-placeholder": "Parolă"
|
||||
},
|
||||
"setup_sync-in-progress": {
|
||||
"heading": "Sincronizare în curs",
|
||||
"outstanding-items": "Elemente de sincronizat:",
|
||||
"outstanding-items-default": "-",
|
||||
"successful": "Sincronizarea a fost configurată cu succes. Poate dura ceva timp pentru a finaliza sincronizarea inițială. După efectuarea ei se va redirecționa către pagina de autentificare."
|
||||
},
|
||||
"share_404": {
|
||||
"heading": "Pagină negăsită",
|
||||
"title": "Pagină negăsită"
|
||||
|
||||
@@ -309,36 +309,6 @@
|
||||
"button": "Установить пароль",
|
||||
"description": "Прежде чем начать использовать Trilium через веб-браузер, вам необходимо установить пароль. Этот пароль будет использоваться для входа."
|
||||
},
|
||||
"setup": {
|
||||
"next": "Далее",
|
||||
"title": "Настройка",
|
||||
"heading": "Настройка Trilium",
|
||||
"new-document": "Я новый пользователь и хочу создать новый документ Trilium для своих заметок",
|
||||
"sync-from-desktop": "У меня уже есть настольное приложение, и я хочу настроить синхронизацию с ним",
|
||||
"sync-from-server": "У меня уже есть сервер, и я хочу настроить синхронизацию с ним",
|
||||
"init-in-progress": "Идет инициализация документа",
|
||||
"redirecting": "Вскоре вы будете перенаправлены на страницу приложения."
|
||||
},
|
||||
"setup_sync-from-server": {
|
||||
"server-host-placeholder": "https://<hostname>:<port>",
|
||||
"proxy-server-placeholder": "https://<hostname>:<port>",
|
||||
"password": "Пароль",
|
||||
"password-placeholder": "Пароль",
|
||||
"back": "Назад",
|
||||
"finish-setup": "Завершение настройки",
|
||||
"heading": "Синхронизация с сервера",
|
||||
"instructions": "Введите адрес сервера Trilium и учётные данные ниже. Документ Trilium будет полностью загружен с сервера и синхронизирован с ним. В зависимости от размера документа и скорости вашего соединения, это может занять некоторое время.",
|
||||
"server-host": "Адрес сервера Trilium",
|
||||
"proxy-server": "Прокси-сервер (необязательно)",
|
||||
"note": "Заметка:",
|
||||
"proxy-instruction": "Если оставить настройки прокси-сервера пустыми, будет использоваться системный прокси-сервер (применимо только к настольному приложению)"
|
||||
},
|
||||
"setup_sync-in-progress": {
|
||||
"outstanding-items-default": "Н/Д",
|
||||
"heading": "Синхронизация в процессе",
|
||||
"successful": "Синхронизация настроена. Первоначальная синхронизация займёт некоторое время. После её завершения вы будете перенаправлены на страницу входа.",
|
||||
"outstanding-items": "Оставшиеся элементы синхронизации:"
|
||||
},
|
||||
"special_notes": {
|
||||
"search_prefix": "Поиск:"
|
||||
},
|
||||
|
||||
@@ -123,47 +123,6 @@
|
||||
"password-confirmation": "確認密碼",
|
||||
"button": "設定密碼"
|
||||
},
|
||||
"setup": {
|
||||
"heading": "Trilium 筆記設定",
|
||||
"new-document": "我是新用戶,我想為我的筆記建立一個新的 Trilium 文件",
|
||||
"sync-from-desktop": "我已經擁有桌面版本,想設定與它進行同步",
|
||||
"sync-from-server": "我已經擁有伺服器版本,想設定與它進行同步",
|
||||
"next": "下一步",
|
||||
"init-in-progress": "文件正在初始化",
|
||||
"redirecting": "您即將被重新導向至應用程式。",
|
||||
"title": "設定"
|
||||
},
|
||||
"setup_sync-from-desktop": {
|
||||
"heading": "從桌面版同步",
|
||||
"description": "此設定需要從桌面版本啟動:",
|
||||
"step1": "打開您的桌面版 Trilium 筆記。",
|
||||
"step2": "從 Trilium 選單中,點擊「選項」。",
|
||||
"step3": "點擊「同步」類別。",
|
||||
"step4": "將伺服器版網址更改為:{{- host}} 並點擊儲存。",
|
||||
"step5": "點擊「測試同步」以驗證連接是否成功。",
|
||||
"step6": "完成這些步驟後,點擊 {{- link}}。",
|
||||
"step6-here": "這裡"
|
||||
},
|
||||
"setup_sync-from-server": {
|
||||
"heading": "從伺服器同步",
|
||||
"instructions": "請在下方輸入 Trilium 伺服器網址和密碼。這將從伺服器下載整個 Trilium 數據庫檔案並同步。取決於數據庫大小和您的連接速度,這可能需要一段時間。",
|
||||
"server-host": "Trilium 伺服器網址",
|
||||
"server-host-placeholder": "https://<主機名稱>:<端口>",
|
||||
"proxy-server": "代理伺服器(可選)",
|
||||
"proxy-server-placeholder": "https://<主機名稱>:<端口>",
|
||||
"note": "注意:",
|
||||
"proxy-instruction": "如果您將代理設定留空,將使用系統代理(僅適用於桌面版)",
|
||||
"password": "密碼",
|
||||
"password-placeholder": "密碼",
|
||||
"back": "返回",
|
||||
"finish-setup": "完成設定"
|
||||
},
|
||||
"setup_sync-in-progress": {
|
||||
"heading": "正在同步",
|
||||
"successful": "已正確設定同步。初次同步可能需要一些時間。完成後,您將被重新導向至登入頁面。",
|
||||
"outstanding-items": "未完成的同步項目:",
|
||||
"outstanding-items-default": "無"
|
||||
},
|
||||
"share_404": {
|
||||
"title": "未找到",
|
||||
"heading": "未找到"
|
||||
|
||||
@@ -220,47 +220,6 @@
|
||||
"password-confirmation": "Підтвердження пароля",
|
||||
"button": "Встановити пароль"
|
||||
},
|
||||
"setup": {
|
||||
"heading": "Налаштування Trilium Notes",
|
||||
"new-document": "Я новий користувач і хочу створити новий документ Trilium для своїх нотаток",
|
||||
"sync-from-desktop": "У мене вже є екземпляр для ПК і я хочу налаштувати синхронізацію з ним",
|
||||
"sync-from-server": "У мене вже є екземпляр сервера, і я хочу налаштувати синхронізацію з ним",
|
||||
"next": "Наступна",
|
||||
"init-in-progress": "Триває ініціалізація документа",
|
||||
"redirecting": "Невдовзі вас буде перенаправлено до програми.",
|
||||
"title": "Налаштування"
|
||||
},
|
||||
"setup_sync-from-desktop": {
|
||||
"heading": "Синхронізація з ПК",
|
||||
"description": "Це налаштування потрібно ініціювати з екземпляра ПК:",
|
||||
"step1": "Відкрийте свій екземпляр Trilium Notes на ПК.",
|
||||
"step2": "У меню Trilium натисніть Параметри.",
|
||||
"step3": "Натисніть на Категорія Синхронізації.",
|
||||
"step4": "Змініть адресу екземпляра сервера на: {{- host}} та натисніть кнопку Зберегти.",
|
||||
"step5": "Натисніть \"Тест синхронізації\", щоб перевірити успішність підключення.",
|
||||
"step6": "Після виконання цих кроків натисніть {{- link}}.",
|
||||
"step6-here": "тут"
|
||||
},
|
||||
"setup_sync-from-server": {
|
||||
"heading": "Синхронізація з сервера",
|
||||
"instructions": "Будь ласка, введіть адресу сервера Trilium та облікові дані нижче. Це завантажить весь документ Trilium із сервера та налаштує синхронізацію з ним. Залежно від розміру документа та швидкості вашого з’єднання, це може зайняти деякий час.",
|
||||
"server-host": "Адреса сервера Trilium",
|
||||
"server-host-placeholder": "https://<hostname>:<port>",
|
||||
"proxy-server": "Проксі-сервер (необов'язково)",
|
||||
"proxy-server-placeholder": "https://<hostname>:<port>",
|
||||
"note": "Нотатка:",
|
||||
"proxy-instruction": "Якщо залишити налаштування проксі-сервера порожнім, використовуватиметься системний проксі-сервер (стосується лише програми на ПК)",
|
||||
"password": "Пароль",
|
||||
"password-placeholder": "Пароль",
|
||||
"back": "Назад",
|
||||
"finish-setup": "Завершити налаштування"
|
||||
},
|
||||
"setup_sync-in-progress": {
|
||||
"heading": "Триває синхронізація",
|
||||
"successful": "Синхронізацію налаштовано правильно. Початкова синхронізація завершиться через деякий час. Після її завершення вас буде перенаправлено на сторінку входу.",
|
||||
"outstanding-items": "Незавершені елементи синхронізації:",
|
||||
"outstanding-items-default": "Недоступно"
|
||||
},
|
||||
"share_404": {
|
||||
"title": "Не знайдено",
|
||||
"heading": "Не знайдено"
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<link rel="shortcut icon" href="favicon.ico">
|
||||
<meta name="mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, viewport-fit=cover" />
|
||||
<link rel="manifest" crossorigin="use-credentials" href="../manifest.webmanifest">
|
||||
<title>Trilium Notes</title>
|
||||
<script src="../<%= appPath %>/runtime.js" crossorigin type="module"></script>
|
||||
</head>
|
||||
<body
|
||||
id="trilium-print"
|
||||
lang="<%= currentLocale.id %>" dir="<%= currentLocale.rtl ? 'rtl' : 'ltr' %>"
|
||||
>
|
||||
<noscript><%= t("javascript-required") %></noscript>
|
||||
|
||||
<script>
|
||||
// hide body to reduce flickering on the startup. This is done through JS and not CSS to not hide <noscript>
|
||||
document.getElementsByTagName("body")[0].style.display = "none";
|
||||
</script>
|
||||
|
||||
<%- include("./partials/windowGlobal.ejs", locals) %>
|
||||
|
||||
<!-- Required for correct loading of scripts in Electron -->
|
||||
<script>if (typeof module === 'object') {window.module = module; module = undefined;}</script>
|
||||
|
||||
<script src="../<%= appPath %>/print.js" crossorigin type="module"></script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,178 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1">
|
||||
<meta id="syncInProgress" content="<%= syncInProgress ? 1 : 0 %>" />
|
||||
<title><%= t("setup.title") %></title>
|
||||
|
||||
<style>
|
||||
body {
|
||||
/* Prevent the content from being rendered before the main stylesheet loads */
|
||||
display: none;
|
||||
}
|
||||
.lds-ring {
|
||||
display: inline-block;
|
||||
position: relative;
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
}
|
||||
.lds-ring div {
|
||||
box-sizing: border-box;
|
||||
display: block;
|
||||
position: absolute;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
margin: 8px;
|
||||
border: 6px solid black;
|
||||
border-radius: 50%;
|
||||
animation: lds-ring 1.2s cubic-bezier(0.5, 0, 0.5, 1) infinite;
|
||||
border-color: black transparent transparent transparent;
|
||||
}
|
||||
.lds-ring div:nth-child(1) {
|
||||
animation-delay: -0.45s;
|
||||
}
|
||||
.lds-ring div:nth-child(2) {
|
||||
animation-delay: -0.3s;
|
||||
}
|
||||
.lds-ring div:nth-child(3) {
|
||||
animation-delay: -0.15s;
|
||||
}
|
||||
@keyframes lds-ring {
|
||||
0% {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
100% {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
<script src="<%= appPath %>/runtime.js" crossorigin type="module"></script>
|
||||
</head>
|
||||
<body lang="<%= currentLocale.id %>" dir="<%= currentLocale.rtl ? 'rtl' : 'ltr' %>">
|
||||
<noscript><%= t("javascript-required") %></noscript>
|
||||
<div class="container">
|
||||
<div id="setup-dialog" class="col-md-12 col-lg-8 col-xl-6 mx-auto" style="padding-top: 25px; font-size: larger; display: none;">
|
||||
<h1><%= t("setup.heading") %></h1>
|
||||
|
||||
<div class="alert alert-warning" id="alert" style="display: none;">
|
||||
</div>
|
||||
|
||||
<div id="setup-type-section" style="margin-top: 20px;">
|
||||
<form id="setup-type-form">
|
||||
|
||||
<div class="radio" style="margin-bottom: 15px;">
|
||||
<label class="tn-radio">
|
||||
<input type="radio" name="setup-type" value="new-document">
|
||||
<%= t("setup.new-document") %>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="radio" style="margin-bottom: 15px;">
|
||||
<label class="tn-radio">
|
||||
<input type="radio" name="setup-type" value="sync-from-desktop">
|
||||
<%= t("setup.sync-from-desktop") %>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="radio" style="margin-bottom: 15px;">
|
||||
<label class="tn-radio">
|
||||
<input type="radio" name="setup-type" value="sync-from-server">
|
||||
<%= t("setup.sync-from-server") %>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<button type="submit" id="setup-type-next" class="btn btn-primary" disabled><%= t("setup.next") %></button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div id="new-document-in-progress-section">
|
||||
<h2><%= t("setup.init-in-progress") %></h2>
|
||||
|
||||
<div style="display: flex; justify-content: flex-start; margin-top: 20px;">
|
||||
<div class="lds-ring" style="margin-right: 20px;">
|
||||
<div></div>
|
||||
<div></div>
|
||||
<div></div>
|
||||
<div></div>
|
||||
</div>
|
||||
|
||||
<div style="line-height: 60px;">
|
||||
<p><%= t("setup.redirecting") %></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="sync-from-desktop-section">
|
||||
<h2><%= t("setup_sync-from-desktop.heading") %></h2>
|
||||
|
||||
<p><%= t("setup_sync-from-desktop.description") %></p>
|
||||
|
||||
<ol>
|
||||
<li><%= t("setup_sync-from-desktop.step1") %></li>
|
||||
<li><%= t("setup_sync-from-desktop.step2") %></li>
|
||||
<li><%= t("setup_sync-from-desktop.step3") %></li>
|
||||
<li><%- t("setup_sync-from-desktop.step4", { host: '<span id="current-host"></span>'}) %></li>
|
||||
<li><%= t("setup_sync-from-desktop.step5") %></li>
|
||||
<li><%- t("setup_sync-from-desktop.step6", { link: `<a href="/">${t("setup_sync-from-desktop.step6-here")}</a>` }) %></li>
|
||||
</ol>
|
||||
|
||||
<button type="button" data-action="back" class="btn btn-secondary">Back</button>
|
||||
</div>
|
||||
|
||||
<div id="sync-from-server-section">
|
||||
<form id="sync-from-server-form">
|
||||
|
||||
<h2><%= t("setup_sync-from-server.heading") %></h2>
|
||||
|
||||
<p><%= t("setup_sync-from-server.instructions") %></p>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="sync-server-host"><%= t("setup_sync-from-server.server-host") %></label>
|
||||
<input type="text" id="sync-server-host" class="form-control" placeholder="<%= t("setup_sync-from-server.server-host-placeholder") %>">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="sync-proxy"><%= t("setup_sync-from-server.proxy-server") %></label>
|
||||
<input type="text" id="sync-proxy" class="form-control" placeholder="<%= t("setup_sync-from-server.proxy-server-placeholder") %>">
|
||||
|
||||
<p><strong><%= t("setup_sync-from-server.note") %></strong> <%= t("setup_sync-from-server.proxy-instruction") %></p>
|
||||
</div>
|
||||
<div class="form-group" style="margin-bottom: 8px;">
|
||||
<label for="password"><%= t("setup_sync-from-server.password") %></label>
|
||||
<input type="password" id="password" class="form-control" placeholder="<%= t("setup_sync-from-server.password-placeholder") %>">
|
||||
</div>
|
||||
|
||||
<button type="button" data-action="back" class="btn btn-secondary"><%= t("setup_sync-from-server.back") %></button>
|
||||
|
||||
<button type="submit" class="btn btn-primary"><%= t("setup_sync-from-server.finish-setup") %></button>
|
||||
</form>
|
||||
|
||||
</div>
|
||||
|
||||
<div id="sync-in-progress-section">
|
||||
<h2><%= t("setup_sync-in-progress.heading") %></h2>
|
||||
|
||||
<div class="alert alert-success"><%= t("setup_sync-in-progress.successful") %></div>
|
||||
|
||||
<div><%= t("setup_sync-in-progress.outstanding-items") %> <strong id="outstanding-syncs"><%= t("setup_sync-in-progress.outstanding-items-default") %></strong></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script type="text/javascript">
|
||||
global = globalThis; /* fixes https://github.com/webpack/webpack/issues/10035 */
|
||||
|
||||
window.glob = {
|
||||
componentId: ''
|
||||
};
|
||||
</script>
|
||||
|
||||
<!-- Required for correct loading of scripts in Electron -->
|
||||
<script>if (typeof module === 'object') {window.module = module; module = undefined;}</script>
|
||||
|
||||
<script src="<%= appPath %>/setup.js" crossorigin type="module"></script>
|
||||
<link href="<%= assetPath %>/stylesheets/theme-light.css" rel="stylesheet" />
|
||||
<link href="<%= assetPath %>/stylesheets/theme-next.css" rel="stylesheet" />
|
||||
<link href="<%= assetPath %>/stylesheets/style.css" rel="stylesheet">
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,7 +1,2 @@
|
||||
"use strict";
|
||||
|
||||
import Becca from "./becca-interface.js";
|
||||
|
||||
const becca = new Becca();
|
||||
|
||||
import { becca } from "@triliumnext/core";
|
||||
export default becca;
|
||||
|
||||
@@ -1,260 +1,2 @@
|
||||
|
||||
|
||||
import type { AttachmentRow } from "@triliumnext/commons";
|
||||
|
||||
import dateUtils from "../../services/date_utils.js";
|
||||
import log from "../../services/log.js";
|
||||
import noteService from "../../services/notes.js";
|
||||
import protectedSessionService from "../../services/protected_session.js";
|
||||
import sql from "../../services/sql.js";
|
||||
import utils from "../../services/utils.js";
|
||||
import AbstractBeccaEntity from "./abstract_becca_entity.js";
|
||||
import type BBranch from "./bbranch.js";
|
||||
import type BNote from "./bnote.js";
|
||||
|
||||
const attachmentRoleToNoteTypeMapping = {
|
||||
image: "image",
|
||||
file: "file"
|
||||
};
|
||||
|
||||
interface ContentOpts {
|
||||
// TODO: Found in bnote.ts, to check if it's actually used and not a typo.
|
||||
forceSave?: boolean;
|
||||
|
||||
/** will also save this BAttachment entity */
|
||||
forceFullSave?: boolean;
|
||||
/** override frontend heuristics on when to reload, instruct to reload */
|
||||
forceFrontendReload?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Attachment represent data related/attached to the note. Conceptually similar to attributes, but intended for
|
||||
* larger amounts of data and generally not accessible to the user.
|
||||
*/
|
||||
class BAttachment extends AbstractBeccaEntity<BAttachment> {
|
||||
static get entityName() {
|
||||
return "attachments";
|
||||
}
|
||||
static get primaryKeyName() {
|
||||
return "attachmentId";
|
||||
}
|
||||
static get hashedProperties() {
|
||||
return ["attachmentId", "ownerId", "role", "mime", "title", "blobId", "utcDateScheduledForErasureSince"];
|
||||
}
|
||||
|
||||
noteId?: number;
|
||||
attachmentId?: string;
|
||||
/** either noteId or revisionId to which this attachment belongs */
|
||||
ownerId!: string;
|
||||
role!: string;
|
||||
mime!: string;
|
||||
title!: string;
|
||||
type?: keyof typeof attachmentRoleToNoteTypeMapping;
|
||||
position?: number;
|
||||
utcDateScheduledForErasureSince?: string | null;
|
||||
/** optionally added to the entity */
|
||||
contentLength?: number;
|
||||
isDecrypted?: boolean;
|
||||
|
||||
constructor(row: AttachmentRow) {
|
||||
super();
|
||||
|
||||
this.updateFromRow(row);
|
||||
this.decrypt();
|
||||
}
|
||||
|
||||
updateFromRow(row: AttachmentRow): void {
|
||||
if (!row.ownerId?.trim()) {
|
||||
throw new Error("'ownerId' must be given to initialize a Attachment entity");
|
||||
} else if (!row.role?.trim()) {
|
||||
throw new Error("'role' must be given to initialize a Attachment entity");
|
||||
} else if (!row.mime?.trim()) {
|
||||
throw new Error("'mime' must be given to initialize a Attachment entity");
|
||||
} else if (!row.title?.trim()) {
|
||||
throw new Error("'title' must be given to initialize a Attachment entity");
|
||||
}
|
||||
|
||||
this.attachmentId = row.attachmentId;
|
||||
this.ownerId = row.ownerId;
|
||||
this.role = row.role;
|
||||
this.mime = row.mime;
|
||||
this.title = row.title;
|
||||
this.position = row.position;
|
||||
this.blobId = row.blobId;
|
||||
this.isProtected = !!row.isProtected;
|
||||
this.dateModified = row.dateModified;
|
||||
this.utcDateModified = row.utcDateModified;
|
||||
this.utcDateScheduledForErasureSince = row.utcDateScheduledForErasureSince;
|
||||
this.contentLength = row.contentLength;
|
||||
}
|
||||
|
||||
copy(): BAttachment {
|
||||
return new BAttachment({
|
||||
ownerId: this.ownerId,
|
||||
role: this.role,
|
||||
mime: this.mime,
|
||||
title: this.title,
|
||||
blobId: this.blobId,
|
||||
isProtected: this.isProtected
|
||||
});
|
||||
}
|
||||
|
||||
getNote(): BNote {
|
||||
return this.becca.notes[this.ownerId];
|
||||
}
|
||||
|
||||
/** @returns true if the note has string content (not binary) */
|
||||
override hasStringContent(): boolean {
|
||||
return utils.isStringNote(this.type, this.mime); // here was !== undefined && utils.isStringNote(this.type, this.mime); I dont know why we need !=undefined. But it filters out canvas libary items
|
||||
}
|
||||
|
||||
isContentAvailable() {
|
||||
return (
|
||||
!this.attachmentId || // new attachment which was not encrypted yet
|
||||
!this.isProtected ||
|
||||
protectedSessionService.isProtectedSessionAvailable()
|
||||
);
|
||||
}
|
||||
|
||||
getTitleOrProtected() {
|
||||
return this.isContentAvailable() ? this.title : "[protected]";
|
||||
}
|
||||
|
||||
decrypt() {
|
||||
if (!this.isProtected || !this.attachmentId) {
|
||||
this.isDecrypted = true;
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.isDecrypted && protectedSessionService.isProtectedSessionAvailable()) {
|
||||
try {
|
||||
this.title = protectedSessionService.decryptString(this.title) || "";
|
||||
this.isDecrypted = true;
|
||||
} catch (e: any) {
|
||||
log.error(`Could not decrypt attachment ${this.attachmentId}: ${e.message} ${e.stack}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
getContent(): Buffer {
|
||||
return this._getContent() as Buffer;
|
||||
}
|
||||
|
||||
setContent(content: string | Buffer, opts?: ContentOpts) {
|
||||
this._setContent(content, opts);
|
||||
}
|
||||
|
||||
convertToNote(): { note: BNote; branch: BBranch } {
|
||||
// TODO: can this ever be "search"?
|
||||
if ((this.type as string) === "search") {
|
||||
throw new Error(`Note of type search cannot have child notes`);
|
||||
}
|
||||
|
||||
if (!this.getNote()) {
|
||||
throw new Error("Cannot find note of this attachment. It is possible that this is note revision's attachment. " + "Converting note revision's attachments to note is not (yet) supported.");
|
||||
}
|
||||
|
||||
if (!(this.role in attachmentRoleToNoteTypeMapping)) {
|
||||
throw new Error(`Mapping from attachment role '${this.role}' to note's type is not defined`);
|
||||
}
|
||||
|
||||
if (!this.isContentAvailable()) {
|
||||
// isProtected is the same for attachment
|
||||
throw new Error(`Cannot convert protected attachment outside of protected session`);
|
||||
}
|
||||
|
||||
const { note, branch } = noteService.createNewNote({
|
||||
parentNoteId: this.ownerId,
|
||||
title: this.title,
|
||||
type: (attachmentRoleToNoteTypeMapping as any)[this.role],
|
||||
mime: this.mime,
|
||||
content: this.getContent(),
|
||||
isProtected: this.isProtected
|
||||
});
|
||||
|
||||
this.markAsDeleted();
|
||||
|
||||
const parentNote = this.getNote();
|
||||
|
||||
if (this.role === "image" && parentNote.type === "text") {
|
||||
const origContent = parentNote.getContent();
|
||||
|
||||
if (typeof origContent !== "string") {
|
||||
throw new Error(`Note with ID '${note.noteId} has a text type but non-string content.`);
|
||||
}
|
||||
|
||||
const oldAttachmentUrl = `api/attachments/${this.attachmentId}/image/`;
|
||||
const newNoteUrl = `api/images/${note.noteId}/`;
|
||||
|
||||
const fixedContent = utils.replaceAll(origContent, oldAttachmentUrl, newNoteUrl);
|
||||
|
||||
if (fixedContent !== origContent) {
|
||||
parentNote.setContent(fixedContent);
|
||||
}
|
||||
|
||||
noteService.asyncPostProcessContent(note, fixedContent);
|
||||
}
|
||||
|
||||
return { note, branch };
|
||||
}
|
||||
|
||||
getFileName() {
|
||||
const type = this.role === "image" ? "image" : "file";
|
||||
|
||||
return utils.formatDownloadTitle(this.title, type, this.mime);
|
||||
}
|
||||
|
||||
override beforeSaving() {
|
||||
super.beforeSaving();
|
||||
|
||||
if (this.position === undefined || this.position === null) {
|
||||
this.position =
|
||||
10 +
|
||||
sql.getValue<number>(
|
||||
/*sql*/`SELECT COALESCE(MAX(position), 0)
|
||||
FROM attachments
|
||||
WHERE ownerId = ?`,
|
||||
[this.noteId]
|
||||
);
|
||||
}
|
||||
|
||||
this.dateModified = dateUtils.localNowDateTime();
|
||||
this.utcDateModified = dateUtils.utcNowDateTime();
|
||||
}
|
||||
|
||||
getPojo() {
|
||||
return {
|
||||
attachmentId: this.attachmentId,
|
||||
ownerId: this.ownerId,
|
||||
role: this.role,
|
||||
mime: this.mime,
|
||||
title: this.title || undefined,
|
||||
position: this.position,
|
||||
blobId: this.blobId,
|
||||
isProtected: !!this.isProtected,
|
||||
isDeleted: false,
|
||||
dateModified: this.dateModified,
|
||||
utcDateModified: this.utcDateModified,
|
||||
utcDateScheduledForErasureSince: this.utcDateScheduledForErasureSince,
|
||||
contentLength: this.contentLength
|
||||
};
|
||||
}
|
||||
|
||||
override getPojoToSave() {
|
||||
const pojo = this.getPojo();
|
||||
delete pojo.contentLength;
|
||||
|
||||
if (pojo.isProtected) {
|
||||
if (this.isDecrypted) {
|
||||
pojo.title = protectedSessionService.encrypt(pojo.title || "") || undefined;
|
||||
} else {
|
||||
// updating protected note outside of protected session means we will keep original ciphertexts
|
||||
delete pojo.title;
|
||||
}
|
||||
}
|
||||
|
||||
return pojo;
|
||||
}
|
||||
}
|
||||
|
||||
import { BAttachment } from "@triliumnext/core";
|
||||
export default BAttachment;
|
||||
|
||||
@@ -1,227 +1,2 @@
|
||||
"use strict";
|
||||
|
||||
import BNote from "./bnote.js";
|
||||
import AbstractBeccaEntity from "./abstract_becca_entity.js";
|
||||
import dateUtils from "../../services/date_utils.js";
|
||||
import promotedAttributeDefinitionParser from "../../services/promoted_attribute_definition_parser.js";
|
||||
import sanitizeAttributeName from "../../services/sanitize_attribute_name.js";
|
||||
import type { AttributeRow, AttributeType } from "@triliumnext/commons";
|
||||
|
||||
interface SavingOpts {
|
||||
skipValidation?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Attribute is an abstract concept which has two real uses - label (key - value pair)
|
||||
* and relation (representing named relationship between source and target note)
|
||||
*/
|
||||
class BAttribute extends AbstractBeccaEntity<BAttribute> {
|
||||
static get entityName() {
|
||||
return "attributes";
|
||||
}
|
||||
static get primaryKeyName() {
|
||||
return "attributeId";
|
||||
}
|
||||
static get hashedProperties() {
|
||||
return ["attributeId", "noteId", "type", "name", "value", "isInheritable"];
|
||||
}
|
||||
|
||||
attributeId!: string;
|
||||
noteId!: string;
|
||||
type!: AttributeType;
|
||||
name!: string;
|
||||
position!: number;
|
||||
value!: string;
|
||||
isInheritable!: boolean;
|
||||
|
||||
constructor(row?: AttributeRow) {
|
||||
super();
|
||||
|
||||
if (!row) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.updateFromRow(row);
|
||||
this.init();
|
||||
}
|
||||
|
||||
updateFromRow(row: AttributeRow) {
|
||||
this.update([row.attributeId, row.noteId, row.type, row.name, row.value, row.isInheritable, row.position, row.utcDateModified]);
|
||||
}
|
||||
|
||||
update([attributeId, noteId, type, name, value, isInheritable, position, utcDateModified]: any) {
|
||||
this.attributeId = attributeId;
|
||||
this.noteId = noteId;
|
||||
this.type = type;
|
||||
this.name = name;
|
||||
this.position = position;
|
||||
this.value = value || "";
|
||||
this.isInheritable = !!isInheritable;
|
||||
this.utcDateModified = utcDateModified;
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
override init() {
|
||||
if (this.attributeId) {
|
||||
this.becca.attributes[this.attributeId] = this;
|
||||
}
|
||||
|
||||
if (!(this.noteId in this.becca.notes)) {
|
||||
// entities can come out of order in sync, create skeleton which will be filled later
|
||||
this.becca.addNote(this.noteId, new BNote({ noteId: this.noteId }));
|
||||
}
|
||||
|
||||
this.becca.notes[this.noteId].ownedAttributes.push(this);
|
||||
|
||||
const key = `${this.type}-${this.name.toLowerCase()}`;
|
||||
this.becca.attributeIndex[key] = this.becca.attributeIndex[key] || [];
|
||||
this.becca.attributeIndex[key].push(this);
|
||||
|
||||
const targetNote = this.targetNote;
|
||||
|
||||
if (targetNote) {
|
||||
targetNote.targetRelations.push(this);
|
||||
}
|
||||
}
|
||||
|
||||
validate() {
|
||||
if (!["label", "relation"].includes(this.type)) {
|
||||
throw new Error(`Invalid attribute type '${this.type}' in attribute '${this.attributeId}' of note '${this.noteId}'`);
|
||||
}
|
||||
|
||||
if (!this.name?.trim()) {
|
||||
throw new Error(`Invalid empty name in attribute '${this.attributeId}' of note '${this.noteId}'`);
|
||||
}
|
||||
|
||||
if (this.type === "relation" && !(this.value in this.becca.notes)) {
|
||||
throw new Error(`Cannot save relation '${this.name}' of note '${this.noteId}' since it targets not existing note '${this.value}'.`);
|
||||
}
|
||||
}
|
||||
|
||||
get isAffectingSubtree() {
|
||||
return this.isInheritable || (this.type === "relation" && ["template", "inherit"].includes(this.name));
|
||||
}
|
||||
|
||||
get targetNoteId() {
|
||||
// alias
|
||||
return this.type === "relation" ? this.value : undefined;
|
||||
}
|
||||
|
||||
isAutoLink() {
|
||||
return this.type === "relation" && ["internalLink", "imageLink", "relationMapLink", "includeNoteLink"].includes(this.name);
|
||||
}
|
||||
|
||||
get note() {
|
||||
return this.becca.notes[this.noteId];
|
||||
}
|
||||
|
||||
get targetNote() {
|
||||
if (this.type === "relation") {
|
||||
return this.becca.notes[this.value];
|
||||
}
|
||||
}
|
||||
|
||||
getNote() {
|
||||
const note = this.becca.getNote(this.noteId);
|
||||
|
||||
if (!note) {
|
||||
throw new Error(`Note '${this.noteId}' of attribute '${this.attributeId}', type '${this.type}', name '${this.name}' does not exist.`);
|
||||
}
|
||||
|
||||
return note;
|
||||
}
|
||||
|
||||
getTargetNote() {
|
||||
if (this.type !== "relation") {
|
||||
throw new Error(`Attribute '${this.attributeId}' is not a relation.`);
|
||||
}
|
||||
|
||||
if (!this.value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return this.becca.getNote(this.value);
|
||||
}
|
||||
|
||||
isDefinition() {
|
||||
return this.type === "label" && (this.name.startsWith("label:") || this.name.startsWith("relation:"));
|
||||
}
|
||||
|
||||
getDefinition() {
|
||||
return promotedAttributeDefinitionParser.parse(this.value);
|
||||
}
|
||||
|
||||
getDefinedName() {
|
||||
if (this.type === "label" && this.name.startsWith("label:")) {
|
||||
return this.name.substr(6);
|
||||
} else if (this.type === "label" && this.name.startsWith("relation:")) {
|
||||
return this.name.substr(9);
|
||||
} else {
|
||||
return this.name;
|
||||
}
|
||||
}
|
||||
|
||||
override get isDeleted() {
|
||||
return !(this.attributeId in this.becca.attributes);
|
||||
}
|
||||
|
||||
override beforeSaving(opts: SavingOpts = {}) {
|
||||
if (!opts.skipValidation) {
|
||||
this.validate();
|
||||
}
|
||||
|
||||
this.name = sanitizeAttributeName(this.name);
|
||||
|
||||
if (!this.value) {
|
||||
// null value isn't allowed
|
||||
this.value = "";
|
||||
}
|
||||
|
||||
if (this.position === undefined || this.position === null) {
|
||||
const maxExistingPosition = this.getNote()
|
||||
.getAttributes()
|
||||
.reduce((maxPosition, attr) => Math.max(maxPosition, attr.position || 0), 0);
|
||||
|
||||
this.position = maxExistingPosition + 10;
|
||||
}
|
||||
|
||||
if (!this.isInheritable) {
|
||||
this.isInheritable = false;
|
||||
}
|
||||
|
||||
this.utcDateModified = dateUtils.utcNowDateTime();
|
||||
|
||||
super.beforeSaving();
|
||||
|
||||
this.becca.attributes[this.attributeId] = this;
|
||||
}
|
||||
|
||||
getPojo() {
|
||||
return {
|
||||
attributeId: this.attributeId,
|
||||
noteId: this.noteId,
|
||||
type: this.type,
|
||||
name: this.name,
|
||||
position: this.position,
|
||||
value: this.value,
|
||||
isInheritable: this.isInheritable,
|
||||
utcDateModified: this.utcDateModified,
|
||||
isDeleted: false
|
||||
};
|
||||
}
|
||||
|
||||
createClone(type: AttributeType, name: string, value: string, isInheritable?: boolean) {
|
||||
return new BAttribute({
|
||||
noteId: this.noteId,
|
||||
type: type,
|
||||
name: name,
|
||||
value: value,
|
||||
position: this.position,
|
||||
isInheritable: isInheritable,
|
||||
utcDateModified: this.utcDateModified
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
import { BAttribute } from "@triliumnext/core";
|
||||
export default BAttribute;
|
||||
|
||||
@@ -1,288 +1,2 @@
|
||||
"use strict";
|
||||
|
||||
import BNote from "./bnote.js";
|
||||
import AbstractBeccaEntity from "./abstract_becca_entity.js";
|
||||
import dateUtils from "../../services/date_utils.js";
|
||||
import utils from "../../services/utils.js";
|
||||
import TaskContext from "../../services/task_context.js";
|
||||
import cls from "../../services/cls.js";
|
||||
import log from "../../services/log.js";
|
||||
import type { BranchRow } from "@triliumnext/commons";
|
||||
import handlers from "../../services/handlers.js";
|
||||
|
||||
/**
|
||||
* Branch represents a relationship between a child note and its parent note. Trilium allows a note to have multiple
|
||||
* parents.
|
||||
*
|
||||
* Note that you should not rely on the branch's identity, since it can change easily with a note's move.
|
||||
* Always check noteId instead.
|
||||
*/
|
||||
class BBranch extends AbstractBeccaEntity<BBranch> {
|
||||
static get entityName() {
|
||||
return "branches";
|
||||
}
|
||||
static get primaryKeyName() {
|
||||
return "branchId";
|
||||
}
|
||||
// notePosition is not part of hash because it would produce a lot of updates in case of reordering
|
||||
static get hashedProperties() {
|
||||
return ["branchId", "noteId", "parentNoteId", "prefix"];
|
||||
}
|
||||
|
||||
branchId?: string;
|
||||
noteId!: string;
|
||||
parentNoteId!: string;
|
||||
prefix!: string | null;
|
||||
notePosition!: number;
|
||||
isExpanded!: boolean;
|
||||
|
||||
constructor(row?: BranchRow) {
|
||||
super();
|
||||
|
||||
if (!row) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.updateFromRow(row);
|
||||
this.init();
|
||||
}
|
||||
|
||||
updateFromRow(row: BranchRow) {
|
||||
this.update([row.branchId, row.noteId, row.parentNoteId, row.prefix, row.notePosition, row.isExpanded, row.utcDateModified]);
|
||||
}
|
||||
|
||||
update([branchId, noteId, parentNoteId, prefix, notePosition, isExpanded, utcDateModified]: any) {
|
||||
this.branchId = branchId;
|
||||
this.noteId = noteId;
|
||||
this.parentNoteId = parentNoteId;
|
||||
this.prefix = prefix;
|
||||
this.notePosition = notePosition;
|
||||
this.isExpanded = !!isExpanded;
|
||||
this.utcDateModified = utcDateModified;
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
override init() {
|
||||
if (this.branchId) {
|
||||
this.becca.branches[this.branchId] = this;
|
||||
}
|
||||
|
||||
this.becca.childParentToBranch[`${this.noteId}-${this.parentNoteId}`] = this;
|
||||
|
||||
const childNote = this.childNote;
|
||||
|
||||
if (!childNote.parentBranches.includes(this)) {
|
||||
childNote.parentBranches.push(this);
|
||||
}
|
||||
|
||||
if (this.noteId === "root") {
|
||||
return;
|
||||
}
|
||||
|
||||
const parentNote = this.parentNote;
|
||||
if (parentNote) {
|
||||
if (!childNote.parents.includes(parentNote)) {
|
||||
childNote.parents.push(parentNote);
|
||||
}
|
||||
|
||||
if (!parentNote.children.includes(childNote)) {
|
||||
parentNote.children.push(childNote);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
get childNote(): BNote {
|
||||
if (!(this.noteId in this.becca.notes)) {
|
||||
// entities can come out of order in sync/import, create skeleton which will be filled later
|
||||
this.becca.addNote(this.noteId, new BNote({ noteId: this.noteId }));
|
||||
}
|
||||
|
||||
return this.becca.notes[this.noteId];
|
||||
}
|
||||
|
||||
getNote(): BNote {
|
||||
return this.childNote;
|
||||
}
|
||||
|
||||
/** @returns root branch will have undefined parent, all other branches have to have a parent note */
|
||||
get parentNote(): BNote | undefined {
|
||||
if (!(this.parentNoteId in this.becca.notes) && this.parentNoteId !== "none") {
|
||||
// entities can come out of order in sync/import, create skeleton which will be filled later
|
||||
this.becca.addNote(this.parentNoteId, new BNote({ noteId: this.parentNoteId }));
|
||||
}
|
||||
|
||||
return this.becca.notes[this.parentNoteId];
|
||||
}
|
||||
|
||||
override get isDeleted() {
|
||||
return this.branchId == undefined || !(this.branchId in this.becca.branches);
|
||||
}
|
||||
|
||||
/**
|
||||
* Branch is weak when its existence should not hinder deletion of its note.
|
||||
* As a result, note with only weak branches should be immediately deleted.
|
||||
* An example is shared or bookmarked clones - they are created automatically and exist for technical reasons,
|
||||
* not as user-intended actions. From user perspective, they don't count as real clones and for the purpose
|
||||
* of deletion should not act as a clone.
|
||||
*/
|
||||
get isWeak() {
|
||||
return ["_share", "_lbBookmarks"].includes(this.parentNoteId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a branch. If this is a last note's branch, delete the note as well.
|
||||
*
|
||||
* @param deleteId - optional delete identified
|
||||
*
|
||||
* @returns true if note has been deleted, false otherwise
|
||||
*/
|
||||
deleteBranch(deleteId?: string, taskContext?: TaskContext<"deleteNotes">): boolean {
|
||||
if (!deleteId) {
|
||||
deleteId = utils.randomString(10);
|
||||
}
|
||||
|
||||
if (!taskContext) {
|
||||
taskContext = new TaskContext("no-progress-reporting", "deleteNotes", null);
|
||||
}
|
||||
|
||||
taskContext.increaseProgressCount();
|
||||
|
||||
const note = this.getNote();
|
||||
|
||||
if (!taskContext.noteDeletionHandlerTriggered) {
|
||||
const parentBranches = note.getParentBranches();
|
||||
|
||||
if (parentBranches.length === 1 && parentBranches[0] === this) {
|
||||
// needs to be run before branches and attributes are deleted and thus attached relations disappear
|
||||
handlers.runAttachedRelations(note, "runOnNoteDeletion", note);
|
||||
}
|
||||
}
|
||||
|
||||
if ((this.noteId === "root" || this.noteId === cls.getHoistedNoteId()) && !this.isWeak) {
|
||||
throw new Error("Can't delete root or hoisted branch/note");
|
||||
}
|
||||
|
||||
this.markAsDeleted(deleteId);
|
||||
|
||||
const notDeletedBranches = note.getStrongParentBranches();
|
||||
|
||||
if (notDeletedBranches.length === 0) {
|
||||
for (const weakBranch of note.getParentBranches()) {
|
||||
weakBranch.markAsDeleted(deleteId);
|
||||
}
|
||||
|
||||
for (const childBranch of note.getChildBranches()) {
|
||||
if (childBranch) {
|
||||
childBranch.deleteBranch(deleteId, taskContext);
|
||||
}
|
||||
}
|
||||
|
||||
// first delete children and then parent - this will show up better in recent changes
|
||||
|
||||
log.info(`Deleting note '${note.noteId}'`);
|
||||
|
||||
this.becca.notes[note.noteId].isBeingDeleted = true;
|
||||
|
||||
for (const attribute of note.getOwnedAttributes().slice()) {
|
||||
attribute.markAsDeleted(deleteId);
|
||||
}
|
||||
|
||||
for (const relation of note.getTargetRelations()) {
|
||||
relation.markAsDeleted(deleteId);
|
||||
}
|
||||
|
||||
for (const attachment of note.getAttachments()) {
|
||||
attachment.markAsDeleted(deleteId);
|
||||
}
|
||||
|
||||
note.markAsDeleted(deleteId);
|
||||
|
||||
return true;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
override beforeSaving() {
|
||||
if (!this.noteId || !this.parentNoteId) {
|
||||
throw new Error(`noteId and parentNoteId are mandatory properties for Branch`);
|
||||
}
|
||||
|
||||
this.branchId = `${this.parentNoteId}_${this.noteId}`;
|
||||
|
||||
if (this.notePosition === undefined || this.notePosition === null) {
|
||||
let maxNotePos = 0;
|
||||
|
||||
if (this.parentNote) {
|
||||
for (const childBranch of this.parentNote.getChildBranches()) {
|
||||
if (!childBranch) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (
|
||||
maxNotePos < childBranch.notePosition &&
|
||||
childBranch.noteId !== "_hidden" // hidden has a very large notePosition to always stay last
|
||||
) {
|
||||
maxNotePos = childBranch.notePosition;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.notePosition = maxNotePos + 10;
|
||||
}
|
||||
|
||||
if (!this.isExpanded) {
|
||||
this.isExpanded = false;
|
||||
}
|
||||
|
||||
if (!this.prefix?.trim()) {
|
||||
this.prefix = null;
|
||||
}
|
||||
|
||||
this.utcDateModified = dateUtils.utcNowDateTime();
|
||||
|
||||
super.beforeSaving();
|
||||
|
||||
this.becca.branches[this.branchId] = this;
|
||||
}
|
||||
|
||||
getPojo() {
|
||||
return {
|
||||
branchId: this.branchId,
|
||||
noteId: this.noteId,
|
||||
parentNoteId: this.parentNoteId,
|
||||
prefix: this.prefix,
|
||||
notePosition: this.notePosition,
|
||||
isExpanded: this.isExpanded,
|
||||
isDeleted: false,
|
||||
utcDateModified: this.utcDateModified
|
||||
};
|
||||
}
|
||||
|
||||
createClone(parentNoteId: string, notePosition?: number) {
|
||||
const existingBranch = this.becca.getBranchFromChildAndParent(this.noteId, parentNoteId);
|
||||
|
||||
if (existingBranch) {
|
||||
if (notePosition) {
|
||||
existingBranch.notePosition = notePosition;
|
||||
}
|
||||
return existingBranch;
|
||||
} else {
|
||||
return new BBranch({
|
||||
noteId: this.noteId,
|
||||
parentNoteId: parentNoteId,
|
||||
notePosition: notePosition || null,
|
||||
prefix: this.prefix,
|
||||
isExpanded: this.isExpanded
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
getParentNote() {
|
||||
return this.parentNote;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
import { BBranch } from "@triliumnext/core";
|
||||
export default BBranch;
|
||||
|
||||
@@ -1,89 +1,2 @@
|
||||
"use strict";
|
||||
|
||||
import type { EtapiTokenRow } from "@triliumnext/commons";
|
||||
|
||||
import dateUtils from "../../services/date_utils.js";
|
||||
import AbstractBeccaEntity from "./abstract_becca_entity.js";
|
||||
|
||||
/**
|
||||
* EtapiToken is an entity representing token used to authenticate against Trilium REST API from client applications.
|
||||
* Used by:
|
||||
* - Trilium Sender
|
||||
* - ETAPI clients
|
||||
*
|
||||
* The format user is presented with is "<etapiTokenId>_<tokenHash>". This is also called "authToken" to distinguish it
|
||||
* from tokenHash and token.
|
||||
*/
|
||||
class BEtapiToken extends AbstractBeccaEntity<BEtapiToken> {
|
||||
static get entityName() {
|
||||
return "etapi_tokens";
|
||||
}
|
||||
static get primaryKeyName() {
|
||||
return "etapiTokenId";
|
||||
}
|
||||
static get hashedProperties() {
|
||||
return ["etapiTokenId", "name", "tokenHash", "utcDateCreated", "utcDateModified", "isDeleted"];
|
||||
}
|
||||
|
||||
etapiTokenId?: string;
|
||||
name!: string;
|
||||
tokenHash!: string;
|
||||
private _isDeleted?: boolean;
|
||||
|
||||
constructor(row?: EtapiTokenRow) {
|
||||
super();
|
||||
|
||||
if (!row) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.updateFromRow(row);
|
||||
this.init();
|
||||
}
|
||||
|
||||
override get isDeleted() {
|
||||
return !!this._isDeleted;
|
||||
}
|
||||
|
||||
updateFromRow(row: EtapiTokenRow) {
|
||||
this.etapiTokenId = row.etapiTokenId;
|
||||
this.name = row.name;
|
||||
this.tokenHash = row.tokenHash;
|
||||
this.utcDateCreated = row.utcDateCreated || dateUtils.utcNowDateTime();
|
||||
this.utcDateModified = row.utcDateModified || this.utcDateCreated;
|
||||
this._isDeleted = !!row.isDeleted;
|
||||
|
||||
if (this.etapiTokenId) {
|
||||
this.becca.etapiTokens[this.etapiTokenId] = this;
|
||||
}
|
||||
}
|
||||
|
||||
override init() {
|
||||
if (this.etapiTokenId) {
|
||||
this.becca.etapiTokens[this.etapiTokenId] = this;
|
||||
}
|
||||
}
|
||||
|
||||
getPojo() {
|
||||
return {
|
||||
etapiTokenId: this.etapiTokenId,
|
||||
name: this.name,
|
||||
tokenHash: this.tokenHash,
|
||||
utcDateCreated: this.utcDateCreated,
|
||||
utcDateModified: this.utcDateModified,
|
||||
isDeleted: this.isDeleted
|
||||
};
|
||||
}
|
||||
|
||||
override beforeSaving() {
|
||||
this.utcDateModified = dateUtils.utcNowDateTime();
|
||||
|
||||
super.beforeSaving();
|
||||
|
||||
if (this.etapiTokenId) {
|
||||
this.becca.etapiTokens[this.etapiTokenId] = this;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
import { BEtapiToken } from "@triliumnext/core";
|
||||
export default BEtapiToken;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,56 +1,2 @@
|
||||
"use strict";
|
||||
|
||||
import dateUtils from "../../services/date_utils.js";
|
||||
import AbstractBeccaEntity from "./abstract_becca_entity.js";
|
||||
import type { OptionRow } from "@triliumnext/commons";
|
||||
|
||||
/**
|
||||
* Option represents a name-value pair, either directly configurable by the user or some system property.
|
||||
*/
|
||||
class BOption extends AbstractBeccaEntity<BOption> {
|
||||
static get entityName() {
|
||||
return "options";
|
||||
}
|
||||
static get primaryKeyName() {
|
||||
return "name";
|
||||
}
|
||||
static get hashedProperties() {
|
||||
return ["name", "value"];
|
||||
}
|
||||
|
||||
name!: string;
|
||||
value!: string;
|
||||
|
||||
constructor(row?: OptionRow) {
|
||||
super();
|
||||
|
||||
if (row) {
|
||||
this.updateFromRow(row);
|
||||
}
|
||||
this.becca.options[this.name] = this;
|
||||
}
|
||||
|
||||
updateFromRow(row: OptionRow) {
|
||||
this.name = row.name;
|
||||
this.value = row.value;
|
||||
this.isSynced = !!row.isSynced;
|
||||
this.utcDateModified = row.utcDateModified;
|
||||
}
|
||||
|
||||
override beforeSaving() {
|
||||
super.beforeSaving();
|
||||
|
||||
this.utcDateModified = dateUtils.utcNowDateTime();
|
||||
}
|
||||
|
||||
getPojo() {
|
||||
return {
|
||||
name: this.name,
|
||||
value: this.value,
|
||||
isSynced: this.isSynced,
|
||||
utcDateModified: this.utcDateModified
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
import { BOption } from "@triliumnext/core";
|
||||
export default BOption;
|
||||
|
||||
@@ -1,46 +1,2 @@
|
||||
"use strict";
|
||||
|
||||
import type { RecentNoteRow } from "@triliumnext/commons";
|
||||
|
||||
import dateUtils from "../../services/date_utils.js";
|
||||
import AbstractBeccaEntity from "./abstract_becca_entity.js";
|
||||
|
||||
/**
|
||||
* RecentNote represents recently visited note.
|
||||
*/
|
||||
class BRecentNote extends AbstractBeccaEntity<BRecentNote> {
|
||||
static get entityName() {
|
||||
return "recent_notes";
|
||||
}
|
||||
static get primaryKeyName() {
|
||||
return "noteId";
|
||||
}
|
||||
static get hashedProperties() {
|
||||
return ["noteId", "notePath"];
|
||||
}
|
||||
|
||||
noteId!: string;
|
||||
notePath!: string;
|
||||
|
||||
constructor(row: RecentNoteRow) {
|
||||
super();
|
||||
|
||||
this.updateFromRow(row);
|
||||
}
|
||||
|
||||
updateFromRow(row: RecentNoteRow): void {
|
||||
this.noteId = row.noteId;
|
||||
this.notePath = row.notePath;
|
||||
this.utcDateCreated = row.utcDateCreated || dateUtils.utcNowDateTime();
|
||||
}
|
||||
|
||||
getPojo() {
|
||||
return {
|
||||
noteId: this.noteId,
|
||||
notePath: this.notePath,
|
||||
utcDateCreated: this.utcDateCreated
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
import { BRecentNote } from "@triliumnext/core";
|
||||
export default BRecentNote;
|
||||
|
||||
@@ -1,225 +1,2 @@
|
||||
"use strict";
|
||||
|
||||
import protectedSessionService from "../../services/protected_session.js";
|
||||
import utils from "../../services/utils.js";
|
||||
import dateUtils from "../../services/date_utils.js";
|
||||
import becca from "../becca.js";
|
||||
import AbstractBeccaEntity from "./abstract_becca_entity.js";
|
||||
import sql from "../../services/sql.js";
|
||||
import BAttachment from "./battachment.js";
|
||||
import type { AttachmentRow, NoteType, RevisionPojo, RevisionRow } from "@triliumnext/commons";
|
||||
import eraseService from "../../services/erase.js";
|
||||
|
||||
interface ContentOpts {
|
||||
/** will also save this BRevision entity */
|
||||
forceSave?: boolean;
|
||||
}
|
||||
|
||||
interface GetByIdOpts {
|
||||
includeContentLength?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Revision represents a snapshot of note's title and content at some point in the past.
|
||||
* It's used for seamless note versioning.
|
||||
*/
|
||||
class BRevision extends AbstractBeccaEntity<BRevision> {
|
||||
static get entityName() {
|
||||
return "revisions";
|
||||
}
|
||||
static get primaryKeyName() {
|
||||
return "revisionId";
|
||||
}
|
||||
static get hashedProperties() {
|
||||
return ["revisionId", "noteId", "title", "isProtected", "dateLastEdited", "dateCreated", "utcDateLastEdited", "utcDateCreated", "utcDateModified", "blobId"];
|
||||
}
|
||||
|
||||
revisionId?: string;
|
||||
noteId!: string;
|
||||
type!: NoteType;
|
||||
mime!: string;
|
||||
title!: string;
|
||||
dateLastEdited?: string;
|
||||
utcDateLastEdited?: string;
|
||||
contentLength?: number;
|
||||
content?: string | Buffer;
|
||||
|
||||
constructor(row: RevisionRow, titleDecrypted = false) {
|
||||
super();
|
||||
|
||||
this.updateFromRow(row);
|
||||
if (this.isProtected && !titleDecrypted) {
|
||||
const decryptedTitle = protectedSessionService.isProtectedSessionAvailable() ? protectedSessionService.decryptString(this.title) : null;
|
||||
this.title = decryptedTitle || "[protected]";
|
||||
}
|
||||
}
|
||||
|
||||
updateFromRow(row: RevisionRow) {
|
||||
this.revisionId = row.revisionId;
|
||||
this.noteId = row.noteId;
|
||||
this.type = row.type;
|
||||
this.mime = row.mime;
|
||||
this.isProtected = !!row.isProtected;
|
||||
this.title = row.title;
|
||||
this.blobId = row.blobId;
|
||||
this.dateLastEdited = row.dateLastEdited;
|
||||
this.dateCreated = row.dateCreated;
|
||||
this.utcDateLastEdited = row.utcDateLastEdited;
|
||||
this.utcDateCreated = row.utcDateCreated;
|
||||
this.utcDateModified = row.utcDateModified;
|
||||
this.contentLength = row.contentLength;
|
||||
}
|
||||
|
||||
getNote() {
|
||||
return becca.notes[this.noteId];
|
||||
}
|
||||
|
||||
/** @returns true if the note has string content (not binary) */
|
||||
override hasStringContent(): boolean {
|
||||
return utils.isStringNote(this.type, this.mime);
|
||||
}
|
||||
|
||||
isContentAvailable() {
|
||||
return (
|
||||
!this.revisionId || // new note which was not encrypted yet
|
||||
!this.isProtected ||
|
||||
protectedSessionService.isProtectedSessionAvailable()
|
||||
);
|
||||
}
|
||||
|
||||
/*
|
||||
* Note revision content has quite special handling - it's not a separate entity, but a lazily loaded
|
||||
* part of Revision entity with its own sync. The reason behind this hybrid design is that
|
||||
* content can be quite large, and it's not necessary to load it / fill memory for any note access even
|
||||
* if we don't need a content, especially for bulk operations like search.
|
||||
*
|
||||
* This is the same approach as is used for Note's content.
|
||||
*/
|
||||
getContent(): string | Buffer {
|
||||
return this._getContent();
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws Error in case of invalid JSON */
|
||||
getJsonContent(): {} | null {
|
||||
const content = this.getContent();
|
||||
|
||||
if (!content || typeof content !== "string" || !content.trim()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return JSON.parse(content);
|
||||
}
|
||||
|
||||
/** @returns valid object or null if the content cannot be parsed as JSON */
|
||||
getJsonContentSafely(): {} | null {
|
||||
try {
|
||||
return this.getJsonContent();
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
setContent(content: string | Buffer, opts: ContentOpts = {}) {
|
||||
this._setContent(content, opts);
|
||||
}
|
||||
|
||||
getAttachments(): BAttachment[] {
|
||||
return sql
|
||||
.getRows<AttachmentRow>(
|
||||
`
|
||||
SELECT attachments.*
|
||||
FROM attachments
|
||||
WHERE ownerId = ?
|
||||
AND isDeleted = 0`,
|
||||
[this.revisionId]
|
||||
)
|
||||
.map((row) => new BAttachment(row));
|
||||
}
|
||||
|
||||
getAttachmentById(attachmentId: String, opts: GetByIdOpts = {}): BAttachment | null {
|
||||
opts.includeContentLength = !!opts.includeContentLength;
|
||||
|
||||
const query = opts.includeContentLength
|
||||
? /*sql*/`SELECT attachments.*, LENGTH(blobs.content) AS contentLength
|
||||
FROM attachments
|
||||
JOIN blobs USING (blobId)
|
||||
WHERE ownerId = ? AND attachmentId = ? AND isDeleted = 0`
|
||||
: /*sql*/`SELECT * FROM attachments WHERE ownerId = ? AND attachmentId = ? AND isDeleted = 0`;
|
||||
|
||||
return sql.getRows<AttachmentRow>(query, [this.revisionId, attachmentId]).map((row) => new BAttachment(row))[0];
|
||||
}
|
||||
|
||||
getAttachmentsByRole(role: string): BAttachment[] {
|
||||
return sql
|
||||
.getRows<AttachmentRow>(
|
||||
`
|
||||
SELECT attachments.*
|
||||
FROM attachments
|
||||
WHERE ownerId = ?
|
||||
AND role = ?
|
||||
AND isDeleted = 0
|
||||
ORDER BY position`,
|
||||
[this.revisionId, role]
|
||||
)
|
||||
.map((row) => new BAttachment(row));
|
||||
}
|
||||
|
||||
getAttachmentByTitle(title: string): BAttachment {
|
||||
// cannot use SQL to filter by title since it can be encrypted
|
||||
return this.getAttachments().filter((attachment) => attachment.title === title)[0];
|
||||
}
|
||||
|
||||
/**
|
||||
* Revisions are not soft-deletable, they are immediately hard-deleted (erased).
|
||||
*/
|
||||
eraseRevision() {
|
||||
if (this.revisionId) {
|
||||
eraseService.eraseRevisions([this.revisionId]);
|
||||
}
|
||||
}
|
||||
|
||||
override beforeSaving() {
|
||||
super.beforeSaving();
|
||||
|
||||
this.utcDateModified = dateUtils.utcNowDateTime();
|
||||
}
|
||||
|
||||
getPojo() {
|
||||
return {
|
||||
revisionId: this.revisionId,
|
||||
noteId: this.noteId,
|
||||
type: this.type,
|
||||
mime: this.mime,
|
||||
isProtected: this.isProtected,
|
||||
title: this.title,
|
||||
blobId: this.blobId,
|
||||
dateLastEdited: this.dateLastEdited,
|
||||
dateCreated: this.dateCreated,
|
||||
utcDateLastEdited: this.utcDateLastEdited,
|
||||
utcDateCreated: this.utcDateCreated,
|
||||
utcDateModified: this.utcDateModified,
|
||||
content: this.content, // used when retrieving full note revision to frontend
|
||||
contentLength: this.contentLength
|
||||
} satisfies RevisionPojo;
|
||||
}
|
||||
|
||||
override getPojoToSave() {
|
||||
const pojo = this.getPojo();
|
||||
delete pojo.content; // not getting persisted
|
||||
delete pojo.contentLength; // not getting persisted
|
||||
|
||||
if (pojo.isProtected) {
|
||||
if (protectedSessionService.isProtectedSessionAvailable()) {
|
||||
pojo.title = protectedSessionService.encrypt(this.title) ?? "";
|
||||
} else {
|
||||
// updating protected note outside of protected session means we will keep original ciphertexts
|
||||
pojo.title = "";
|
||||
}
|
||||
}
|
||||
|
||||
return pojo;
|
||||
}
|
||||
}
|
||||
|
||||
import { BRevision } from "@triliumnext/core";
|
||||
export default BRevision;
|
||||
|
||||
24
apps/server/src/cls_provider.ts
Normal file
24
apps/server/src/cls_provider.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { ExecutionContext } from "@triliumnext/core";
|
||||
import clsHooked from "cls-hooked";
|
||||
|
||||
export const namespace = clsHooked.createNamespace("trilium");
|
||||
|
||||
export default class ClsHookedExecutionContext implements ExecutionContext {
|
||||
|
||||
get<T = any>(key: string): T | undefined {
|
||||
return namespace.get(key);
|
||||
}
|
||||
|
||||
set(key: string, value: any): void {
|
||||
namespace.set(key, value);
|
||||
}
|
||||
|
||||
reset(): void {
|
||||
clsHooked.reset();
|
||||
}
|
||||
|
||||
init<T>(callback: () => T): T {
|
||||
return namespace.runAndReturn(callback);
|
||||
}
|
||||
|
||||
}
|
||||
35
apps/server/src/crypto_provider.ts
Normal file
35
apps/server/src/crypto_provider.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { CryptoProvider } from "@triliumnext/core";
|
||||
import crypto from "crypto";
|
||||
import { generator } from "rand-token";
|
||||
|
||||
const randtoken = generator({ source: "crypto" });
|
||||
|
||||
export default class NodejsCryptoProvider implements CryptoProvider {
|
||||
|
||||
createHash(algorithm: "sha1", content: string | Uint8Array): Uint8Array {
|
||||
return crypto.createHash(algorithm).update(content).digest();
|
||||
}
|
||||
|
||||
createCipheriv(algorithm: "aes-128-cbc", key: Uint8Array, iv: Uint8Array): { update(data: Uint8Array): Uint8Array; final(): Uint8Array; } {
|
||||
return crypto.createCipheriv(algorithm, key, iv);
|
||||
}
|
||||
|
||||
createDecipheriv(algorithm: "aes-128-cbc", key: Uint8Array, iv: Uint8Array) {
|
||||
return crypto.createDecipheriv(algorithm, key, iv);
|
||||
}
|
||||
|
||||
randomBytes(size: number): Uint8Array {
|
||||
return crypto.randomBytes(size);
|
||||
}
|
||||
|
||||
randomString(length: number): string {
|
||||
return randtoken.generate(length);
|
||||
}
|
||||
|
||||
hmac(secret: string | Uint8Array, value: string | Uint8Array) {
|
||||
const hmac = crypto.createHmac("sha256", Buffer.from(secret.toString(), "ascii"));
|
||||
hmac.update(value.toString());
|
||||
return hmac.digest("base64");
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
import HttpError from "./http_error.js";
|
||||
|
||||
class ForbiddenError extends HttpError {
|
||||
|
||||
constructor(message: string) {
|
||||
super(message, 403);
|
||||
this.name = "ForbiddenError";
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export default ForbiddenError;
|
||||
@@ -1,13 +0,0 @@
|
||||
class HttpError extends Error {
|
||||
|
||||
statusCode: number;
|
||||
|
||||
constructor(message: string, statusCode: number) {
|
||||
super(message);
|
||||
this.name = "HttpError";
|
||||
this.statusCode = statusCode;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export default HttpError;
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user