mirror of
https://github.com/zadam/trilium.git
synced 2026-02-13 09:56:59 +01:00
Compare commits
64 Commits
standalone
...
feature/cu
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e6d728715f | ||
|
|
54a52f0589 | ||
|
|
badfa23f86 | ||
|
|
b83eee9bdc | ||
|
|
e41b2e8d31 | ||
|
|
d93cec2bfd | ||
|
|
9eb87a39cd | ||
|
|
07818ec1df | ||
|
|
d4052dbe37 | ||
|
|
656f5e0a7f | ||
|
|
0cc5e4dac3 | ||
|
|
8bbfff3cb2 | ||
|
|
33fae88cad | ||
|
|
4feb23e9ca | ||
|
|
c2b6b7ba72 | ||
|
|
fb76aee258 | ||
|
|
320b1829cc | ||
|
|
601f0255a4 | ||
|
|
b6cc2b227a | ||
|
|
1ae3be2fda | ||
|
|
5b4d35ea86 | ||
|
|
00735e6c8e | ||
|
|
66a42a38c9 | ||
|
|
05af1fba80 | ||
|
|
3b2dd0f5e9 | ||
|
|
1493a66a36 | ||
|
|
b4bf103fd8 | ||
|
|
94286becfd | ||
|
|
a68aade58c | ||
|
|
014201edf4 | ||
|
|
8ae6297148 | ||
|
|
5e0300aa8e | ||
|
|
80a7e18413 | ||
|
|
43be0a1a3f | ||
|
|
5eb32744c3 | ||
|
|
89d39f5f2b | ||
|
|
1f4900dd1e | ||
|
|
1c561c1483 | ||
|
|
6baaf60b67 | ||
|
|
dde73f6c2b | ||
|
|
f445a49b34 | ||
|
|
29016d1cf5 | ||
|
|
c06435046b | ||
|
|
134422802f | ||
|
|
5e00d6a305 | ||
|
|
b5f0137d8e | ||
|
|
081d041cbc | ||
|
|
95e733a67c | ||
|
|
a664057312 | ||
|
|
5b1a2d93bf | ||
|
|
0323f95828 | ||
|
|
375838449f | ||
|
|
4562de8c2c | ||
|
|
68d21669e7 | ||
|
|
625e0cf159 | ||
|
|
551ef00c61 | ||
|
|
10518f6364 | ||
|
|
3c33b5b169 | ||
|
|
000c31b66c | ||
|
|
fd7780abb0 | ||
|
|
c8a981e8d6 | ||
|
|
faac45784c | ||
|
|
f6b454cb9a | ||
|
|
563463782c |
67
.github/workflows/deploy-app.yml
vendored
67
.github/workflows/deploy-app.yml
vendored
@@ -1,67 +0,0 @@
|
||||
name: Deploy Standalone App
|
||||
|
||||
on:
|
||||
# Trigger on push to main branch
|
||||
push:
|
||||
branches:
|
||||
- standalone
|
||||
# Only run when app files change
|
||||
paths:
|
||||
- 'apps/client/**'
|
||||
- 'apps/client-standalone/**'
|
||||
- 'packages/trilium-core/**'
|
||||
- '.github/workflows/deploy-app.yml'
|
||||
|
||||
# Allow manual triggering from Actions tab
|
||||
workflow_dispatch:
|
||||
|
||||
# Run on pull requests for preview deployments
|
||||
pull_request:
|
||||
paths:
|
||||
- 'apps/client/**'
|
||||
- 'apps/client-standalone/**'
|
||||
- 'packages/trilium-core/**'
|
||||
- '.github/workflows/deploy-app.yml'
|
||||
jobs:
|
||||
build-and-deploy:
|
||||
name: Build and Deploy App
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
|
||||
# Required permissions for deployment
|
||||
permissions:
|
||||
contents: read
|
||||
deployments: write
|
||||
pull-requests: write # For PR preview comments
|
||||
id-token: write # For OIDC authentication (if needed)
|
||||
|
||||
steps:
|
||||
- name: Checkout Repository
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: '24'
|
||||
cache: 'pnpm'
|
||||
|
||||
- name: Install Dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Trigger build of app
|
||||
run: pnpm --filter=client-standalone build
|
||||
|
||||
- name: Deploy
|
||||
uses: ./.github/actions/deploy-to-cloudflare-pages
|
||||
if: github.repository == vars.REPO_MAIN
|
||||
with:
|
||||
project_name: "trilium-app"
|
||||
comment_body: "🖥️ App preview is ready"
|
||||
production_url: "https://app.triliumnotes.org"
|
||||
deploy_dir: "apps/client-standalone/dist"
|
||||
cloudflare_api_token: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
cloudflare_account_id: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
||||
github_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
4
.github/workflows/dev.yml
vendored
4
.github/workflows/dev.yml
vendored
@@ -1,9 +1,9 @@
|
||||
name: Dev
|
||||
on:
|
||||
push:
|
||||
branches: [ main, standalone ]
|
||||
branches: [ main ]
|
||||
pull_request:
|
||||
branches: [ main, standalone ]
|
||||
branches: [ main ]
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
|
||||
@@ -9,9 +9,9 @@
|
||||
"keywords": [],
|
||||
"author": "Elian Doran <contact@eliandoran.me>",
|
||||
"license": "AGPL-3.0-only",
|
||||
"packageManager": "pnpm@10.29.1",
|
||||
"packageManager": "pnpm@10.29.2",
|
||||
"devDependencies": {
|
||||
"@redocly/cli": "2.15.1",
|
||||
"@redocly/cli": "2.17.0",
|
||||
"archiver": "7.0.1",
|
||||
"fs-extra": "11.3.3",
|
||||
"react": "19.2.4",
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
# The development license key for premium CKEditor features.
|
||||
# Note: This key must only be used for the Trilium Notes project.
|
||||
VITE_CKEDITOR_KEY=eyJhbGciOiJFUzI1NiJ9.eyJleHAiOjE3ODcyNzA0MDAsImp0aSI6IjkyMWE1MWNlLTliNDMtNGRlMC1iOTQwLTc5ZjM2MDBkYjg1NyIsImRpc3RyaWJ1dGlvbkNoYW5uZWwiOiJ0cmlsaXVtIiwiZmVhdHVyZXMiOlsiVFJJTElVTSJdLCJ2YyI6ImU4YzRhMjBkIn0.hny77p-U4-jTkoqbwPytrEar5ylGCWBN7Ez3SlB8i6_mJCBIeCSTOlVQk_JMiOEq3AGykUMHzWXzjdMFwgniOw
|
||||
VITE_CKEDITOR_ENABLE_INSPECTOR=false
|
||||
@@ -1 +0,0 @@
|
||||
VITE_CKEDITOR_ENABLE_INSPECTOR=false
|
||||
@@ -1,86 +0,0 @@
|
||||
{
|
||||
"name": "@triliumnext/client-standalone",
|
||||
"version": "0.101.3",
|
||||
"description": "Standalone client for TriliumNext with SQLite WASM backend",
|
||||
"private": true,
|
||||
"license": "AGPL-3.0-only",
|
||||
"scripts": {
|
||||
"build": "cross-env NODE_OPTIONS=--max-old-space-size=4096 vite build",
|
||||
"dev": "vite dev",
|
||||
"test": "vitest",
|
||||
"start-prod": "pnpm build && pnpm http-server dist -p 8888",
|
||||
"coverage": "vitest --coverage"
|
||||
},
|
||||
"dependencies": {
|
||||
"@excalidraw/excalidraw": "0.18.0",
|
||||
"@fullcalendar/core": "6.1.20",
|
||||
"@fullcalendar/daygrid": "6.1.20",
|
||||
"@fullcalendar/interaction": "6.1.20",
|
||||
"@fullcalendar/list": "6.1.20",
|
||||
"@fullcalendar/multimonth": "6.1.20",
|
||||
"@fullcalendar/timegrid": "6.1.20",
|
||||
"@maplibre/maplibre-gl-leaflet": "0.1.3",
|
||||
"@mermaid-js/layout-elk": "0.2.0",
|
||||
"@mind-elixir/node-menu": "5.0.1",
|
||||
"@popperjs/core": "2.11.8",
|
||||
"@preact/signals": "2.5.1",
|
||||
"@sqlite.org/sqlite-wasm": "3.51.1-build2",
|
||||
"@triliumnext/ckeditor5": "workspace:*",
|
||||
"@triliumnext/codemirror": "workspace:*",
|
||||
"@triliumnext/commons": "workspace:*",
|
||||
"@triliumnext/core": "workspace:*",
|
||||
"@triliumnext/highlightjs": "workspace:*",
|
||||
"@triliumnext/share-theme": "workspace:*",
|
||||
"@triliumnext/split.js": "workspace:*",
|
||||
"@zumer/snapdom": "2.0.1",
|
||||
"autocomplete.js": "0.38.1",
|
||||
"bootstrap": "5.3.8",
|
||||
"boxicons": "2.1.4",
|
||||
"clsx": "2.1.1",
|
||||
"color": "5.0.3",
|
||||
"debounce": "3.0.0",
|
||||
"draggabilly": "3.0.0",
|
||||
"force-graph": "1.51.0",
|
||||
"globals": "17.0.0",
|
||||
"i18next": "25.7.3",
|
||||
"i18next-http-backend": "3.0.2",
|
||||
"jquery": "3.7.1",
|
||||
"jquery.fancytree": "2.38.5",
|
||||
"js-sha1": "0.7.0",
|
||||
"js-sha512": "0.9.0",
|
||||
"jsplumb": "2.15.6",
|
||||
"katex": "0.16.27",
|
||||
"knockout": "3.5.1",
|
||||
"leaflet": "1.9.4",
|
||||
"leaflet-gpx": "2.2.0",
|
||||
"mark.js": "8.11.1",
|
||||
"marked": "17.0.1",
|
||||
"mermaid": "11.12.2",
|
||||
"mind-elixir": "5.4.0",
|
||||
"normalize.css": "8.0.1",
|
||||
"panzoom": "9.4.3",
|
||||
"preact": "10.28.2",
|
||||
"react-i18next": "16.5.1",
|
||||
"react-window": "2.2.3",
|
||||
"reveal.js": "5.2.1",
|
||||
"svg-pan-zoom": "3.6.2",
|
||||
"tabulator-tables": "6.3.1",
|
||||
"vanilla-js-wheel-zoom": "9.0.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@ckeditor/ckeditor5-inspector": "5.0.0",
|
||||
"@preact/preset-vite": "2.10.2",
|
||||
"@types/bootstrap": "5.2.10",
|
||||
"@types/jquery": "3.5.33",
|
||||
"@types/leaflet": "1.9.21",
|
||||
"@types/leaflet-gpx": "1.3.8",
|
||||
"@types/mark.js": "8.11.12",
|
||||
"@types/reveal.js": "5.2.2",
|
||||
"@types/tabulator-tables": "6.3.1",
|
||||
"copy-webpack-plugin": "13.0.1",
|
||||
"cross-env": "7.0.3",
|
||||
"happy-dom": "20.0.11",
|
||||
"script-loader": "0.7.2",
|
||||
"vite-plugin-static-copy": "3.1.4"
|
||||
}
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
/*
|
||||
Cross-Origin-Opener-Policy: same-origin
|
||||
Cross-Origin-Embedder-Policy: require-corp
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 112 KiB |
@@ -1,20 +0,0 @@
|
||||
{
|
||||
"name": "Trilium Notes",
|
||||
"short_name": "Trilium",
|
||||
"description": "Trilium Notes is a hierarchical note taking application with focus on building large personal knowledge bases.",
|
||||
"theme_color": "#333333",
|
||||
"background_color": "#1F1F1F",
|
||||
"display": "standalone",
|
||||
"scope": "/",
|
||||
"start_url": "/",
|
||||
"display_override": [
|
||||
"window-controls-overlay"
|
||||
],
|
||||
"icons": [
|
||||
{
|
||||
"src": "assets/icon.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
// Re-export desktop from client
|
||||
export * from "../../client/src/desktop";
|
||||
@@ -1,31 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<link rel="shortcut icon" href="favicon.ico">
|
||||
<meta name="mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, viewport-fit=cover" />
|
||||
<link rel="manifest" crossorigin="use-credentials" href="manifest.webmanifest">
|
||||
<title>Trilium Notes</title>
|
||||
</head>
|
||||
|
||||
<body id="trilium-app">
|
||||
<noscript>Trilium requires JavaScript to be enabled.</noscript>
|
||||
|
||||
<div id="context-menu-cover"></div>
|
||||
<div class="dropdown-menu dropdown-menu-sm" id="context-menu-container" style="display: none"></div>
|
||||
|
||||
<!-- Required for match the PWA's top bar color with the theme -->
|
||||
<!-- This works even when the user directly changes --root-background in CSS -->
|
||||
<div id="background-color-tracker" style="position: absolute; visibility: hidden; color: var(--root-background); transition: color 1ms;"></div>
|
||||
|
||||
<!-- Bootstrap (request server for required information) -->
|
||||
<script src="./main.ts" type="module"></script>
|
||||
|
||||
<!-- Required for correct loading of scripts in Electron -->
|
||||
<script>
|
||||
if (typeof module === 'object') {window.module = module; module = undefined;}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,254 +0,0 @@
|
||||
/**
|
||||
* Browser-compatible router that mimics Express routing patterns.
|
||||
* Supports path parameters (e.g., /api/notes/:noteId) and query strings.
|
||||
*/
|
||||
|
||||
import { getContext, routes } from "@triliumnext/core";
|
||||
|
||||
export interface BrowserRequest {
|
||||
method: string;
|
||||
url: string;
|
||||
path: string;
|
||||
params: Record<string, string>;
|
||||
query: Record<string, string | undefined>;
|
||||
body?: unknown;
|
||||
}
|
||||
|
||||
export interface BrowserResponse {
|
||||
status: number;
|
||||
headers: Record<string, string>;
|
||||
body: ArrayBuffer | null;
|
||||
}
|
||||
|
||||
export type RouteHandler = (req: BrowserRequest) => unknown | Promise<unknown>;
|
||||
|
||||
interface Route {
|
||||
method: string;
|
||||
pattern: RegExp;
|
||||
paramNames: string[];
|
||||
handler: RouteHandler;
|
||||
}
|
||||
|
||||
const encoder = new TextEncoder();
|
||||
|
||||
/**
|
||||
* Convert an Express-style path pattern to a RegExp.
|
||||
* Supports :param syntax for path parameters.
|
||||
*
|
||||
* Examples:
|
||||
* /api/notes/:noteId -> /^\/api\/notes\/([^\/]+)$/
|
||||
* /api/notes/:noteId/revisions -> /^\/api\/notes\/([^\/]+)\/revisions$/
|
||||
*/
|
||||
function pathToRegex(path: string): { pattern: RegExp; paramNames: string[] } {
|
||||
const paramNames: string[] = [];
|
||||
|
||||
// Escape special regex characters except for :param patterns
|
||||
const regexPattern = path
|
||||
.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') // Escape special chars
|
||||
.replace(/:([a-zA-Z_][a-zA-Z0-9_]*)/g, (_, paramName) => {
|
||||
paramNames.push(paramName);
|
||||
return '([^/]+)';
|
||||
});
|
||||
|
||||
return {
|
||||
pattern: new RegExp(`^${regexPattern}$`),
|
||||
paramNames
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse query string into an object.
|
||||
*/
|
||||
function parseQuery(search: string): Record<string, string | undefined> {
|
||||
const query: Record<string, string | undefined> = {};
|
||||
if (!search || search === '?') return query;
|
||||
|
||||
const params = new URLSearchParams(search);
|
||||
for (const [key, value] of params) {
|
||||
query[key] = value;
|
||||
}
|
||||
return query;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a result to a JSON response.
|
||||
*/
|
||||
function jsonResponse(obj: unknown, status = 200, extraHeaders: Record<string, string> = {}): BrowserResponse {
|
||||
const parsedObj = routes.convertEntitiesToPojo(obj);
|
||||
const body = encoder.encode(JSON.stringify(parsedObj)).buffer as ArrayBuffer;
|
||||
return {
|
||||
status,
|
||||
headers: { "content-type": "application/json; charset=utf-8", ...extraHeaders },
|
||||
body
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a string to a text response.
|
||||
*/
|
||||
function textResponse(text: string, status = 200, extraHeaders: Record<string, string> = {}): BrowserResponse {
|
||||
const body = encoder.encode(text).buffer as ArrayBuffer;
|
||||
return {
|
||||
status,
|
||||
headers: { "content-type": "text/plain; charset=utf-8", ...extraHeaders },
|
||||
body
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Browser router class that handles route registration and dispatching.
|
||||
*/
|
||||
export class BrowserRouter {
|
||||
private routes: Route[] = [];
|
||||
|
||||
/**
|
||||
* Register a route handler.
|
||||
*/
|
||||
register(method: string, path: string, handler: RouteHandler): void {
|
||||
const { pattern, paramNames } = pathToRegex(path);
|
||||
this.routes.push({
|
||||
method: method.toUpperCase(),
|
||||
pattern,
|
||||
paramNames,
|
||||
handler
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience methods for common HTTP methods.
|
||||
*/
|
||||
get(path: string, handler: RouteHandler): void {
|
||||
this.register('GET', path, handler);
|
||||
}
|
||||
|
||||
post(path: string, handler: RouteHandler): void {
|
||||
this.register('POST', path, handler);
|
||||
}
|
||||
|
||||
put(path: string, handler: RouteHandler): void {
|
||||
this.register('PUT', path, handler);
|
||||
}
|
||||
|
||||
patch(path: string, handler: RouteHandler): void {
|
||||
this.register('PATCH', path, handler);
|
||||
}
|
||||
|
||||
delete(path: string, handler: RouteHandler): void {
|
||||
this.register('DELETE', path, handler);
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispatch a request to the appropriate handler.
|
||||
*/
|
||||
async dispatch(method: string, urlString: string, body?: unknown, headers?: Record<string, string>): Promise<BrowserResponse> {
|
||||
const url = new URL(urlString);
|
||||
const path = url.pathname;
|
||||
const query = parseQuery(url.search);
|
||||
const upperMethod = method.toUpperCase();
|
||||
|
||||
// Parse JSON body if it's an ArrayBuffer and content-type suggests JSON
|
||||
let parsedBody = body;
|
||||
if (body instanceof ArrayBuffer && headers) {
|
||||
const contentType = headers['content-type'] || headers['Content-Type'] || '';
|
||||
if (contentType.includes('application/json')) {
|
||||
try {
|
||||
const text = new TextDecoder().decode(body);
|
||||
if (text.trim()) {
|
||||
parsedBody = JSON.parse(text);
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[Router] Failed to parse JSON body:', e);
|
||||
// Keep original body if JSON parsing fails
|
||||
parsedBody = body;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Find matching route
|
||||
for (const route of this.routes) {
|
||||
if (route.method !== upperMethod) continue;
|
||||
|
||||
const match = path.match(route.pattern);
|
||||
if (!match) continue;
|
||||
|
||||
// Extract path parameters
|
||||
const params: Record<string, string> = {};
|
||||
for (let i = 0; i < route.paramNames.length; i++) {
|
||||
params[route.paramNames[i]] = decodeURIComponent(match[i + 1]);
|
||||
}
|
||||
|
||||
const request: BrowserRequest = {
|
||||
method: upperMethod,
|
||||
url: urlString,
|
||||
path,
|
||||
params,
|
||||
query,
|
||||
body: parsedBody
|
||||
};
|
||||
|
||||
try {
|
||||
const result = await getContext().init(async () => await route.handler(request));
|
||||
return this.formatResult(result);
|
||||
} catch (error) {
|
||||
return this.formatError(error, `Error handling ${method} ${path}`);
|
||||
}
|
||||
}
|
||||
|
||||
// No route matched
|
||||
return textResponse(`Not found: ${method} ${path}`, 404);
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a handler result into a response.
|
||||
* Follows the same patterns as the server's apiResultHandler.
|
||||
*/
|
||||
private formatResult(result: unknown): BrowserResponse {
|
||||
// Handle [statusCode, response] format
|
||||
if (Array.isArray(result) && result.length > 0 && Number.isInteger(result[0])) {
|
||||
const [statusCode, response] = result;
|
||||
return jsonResponse(response, statusCode);
|
||||
}
|
||||
|
||||
// Handle undefined (no content) - 204 should have no body
|
||||
if (result === undefined) {
|
||||
return {
|
||||
status: 204,
|
||||
headers: {},
|
||||
body: null
|
||||
};
|
||||
}
|
||||
|
||||
// Default: JSON response with 200
|
||||
return jsonResponse(result, 200);
|
||||
}
|
||||
|
||||
/**
|
||||
* Format an error into a response.
|
||||
*/
|
||||
private formatError(error: unknown, context: string): BrowserResponse {
|
||||
console.error('[Router] Handler error:', context, error);
|
||||
|
||||
// Check for known error types
|
||||
if (error && typeof error === 'object') {
|
||||
const err = error as { constructor?: { name?: string }; message?: string };
|
||||
|
||||
if (err.constructor?.name === 'NotFoundError') {
|
||||
return jsonResponse({ message: err.message || 'Not found' }, 404);
|
||||
}
|
||||
|
||||
if (err.constructor?.name === 'ValidationError') {
|
||||
return jsonResponse({ message: err.message || 'Validation error' }, 400);
|
||||
}
|
||||
}
|
||||
|
||||
// Generic error
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
return jsonResponse({ message }, 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new router instance.
|
||||
*/
|
||||
export function createRouter(): BrowserRouter {
|
||||
return new BrowserRouter();
|
||||
}
|
||||
@@ -1,101 +0,0 @@
|
||||
/**
|
||||
* Browser route definitions.
|
||||
* This integrates with the shared route builder from @triliumnext/core.
|
||||
*/
|
||||
|
||||
import { BootstrapDefinition } from '@triliumnext/commons';
|
||||
import { getSharedBootstrapItems, getSql, routes } from '@triliumnext/core';
|
||||
|
||||
import packageJson from '../../package.json' with { type: 'json' };
|
||||
import { type BrowserRequest,BrowserRouter } from './browser_router';
|
||||
|
||||
type HttpMethod = 'get' | 'post' | 'put' | 'patch' | 'delete';
|
||||
|
||||
/**
|
||||
* Wraps a core route handler to work with the BrowserRouter.
|
||||
* Core handlers expect an Express-like request object with params, query, and body.
|
||||
*/
|
||||
function wrapHandler(handler: (req: any) => unknown, transactional: boolean) {
|
||||
return (req: BrowserRequest) => {
|
||||
// Create an Express-like request object
|
||||
const expressLikeReq = {
|
||||
params: req.params,
|
||||
query: req.query,
|
||||
body: req.body
|
||||
};
|
||||
if (transactional) {
|
||||
return getSql().transactional(() => handler(expressLikeReq));
|
||||
}
|
||||
return handler(expressLikeReq);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an apiRoute function compatible with buildSharedApiRoutes.
|
||||
* This bridges the core's route registration to the BrowserRouter.
|
||||
*/
|
||||
function createApiRoute(router: BrowserRouter) {
|
||||
return (method: HttpMethod, path: string, handler: (req: any) => unknown) => {
|
||||
router.register(method, path, wrapHandler(handler, true));
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Register all API routes on the browser router using the shared builder.
|
||||
*
|
||||
* @param router - The browser router instance
|
||||
*/
|
||||
export function registerRoutes(router: BrowserRouter): void {
|
||||
const apiRoute = createApiRoute(router);
|
||||
routes.buildSharedApiRoutes(apiRoute);
|
||||
apiRoute('get', '/bootstrap', bootstrapRoute);
|
||||
|
||||
// Dummy routes for compatibility.
|
||||
apiRoute("get", "/api/script/widgets", () => []);
|
||||
apiRoute("get", "/api/script/startup", () => []);
|
||||
apiRoute("get", "/api/system-checks", () => ({ isCpuArchMismatch: false }));
|
||||
apiRoute("get", "/api/search/:searchString", () => []);
|
||||
apiRoute("get", "/api/search-templates", () => []);
|
||||
apiRoute("get", "/api/autocomplete", () => []);
|
||||
}
|
||||
|
||||
function bootstrapRoute() {
|
||||
|
||||
const assetPath = ".";
|
||||
|
||||
return {
|
||||
...getSharedBootstrapItems(assetPath),
|
||||
appPath: assetPath,
|
||||
device: false, // Let the client detect device type.
|
||||
csrfToken: "dummy-csrf-token",
|
||||
themeCssUrl: false,
|
||||
themeUseNextAsBase: "next",
|
||||
triliumVersion: packageJson.version,
|
||||
baseApiUrl: "../api/",
|
||||
headingStyle: "plain",
|
||||
layoutOrientation: "vertical",
|
||||
platform: "web",
|
||||
isDev: import.meta.env.DEV,
|
||||
isMainWindow: true,
|
||||
isElectron: false,
|
||||
isStandalone: true,
|
||||
hasNativeTitleBar: false,
|
||||
hasBackgroundEffects: false,
|
||||
|
||||
// TODO: Fill properly
|
||||
currentLocale: { id: "en", name: "English", rtl: false },
|
||||
isRtl: false,
|
||||
instanceName: null,
|
||||
appCssNoteIds: [],
|
||||
TRILIUM_SAFE_MODE: false
|
||||
} satisfies BootstrapDefinition;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create and configure a router with all routes registered.
|
||||
*/
|
||||
export function createConfiguredRouter(): BrowserRouter {
|
||||
const router = new BrowserRouter();
|
||||
registerRoutes(router);
|
||||
return router;
|
||||
}
|
||||
@@ -1,77 +0,0 @@
|
||||
import { ExecutionContext } from "@triliumnext/core";
|
||||
|
||||
/**
|
||||
* Browser execution context implementation.
|
||||
*
|
||||
* Handles per-request context isolation with support for fire-and-forget async operations
|
||||
* using a context stack and grace-period cleanup to allow unawaited promises to complete.
|
||||
*/
|
||||
export default class BrowserExecutionContext implements ExecutionContext {
|
||||
private contextStack: Map<string, any>[] = [];
|
||||
private cleanupTimers = new WeakMap<Map<string, any>, ReturnType<typeof setTimeout>>();
|
||||
private readonly CLEANUP_GRACE_PERIOD = 1000; // 1 second for fire-and-forget operations
|
||||
|
||||
private getCurrentContext(): Map<string, any> {
|
||||
if (this.contextStack.length === 0) {
|
||||
throw new Error("ExecutionContext not initialized");
|
||||
}
|
||||
return this.contextStack[this.contextStack.length - 1];
|
||||
}
|
||||
|
||||
get<T = any>(key: string): T {
|
||||
return this.getCurrentContext().get(key);
|
||||
}
|
||||
|
||||
set(key: string, value: any): void {
|
||||
this.getCurrentContext().set(key, value);
|
||||
}
|
||||
|
||||
reset(): void {
|
||||
this.contextStack = [];
|
||||
}
|
||||
|
||||
init<T>(callback: () => T): T {
|
||||
const context = new Map<string, any>();
|
||||
this.contextStack.push(context);
|
||||
|
||||
// Cancel any pending cleanup timer for this context
|
||||
const existingTimer = this.cleanupTimers.get(context);
|
||||
if (existingTimer) {
|
||||
clearTimeout(existingTimer);
|
||||
this.cleanupTimers.delete(context);
|
||||
}
|
||||
|
||||
try {
|
||||
const result = callback();
|
||||
|
||||
// If the result is a Promise
|
||||
if (result && typeof result === 'object' && 'then' in result && 'catch' in result) {
|
||||
const promise = result as unknown as Promise<any>;
|
||||
return promise.finally(() => {
|
||||
this.scheduleContextCleanup(context);
|
||||
}) as T;
|
||||
} else {
|
||||
// For synchronous results, schedule delayed cleanup to allow fire-and-forget operations
|
||||
this.scheduleContextCleanup(context);
|
||||
return result;
|
||||
}
|
||||
} catch (error) {
|
||||
// Always clean up on error with grace period
|
||||
this.scheduleContextCleanup(context);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private scheduleContextCleanup(context: Map<string, any>): void {
|
||||
const timer = setTimeout(() => {
|
||||
// Remove from stack if still present
|
||||
const index = this.contextStack.indexOf(context);
|
||||
if (index !== -1) {
|
||||
this.contextStack.splice(index, 1);
|
||||
}
|
||||
this.cleanupTimers.delete(context);
|
||||
}, this.CLEANUP_GRACE_PERIOD);
|
||||
|
||||
this.cleanupTimers.set(context, timer);
|
||||
}
|
||||
}
|
||||
@@ -1,145 +0,0 @@
|
||||
import type { CryptoProvider } from "@triliumnext/core";
|
||||
import { sha1 } from "js-sha1";
|
||||
import { sha512 } from "js-sha512";
|
||||
|
||||
interface Cipher {
|
||||
update(data: Uint8Array): Uint8Array;
|
||||
final(): Uint8Array;
|
||||
}
|
||||
|
||||
const CHARS = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
|
||||
|
||||
/**
|
||||
* Crypto provider for browser environments using the Web Crypto API.
|
||||
*/
|
||||
export default class BrowserCryptoProvider implements CryptoProvider {
|
||||
|
||||
createHash(algorithm: "sha1" | "sha512", content: string | Uint8Array): Uint8Array {
|
||||
const data = typeof content === "string" ? content :
|
||||
new TextDecoder().decode(content);
|
||||
|
||||
const hexHash = algorithm === "sha1" ? sha1(data) : sha512(data);
|
||||
|
||||
// Convert hex string to Uint8Array
|
||||
const bytes = new Uint8Array(hexHash.length / 2);
|
||||
for (let i = 0; i < hexHash.length; i += 2) {
|
||||
bytes[i / 2] = parseInt(hexHash.substr(i, 2), 16);
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
|
||||
createCipheriv(algorithm: "aes-128-cbc", key: Uint8Array, iv: Uint8Array): Cipher {
|
||||
// Web Crypto API doesn't support streaming cipher like Node.js
|
||||
// We need to implement a wrapper that collects data and encrypts on final()
|
||||
return new WebCryptoCipher(algorithm, key, iv, "encrypt");
|
||||
}
|
||||
|
||||
createDecipheriv(algorithm: "aes-128-cbc", key: Uint8Array, iv: Uint8Array): Cipher {
|
||||
return new WebCryptoCipher(algorithm, key, iv, "decrypt");
|
||||
}
|
||||
|
||||
randomBytes(size: number): Uint8Array {
|
||||
const bytes = new Uint8Array(size);
|
||||
crypto.getRandomValues(bytes);
|
||||
return bytes;
|
||||
}
|
||||
|
||||
randomString(length: number): string {
|
||||
const bytes = this.randomBytes(length);
|
||||
let result = "";
|
||||
for (let i = 0; i < length; i++) {
|
||||
result += CHARS[bytes[i] % CHARS.length];
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A cipher implementation that wraps Web Crypto API.
|
||||
* Note: This buffers all data until final() is called, which differs from
|
||||
* Node.js's streaming cipher behavior.
|
||||
*/
|
||||
class WebCryptoCipher implements Cipher {
|
||||
private chunks: Uint8Array[] = [];
|
||||
private algorithm: string;
|
||||
private key: Uint8Array;
|
||||
private iv: Uint8Array;
|
||||
private mode: "encrypt" | "decrypt";
|
||||
private finalized = false;
|
||||
|
||||
constructor(
|
||||
algorithm: "aes-128-cbc",
|
||||
key: Uint8Array,
|
||||
iv: Uint8Array,
|
||||
mode: "encrypt" | "decrypt"
|
||||
) {
|
||||
this.algorithm = algorithm;
|
||||
this.key = key;
|
||||
this.iv = iv;
|
||||
this.mode = mode;
|
||||
}
|
||||
|
||||
update(data: Uint8Array): Uint8Array {
|
||||
if (this.finalized) {
|
||||
throw new Error("Cipher has already been finalized");
|
||||
}
|
||||
// Buffer the data - Web Crypto doesn't support streaming
|
||||
this.chunks.push(data);
|
||||
// Return empty array since we process everything in final()
|
||||
return new Uint8Array(0);
|
||||
}
|
||||
|
||||
final(): Uint8Array {
|
||||
if (this.finalized) {
|
||||
throw new Error("Cipher has already been finalized");
|
||||
}
|
||||
this.finalized = true;
|
||||
|
||||
// Web Crypto API is async, but we need sync behavior
|
||||
// This is a fundamental limitation that requires architectural changes
|
||||
// For now, throw an error directing users to use async methods
|
||||
throw new Error(
|
||||
"Synchronous cipher finalization not available in browser. " +
|
||||
"The Web Crypto API is async-only. Use finalizeAsync() instead."
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Async version that actually performs the encryption/decryption.
|
||||
*/
|
||||
async finalizeAsync(): Promise<Uint8Array> {
|
||||
if (this.finalized) {
|
||||
throw new Error("Cipher has already been finalized");
|
||||
}
|
||||
this.finalized = true;
|
||||
|
||||
// Concatenate all chunks
|
||||
const totalLength = this.chunks.reduce((sum, chunk) => sum + chunk.length, 0);
|
||||
const data = new Uint8Array(totalLength);
|
||||
let offset = 0;
|
||||
for (const chunk of this.chunks) {
|
||||
data.set(chunk, offset);
|
||||
offset += chunk.length;
|
||||
}
|
||||
|
||||
// Copy key and iv to ensure they're plain ArrayBuffer-backed
|
||||
const keyBuffer = new Uint8Array(this.key);
|
||||
const ivBuffer = new Uint8Array(this.iv);
|
||||
|
||||
// Import the key
|
||||
const cryptoKey = await crypto.subtle.importKey(
|
||||
"raw",
|
||||
keyBuffer,
|
||||
{ name: "AES-CBC" },
|
||||
false,
|
||||
[this.mode]
|
||||
);
|
||||
|
||||
// Perform encryption/decryption
|
||||
const result = this.mode === "encrypt"
|
||||
? await crypto.subtle.encrypt({ name: "AES-CBC", iv: ivBuffer }, cryptoKey, data)
|
||||
: await crypto.subtle.decrypt({ name: "AES-CBC", iv: ivBuffer }, cryptoKey, data);
|
||||
|
||||
return new Uint8Array(result);
|
||||
}
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
@@ -1,90 +0,0 @@
|
||||
import type { WebSocketMessage } from "@triliumnext/commons";
|
||||
import type { MessagingProvider, MessageHandler } from "@triliumnext/core";
|
||||
|
||||
/**
|
||||
* Messaging provider for browser Worker environments.
|
||||
*
|
||||
* This provider uses the Worker's postMessage API to communicate
|
||||
* with the main thread. It's designed to be used inside a Web Worker
|
||||
* that runs the core services.
|
||||
*
|
||||
* Message flow:
|
||||
* - Outbound (worker → main): Uses self.postMessage() with type: "WS_MESSAGE"
|
||||
* - Inbound (main → worker): Listens to onmessage for type: "WS_MESSAGE"
|
||||
*/
|
||||
export default class WorkerMessagingProvider implements MessagingProvider {
|
||||
private messageHandlers: MessageHandler[] = [];
|
||||
private isDisposed = false;
|
||||
|
||||
constructor() {
|
||||
// Listen for incoming messages from the main thread
|
||||
self.addEventListener("message", this.handleIncomingMessage);
|
||||
}
|
||||
|
||||
private handleIncomingMessage = (event: MessageEvent) => {
|
||||
if (this.isDisposed) return;
|
||||
|
||||
const { type, message } = event.data || {};
|
||||
|
||||
if (type === "WS_MESSAGE" && message) {
|
||||
// Dispatch to all registered handlers
|
||||
for (const handler of this.messageHandlers) {
|
||||
try {
|
||||
handler(message as WebSocketMessage);
|
||||
} catch (e) {
|
||||
console.error("[WorkerMessagingProvider] Error in message handler:", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Send a message to all clients (in this case, the main thread).
|
||||
* The main thread is responsible for further distribution if needed.
|
||||
*/
|
||||
sendMessageToAllClients(message: WebSocketMessage): void {
|
||||
if (this.isDisposed) {
|
||||
console.warn("[WorkerMessagingProvider] Cannot send message - provider is disposed");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
self.postMessage({
|
||||
type: "WS_MESSAGE",
|
||||
message
|
||||
});
|
||||
} catch (e) {
|
||||
console.error("[WorkerMessagingProvider] Error sending message:", e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to incoming messages from the main thread.
|
||||
*/
|
||||
onMessage(handler: MessageHandler): () => void {
|
||||
this.messageHandlers.push(handler);
|
||||
|
||||
return () => {
|
||||
this.messageHandlers = this.messageHandlers.filter(h => h !== handler);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the number of connected "clients".
|
||||
* In worker context, there's always exactly 1 client (the main thread).
|
||||
*/
|
||||
getClientCount(): number {
|
||||
return this.isDisposed ? 0 : 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up resources.
|
||||
*/
|
||||
dispose(): void {
|
||||
if (this.isDisposed) return;
|
||||
|
||||
this.isDisposed = true;
|
||||
self.removeEventListener("message", this.handleIncomingMessage);
|
||||
this.messageHandlers = [];
|
||||
}
|
||||
}
|
||||
@@ -1,618 +0,0 @@
|
||||
import { type BindableValue, default as sqlite3InitModule } from "@sqlite.org/sqlite-wasm";
|
||||
import type { DatabaseProvider, RunResult, Statement, Transaction } from "@triliumnext/core";
|
||||
|
||||
import demoDbSql from "./db.sql?raw";
|
||||
|
||||
// Type definitions for SQLite WASM (the library doesn't export these directly)
|
||||
type Sqlite3Module = Awaited<ReturnType<typeof sqlite3InitModule>>;
|
||||
type Sqlite3Database = InstanceType<Sqlite3Module["oo1"]["DB"]>;
|
||||
type Sqlite3PreparedStatement = ReturnType<Sqlite3Database["prepare"]>;
|
||||
|
||||
/**
|
||||
* Wraps an SQLite WASM PreparedStatement to match the Statement interface
|
||||
* expected by trilium-core.
|
||||
*/
|
||||
class WasmStatement implements Statement {
|
||||
private isRawMode = false;
|
||||
private isPluckMode = false;
|
||||
private isFinalized = false;
|
||||
|
||||
constructor(
|
||||
private stmt: Sqlite3PreparedStatement,
|
||||
private db: Sqlite3Database,
|
||||
private sqlite3: Sqlite3Module
|
||||
) {}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
private bindParams(params: unknown[]): void {
|
||||
this.stmt.clearBindings();
|
||||
if (params.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle single object with named parameters
|
||||
if (params.length === 1 && typeof params[0] === "object" && params[0] !== null && !Array.isArray(params[0])) {
|
||||
const inputBindings = params[0] as { [paramName: string]: BindableValue };
|
||||
|
||||
// SQLite WASM expects parameter names to include the prefix (@ : or $)
|
||||
// better-sqlite3 automatically maps unprefixed names to @name
|
||||
// We need to add the @ prefix for compatibility
|
||||
const bindings: { [paramName: string]: BindableValue } = {};
|
||||
for (const [key, value] of Object.entries(inputBindings)) {
|
||||
// If the key already has a prefix, use it as-is
|
||||
if (key.startsWith('@') || key.startsWith(':') || key.startsWith('$')) {
|
||||
bindings[key] = value;
|
||||
} else {
|
||||
// Add @ prefix to match better-sqlite3 behavior
|
||||
bindings[`@${key}`] = value;
|
||||
}
|
||||
}
|
||||
|
||||
this.stmt.bind(bindings);
|
||||
} else {
|
||||
// Handle positional parameters - flatten and cast to BindableValue[]
|
||||
const flatParams = params.flat() as BindableValue[];
|
||||
if (flatParams.length > 0) {
|
||||
this.stmt.bind(flatParams);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
finalize(): void {
|
||||
if (!this.isFinalized) {
|
||||
try {
|
||||
this.stmt.finalize();
|
||||
} catch (e) {
|
||||
console.warn("Error finalizing SQLite statement:", e);
|
||||
} finally {
|
||||
this.isFinalized = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* SQLite database provider for browser environments using SQLite WASM.
|
||||
*
|
||||
* This provider wraps the official @sqlite.org/sqlite-wasm package to provide
|
||||
* a DatabaseProvider implementation compatible with trilium-core.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const provider = new BrowserSqlProvider();
|
||||
* await provider.initWasm(); // Initialize SQLite WASM module
|
||||
* provider.loadFromMemory(); // Open an in-memory database
|
||||
* // or
|
||||
* provider.loadFromBuffer(existingDbBuffer); // Load from existing data
|
||||
* ```
|
||||
*/
|
||||
export default class BrowserSqlProvider implements DatabaseProvider {
|
||||
private db?: Sqlite3Database;
|
||||
private sqlite3?: Sqlite3Module;
|
||||
private _inTransaction = false;
|
||||
private initPromise?: Promise<void>;
|
||||
private initError?: Error;
|
||||
private statementCache: Map<string, WasmStatement> = new Map();
|
||||
|
||||
// OPFS state tracking
|
||||
private opfsDbPath?: string;
|
||||
|
||||
/**
|
||||
* Get the SQLite WASM module version info.
|
||||
* Returns undefined if the module hasn't been initialized yet.
|
||||
*/
|
||||
get version(): { libVersion: string; sourceId: string } | undefined {
|
||||
return this.sqlite3?.version;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the SQLite WASM module.
|
||||
* This must be called before using any database operations.
|
||||
* Safe to call multiple times - subsequent calls return the same promise.
|
||||
*
|
||||
* @returns A promise that resolves when the module is initialized
|
||||
* @throws Error if initialization fails
|
||||
*/
|
||||
async initWasm(): Promise<void> {
|
||||
// Return existing promise if already initializing/initialized
|
||||
if (this.initPromise) {
|
||||
return this.initPromise;
|
||||
}
|
||||
|
||||
// Fail fast if we already tried and failed
|
||||
if (this.initError) {
|
||||
throw this.initError;
|
||||
}
|
||||
|
||||
this.initPromise = this.doInitWasm();
|
||||
return this.initPromise;
|
||||
}
|
||||
|
||||
private async doInitWasm(): Promise<void> {
|
||||
try {
|
||||
console.log("[BrowserSqlProvider] Initializing SQLite WASM...");
|
||||
const startTime = performance.now();
|
||||
|
||||
this.sqlite3 = await sqlite3InitModule({
|
||||
print: console.log,
|
||||
printErr: console.error,
|
||||
});
|
||||
|
||||
const initTime = performance.now() - startTime;
|
||||
console.log(
|
||||
`[BrowserSqlProvider] SQLite WASM initialized in ${initTime.toFixed(2)}ms:`,
|
||||
this.sqlite3.version.libVersion
|
||||
);
|
||||
} catch (e) {
|
||||
this.initError = e instanceof Error ? e : new Error(String(e));
|
||||
console.error("[BrowserSqlProvider] SQLite WASM initialization failed:", this.initError);
|
||||
throw this.initError;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the SQLite WASM module has been initialized.
|
||||
*/
|
||||
get isInitialized(): boolean {
|
||||
return this.sqlite3 !== undefined;
|
||||
}
|
||||
|
||||
// ==================== OPFS Support ====================
|
||||
|
||||
/**
|
||||
* Check if the OPFS VFS is available.
|
||||
* This requires:
|
||||
* - Running in a Worker context
|
||||
* - Browser support for OPFS APIs
|
||||
* - COOP/COEP headers sent by the server (for SharedArrayBuffer)
|
||||
*
|
||||
* @returns true if OPFS VFS is available for use
|
||||
*/
|
||||
isOpfsAvailable(): boolean {
|
||||
this.ensureSqlite3();
|
||||
// SQLite WASM automatically installs the OPFS VFS if the environment supports it
|
||||
// We can check for its presence via sqlite3_vfs_find or the OpfsDb class
|
||||
return this.sqlite3!.oo1.OpfsDb !== undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load or create a database stored in OPFS for persistent storage.
|
||||
* The database will persist across browser sessions.
|
||||
*
|
||||
* Requires COOP/COEP headers to be set by the server:
|
||||
* - Cross-Origin-Opener-Policy: same-origin
|
||||
* - Cross-Origin-Embedder-Policy: require-corp
|
||||
*
|
||||
* @param path - The path for the database file in OPFS (e.g., "/trilium.db")
|
||||
* Paths without a leading slash are treated as relative to OPFS root.
|
||||
* Leading directories are created automatically.
|
||||
* @param options - Additional options
|
||||
* @throws Error if OPFS VFS is not available
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const provider = new BrowserSqlProvider();
|
||||
* await provider.initWasm();
|
||||
* if (provider.isOpfsAvailable()) {
|
||||
* provider.loadFromOpfs("/my-database.db");
|
||||
* } else {
|
||||
* console.warn("OPFS not available, using in-memory database");
|
||||
* provider.loadFromMemory();
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
loadFromOpfs(path: string, options: { createIfNotExists?: boolean } = {}): void {
|
||||
this.ensureSqlite3();
|
||||
|
||||
if (!this.isOpfsAvailable()) {
|
||||
throw new Error(
|
||||
"OPFS VFS is not available. This requires:\n" +
|
||||
"1. Running in a Worker context\n" +
|
||||
"2. Browser support for OPFS (Chrome 102+, Firefox 111+, Safari 17+)\n" +
|
||||
"3. COOP/COEP headers from the server:\n" +
|
||||
" Cross-Origin-Opener-Policy: same-origin\n" +
|
||||
" Cross-Origin-Embedder-Policy: require-corp"
|
||||
);
|
||||
}
|
||||
|
||||
console.log(`[BrowserSqlProvider] Loading database from OPFS: ${path}`);
|
||||
const startTime = performance.now();
|
||||
|
||||
try {
|
||||
// OpfsDb automatically creates directories in the path
|
||||
// Mode 'c' = create if not exists
|
||||
const mode = options.createIfNotExists !== false ? 'c' : '';
|
||||
this.db = new this.sqlite3!.oo1.OpfsDb(path, mode);
|
||||
this.opfsDbPath = path;
|
||||
|
||||
// Configure the database for OPFS
|
||||
// Note: WAL mode requires exclusive locking in OPFS environment
|
||||
this.db.exec("PRAGMA journal_mode = DELETE");
|
||||
this.db.exec("PRAGMA synchronous = NORMAL");
|
||||
|
||||
const loadTime = performance.now() - startTime;
|
||||
console.log(`[BrowserSqlProvider] OPFS database loaded in ${loadTime.toFixed(2)}ms`);
|
||||
} catch (e) {
|
||||
const error = e instanceof Error ? e : new Error(String(e));
|
||||
console.error(`[BrowserSqlProvider] Failed to load OPFS database: ${error.message}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the currently open database is stored in OPFS.
|
||||
*/
|
||||
get isUsingOpfs(): boolean {
|
||||
return this.opfsDbPath !== undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the OPFS path of the currently open database.
|
||||
* Returns undefined if not using OPFS.
|
||||
*/
|
||||
get currentOpfsPath(): string | undefined {
|
||||
return this.opfsDbPath;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the database has been initialized with a schema.
|
||||
* This is a simple sanity check that looks for the existence of core tables.
|
||||
*
|
||||
* @returns true if the database appears to be initialized
|
||||
*/
|
||||
isDbInitialized(): boolean {
|
||||
this.ensureDb();
|
||||
|
||||
// Check if the 'notes' table exists (a core table that must exist in an initialized DB)
|
||||
const tableExists = this.db!.selectValue(
|
||||
"SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'notes'"
|
||||
);
|
||||
|
||||
return tableExists !== undefined;
|
||||
}
|
||||
|
||||
// ==================== End OPFS Support ====================
|
||||
|
||||
loadFromFile(_path: string, _isReadOnly: boolean): void {
|
||||
// Browser environment doesn't have direct file system access.
|
||||
// Use OPFS for persistent storage.
|
||||
throw new Error(
|
||||
"loadFromFile is not supported in browser environment. " +
|
||||
"Use loadFromMemory() for temporary databases, loadFromBuffer() to load from data, " +
|
||||
"or loadFromOpfs() for persistent storage."
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an empty in-memory database.
|
||||
* Data will be lost when the page is closed.
|
||||
*
|
||||
* For persistent storage, use loadFromOpfs() instead.
|
||||
* To load demo data, call initializeDemoDatabase() after this.
|
||||
*/
|
||||
loadFromMemory(): void {
|
||||
this.ensureSqlite3();
|
||||
console.log("[BrowserSqlProvider] Creating in-memory database...");
|
||||
const startTime = performance.now();
|
||||
|
||||
this.db = new this.sqlite3!.oo1.DB(":memory:", "c");
|
||||
this.opfsDbPath = undefined; // Not using OPFS
|
||||
this.db.exec("PRAGMA journal_mode = WAL");
|
||||
|
||||
// Initialize with demo data for in-memory databases
|
||||
// (since they won't persist anyway)
|
||||
this.initializeDemoDatabase();
|
||||
|
||||
const loadTime = performance.now() - startTime;
|
||||
console.log(`[BrowserSqlProvider] In-memory database created in ${loadTime.toFixed(2)}ms`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the database with demo/starter data.
|
||||
* This should only be called once when creating a new database.
|
||||
*
|
||||
* For OPFS databases, this is called automatically only if the database
|
||||
* doesn't already exist.
|
||||
*/
|
||||
initializeDemoDatabase(): void {
|
||||
this.ensureDb();
|
||||
console.log("[BrowserSqlProvider] Initializing database with demo data...");
|
||||
const startTime = performance.now();
|
||||
|
||||
this.db!.exec(demoDbSql);
|
||||
|
||||
const loadTime = performance.now() - startTime;
|
||||
console.log(`[BrowserSqlProvider] Demo data loaded in ${loadTime.toFixed(2)}ms`);
|
||||
}
|
||||
|
||||
loadFromBuffer(buffer: Uint8Array): void {
|
||||
this.ensureSqlite3();
|
||||
// SQLite WASM can deserialize a database from a byte array
|
||||
const p = this.sqlite3!.wasm.allocFromTypedArray(buffer);
|
||||
try {
|
||||
this.db = new this.sqlite3!.oo1.DB({ filename: ":memory:", flags: "c" });
|
||||
this.opfsDbPath = undefined; // Not using OPFS
|
||||
|
||||
const rc = this.sqlite3!.capi.sqlite3_deserialize(
|
||||
this.db.pointer!,
|
||||
"main",
|
||||
p,
|
||||
buffer.byteLength,
|
||||
buffer.byteLength,
|
||||
this.sqlite3!.capi.SQLITE_DESERIALIZE_FREEONCLOSE |
|
||||
this.sqlite3!.capi.SQLITE_DESERIALIZE_RESIZEABLE
|
||||
);
|
||||
if (rc !== 0) {
|
||||
throw new Error(`Failed to deserialize database: ${rc}`);
|
||||
}
|
||||
} catch (e) {
|
||||
this.sqlite3!.wasm.dealloc(p);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
backup(_destinationFile: string): void {
|
||||
// In browser, we can serialize the database to a byte array
|
||||
// For actual file backup, we'd need to use File System Access API or download
|
||||
throw new Error(
|
||||
"backup to file is not supported in browser environment. " +
|
||||
"Use serialize() to get the database as a Uint8Array instead."
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Serialize the database to a byte array.
|
||||
* This can be used to save the database to IndexedDB, download it, etc.
|
||||
*/
|
||||
serialize(): Uint8Array {
|
||||
this.ensureDb();
|
||||
// Use the convenience wrapper which handles all the memory management
|
||||
return this.sqlite3!.capi.sqlite3_js_db_export(this.db!);
|
||||
}
|
||||
|
||||
prepare(query: string): Statement {
|
||||
this.ensureDb();
|
||||
|
||||
// Check if we already have this statement cached
|
||||
if (this.statementCache.has(query)) {
|
||||
return this.statementCache.get(query)!;
|
||||
}
|
||||
|
||||
// Create new statement and cache it
|
||||
const stmt = this.db!.prepare(query);
|
||||
const wasmStatement = new WasmStatement(stmt, this.db!, this.sqlite3!);
|
||||
this.statementCache.set(query, wasmStatement);
|
||||
return wasmStatement;
|
||||
}
|
||||
|
||||
transaction<T>(func: (statement: Statement) => T): Transaction {
|
||||
this.ensureDb();
|
||||
|
||||
const self = this;
|
||||
let savepointCounter = 0;
|
||||
|
||||
// Helper function to execute within a transaction
|
||||
const executeTransaction = (beginStatement: string, ...args: unknown[]): T => {
|
||||
// If we're already in a transaction, use SAVEPOINTs for nesting
|
||||
// This mimics better-sqlite3's behavior
|
||||
if (self._inTransaction) {
|
||||
const savepointName = `sp_${++savepointCounter}_${Date.now()}`;
|
||||
self.db!.exec(`SAVEPOINT ${savepointName}`);
|
||||
try {
|
||||
const result = func.apply(null, args as [Statement]);
|
||||
self.db!.exec(`RELEASE SAVEPOINT ${savepointName}`);
|
||||
return result;
|
||||
} catch (e) {
|
||||
self.db!.exec(`ROLLBACK TO SAVEPOINT ${savepointName}`);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
// Not in a transaction, start a new one
|
||||
self._inTransaction = true;
|
||||
self.db!.exec(beginStatement);
|
||||
try {
|
||||
const result = func.apply(null, args as [Statement]);
|
||||
self.db!.exec("COMMIT");
|
||||
return result;
|
||||
} catch (e) {
|
||||
self.db!.exec("ROLLBACK");
|
||||
throw e;
|
||||
} finally {
|
||||
self._inTransaction = false;
|
||||
}
|
||||
};
|
||||
|
||||
// Create the transaction function that acts like better-sqlite3's Transaction interface
|
||||
// In better-sqlite3, the transaction function is callable and has .deferred(), .immediate(), etc.
|
||||
const transactionWrapper = Object.assign(
|
||||
// Default call executes with BEGIN (same as immediate)
|
||||
(...args: unknown[]): T => executeTransaction("BEGIN", ...args),
|
||||
{
|
||||
// Deferred transaction - locks acquired on first data access
|
||||
deferred: (...args: unknown[]): T => executeTransaction("BEGIN DEFERRED", ...args),
|
||||
// Immediate transaction - acquires write lock immediately
|
||||
immediate: (...args: unknown[]): T => executeTransaction("BEGIN IMMEDIATE", ...args),
|
||||
// Exclusive transaction - exclusive lock
|
||||
exclusive: (...args: unknown[]): T => executeTransaction("BEGIN EXCLUSIVE", ...args),
|
||||
// Default is same as calling directly
|
||||
default: (...args: unknown[]): T => executeTransaction("BEGIN", ...args)
|
||||
}
|
||||
);
|
||||
|
||||
return transactionWrapper as unknown as Transaction;
|
||||
}
|
||||
|
||||
get inTransaction(): boolean {
|
||||
return this._inTransaction;
|
||||
}
|
||||
|
||||
exec(query: string): void {
|
||||
this.ensureDb();
|
||||
this.db!.exec(query);
|
||||
}
|
||||
|
||||
close(): void {
|
||||
// Clean up all cached statements first
|
||||
for (const statement of this.statementCache.values()) {
|
||||
try {
|
||||
statement.finalize();
|
||||
} catch (e) {
|
||||
// Ignore errors during cleanup
|
||||
console.warn("Error finalizing statement during cleanup:", e);
|
||||
}
|
||||
}
|
||||
this.statementCache.clear();
|
||||
|
||||
if (this.db) {
|
||||
this.db.close();
|
||||
this.db = undefined;
|
||||
}
|
||||
|
||||
// Reset OPFS state
|
||||
this.opfsDbPath = undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the number of rows changed by the last INSERT, UPDATE, or DELETE statement.
|
||||
*/
|
||||
changes(): number {
|
||||
this.ensureDb();
|
||||
return this.db!.changes();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the database is currently open.
|
||||
*/
|
||||
isOpen(): boolean {
|
||||
return this.db !== undefined && this.db.isOpen();
|
||||
}
|
||||
|
||||
private ensureSqlite3(): void {
|
||||
if (!this.sqlite3) {
|
||||
throw new Error(
|
||||
"SQLite WASM module not initialized. Call initialize() first with the sqlite3 module."
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private ensureDb(): void {
|
||||
this.ensureSqlite3();
|
||||
if (!this.db) {
|
||||
throw new Error("Database not opened. Call loadFromMemory(), loadFromBuffer(), or loadFromOpfs() first.");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
import { LOCALE_IDS } from "@triliumnext/commons";
|
||||
import type i18next from "i18next";
|
||||
import I18NextHttpBackend from "i18next-http-backend";
|
||||
|
||||
export default async function translationProvider(i18nextInstance: typeof i18next, locale: LOCALE_IDS) {
|
||||
await i18nextInstance.use(I18NextHttpBackend).init({
|
||||
lng: locale,
|
||||
fallbackLng: "en",
|
||||
ns: "server",
|
||||
backend: {
|
||||
loadPath: `${import.meta.resolve("../server-assets/translations")}/{{lng}}/{{ns}}.json`
|
||||
},
|
||||
returnEmptyString: false,
|
||||
debug: true
|
||||
});
|
||||
}
|
||||
@@ -1,88 +0,0 @@
|
||||
import LocalServerWorker from "./local-server-worker?worker";
|
||||
let localWorker: Worker | null = null;
|
||||
const pending = new Map();
|
||||
|
||||
export function startLocalServerWorker() {
|
||||
if (localWorker) return localWorker;
|
||||
localWorker = new LocalServerWorker();
|
||||
|
||||
// Handle worker errors during initialization
|
||||
localWorker.onerror = (event) => {
|
||||
console.error("[LocalBridge] Worker error:", event);
|
||||
// Reject all pending requests
|
||||
for (const [, resolver] of pending) {
|
||||
resolver.reject(new Error(`Worker error: ${event.message}`));
|
||||
}
|
||||
pending.clear();
|
||||
};
|
||||
|
||||
localWorker.onmessage = (event) => {
|
||||
const msg = event.data;
|
||||
|
||||
// Handle worker error reports
|
||||
if (msg?.type === "WORKER_ERROR") {
|
||||
console.error("[LocalBridge] Worker reported error:", msg.error);
|
||||
// Reject all pending requests with the error
|
||||
for (const [, resolver] of pending) {
|
||||
resolver.reject(new Error(msg.error?.message || "Unknown worker error"));
|
||||
}
|
||||
pending.clear();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!msg || msg.type !== "LOCAL_RESPONSE") return;
|
||||
|
||||
const { id, response, error } = msg;
|
||||
const resolver = pending.get(id);
|
||||
if (!resolver) return;
|
||||
pending.delete(id);
|
||||
|
||||
if (error) resolver.reject(new Error(error));
|
||||
else resolver.resolve(response);
|
||||
};
|
||||
|
||||
return localWorker;
|
||||
}
|
||||
|
||||
export function attachServiceWorkerBridge() {
|
||||
navigator.serviceWorker.addEventListener("message", async (event) => {
|
||||
const msg = event.data;
|
||||
if (!msg || msg.type !== "LOCAL_FETCH") return;
|
||||
|
||||
const port = event.ports && event.ports[0];
|
||||
if (!port) return;
|
||||
|
||||
try {
|
||||
startLocalServerWorker();
|
||||
|
||||
const id = msg.id;
|
||||
const req = msg.request;
|
||||
|
||||
const response = await new Promise((resolve, reject) => {
|
||||
pending.set(id, { resolve, reject });
|
||||
// Transfer body to worker for efficiency (if present)
|
||||
localWorker.postMessage({
|
||||
type: "LOCAL_REQUEST",
|
||||
id,
|
||||
request: req
|
||||
}, req.body ? [req.body] : []);
|
||||
});
|
||||
|
||||
port.postMessage({
|
||||
type: "LOCAL_FETCH_RESPONSE",
|
||||
id,
|
||||
response
|
||||
}, response.body ? [response.body] : []);
|
||||
} catch (e) {
|
||||
port.postMessage({
|
||||
type: "LOCAL_FETCH_RESPONSE",
|
||||
id: msg.id,
|
||||
response: {
|
||||
status: 500,
|
||||
headers: { "content-type": "text/plain; charset=utf-8" },
|
||||
body: new TextEncoder().encode(String(e?.message || e)).buffer
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -1,207 +0,0 @@
|
||||
import { BrowserRouter } from './lightweight/browser_router';
|
||||
import { createConfiguredRouter } from './lightweight/browser_routes';
|
||||
import BrowserExecutionContext from './lightweight/cls_provider';
|
||||
import BrowserCryptoProvider from './lightweight/crypto_provider';
|
||||
import WorkerMessagingProvider from './lightweight/messaging_provider';
|
||||
import BrowserSqlProvider from './lightweight/sql_provider';
|
||||
import translationProvider from './lightweight/translation_provider';
|
||||
|
||||
// Global error handlers - MUST be set up before any async imports
|
||||
self.onerror = (message, source, lineno, colno, error) => {
|
||||
console.error("[Worker] Uncaught error:", message, source, lineno, colno, error);
|
||||
// Try to notify the main thread about the error
|
||||
try {
|
||||
self.postMessage({
|
||||
type: "WORKER_ERROR",
|
||||
error: {
|
||||
message: String(message),
|
||||
source,
|
||||
lineno,
|
||||
colno,
|
||||
stack: error?.stack
|
||||
}
|
||||
});
|
||||
} catch (e) {
|
||||
// Can't even post message, just log
|
||||
console.error("[Worker] Failed to report error:", e);
|
||||
}
|
||||
return false; // Don't suppress the error
|
||||
};
|
||||
|
||||
self.onunhandledrejection = (event) => {
|
||||
console.error("[Worker] Unhandled rejection:", event.reason);
|
||||
try {
|
||||
self.postMessage({
|
||||
type: "WORKER_ERROR",
|
||||
error: {
|
||||
message: String(event.reason?.message || event.reason),
|
||||
stack: event.reason?.stack
|
||||
}
|
||||
});
|
||||
} catch (e) {
|
||||
console.error("[Worker] Failed to report rejection:", e);
|
||||
}
|
||||
};
|
||||
|
||||
console.log("[Worker] Error handlers installed");
|
||||
|
||||
// Shared SQL provider instance
|
||||
const sqlProvider = new BrowserSqlProvider();
|
||||
|
||||
// Messaging provider for worker-to-main-thread communication
|
||||
const messagingProvider = new WorkerMessagingProvider();
|
||||
|
||||
// Core module, router, and initialization state
|
||||
let coreModule: typeof import("@triliumnext/core") | null = null;
|
||||
let router: BrowserRouter | null = null;
|
||||
let initPromise: Promise<void> | null = null;
|
||||
let initError: Error | null = null;
|
||||
|
||||
/**
|
||||
* Initialize SQLite WASM and load the core module.
|
||||
* This happens once at worker startup.
|
||||
*/
|
||||
async function initialize(): Promise<void> {
|
||||
if (initPromise) {
|
||||
return initPromise; // Already initializing
|
||||
}
|
||||
if (initError) {
|
||||
throw initError; // Failed before, don't retry
|
||||
}
|
||||
|
||||
initPromise = (async () => {
|
||||
try {
|
||||
console.log("[Worker] Initializing SQLite WASM...");
|
||||
await sqlProvider.initWasm();
|
||||
|
||||
// Try to use OPFS for persistent storage
|
||||
if (sqlProvider.isOpfsAvailable()) {
|
||||
console.log("[Worker] OPFS available, loading persistent database...");
|
||||
sqlProvider.loadFromOpfs("/trilium.db");
|
||||
|
||||
// Check if database is initialized (schema exists)
|
||||
if (!sqlProvider.isDbInitialized()) {
|
||||
console.log("[Worker] Database not initialized, loading demo data...");
|
||||
sqlProvider.initializeDemoDatabase();
|
||||
console.log("[Worker] Demo data loaded");
|
||||
} else {
|
||||
console.log("[Worker] Existing initialized database loaded");
|
||||
}
|
||||
} else {
|
||||
// Fall back to in-memory database (non-persistent)
|
||||
console.warn("[Worker] OPFS not available, using in-memory database (data will not persist)");
|
||||
console.warn("[Worker] To enable persistence, ensure COOP/COEP headers are set by the server");
|
||||
sqlProvider.loadFromMemory();
|
||||
}
|
||||
|
||||
console.log("[Worker] Database loaded");
|
||||
|
||||
console.log("[Worker] Loading @triliumnext/core...");
|
||||
coreModule = await import("@triliumnext/core");
|
||||
await coreModule.initializeCore({
|
||||
executionContext: new BrowserExecutionContext(),
|
||||
crypto: new BrowserCryptoProvider(),
|
||||
messaging: messagingProvider,
|
||||
translations: translationProvider,
|
||||
dbConfig: {
|
||||
provider: sqlProvider,
|
||||
isReadOnly: false,
|
||||
onTransactionCommit: () => {
|
||||
// No-op for now
|
||||
},
|
||||
onTransactionRollback: () => {
|
||||
// No-op for now
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
console.log("[Worker] Supported routes", Object.keys(coreModule.routes));
|
||||
|
||||
// Create and configure the router
|
||||
router = createConfiguredRouter();
|
||||
console.log("[Worker] Router configured");
|
||||
|
||||
console.log("[Worker] Initializing becca...");
|
||||
await coreModule.becca_loader.beccaLoaded;
|
||||
|
||||
console.log("[Worker] Initialization complete");
|
||||
} catch (error) {
|
||||
initError = error instanceof Error ? error : new Error(String(error));
|
||||
console.error("[Worker] Initialization failed:", initError);
|
||||
throw initError;
|
||||
}
|
||||
})();
|
||||
|
||||
return initPromise;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure the worker is initialized before processing requests.
|
||||
* Returns the router if initialization was successful.
|
||||
*/
|
||||
async function ensureInitialized() {
|
||||
await initialize();
|
||||
if (!router) {
|
||||
throw new Error("Router not initialized");
|
||||
}
|
||||
return router;
|
||||
}
|
||||
|
||||
interface LocalRequest {
|
||||
method: string;
|
||||
url: string;
|
||||
body?: unknown;
|
||||
headers?: Record<string, string>;
|
||||
}
|
||||
|
||||
// Main dispatch
|
||||
async function dispatch(request: LocalRequest) {
|
||||
const url = new URL(request.url);
|
||||
|
||||
console.log("[Worker] Dispatch:", url.pathname);
|
||||
|
||||
// Ensure initialization is complete and get the router
|
||||
const appRouter = await ensureInitialized();
|
||||
|
||||
// Dispatch to the router
|
||||
return appRouter.dispatch(request.method, request.url, request.body, request.headers);
|
||||
}
|
||||
|
||||
// Start initialization immediately when the worker loads
|
||||
console.log("[Worker] Starting initialization...");
|
||||
initialize().catch(err => {
|
||||
console.error("[Worker] Initialization failed:", err);
|
||||
// Post error to main thread
|
||||
self.postMessage({
|
||||
type: "WORKER_ERROR",
|
||||
error: {
|
||||
message: String(err?.message || err),
|
||||
stack: err?.stack
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
self.onmessage = async (event) => {
|
||||
const msg = event.data;
|
||||
if (!msg || msg.type !== "LOCAL_REQUEST") return;
|
||||
|
||||
const { id, request } = msg;
|
||||
|
||||
try {
|
||||
const response = await dispatch(request);
|
||||
|
||||
// Transfer body back (if any) - use options object for proper typing
|
||||
(self as unknown as Worker).postMessage({
|
||||
type: "LOCAL_RESPONSE",
|
||||
id,
|
||||
response
|
||||
}, { transfer: response.body ? [response.body] : [] });
|
||||
} catch (e) {
|
||||
console.error("[Worker] Dispatch error:", e);
|
||||
(self as unknown as Worker).postMessage({
|
||||
type: "LOCAL_RESPONSE",
|
||||
id,
|
||||
error: String((e as Error)?.message || e)
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -1,84 +0,0 @@
|
||||
import { attachServiceWorkerBridge, startLocalServerWorker } from "./local-bridge.js";
|
||||
|
||||
async function waitForServiceWorkerControl(): Promise<void> {
|
||||
if (!("serviceWorker" in navigator)) {
|
||||
throw new Error("Service Worker not supported in this browser");
|
||||
}
|
||||
|
||||
// If already controlling, we're good
|
||||
if (navigator.serviceWorker.controller) {
|
||||
console.log("[Bootstrap] Service worker already controlling");
|
||||
return;
|
||||
}
|
||||
|
||||
console.log("[Bootstrap] Waiting for service worker to take control...");
|
||||
|
||||
// Register service worker
|
||||
await navigator.serviceWorker.register("./sw.js", { scope: "/" });
|
||||
|
||||
// Wait for it to be ready (installed + activated)
|
||||
await navigator.serviceWorker.ready;
|
||||
|
||||
// Check if we're now controlling
|
||||
if (navigator.serviceWorker.controller) {
|
||||
console.log("[Bootstrap] Service worker now controlling");
|
||||
return;
|
||||
}
|
||||
|
||||
// If not controlling yet, we need to reload the page for SW to take control
|
||||
// This is standard PWA behavior on first install
|
||||
console.log("[Bootstrap] Service worker installed but not controlling yet - reloading page");
|
||||
|
||||
// Wait a tiny bit for SW to fully activate
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
|
||||
// Reload to let SW take control
|
||||
window.location.reload();
|
||||
|
||||
// Throw to stop execution (page will reload)
|
||||
throw new Error("Reloading for service worker activation");
|
||||
}
|
||||
|
||||
async function bootstrap() {
|
||||
/* fixes https://github.com/webpack/webpack/issues/10035 */
|
||||
window.global = globalThis;
|
||||
|
||||
try {
|
||||
// 1) Start local worker ASAP (so /bootstrap is fast)
|
||||
startLocalServerWorker();
|
||||
|
||||
// 2) Bridge SW -> local worker
|
||||
attachServiceWorkerBridge();
|
||||
|
||||
// 3) Wait for service worker to control the page (may reload on first install)
|
||||
await waitForServiceWorkerControl();
|
||||
|
||||
await loadScripts();
|
||||
} catch (err) {
|
||||
// If error is from reload, it will stop here (page reloads)
|
||||
// Otherwise, show error to user
|
||||
if (err instanceof Error && err.message.includes("Reloading")) {
|
||||
// Page is reloading, do nothing
|
||||
return;
|
||||
}
|
||||
|
||||
console.error("[Bootstrap] Fatal error:", err);
|
||||
document.body.innerHTML = `
|
||||
<div style="padding: 40px; max-width: 600px; margin: 0 auto; font-family: system-ui, sans-serif;">
|
||||
<h1 style="color: #d32f2f;">Failed to Initialize</h1>
|
||||
<p>The application failed to start. Please check the browser console for details.</p>
|
||||
<pre style="background: #f5f5f5; padding: 16px; border-radius: 4px; overflow: auto;">${err instanceof Error ? err.message : String(err)}</pre>
|
||||
<button onclick="location.reload()" style="padding: 12px 24px; background: #1976d2; color: white; border: none; border-radius: 4px; cursor: pointer; font-size: 16px;">
|
||||
Reload Page
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
document.body.style.display = "block";
|
||||
}
|
||||
}
|
||||
|
||||
async function loadScripts() {
|
||||
await import("../../client/src/index.js");
|
||||
}
|
||||
|
||||
bootstrap();
|
||||
@@ -1,185 +0,0 @@
|
||||
// public/sw.js
|
||||
const VERSION = "localserver-v1.4";
|
||||
const STATIC_CACHE = `static-${VERSION}`;
|
||||
|
||||
// Check if running in dev mode (passed via URL parameter)
|
||||
const isDev = true;
|
||||
|
||||
if (isDev) {
|
||||
console.log('[Service Worker] Running in DEV mode - caching disabled');
|
||||
}
|
||||
|
||||
// Adjust these to your routes:
|
||||
const LOCAL_FIRST_PREFIXES = [
|
||||
"/bootstrap",
|
||||
"/api/",
|
||||
"/sync/",
|
||||
"/search/"
|
||||
];
|
||||
|
||||
// Optional: basic precache list (keep small; you can expand later)
|
||||
const PRECACHE_URLS = [
|
||||
// "/",
|
||||
// "/index.html",
|
||||
// "/manifest.webmanifest",
|
||||
// "/favicon.ico",
|
||||
];
|
||||
|
||||
self.addEventListener("install", (event) => {
|
||||
event.waitUntil((async () => {
|
||||
// Skip precaching in dev mode
|
||||
if (!isDev) {
|
||||
const cache = await caches.open(STATIC_CACHE);
|
||||
await cache.addAll(PRECACHE_URLS);
|
||||
}
|
||||
self.skipWaiting();
|
||||
})());
|
||||
});
|
||||
|
||||
self.addEventListener("activate", (event) => {
|
||||
event.waitUntil((async () => {
|
||||
// Cleanup old caches
|
||||
const keys = await caches.keys();
|
||||
await Promise.all(keys.map((k) => (k === STATIC_CACHE ? Promise.resolve() : caches.delete(k))));
|
||||
await self.clients.claim();
|
||||
})());
|
||||
});
|
||||
|
||||
function isLocalFirst(url) {
|
||||
return LOCAL_FIRST_PREFIXES.some((p) => url.pathname.startsWith(p));
|
||||
}
|
||||
|
||||
async function cacheFirst(request) {
|
||||
// In dev mode, always bypass cache
|
||||
if (isDev) {
|
||||
return fetch(request);
|
||||
}
|
||||
|
||||
const cache = await caches.open(STATIC_CACHE);
|
||||
const cached = await cache.match(request);
|
||||
if (cached) return cached;
|
||||
|
||||
const fresh = await fetch(request);
|
||||
// Cache only successful GETs
|
||||
if (request.method === "GET" && fresh.ok) cache.put(request, fresh.clone());
|
||||
return fresh;
|
||||
}
|
||||
|
||||
async function networkFirst(request) {
|
||||
// In dev mode, always bypass cache
|
||||
if (isDev) {
|
||||
return fetch(request);
|
||||
}
|
||||
|
||||
const cache = await caches.open(STATIC_CACHE);
|
||||
try {
|
||||
const fresh = await fetch(request);
|
||||
// Cache only successful GETs
|
||||
if (request.method === "GET" && fresh.ok) cache.put(request, fresh.clone());
|
||||
return fresh;
|
||||
} catch (error) {
|
||||
// Fallback to cache if network fails
|
||||
const cached = await cache.match(request);
|
||||
if (cached) return cached;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function forwardToClientLocalServer(request, clientId) {
|
||||
// Find a client to handle the request (prefer the initiating client if available)
|
||||
let client = clientId ? await self.clients.get(clientId) : null;
|
||||
|
||||
if (!client) {
|
||||
const all = await self.clients.matchAll({ type: "window", includeUncontrolled: true });
|
||||
client = all[0] || null;
|
||||
}
|
||||
|
||||
// If no page is available, fall back to network
|
||||
if (!client) return fetch(request);
|
||||
|
||||
const reqUrl = request.url;
|
||||
const headersObj = {};
|
||||
for (const [k, v] of request.headers.entries()) headersObj[k] = v;
|
||||
|
||||
const body = (request.method === "GET" || request.method === "HEAD")
|
||||
? null
|
||||
: await request.arrayBuffer();
|
||||
|
||||
const id = crypto.randomUUID();
|
||||
const channel = new MessageChannel();
|
||||
|
||||
const responsePromise = new Promise((resolve, reject) => {
|
||||
const timeout = setTimeout(() => {
|
||||
reject(new Error("Local server timeout"));
|
||||
}, 30_000);
|
||||
|
||||
channel.port1.onmessage = (event) => {
|
||||
clearTimeout(timeout);
|
||||
resolve(event.data);
|
||||
};
|
||||
channel.port1.onmessageerror = () => {
|
||||
clearTimeout(timeout);
|
||||
reject(new Error("Local server message error"));
|
||||
};
|
||||
});
|
||||
|
||||
// Send to the client with a reply port
|
||||
client.postMessage({
|
||||
type: "LOCAL_FETCH",
|
||||
id,
|
||||
request: {
|
||||
url: reqUrl,
|
||||
method: request.method,
|
||||
headers: headersObj,
|
||||
body // ArrayBuffer or null
|
||||
}
|
||||
}, [channel.port2]);
|
||||
|
||||
const localResp = await responsePromise;
|
||||
|
||||
if (!localResp || localResp.type !== "LOCAL_FETCH_RESPONSE" || localResp.id !== id) {
|
||||
// Protocol mismatch; fall back
|
||||
return fetch(request);
|
||||
}
|
||||
|
||||
// localResp.response: { status, headers, body }
|
||||
const { status, headers, body: respBody } = localResp.response;
|
||||
|
||||
const respHeaders = new Headers();
|
||||
if (headers) {
|
||||
for (const [k, v] of Object.entries(headers)) respHeaders.set(k, String(v));
|
||||
}
|
||||
|
||||
return new Response(respBody ? respBody : null, {
|
||||
status: status || 200,
|
||||
headers: respHeaders
|
||||
});
|
||||
}
|
||||
|
||||
self.addEventListener("fetch", (event) => {
|
||||
const url = new URL(event.request.url);
|
||||
|
||||
// Only handle same-origin
|
||||
if (url.origin !== self.location.origin) return;
|
||||
|
||||
// HTML files: network-first to ensure updates are reflected immediately
|
||||
if (event.request.mode === "navigate" || url.pathname.endsWith(".html")) {
|
||||
event.respondWith(networkFirst(event.request));
|
||||
return;
|
||||
}
|
||||
|
||||
// Static assets: cache-first for performance
|
||||
if (event.request.method === "GET" && !isLocalFirst(url)) {
|
||||
event.respondWith(cacheFirst(event.request));
|
||||
return;
|
||||
}
|
||||
|
||||
// API-ish: local-first via bridge
|
||||
if (isLocalFirst(url)) {
|
||||
event.respondWith(forwardToClientLocalServer(event.request, event.clientId));
|
||||
return;
|
||||
}
|
||||
|
||||
// Default
|
||||
event.respondWith(fetch(event.request));
|
||||
});
|
||||
31
apps/client-standalone/src/vite-env.d.ts
vendored
31
apps/client-standalone/src/vite-env.d.ts
vendored
@@ -1,31 +0,0 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
interface ImportMetaEnv {
|
||||
readonly VITE_APP_TITLE: string
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
readonly env: ImportMetaEnv
|
||||
}
|
||||
|
||||
interface Window {
|
||||
glob: {
|
||||
assetPath: string;
|
||||
themeCssUrl?: string;
|
||||
themeUseNextAsBase?: string;
|
||||
iconPackCss: string;
|
||||
device: string;
|
||||
headingStyle: string;
|
||||
layoutOrientation: string;
|
||||
platform: string;
|
||||
isElectron: boolean;
|
||||
hasNativeTitleBar: boolean;
|
||||
hasBackgroundEffects: boolean;
|
||||
currentLocale: {
|
||||
id: string;
|
||||
rtl: boolean;
|
||||
};
|
||||
activeDialog: any;
|
||||
};
|
||||
global: typeof globalThis;
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"lib": [
|
||||
"ES2022",
|
||||
"dom",
|
||||
"dom.iterable"
|
||||
],
|
||||
"skipLibCheck": true,
|
||||
"types": [
|
||||
"vite/client"
|
||||
],
|
||||
"jsx": "react-jsx",
|
||||
"jsxImportSource": "preact"
|
||||
},
|
||||
"include": [
|
||||
"src/**/*",
|
||||
"../client/src/**/*"
|
||||
],
|
||||
"exclude": [
|
||||
"src/**/*.spec.ts",
|
||||
"src/**/*.test.ts",
|
||||
"../client/src/**/*.spec.ts",
|
||||
"../client/src/**/*.test.ts"
|
||||
]
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.spec.json" }
|
||||
]
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"lib": [
|
||||
"ES2022",
|
||||
"dom",
|
||||
"dom.iterable"
|
||||
],
|
||||
"types": [
|
||||
"vitest/globals",
|
||||
"happy-dom"
|
||||
]
|
||||
},
|
||||
"include": [
|
||||
"src/**/*.spec.ts",
|
||||
"src/**/*.test.ts"
|
||||
]
|
||||
}
|
||||
@@ -1,188 +0,0 @@
|
||||
import prefresh from '@prefresh/vite';
|
||||
import { join } from 'path';
|
||||
import { defineConfig } from 'vite';
|
||||
import { viteStaticCopy } from 'vite-plugin-static-copy';
|
||||
|
||||
const clientAssets = ["assets", "stylesheets", "fonts", "translations"];
|
||||
|
||||
const isDev = process.env.NODE_ENV === "development";
|
||||
|
||||
// Watch client files and trigger reload in development
|
||||
const clientWatchPlugin = () => ({
|
||||
name: 'client-watch',
|
||||
configureServer(server: any) {
|
||||
if (isDev) {
|
||||
// Watch client source files (adjusted for new root)
|
||||
server.watcher.add('../../client/src/**/*');
|
||||
server.watcher.on('change', (file: string) => {
|
||||
if (file.includes('../../client/src/')) {
|
||||
server.ws.send({
|
||||
type: 'full-reload'
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Always copy SQLite WASM files so they're available to the module
|
||||
const sqliteWasmPlugin = viteStaticCopy({
|
||||
targets: [
|
||||
{
|
||||
src: "../../../node_modules/@sqlite.org/sqlite-wasm/sqlite-wasm/jswasm/sqlite3.wasm",
|
||||
dest: "assets"
|
||||
},
|
||||
{
|
||||
src: "../../../node_modules/@sqlite.org/sqlite-wasm/sqlite-wasm/jswasm/sqlite3-opfs-async-proxy.js",
|
||||
dest: "assets"
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
let plugins: any = [
|
||||
sqliteWasmPlugin, // Always include SQLite WASM files
|
||||
viteStaticCopy({
|
||||
targets: clientAssets.map((asset) => ({
|
||||
src: `../../client/src/${asset}/*`,
|
||||
dest: asset
|
||||
})),
|
||||
// Enable watching in development
|
||||
...(isDev && {
|
||||
watch: {
|
||||
reloadPageOnChange: true
|
||||
}
|
||||
})
|
||||
}),
|
||||
viteStaticCopy({
|
||||
targets: [
|
||||
{
|
||||
src: "../../server/src/assets/*",
|
||||
dest: "server-assets"
|
||||
}
|
||||
]
|
||||
}),
|
||||
// Watch client files for changes in development
|
||||
...(isDev ? [
|
||||
prefresh(),
|
||||
clientWatchPlugin()
|
||||
] : [])
|
||||
];
|
||||
|
||||
if (!isDev) {
|
||||
plugins = [
|
||||
...plugins,
|
||||
viteStaticCopy({
|
||||
structured: true,
|
||||
targets: [
|
||||
{
|
||||
src: "../../../node_modules/@excalidraw/excalidraw/dist/prod/fonts/*",
|
||||
dest: "",
|
||||
}
|
||||
]
|
||||
})
|
||||
]
|
||||
}
|
||||
|
||||
export default defineConfig(() => ({
|
||||
root: join(__dirname, 'src'), // Set src as root so index.html is served from /
|
||||
envDir: __dirname, // Load .env files from client-standalone directory, not src/
|
||||
cacheDir: '../../../node_modules/.vite/apps/client-standalone',
|
||||
base: "",
|
||||
plugins,
|
||||
esbuild: {
|
||||
jsx: 'automatic',
|
||||
jsxImportSource: 'preact',
|
||||
jsxDev: isDev
|
||||
},
|
||||
css: {
|
||||
transformer: 'lightningcss',
|
||||
devSourcemap: isDev
|
||||
},
|
||||
publicDir: join(__dirname, 'public'),
|
||||
resolve: {
|
||||
alias: [
|
||||
{
|
||||
find: "react",
|
||||
replacement: "preact/compat"
|
||||
},
|
||||
{
|
||||
find: "react-dom",
|
||||
replacement: "preact/compat"
|
||||
},
|
||||
{
|
||||
find: "@client",
|
||||
replacement: join(__dirname, "../client/src")
|
||||
}
|
||||
],
|
||||
dedupe: [
|
||||
"react",
|
||||
"react-dom",
|
||||
"preact",
|
||||
"preact/compat",
|
||||
"preact/hooks"
|
||||
]
|
||||
},
|
||||
server: {
|
||||
watch: {
|
||||
// Watch workspace packages
|
||||
ignored: ['!**/node_modules/@triliumnext/**'],
|
||||
// Also watch client assets for live reload
|
||||
usePolling: false,
|
||||
interval: 100,
|
||||
binaryInterval: 300
|
||||
},
|
||||
// Watch additional directories for changes
|
||||
fs: {
|
||||
allow: [
|
||||
// Allow access to workspace root
|
||||
'../../../',
|
||||
// Explicitly allow client directory
|
||||
'../../client/src/'
|
||||
]
|
||||
},
|
||||
headers: {
|
||||
// Required for SharedArrayBuffer which is needed by SQLite WASM OPFS VFS
|
||||
// See: https://sqlite.org/wasm/doc/trunk/persistence.md#coop-coep
|
||||
"Cross-Origin-Opener-Policy": "same-origin",
|
||||
"Cross-Origin-Embedder-Policy": "require-corp"
|
||||
}
|
||||
},
|
||||
optimizeDeps: {
|
||||
exclude: ['@sqlite.org/sqlite-wasm', '@triliumnext/core']
|
||||
},
|
||||
worker: {
|
||||
format: "es" as const
|
||||
},
|
||||
commonjsOptions: {
|
||||
transformMixedEsModules: true,
|
||||
},
|
||||
build: {
|
||||
target: "esnext",
|
||||
outDir: join(__dirname, 'dist'),
|
||||
emptyOutDir: true,
|
||||
rollupOptions: {
|
||||
input: {
|
||||
main: join(__dirname, 'src', 'index.html'),
|
||||
sw: join(__dirname, 'src', 'sw.ts'),
|
||||
'local-bridge': join(__dirname, 'src', 'local-bridge.ts'),
|
||||
},
|
||||
output: {
|
||||
entryFileNames: (chunkInfo) => {
|
||||
// Service worker and other workers should be at root level
|
||||
if (chunkInfo.name === 'sw') {
|
||||
return '[name].js';
|
||||
}
|
||||
return 'src/[name].js';
|
||||
},
|
||||
chunkFileNames: "src/[name].js",
|
||||
assetFileNames: "src/[name].[ext]"
|
||||
}
|
||||
}
|
||||
},
|
||||
test: {
|
||||
environment: "happy-dom"
|
||||
},
|
||||
define: {
|
||||
"process.env.IS_PREACT": JSON.stringify("true"),
|
||||
}
|
||||
}));
|
||||
@@ -27,7 +27,7 @@
|
||||
"@mermaid-js/layout-elk": "0.2.0",
|
||||
"@mind-elixir/node-menu": "5.0.1",
|
||||
"@popperjs/core": "2.11.8",
|
||||
"@preact/signals": "2.6.2",
|
||||
"@preact/signals": "2.7.1",
|
||||
"@triliumnext/ckeditor5": "workspace:*",
|
||||
"@triliumnext/codemirror": "workspace:*",
|
||||
"@triliumnext/commons": "workspace:*",
|
||||
@@ -44,7 +44,7 @@
|
||||
"draggabilly": "3.0.0",
|
||||
"force-graph": "1.51.1",
|
||||
"globals": "17.3.0",
|
||||
"i18next": "25.8.0",
|
||||
"i18next": "25.8.4",
|
||||
"i18next-http-backend": "3.0.2",
|
||||
"jquery": "4.0.0",
|
||||
"jquery.fancytree": "2.38.5",
|
||||
|
||||
@@ -36,37 +36,10 @@ async function setupGlob() {
|
||||
window.global = globalThis; /* fixes https://github.com/webpack/webpack/issues/10035 */
|
||||
window.glob = {
|
||||
...json,
|
||||
activeDialog: null,
|
||||
device: json.device || getDevice()
|
||||
activeDialog: null
|
||||
};
|
||||
}
|
||||
|
||||
function getDevice() {
|
||||
// Respect user's manual override via URL.
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
if (urlParams.has("print")) {
|
||||
return "print";
|
||||
} else if (urlParams.has("desktop")) {
|
||||
return "desktop";
|
||||
} else if (urlParams.has("mobile")) {
|
||||
return "mobile";
|
||||
}
|
||||
|
||||
const deviceCookie = document.cookie.split("; ").find(row => row.startsWith("trilium-device="))?.split("=")[1];
|
||||
if (deviceCookie === "desktop" || deviceCookie === "mobile") return deviceCookie;
|
||||
return isMobile() ? "mobile" : "desktop";
|
||||
}
|
||||
|
||||
// https://stackoverflow.com/a/73731646/944162
|
||||
function isMobile() {
|
||||
const mQ = matchMedia?.("(pointer:coarse)");
|
||||
if (mQ?.media === "(pointer:coarse)") return !!mQ.matches;
|
||||
|
||||
if ("orientation" in window) return true;
|
||||
const userAgentsRegEx = /\b(Android|iPhone|iPad|iPod|Windows Phone|BlackBerry|webOS|IEMobile)\b/i;
|
||||
return userAgentsRegEx.test(navigator.userAgent);
|
||||
}
|
||||
|
||||
async function loadBootstrapCss() {
|
||||
// We have to selectively import Bootstrap CSS based on text direction.
|
||||
if (glob.isRtl) {
|
||||
|
||||
@@ -30,7 +30,6 @@ import SpacerWidget from "../widgets/launch_bar/SpacerWidget.jsx";
|
||||
import InlineTitle from "../widgets/layout/InlineTitle.jsx";
|
||||
import NoteBadges from "../widgets/layout/NoteBadges.jsx";
|
||||
import NoteTitleActions from "../widgets/layout/NoteTitleActions.jsx";
|
||||
import StandaloneWarningBar from "../widgets/layout/StandaloneWarningBar.jsx";
|
||||
import StatusBar from "../widgets/layout/StatusBar.jsx";
|
||||
import NoteIconWidget from "../widgets/note_icon.jsx";
|
||||
import NoteTitleWidget from "../widgets/note_title.jsx";
|
||||
@@ -187,7 +186,6 @@ export default class DesktopLayout {
|
||||
)
|
||||
)
|
||||
.optChild(launcherPaneIsHorizontal && isNewLayout, <StatusBar />)
|
||||
.optChild(glob.isStandalone, <StandaloneWarningBar />)
|
||||
.child(<CloseZenModeButton />)
|
||||
|
||||
// Desktop-specific dialogs.
|
||||
|
||||
@@ -13,7 +13,6 @@ 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";
|
||||
@@ -25,7 +24,9 @@ import NoteWrapperWidget from "../widgets/note_wrapper.js";
|
||||
import NoteDetail from "../widgets/NoteDetail.jsx";
|
||||
import QuickSearchWidget from "../widgets/quick_search.js";
|
||||
import { useNoteContext } from "../widgets/react/hooks.jsx";
|
||||
import StandaloneRibbonAdapter from "../widgets/ribbon/components/StandaloneRibbonAdapter.jsx";
|
||||
import FilePropertiesTab from "../widgets/ribbon/FilePropertiesTab.jsx";
|
||||
import SearchDefinitionTab from "../widgets/ribbon/SearchDefinitionTab.jsx";
|
||||
import SearchResult from "../widgets/search_result.jsx";
|
||||
import MobileEditorToolbar from "../widgets/type_widgets/text/mobile_editor_toolbar.jsx";
|
||||
import { applyModals } from "./layout_commons.js";
|
||||
@@ -57,7 +58,6 @@ export default class MobileLayout {
|
||||
.child(
|
||||
new SplitNoteContainer(() =>
|
||||
new NoteWrapperWidget()
|
||||
.optChild(glob.isStandalone, <StandaloneWarningBar />)
|
||||
.child(
|
||||
new FlexContainer("row")
|
||||
.class("title-row note-split-title")
|
||||
|
||||
@@ -18,6 +18,10 @@ export type PrintReport = {
|
||||
} | {
|
||||
type: "collection";
|
||||
ignoredNoteIds: string[];
|
||||
} | {
|
||||
type: "error";
|
||||
message: string;
|
||||
stack?: string;
|
||||
};
|
||||
|
||||
async function main() {
|
||||
|
||||
@@ -5,7 +5,7 @@ import { formatCodeBlocks } from "./syntax_highlight.js";
|
||||
|
||||
export default function renderDoc(note: FNote) {
|
||||
return new Promise<JQuery<HTMLElement>>((resolve) => {
|
||||
const docName = note.getLabelValue("docName");
|
||||
let docName = note.getLabelValue("docName");
|
||||
const $content = $("<div>");
|
||||
|
||||
if (docName) {
|
||||
@@ -16,7 +16,7 @@ export default function renderDoc(note: FNote) {
|
||||
if (status === "error") {
|
||||
const fallbackUrl = getUrl(docName, "en");
|
||||
$content.load(fallbackUrl, async () => {
|
||||
await processContent(fallbackUrl, $content);
|
||||
await processContent(fallbackUrl, $content)
|
||||
resolve($content);
|
||||
});
|
||||
return;
|
||||
@@ -37,9 +37,9 @@ async function processContent(url: string, $content: JQuery<HTMLElement>) {
|
||||
const dir = url.substring(0, url.lastIndexOf("/"));
|
||||
|
||||
// Images are relative to the docnote but that will not work when rendered in the application since the path breaks.
|
||||
$content.find("img").each((_i, el) => {
|
||||
$content.find("img").each((i, el) => {
|
||||
const $img = $(el);
|
||||
$img.attr("src", `${dir}/${$img.attr("src")}`);
|
||||
$img.attr("src", dir + "/" + $img.attr("src"));
|
||||
});
|
||||
|
||||
formatCodeBlocks($content);
|
||||
@@ -51,17 +51,7 @@ async function processContent(url: string, $content: JQuery<HTMLElement>) {
|
||||
function getUrl(docNameValue: string, language: string) {
|
||||
// Cannot have spaces in the URL due to how JQuery.load works.
|
||||
docNameValue = docNameValue.replaceAll(" ", "%20");
|
||||
// The user guide is available only in English, so make sure we are requesting correctly since 404s in standalone client are treated differently.
|
||||
if (docNameValue.includes("User%20Guide")) language = "en";
|
||||
return `${getBasePath()}/doc_notes/${language}/${docNameValue}.html`;
|
||||
}
|
||||
|
||||
function getBasePath() {
|
||||
if (window.glob.isStandalone) {
|
||||
return `server-assets`;
|
||||
}
|
||||
if (window.glob.isDev) {
|
||||
return `${window.glob.assetPath }/..`;
|
||||
}
|
||||
return window.glob.assetPath;
|
||||
const basePath = window.glob.isDev ? window.glob.assetPath + "/.." : window.glob.assetPath;
|
||||
return `${basePath}/doc_notes/${language}/${docNameValue}.html`;
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ const SELECTED_NOTE_PATH_KEY = "data-note-path";
|
||||
const SELECTED_EXTERNAL_LINK_KEY = "data-external-link";
|
||||
|
||||
// To prevent search lag when there are a large number of notes, set a delay based on the number of notes to avoid jitter.
|
||||
const notesCount = 10000; // TODO: Replace with dynamic count from becca once available.
|
||||
const notesCount = await server.get<number>(`autocomplete/notesCount`);
|
||||
let debounceTimeoutId: ReturnType<typeof setTimeout>;
|
||||
|
||||
function getSearchDelay(notesCount: number): number {
|
||||
|
||||
@@ -1,4 +1,14 @@
|
||||
import { DefinitionObject, LabelType, Multiplicity } from "@triliumnext/commons";
|
||||
export type LabelType = "text" | "number" | "boolean" | "date" | "datetime" | "time" | "url" | "color";
|
||||
type Multiplicity = "single" | "multi";
|
||||
|
||||
export interface DefinitionObject {
|
||||
isPromoted?: boolean;
|
||||
labelType?: LabelType;
|
||||
multiplicity?: Multiplicity;
|
||||
numberPrecision?: number;
|
||||
promotedAlias?: string;
|
||||
inverseRelation?: string;
|
||||
}
|
||||
|
||||
function parse(value: string) {
|
||||
const tokens = value.split(",").map((t) => t.trim());
|
||||
|
||||
@@ -133,8 +133,6 @@ export function isElectron() {
|
||||
return !!(window && window.process && window.process.type);
|
||||
}
|
||||
|
||||
export const isStandalone = window.glob.isStandalone;
|
||||
|
||||
/**
|
||||
* Returns `true` if the client is running as a PWA, otherwise `false`.
|
||||
*/
|
||||
@@ -816,7 +814,7 @@ function compareVersions(v1: string, v2: string): number {
|
||||
/**
|
||||
* Compares two semantic version strings and returns `true` if the latest version is greater than the current version.
|
||||
*/
|
||||
export function isUpdateAvailable(latestVersion: string | null | undefined, currentVersion: string): boolean {
|
||||
function isUpdateAvailable(latestVersion: string | null | undefined, currentVersion: string): boolean {
|
||||
if (!latestVersion) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -304,7 +304,7 @@ async function sendPing() {
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
if (glob.device === "print" || glob.isStandalone) return;
|
||||
if (glob.device === "print") return;
|
||||
|
||||
ws = connectWebSocket();
|
||||
|
||||
|
||||
@@ -800,3 +800,18 @@ li.dropdown-item a.dropdown-item-button:focus-visible {
|
||||
background: var(--hover-item-background-color);
|
||||
color: var(--hover-item-text-color);
|
||||
}
|
||||
|
||||
/*
|
||||
* Alert bars
|
||||
*/
|
||||
|
||||
div.alert {
|
||||
margin-bottom: 8px;
|
||||
background: var(--alert-bar-background) !important;
|
||||
border-radius: 8px;
|
||||
font-size: .85em;
|
||||
}
|
||||
|
||||
div.alert p + p {
|
||||
margin-block: 1em 0;
|
||||
}
|
||||
@@ -84,6 +84,22 @@ button.btn.btn-success kbd {
|
||||
letter-spacing: 0.5pt;
|
||||
}
|
||||
|
||||
/*
|
||||
* Low profile buttons
|
||||
*/
|
||||
|
||||
button.tn-low-profile {
|
||||
appearance: none;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
border-radius: 8px;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
button.tn-low-profile:hover {
|
||||
background-color: var(--icon-button-hover-background);
|
||||
}
|
||||
|
||||
/*
|
||||
* Icon buttons
|
||||
*/
|
||||
@@ -794,3 +810,35 @@ input[type="range"] {
|
||||
scrollbar-width: unset;
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* Centered forms
|
||||
*/
|
||||
|
||||
.tn-centered-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
margin-bottom: 20vh;
|
||||
}
|
||||
|
||||
.tn-centered-form .form-group {
|
||||
text-align: center;
|
||||
color: var(--muted-text-color);
|
||||
}
|
||||
|
||||
.tn-centered-form .form-icon {
|
||||
font-size: 140px;
|
||||
color: var(--main-border-color);
|
||||
}
|
||||
|
||||
.tn-centered-form .protected-session-password {
|
||||
margin-inline: auto;
|
||||
max-width: 350px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.tn-centered-form input,
|
||||
.tn-centered-form button {
|
||||
margin-top: 12px;
|
||||
}
|
||||
@@ -265,13 +265,6 @@ body.desktop .options-section:not(.tn-no-card) {
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.options-section .alert {
|
||||
margin-bottom: 8px;
|
||||
background: var(--alert-bar-background) !important;
|
||||
border-radius: 8px;
|
||||
font-size: .85em;
|
||||
}
|
||||
|
||||
nav.options-section-tabs {
|
||||
min-width: var(--options-card-min-width);
|
||||
max-width: var(--options-card-max-width);
|
||||
|
||||
@@ -1070,10 +1070,12 @@
|
||||
"note_detail_render_help_1": "This help note is shown because this note of type Render HTML doesn't have required relation to function properly.",
|
||||
"note_detail_render_help_2": "Render HTML note type is used for <a class=\"external\" href=\"https://triliumnext.github.io/Docs/Wiki/scripts.html\">scripting</a>. In short, you have a HTML code note (optionally with some JavaScript) and this note will render it. To make it work, you need to define a <a class=\"external\" href=\"https://triliumnext.github.io/Docs/Wiki/attributes.html\">relation</a> called \"renderNote\" pointing to the HTML note to render."
|
||||
},
|
||||
"web_view": {
|
||||
"web_view": "Web View",
|
||||
"embed_websites": "Note of type Web View allows you to embed websites into Trilium.",
|
||||
"create_label": "To start, please create a label with a URL address you want to embed, e.g. #webViewSrc=\"https://www.google.com\""
|
||||
"web_view_setup": {
|
||||
"title": "Create a live view of a webpage directly into Trilium",
|
||||
"url_placeholder": "Enter or paste the website address, for example https://triliumnotes.org",
|
||||
"create_button": "Create Web View",
|
||||
"invalid_url_title": "Invalid address",
|
||||
"invalid_url_message": "Insert a valid web address, for example https://triliumnotes.org."
|
||||
},
|
||||
"backend_log": {
|
||||
"refresh": "Refresh"
|
||||
@@ -1589,7 +1591,8 @@
|
||||
"description": "Description",
|
||||
"reload_app": "Reload app to apply changes",
|
||||
"set_all_to_default": "Set all shortcuts to the default",
|
||||
"confirm_reset": "Do you really want to reset all keyboard shortcuts to the default?"
|
||||
"confirm_reset": "Do you really want to reset all keyboard shortcuts to the default?",
|
||||
"no_results": "No shortcuts found matching '{{filter}}'"
|
||||
},
|
||||
"spellcheck": {
|
||||
"title": "Spell Check",
|
||||
@@ -1795,6 +1798,8 @@
|
||||
"printing": "Printing in progress...",
|
||||
"printing_pdf": "Exporting to PDF in progress...",
|
||||
"print_report_title": "Print report",
|
||||
"print_report_error_title": "Failed to print",
|
||||
"print_report_stack_trace": "Stack trace",
|
||||
"print_report_collection_content_one": "{{count}} note in the collection could not be printed because they are not supported or they are protected.",
|
||||
"print_report_collection_content_other": "{{count}} notes in the collection could not be printed because they are not supported or they are protected.",
|
||||
"print_report_collection_details_button": "See details",
|
||||
@@ -2099,7 +2104,8 @@
|
||||
"raster": "Raster",
|
||||
"vector_light": "Vector (Light)",
|
||||
"vector_dark": "Vector (Dark)",
|
||||
"show-scale": "Show scale"
|
||||
"show-scale": "Show scale",
|
||||
"show-labels": "Show marker names"
|
||||
},
|
||||
"table_context_menu": {
|
||||
"delete_row": "Delete row"
|
||||
|
||||
30
apps/client/src/types.d.ts
vendored
30
apps/client/src/types.d.ts
vendored
@@ -1,4 +1,4 @@
|
||||
import { BootstrapDefinition } from "@triliumnext/commons";
|
||||
import { IconRegistry, Locale } from "@triliumnext/commons";
|
||||
|
||||
import appContext, { AppContext } from "./components/app_context";
|
||||
import type FNote from "./entities/fnote";
|
||||
@@ -15,9 +15,10 @@ interface ElectronProcess {
|
||||
platform: string;
|
||||
}
|
||||
|
||||
interface CustomGlobals extends BootstrapDefinition {
|
||||
interface CustomGlobals {
|
||||
isDesktop: typeof utils.isDesktop;
|
||||
isMobile: typeof utils.isMobile;
|
||||
device: "mobile" | "desktop" | "print";
|
||||
getComponentByEl: typeof appContext.getComponentByEl;
|
||||
getHeaders: typeof server.getHeaders;
|
||||
getReferenceLinkTitle: (href: string) => Promise<string>;
|
||||
@@ -30,7 +31,32 @@ interface CustomGlobals extends BootstrapDefinition {
|
||||
SEARCH_HELP_TEXT: string;
|
||||
activeDialog: JQuery<HTMLElement> | null;
|
||||
componentId: string;
|
||||
csrfToken: string;
|
||||
baseApiUrl: string;
|
||||
isProtectedSessionAvailable: boolean;
|
||||
isDev: boolean;
|
||||
isMainWindow: boolean;
|
||||
maxEntityChangeIdAtLoad: number;
|
||||
maxEntityChangeSyncIdAtLoad: number;
|
||||
assetPath: string;
|
||||
appPath: string;
|
||||
instanceName: string;
|
||||
appCssNoteIds: string[];
|
||||
triliumVersion: string;
|
||||
TRILIUM_SAFE_MODE: boolean;
|
||||
platform?: typeof process.platform;
|
||||
linter: typeof lint;
|
||||
hasNativeTitleBar: boolean;
|
||||
hasBackgroundEffects: boolean;
|
||||
isElectron: boolean;
|
||||
isRtl: boolean;
|
||||
iconRegistry: IconRegistry;
|
||||
themeCssUrl: string;
|
||||
themeUseNextAsBase?: "next" | "next-light" | "next-dark";
|
||||
iconPackCss: string;
|
||||
headingStyle: "plain" | "underline" | "markdown";
|
||||
layoutOrientation: "vertical" | "horizontal";
|
||||
currentLocale: Locale;
|
||||
}
|
||||
|
||||
type RequireMethod = (moduleName: string) => any;
|
||||
|
||||
@@ -370,7 +370,33 @@ function showToast(type: "printing" | "exporting_pdf", progress: number = 0) {
|
||||
}
|
||||
|
||||
function handlePrintReport(printReport?: PrintReport) {
|
||||
if (printReport?.type === "collection" && printReport.ignoredNoteIds.length > 0) {
|
||||
if (!printReport) return;
|
||||
|
||||
if (printReport.type === "error") {
|
||||
toast.showPersistent({
|
||||
id: "print-error",
|
||||
icon: "bx bx-error-circle",
|
||||
title: t("note_detail.print_report_error_title"),
|
||||
message: printReport.message,
|
||||
buttons: printReport.stack ? [
|
||||
{
|
||||
text: t("note_detail.print_report_collection_details_button"),
|
||||
onClick(api) {
|
||||
api.dismissToast();
|
||||
dialog.info(<>
|
||||
<p>{printReport.message}</p>
|
||||
<details>
|
||||
<summary>{t("note_detail.print_report_stack_trace")}</summary>
|
||||
<pre style="font-size: 0.85em; overflow-x: auto;">{printReport.stack}</pre>
|
||||
</details>
|
||||
</>, {
|
||||
title: t("note_detail.print_report_error_title")
|
||||
});
|
||||
}
|
||||
}
|
||||
] : undefined
|
||||
});
|
||||
} else if (printReport.type === "collection" && printReport.ignoredNoteIds.length > 0) {
|
||||
toast.showPersistent({
|
||||
id: "print-report",
|
||||
icon: "bx bx-collection",
|
||||
|
||||
@@ -1,16 +1,14 @@
|
||||
import "./UserAttributesList.css";
|
||||
|
||||
import type { DefinitionObject } from "@triliumnext/commons";
|
||||
import { ComponentChildren, CSSProperties } from "preact";
|
||||
import { useState } from "preact/hooks";
|
||||
|
||||
import FNote from "../../entities/fnote";
|
||||
import attributes from "../../services/attributes";
|
||||
import { getReadableTextColor } from "../../services/css_class_manager";
|
||||
import { formatDateTime } from "../../utils/formatters";
|
||||
import "./UserAttributesList.css";
|
||||
import { useTriliumEvent } from "../react/hooks";
|
||||
import attributes from "../../services/attributes";
|
||||
import { DefinitionObject } from "../../services/promoted_attribute_definition_parser";
|
||||
import { formatDateTime } from "../../utils/formatters";
|
||||
import { ComponentChildren, CSSProperties } from "preact";
|
||||
import Icon from "../react/Icon";
|
||||
import NoteLink from "../react/NoteLink";
|
||||
import { getReadableTextColor } from "../../services/css_class_manager";
|
||||
|
||||
interface UserAttributesListProps {
|
||||
note: FNote;
|
||||
@@ -31,7 +29,7 @@ export default function UserAttributesDisplay({ note, ignoredAttributes }: UserA
|
||||
<div className="user-attributes">
|
||||
{userAttributes?.map(attr => buildUserAttribute(attr))}
|
||||
</div>
|
||||
);
|
||||
)
|
||||
|
||||
}
|
||||
|
||||
@@ -48,13 +46,13 @@ function useNoteAttributesWithDefinitions(note: FNote, attributesToIgnore: stri
|
||||
}
|
||||
|
||||
function UserAttribute({ attr, children, style }: { attr: AttributeWithDefinitions, children: ComponentChildren, style?: CSSProperties }) {
|
||||
const className = attr.type === "label" ? `label ${attr.def.labelType}` : "relation";
|
||||
const className = `${attr.type === "label" ? "label" + " " + attr.def.labelType : "relation"}`;
|
||||
|
||||
return (
|
||||
<span key={attr.friendlyName} className={`user-attribute type-${className}`} style={style}>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
function buildUserAttribute(attr: AttributeWithDefinitions): ComponentChildren {
|
||||
@@ -63,7 +61,7 @@ function buildUserAttribute(attr: AttributeWithDefinitions): ComponentChildren {
|
||||
let style: CSSProperties | undefined;
|
||||
|
||||
if (attr.type === "label") {
|
||||
const value = attr.value;
|
||||
let value = attr.value;
|
||||
switch (attr.def.labelType) {
|
||||
case "number":
|
||||
let formattedValue = value;
|
||||
@@ -104,7 +102,7 @@ function buildUserAttribute(attr: AttributeWithDefinitions): ComponentChildren {
|
||||
content = <>{defaultLabel}<NoteLink notePath={attr.value} showNoteIcon /></>;
|
||||
}
|
||||
|
||||
return <UserAttribute attr={attr} style={style}>{content}</UserAttribute>;
|
||||
return <UserAttribute attr={attr} style={style}>{content}</UserAttribute>
|
||||
}
|
||||
|
||||
function getAttributesWithDefinitions(note: FNote, attributesToIgnore: string[] = []): AttributeWithDefinitions[] {
|
||||
|
||||
@@ -8,7 +8,7 @@ import { CommandNames } from "../../components/app_context";
|
||||
import Component from "../../components/component";
|
||||
import { ExperimentalFeature, ExperimentalFeatureId, experimentalFeatures, isExperimentalFeatureEnabled, toggleExperimentalFeature } from "../../services/experimental_features";
|
||||
import { t } from "../../services/i18n";
|
||||
import utils, { dynamicRequire, isElectron, isMobile, isStandalone, reloadFrontendApp } from "../../services/utils";
|
||||
import utils, { dynamicRequire, isElectron, isMobile, reloadFrontendApp } from "../../services/utils";
|
||||
import Dropdown from "../react/Dropdown";
|
||||
import { FormDropdownDivider, FormDropdownSubmenu, FormListHeader, FormListItem } from "../react/FormList";
|
||||
import { useStaticTooltip, useStaticTooltipWithKeyboardShortcut, useTriliumOption, useTriliumOptionBool, useTriliumOptionInt } from "../react/hooks";
|
||||
@@ -250,7 +250,7 @@ function ToggleWindowOnTop() {
|
||||
function useTriliumUpdateStatus() {
|
||||
const [ latestVersion, setLatestVersion ] = useState<string>();
|
||||
const [ checkForUpdates ] = useTriliumOptionBool("checkForUpdates");
|
||||
const isUpdateAvailable = utils.isUpdateAvailable(latestVersion, window.glob.triliumVersion);
|
||||
const isUpdateAvailable = utils.isUpdateAvailable(latestVersion, glob.triliumVersion);
|
||||
|
||||
async function updateVersionStatus() {
|
||||
const RELEASES_API_URL = "https://api.github.com/repos/TriliumNext/Trilium/releases/latest";
|
||||
@@ -268,7 +268,7 @@ function useTriliumUpdateStatus() {
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!checkForUpdates || !isStandalone) {
|
||||
if (!checkForUpdates) {
|
||||
setLatestVersion(undefined);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
|
||||
> .collection-properties {
|
||||
position: relative;
|
||||
z-index: 2000;
|
||||
z-index: 998;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
|
||||
.leaflet-top,
|
||||
.leaflet-bottom {
|
||||
z-index: 997;
|
||||
z-index: 997 !important;
|
||||
}
|
||||
|
||||
.geo-view.placing-note .geo-map-container {
|
||||
|
||||
@@ -22,7 +22,7 @@ import { ViewModeProps } from "../interface";
|
||||
import { createNewNote, moveMarker } from "./api";
|
||||
import openContextMenu, { openMapContextMenu } from "./context_menu";
|
||||
import Map from "./map";
|
||||
import { DEFAULT_MAP_LAYER_NAME } from "./map_layer";
|
||||
import { DEFAULT_MAP_LAYER_NAME, MAP_LAYERS, MapLayer } from "./map_layer";
|
||||
import Marker, { GpxTrack } from "./marker";
|
||||
|
||||
const DEFAULT_COORDINATES: [number, number] = [3.878638227135724, 446.6630455551659];
|
||||
@@ -45,10 +45,11 @@ export default function GeoView({ note, noteIds, viewConfig, saveConfig }: ViewM
|
||||
const [ state, setState ] = useState(State.Normal);
|
||||
const [ coordinates, setCoordinates ] = useState(viewConfig?.view?.center);
|
||||
const [ zoom, setZoom ] = useState(viewConfig?.view?.zoom);
|
||||
const [ layerName ] = useNoteLabel(note, "map:style");
|
||||
const [ hasScale ] = useNoteLabelBoolean(note, "map:scale");
|
||||
const [ hideLabels ] = useNoteLabelBoolean(note, "map:hideLabels");
|
||||
const [ isReadOnly ] = useNoteLabelBoolean(note, "readOnly");
|
||||
const [ notes, setNotes ] = useState<FNote[]>([]);
|
||||
const layerData = useLayerData(note);
|
||||
const spacedUpdate = useSpacedUpdate(() => {
|
||||
if (viewConfig) {
|
||||
saveConfig(viewConfig);
|
||||
@@ -152,7 +153,7 @@ export default function GeoView({ note, noteIds, viewConfig, saveConfig }: ViewM
|
||||
apiRef={apiRef} containerRef={containerRef}
|
||||
coordinates={coordinates}
|
||||
zoom={zoom}
|
||||
layerName={layerName ?? DEFAULT_MAP_LAYER_NAME}
|
||||
layerData={layerData}
|
||||
viewportChanged={(coordinates, zoom) => {
|
||||
if (!viewConfig) viewConfig = {};
|
||||
viewConfig.view = { center: coordinates, zoom };
|
||||
@@ -162,13 +163,35 @@ export default function GeoView({ note, noteIds, viewConfig, saveConfig }: ViewM
|
||||
onContextMenu={onContextMenu}
|
||||
scale={hasScale}
|
||||
>
|
||||
{notes.map(note => <NoteWrapper note={note} isReadOnly={isReadOnly} />)}
|
||||
{notes.map(note => <NoteWrapper note={note} isReadOnly={isReadOnly} hideLabels={hideLabels} />)}
|
||||
</Map>}
|
||||
<GeoMapTouchBar state={state} map={apiRef.current} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function useLayerData(note: FNote) {
|
||||
const [ layerName ] = useNoteLabel(note, "map:style");
|
||||
// Memo is needed because it would generate unnecessary reloads due to layer change.
|
||||
const layerData = useMemo(() => {
|
||||
// Custom layers.
|
||||
if (layerName?.startsWith("http")) {
|
||||
return {
|
||||
name: "Custom",
|
||||
type: "raster",
|
||||
url: layerName,
|
||||
attribution: ""
|
||||
} satisfies MapLayer;
|
||||
}
|
||||
|
||||
// Built-in layers.
|
||||
const layerData = MAP_LAYERS[layerName ?? ""] ?? MAP_LAYERS[DEFAULT_MAP_LAYER_NAME];
|
||||
return layerData;
|
||||
}, [ layerName ]);
|
||||
|
||||
return layerData;
|
||||
}
|
||||
|
||||
function ToggleReadOnlyButton({ note }: { note: FNote }) {
|
||||
const [ isReadOnly, setReadOnly ] = useNoteLabelBoolean(note, "readOnly");
|
||||
|
||||
@@ -179,22 +202,26 @@ function ToggleReadOnlyButton({ note }: { note: FNote }) {
|
||||
/>;
|
||||
}
|
||||
|
||||
function NoteWrapper({ note, isReadOnly }: { note: FNote, isReadOnly: boolean }) {
|
||||
function NoteWrapper({ note, isReadOnly, hideLabels }: {
|
||||
note: FNote,
|
||||
isReadOnly: boolean,
|
||||
hideLabels: boolean
|
||||
}) {
|
||||
const mime = useNoteProperty(note, "mime");
|
||||
const [ location ] = useNoteLabel(note, LOCATION_ATTRIBUTE);
|
||||
|
||||
if (mime === "application/gpx+xml") {
|
||||
return <NoteGpxTrack note={note} />;
|
||||
return <NoteGpxTrack note={note} hideLabels={hideLabels} />;
|
||||
}
|
||||
|
||||
if (location) {
|
||||
const latLng = location?.split(",", 2).map((el) => parseFloat(el)) as [ number, number ] | undefined;
|
||||
if (!latLng) return;
|
||||
return <NoteMarker note={note} editable={!isReadOnly} latLng={latLng} />;
|
||||
return <NoteMarker note={note} editable={!isReadOnly} latLng={latLng} hideLabels={hideLabels} />;
|
||||
}
|
||||
}
|
||||
|
||||
function NoteMarker({ note, editable, latLng }: { note: FNote, editable: boolean, latLng: [number, number] }) {
|
||||
function NoteMarker({ note, editable, latLng, hideLabels }: { note: FNote, editable: boolean, latLng: [number, number], hideLabels: boolean }) {
|
||||
// React to changes
|
||||
const [ color ] = useNoteLabel(note, "color");
|
||||
const [ iconClass ] = useNoteLabel(note, "iconClass");
|
||||
@@ -202,8 +229,9 @@ function NoteMarker({ note, editable, latLng }: { note: FNote, editable: boolean
|
||||
|
||||
const title = useNoteProperty(note, "title");
|
||||
const icon = useMemo(() => {
|
||||
return buildIcon(note.getIcon(), note.getColorClass() ?? undefined, title, note.noteId, archived);
|
||||
}, [ iconClass, color, title, note.noteId, archived]);
|
||||
const titleOrNone = hideLabels ? undefined : title;
|
||||
return buildIcon(note.getIcon(), note.getColorClass() ?? undefined, titleOrNone, note.noteId, archived);
|
||||
}, [ iconClass, color, title, note.noteId, archived, hideLabels ]);
|
||||
|
||||
const onClick = useCallback(() => {
|
||||
appContext.triggerCommand("openInPopup", { noteIdOrPath: note.noteId });
|
||||
@@ -235,7 +263,7 @@ function NoteMarker({ note, editable, latLng }: { note: FNote, editable: boolean
|
||||
/>;
|
||||
}
|
||||
|
||||
function NoteGpxTrack({ note }: { note: FNote }) {
|
||||
function NoteGpxTrack({ note, hideLabels }: { note: FNote, hideLabels?: boolean }) {
|
||||
const [ xmlString, setXmlString ] = useState<string>();
|
||||
const blob = useNoteBlob(note);
|
||||
|
||||
@@ -256,7 +284,7 @@ function NoteGpxTrack({ note }: { note: FNote }) {
|
||||
|
||||
const options = useMemo<GPXOptions>(() => ({
|
||||
markers: {
|
||||
startIcon: buildIcon(note.getIcon(), note.getColorClass(), note.title),
|
||||
startIcon: buildIcon(note.getIcon(), note.getColorClass(), hideLabels ? undefined : note.title),
|
||||
endIcon: buildIcon("bxs-flag-checkered"),
|
||||
wptIcons: {
|
||||
"": buildIcon("bx bx-pin")
|
||||
@@ -265,7 +293,7 @@ function NoteGpxTrack({ note }: { note: FNote }) {
|
||||
polyline_options: {
|
||||
color: note.getLabelValue("color") ?? "blue"
|
||||
}
|
||||
}), [ color, iconClass ]);
|
||||
}), [ color, iconClass, hideLabels ]);
|
||||
return xmlString && <GpxTrack gpxXmlString={xmlString} options={options} />;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useEffect, useImperativeHandle, useRef, useState } from "preact/hooks";
|
||||
import L, { control, LatLng, Layer, LeafletMouseEvent } from "leaflet";
|
||||
import "leaflet/dist/leaflet.css";
|
||||
import { MAP_LAYERS } from "./map_layer";
|
||||
import { MAP_LAYERS, type MapLayer } from "./map_layer";
|
||||
import { ComponentChildren, createContext, RefObject } from "preact";
|
||||
import { useElementSize, useSyncedRef } from "../../react/hooks";
|
||||
|
||||
@@ -12,7 +12,7 @@ interface MapProps {
|
||||
containerRef?: RefObject<HTMLDivElement>;
|
||||
coordinates: LatLng | [number, number];
|
||||
zoom: number;
|
||||
layerName: string;
|
||||
layerData: MapLayer;
|
||||
viewportChanged: (coordinates: LatLng, zoom: number) => void;
|
||||
children: ComponentChildren;
|
||||
onClick?: (e: LeafletMouseEvent) => void;
|
||||
@@ -21,7 +21,7 @@ interface MapProps {
|
||||
scale: boolean;
|
||||
}
|
||||
|
||||
export default function Map({ coordinates, zoom, layerName, viewportChanged, children, onClick, onContextMenu, scale, apiRef, containerRef: _containerRef, onZoom }: MapProps) {
|
||||
export default function Map({ coordinates, zoom, layerData, viewportChanged, children, onClick, onContextMenu, scale, apiRef, containerRef: _containerRef, onZoom }: MapProps) {
|
||||
const mapRef = useRef<L.Map>(null);
|
||||
const containerRef = useSyncedRef<HTMLDivElement>(_containerRef);
|
||||
|
||||
@@ -49,8 +49,6 @@ export default function Map({ coordinates, zoom, layerName, viewportChanged, chi
|
||||
const [ layer, setLayer ] = useState<Layer>();
|
||||
useEffect(() => {
|
||||
async function load() {
|
||||
const layerData = MAP_LAYERS[layerName];
|
||||
|
||||
if (layerData.type === "vector") {
|
||||
const style = (typeof layerData.style === "string" ? layerData.style : await layerData.style());
|
||||
await import("@maplibre/maplibre-gl-leaflet");
|
||||
@@ -68,7 +66,7 @@ export default function Map({ coordinates, zoom, layerName, viewportChanged, chi
|
||||
}
|
||||
|
||||
load();
|
||||
}, [ layerName ]);
|
||||
}, [ layerData ]);
|
||||
|
||||
// Attach layer to the map.
|
||||
useEffect(() => {
|
||||
@@ -139,7 +137,7 @@ export default function Map({ coordinates, zoom, layerName, viewportChanged, chi
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={`geo-map-container ${MAP_LAYERS[layerName].isDarkTheme ? "dark" : ""}`}
|
||||
className={`geo-map-container ${layerData.isDarkTheme ? "dark" : ""}`}
|
||||
>
|
||||
<ParentMap.Provider value={mapRef.current}>
|
||||
{children}
|
||||
|
||||
@@ -1,20 +1,17 @@
|
||||
export interface MapLayer {
|
||||
name: string;
|
||||
isDarkTheme?: boolean;
|
||||
}
|
||||
|
||||
interface VectorLayer extends MapLayer {
|
||||
export type MapLayer = ({
|
||||
type: "vector";
|
||||
style: string | (() => Promise<{}>)
|
||||
}
|
||||
|
||||
interface RasterLayer extends MapLayer {
|
||||
} | {
|
||||
type: "raster";
|
||||
url: string;
|
||||
attribution: string;
|
||||
}
|
||||
}) & {
|
||||
// Common properties
|
||||
name: string;
|
||||
isDarkTheme?: boolean;
|
||||
};
|
||||
|
||||
export const MAP_LAYERS: Record<string, VectorLayer | RasterLayer> = {
|
||||
export const MAP_LAYERS: Record<string, MapLayer> = {
|
||||
"openstreetmap": {
|
||||
name: "OpenStreetMap",
|
||||
type: "raster",
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
import { LabelType } from "@triliumnext/commons";
|
||||
import { JSX } from "preact";
|
||||
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 { LabelType } from "../../../services/promoted_attribute_definition_parser.js";
|
||||
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 froca from "../../../services/froca.js";
|
||||
import NoteAutocomplete from "../../react/NoteAutocomplete.jsx";
|
||||
|
||||
type ColumnType = LabelType | "relation";
|
||||
|
||||
@@ -79,7 +78,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" />{" "}</>}
|
||||
{(formatterParams as RowNumberFormatterParams).movableRows && <><span class="bx bx-dots-vertical-rounded"></span>{" "}</>}
|
||||
{cell.getRow().getPosition(true)}
|
||||
</div>),
|
||||
formatterParams: { movableRows } satisfies RowNumberFormatterParams
|
||||
@@ -201,14 +200,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;
|
||||
@@ -232,5 +231,5 @@ function RelationEditor({ cell, success }: EditorOpts) {
|
||||
hideAllButtons: true
|
||||
}}
|
||||
noteIdChanged={success}
|
||||
/>;
|
||||
/>
|
||||
}
|
||||
|
||||
@@ -1,14 +1,13 @@
|
||||
import type { AppInfo } from "@triliumnext/commons";
|
||||
import type { CSSProperties } from "preact/compat";
|
||||
import { useState } from "preact/hooks";
|
||||
|
||||
import Modal from "../react/Modal.js";
|
||||
import { t } from "../../services/i18n.js";
|
||||
import openService from "../../services/open.js";
|
||||
import { formatDateTime } from "../../utils/formatters.js";
|
||||
import server from "../../services/server.js";
|
||||
import utils from "../../services/utils.js";
|
||||
import { formatDateTime } from "../../utils/formatters.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 { useTriliumEvent } from "../react/hooks.jsx";
|
||||
import Modal from "../react/Modal.js";
|
||||
|
||||
export default function AboutDialog() {
|
||||
const [appInfo, setAppInfo] = useState<AppInfo | null>(null);
|
||||
@@ -55,15 +54,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} rel="noreferrer">{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}>{appInfo.buildRevision}</a>}
|
||||
</td>
|
||||
</tr>
|
||||
{ appInfo?.dataDirectory && <tr>
|
||||
<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>
|
||||
@@ -77,8 +76,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>;
|
||||
}
|
||||
return <span className="selectable-text" style={style}>{directory}</span>;
|
||||
|
||||
return <a className="tn-link selectable-text" href="#" onClick={onClick} style={style}>{directory}</a>
|
||||
} else {
|
||||
return <span className="selectable-text" style={style}>{directory}</span>;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,7 +27,6 @@ export default function RecentChangesDialog() {
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!ancestorNoteId) return;
|
||||
server.get<RecentChangeRow[]>(`recent-changes/${ancestorNoteId}`)
|
||||
.then(async (recentChanges) => {
|
||||
// preload all notes into cache
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -37,6 +37,10 @@
|
||||
&:hover {
|
||||
background: var(--input-background-color);
|
||||
}
|
||||
|
||||
.text {
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
.status-bar-dropdown-button {
|
||||
|
||||
@@ -226,8 +226,8 @@ function CheckBoxPropertyView({ note, property }: { note: FNote, property: Check
|
||||
<FormListToggleableItem
|
||||
icon={property.icon}
|
||||
title={property.label}
|
||||
currentValue={value}
|
||||
onChange={setValue}
|
||||
currentValue={ property.reverseValue ? !value : value }
|
||||
onChange={newValue => setValue(property.reverseValue ? !newValue : newValue)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -9,7 +9,6 @@
|
||||
border-radius: 0.5em;
|
||||
font-size: 0.7rem;
|
||||
font-weight: normal;
|
||||
float: right;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
|
||||
@@ -1992,7 +1992,7 @@ function buildEnhanceTitle() {
|
||||
if (isSubtreeHidden && count > 0) {
|
||||
const $badge = $(`<span class="note-indicator-icon subtree-hidden-badge">${count}</span>`);
|
||||
$badge.attr("title", t("note_tree.subtree-hidden-tooltip", { count }));
|
||||
$span.find(".fancytree-title").append($badge);
|
||||
$span.append($badge);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -43,7 +43,8 @@ export const TYPE_MAPPINGS: Record<ExtendedNoteType, NoteTypeMapping> = {
|
||||
},
|
||||
protectedSession: {
|
||||
view: () => import("./type_widgets/ProtectedSession"),
|
||||
className: "protected-session-password-component"
|
||||
className: "protected-session-password-component",
|
||||
isFullHeight: true
|
||||
},
|
||||
book: {
|
||||
view: () => import("./type_widgets/Book"),
|
||||
|
||||
@@ -1,16 +1,15 @@
|
||||
import { HTML } from "mermaid/dist/diagram-api/types.js";
|
||||
import { ComponentChildren, HTMLAttributes } from "preact";
|
||||
import { ComponentChildren } from "preact";
|
||||
|
||||
interface AdmonitionProps extends Pick<HTMLAttributes<HTMLDivElement>, "style"> {
|
||||
interface AdmonitionProps {
|
||||
type: "warning" | "note" | "caution";
|
||||
children: ComponentChildren;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export default function Admonition({ type, children, className, ...props }: AdmonitionProps) {
|
||||
export default function Admonition({ type, children, className }: AdmonitionProps) {
|
||||
return (
|
||||
<div className={`admonition ${type} ${className}`} role="alert" {...props}>
|
||||
<div className={`admonition ${type} ${className}`} role="alert">
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
@@ -3,10 +3,7 @@
|
||||
line-height: 1em;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
appearance: none;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
color: inherit;
|
||||
padding-inline-end: 12px;
|
||||
|
||||
.arrow {
|
||||
font-size: 1.3em;
|
||||
|
||||
@@ -57,7 +57,7 @@ export function ExternallyControlledCollapsible({ title, children, className, ex
|
||||
"with-transition": transitionEnabled
|
||||
})}>
|
||||
<button
|
||||
className="collapsible-title"
|
||||
className="collapsible-title tn-low-profile"
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
aria-expanded={expanded}
|
||||
aria-controls={contentId}
|
||||
|
||||
4
apps/client/src/widgets/ribbon/SimilarNotesTab.css
Normal file
4
apps/client/src/widgets/ribbon/SimilarNotesTab.css
Normal file
@@ -0,0 +1,4 @@
|
||||
.similar-notes-widget > .similar-notes-wrapper {
|
||||
/* The font size of the links with the highest similarity score */
|
||||
font-size: 17px;
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
import "./SimilarNotesTab.css";
|
||||
|
||||
import { SimilarNoteResponse } from "@triliumnext/commons";
|
||||
import { useEffect, useState } from "preact/hooks";
|
||||
|
||||
@@ -33,7 +35,7 @@ export default function SimilarNotesTab({ note }: Pick<TabContext, "note">) {
|
||||
notePath={notePath}
|
||||
noTnLink
|
||||
style={{
|
||||
"font-size": 20 * (1 - 1 / (1 + score))
|
||||
"font-size": (1 - 1 / (1 + score)) + "em"
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
|
||||
@@ -20,6 +20,8 @@ export interface CheckBoxProperty {
|
||||
label: string;
|
||||
bindToLabel: FilterLabelsByType<boolean>;
|
||||
icon?: string;
|
||||
/** When true, the checkbox will be checked when the label value is false. Useful when the label represents a "hide" action, without exposing double negatives to the user. */
|
||||
reverseValue?: boolean;
|
||||
}
|
||||
|
||||
export interface ButtonProperty {
|
||||
@@ -156,6 +158,13 @@ export const bookPropertiesConfig: Record<ViewTypeOptions, BookConfig> = {
|
||||
icon: "bx bx-ruler",
|
||||
type: "checkbox",
|
||||
bindToLabel: "map:scale"
|
||||
},
|
||||
{
|
||||
label: t("book_properties_config.show-labels"),
|
||||
icon: "bx bx-label",
|
||||
type: "checkbox",
|
||||
bindToLabel: "map:hideLabels",
|
||||
reverseValue: true
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
.attachment-list .links-wrapper {
|
||||
font-size: larger;
|
||||
margin-bottom: 15px;
|
||||
margin-block: 12px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: baseline;
|
||||
|
||||
@@ -1,16 +1,7 @@
|
||||
.type-contentWidget .note-detail {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.note-detail-content-widget {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.note-detail-content-widget-content {
|
||||
padding: 15px;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.note-detail.full-height .note-detail-content-widget-content {
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
.protected-session-password-component {
|
||||
width: 300px;
|
||||
margin: 30px auto auto;
|
||||
}
|
||||
|
||||
.protected-session-password-component input,
|
||||
.protected-session-password-component button {
|
||||
margin-top: 12px;
|
||||
display: flex;
|
||||
margin-inline: 40px;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
}
|
||||
@@ -20,7 +20,9 @@ export default function ProtectedSession() {
|
||||
}, [ passwordRef ]);
|
||||
|
||||
return (
|
||||
<form class="protected-session-password-form" onSubmit={submitCallback}>
|
||||
<form class="protected-session-password-form tn-centered-form" onSubmit={submitCallback}>
|
||||
<span class="form-icon bx bx-key" />
|
||||
|
||||
<FormGroup name="protected-session-password-in-detail" label={t("protected_session.enter_password_instruction")}>
|
||||
<FormTextBox
|
||||
type="password"
|
||||
@@ -37,4 +39,4 @@ export default function ProtectedSession() {
|
||||
/>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -16,3 +16,20 @@
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.web-view-setup-form {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
padding-inline: 40px;
|
||||
|
||||
.form-icon {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
width: 100%;
|
||||
max-width: 600px;
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,13 @@
|
||||
import { useCallback, useState } from "preact/hooks";
|
||||
import FNote from "../../entities/fnote";
|
||||
import { t } from "../../services/i18n";
|
||||
import utils from "../../services/utils";
|
||||
import Alert from "../react/Alert";
|
||||
import { useNoteLabel } from "../react/hooks";
|
||||
import { TypeWidgetProps } from "./type_widget";
|
||||
import "./WebView.css";
|
||||
import FormGroup from "../react/FormGroup";
|
||||
import toast from "../../services/toast";
|
||||
import Button from "../react/Button";
|
||||
|
||||
const isElectron = utils.isElectron();
|
||||
|
||||
@@ -12,7 +16,7 @@ export default function WebView({ note }: TypeWidgetProps) {
|
||||
|
||||
return (webViewSrc
|
||||
? <WebViewContent src={webViewSrc} />
|
||||
: <WebViewHelp />
|
||||
: <SetupWebView note={note} />
|
||||
);
|
||||
}
|
||||
|
||||
@@ -24,12 +28,41 @@ function WebViewContent({ src }: { src: string }) {
|
||||
}
|
||||
}
|
||||
|
||||
function WebViewHelp() {
|
||||
return (
|
||||
<Alert className="note-detail-web-view-help" type="warning">
|
||||
<h4>{t("web_view.web_view")}</h4>
|
||||
<p>{t("web_view.embed_websites")}</p>
|
||||
<p>{t("web_view.create_label")}</p>
|
||||
</Alert>
|
||||
)
|
||||
function SetupWebView({note}: {note: FNote}) {
|
||||
const [srcLabel, setSrcLabel] = useNoteLabel(note, "webViewSrc");
|
||||
const [src, setSrc] = useState("");
|
||||
|
||||
const submit = useCallback((url: string) => {
|
||||
try {
|
||||
// Validate URL
|
||||
new URL(url);
|
||||
} catch (ex) {
|
||||
toast.showErrorTitleAndMessage(t("web_view_setup.invalid_url_title"),
|
||||
t("web_view_setup.invalid_url_message"));
|
||||
return;
|
||||
}
|
||||
|
||||
setSrcLabel(url);
|
||||
}, [note]);
|
||||
|
||||
return <div class="web-view-setup-form">
|
||||
<form class="tn-centered-form" onSubmit={() => submit(src)}>
|
||||
<span className="bx bx-globe-alt form-icon" />
|
||||
|
||||
<FormGroup name="web-view-src-detail" label={t("web_view_setup.title")}>
|
||||
<input className="form-control"
|
||||
type="text"
|
||||
value={src}
|
||||
placeholder={t("web_view_setup.url_placeholder")}
|
||||
onChange={(e) => {setSrc((e.target as HTMLInputElement)?.value)}}
|
||||
/>
|
||||
</FormGroup>
|
||||
|
||||
<Button
|
||||
text={t("web_view_setup.create_button")}
|
||||
primary
|
||||
keyboardShortcut="Enter"
|
||||
/>
|
||||
</form>
|
||||
</div>
|
||||
}
|
||||
|
||||
40
apps/client/src/widgets/type_widgets/options/shortcuts.css
Normal file
40
apps/client/src/widgets/type_widgets/options/shortcuts.css
Normal file
@@ -0,0 +1,40 @@
|
||||
.shortcuts-options-section {
|
||||
> header {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
background: var(--main-background-color);
|
||||
padding-block: 0.5em;
|
||||
border-bottom: 1px solid var(--main-border-color);
|
||||
}
|
||||
|
||||
> footer {
|
||||
position: sticky;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
background: var(--main-background-color);
|
||||
padding-block: 0.5em;
|
||||
border-top: 1px solid var(--main-border-color);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
|
||||
th {
|
||||
width: 25%;
|
||||
}
|
||||
|
||||
.separator {
|
||||
background-color: var(--accented-background-color);
|
||||
font-weight: bold;
|
||||
|
||||
&:first-of-type {
|
||||
padding-top: 1em;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,6 @@ import { ActionKeyboardShortcut, KeyboardShortcut, OptionNames } from "@triliumn
|
||||
import { t } from "../../../services/i18n";
|
||||
import { arrayEqual, reloadFrontendApp } from "../../../services/utils";
|
||||
import Button from "../../react/Button";
|
||||
import FormGroup from "../../react/FormGroup";
|
||||
import FormText from "../../react/FormText";
|
||||
import FormTextBox from "../../react/FormTextBox";
|
||||
import RawHtml from "../../react/RawHtml";
|
||||
@@ -12,6 +11,8 @@ import server from "../../../services/server";
|
||||
import options from "../../../services/options";
|
||||
import dialog from "../../../services/dialog";
|
||||
import { useTriliumEvent } from "../../react/hooks";
|
||||
import "./shortcuts.css";
|
||||
import NoItems from "../../react/NoItems";
|
||||
|
||||
export default function ShortcutSettings() {
|
||||
const [ keyboardShortcuts, setKeyboardShortcuts ] = useState<KeyboardShortcut[]>([]);
|
||||
@@ -70,29 +71,29 @@ export default function ShortcutSettings() {
|
||||
options.saveMany(optionsToSet);
|
||||
}, [ keyboardShortcuts ]);
|
||||
|
||||
const filterLowerCase = filter?.toLowerCase() ?? "";
|
||||
const filteredKeyboardShortcuts = filter ? keyboardShortcuts.filter((action) => filterKeyboardAction(action, filterLowerCase)) : keyboardShortcuts;
|
||||
|
||||
return (
|
||||
<OptionsSection
|
||||
className="shortcuts-options-section"
|
||||
style={{ display: "flex", flexDirection: "column", height: "100%" }}
|
||||
noCard
|
||||
>
|
||||
<FormText>
|
||||
{t("shortcuts.multiple_shortcuts")}
|
||||
{t("shortcuts.multiple_shortcuts")}{" "}
|
||||
<RawHtml html={t("shortcuts.electron_documentation")} />
|
||||
</FormText>
|
||||
|
||||
<FormGroup name="keyboard-shortcut-filter">
|
||||
<header>
|
||||
<FormTextBox
|
||||
placeholder={t("shortcuts.type_text_to_filter")}
|
||||
currentValue={filter} onChange={(value) => setFilter(value.toLowerCase())}
|
||||
currentValue={filter} onChange={(value) => setFilter(value)}
|
||||
/>
|
||||
</FormGroup>
|
||||
</header>
|
||||
|
||||
<div style={{overflow: "auto", flexGrow: 1, flexShrink: 1}}>
|
||||
<KeyboardShortcutTable keyboardShortcuts={keyboardShortcuts} filter={filter} />
|
||||
</div>
|
||||
<KeyboardShortcutTable filteredKeyboardActions={filteredKeyboardShortcuts} filter={filter} />
|
||||
|
||||
<div style={{ display: "flex", justifyContent: "space-between", margin: "15px 15px 0 15px"}}>
|
||||
<footer>
|
||||
<Button
|
||||
text={t("shortcuts.reload_app")}
|
||||
onClick={reloadFrontendApp}
|
||||
@@ -102,12 +103,17 @@ export default function ShortcutSettings() {
|
||||
text={t("shortcuts.set_all_to_default")}
|
||||
onClick={resetShortcuts}
|
||||
/>
|
||||
</div>
|
||||
</footer>
|
||||
</OptionsSection>
|
||||
)
|
||||
}
|
||||
|
||||
function filterKeyboardAction(action: ActionKeyboardShortcut, filter: string) {
|
||||
function filterKeyboardAction(action: KeyboardShortcut, filter: string) {
|
||||
// Hide separators when filtering is active.
|
||||
if ("separator" in action) {
|
||||
return !filter;
|
||||
}
|
||||
|
||||
return action.actionName.toLowerCase().includes(filter) ||
|
||||
(action.friendlyName && action.friendlyName.toLowerCase().includes(filter)) ||
|
||||
(action.defaultShortcuts ?? []).some((shortcut) => shortcut.toLowerCase().includes(filter)) ||
|
||||
@@ -115,7 +121,7 @@ function filterKeyboardAction(action: ActionKeyboardShortcut, filter: string) {
|
||||
(action.description && action.description.toLowerCase().includes(filter));
|
||||
}
|
||||
|
||||
function KeyboardShortcutTable({ filter, keyboardShortcuts }: { filter?: string, keyboardShortcuts: KeyboardShortcut[] }) {
|
||||
function KeyboardShortcutTable({ filteredKeyboardActions, filter }: { filteredKeyboardActions: KeyboardShortcut[], filter: string | undefined }) {
|
||||
return (
|
||||
<table class="keyboard-shortcut-table" cellPadding="10">
|
||||
<thead>
|
||||
@@ -127,16 +133,17 @@ function KeyboardShortcutTable({ filter, keyboardShortcuts }: { filter?: string,
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{keyboardShortcuts.map(action => (
|
||||
{filteredKeyboardActions.length > 0
|
||||
? filteredKeyboardActions.map(action => (
|
||||
<tr>
|
||||
{"separator" in action ? ( !filter &&
|
||||
{"separator" in action ?
|
||||
<td class="separator" colspan={4} style={{
|
||||
backgroundColor: "var(--accented-background-color)",
|
||||
fontWeight: "bold"
|
||||
}}>
|
||||
{action.separator}
|
||||
</td>
|
||||
) : ( (!filter || filterKeyboardAction(action, filter)) &&
|
||||
: (
|
||||
<>
|
||||
<td>{action.friendlyName}</td>
|
||||
<td>
|
||||
@@ -147,7 +154,17 @@ function KeyboardShortcutTable({ filter, keyboardShortcuts }: { filter?: string,
|
||||
</>
|
||||
)}
|
||||
</tr>
|
||||
))}
|
||||
))
|
||||
: (
|
||||
<tr>
|
||||
<td colspan={4} class="text-center">
|
||||
<NoItems
|
||||
icon="bx bx-filter-alt"
|
||||
text={t("shortcuts.no_results", { filter })}
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
);
|
||||
|
||||
@@ -3,6 +3,7 @@ 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 {
|
||||
@@ -20,25 +21,20 @@ const debouncedHandleContentUpdate = debounce(handleContentUpdate, 1000);
|
||||
* @returns the list of templates.
|
||||
*/
|
||||
export default async function getTemplates() {
|
||||
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);
|
||||
// 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
|
||||
});
|
||||
}
|
||||
return definitions;
|
||||
} catch (e) {
|
||||
logError("Error while building text snippet templates: ", e);
|
||||
return [];
|
||||
definitions.push({
|
||||
title: snippet.title,
|
||||
data: () => templateCache.get(snippet.noteId)?.content ?? "",
|
||||
icon: buildIcon(snippet),
|
||||
description
|
||||
});
|
||||
}
|
||||
return definitions;
|
||||
}
|
||||
|
||||
async function invalidateCacheFor(snippet: FNote) {
|
||||
|
||||
@@ -95,7 +95,7 @@ export default defineConfig(() => ({
|
||||
output: {
|
||||
entryFileNames: (chunk) => {
|
||||
// We enforce a hash in the main index file to avoid caching issues, this only works because we have the HTML entry point.
|
||||
if (chunk.name === "index") {
|
||||
if (chunk.name === "index" || chunk.name === "print") {
|
||||
return "src/[name]-[hash].js";
|
||||
}
|
||||
|
||||
|
||||
@@ -35,11 +35,10 @@
|
||||
"sucrase": "3.35.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@anthropic-ai/sdk": "0.71.2",
|
||||
"@anthropic-ai/sdk": "0.74.0",
|
||||
"@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:*",
|
||||
@@ -50,13 +49,16 @@
|
||||
"@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.0.0",
|
||||
"@types/safe-compare": "1.1.2",
|
||||
"@types/sanitize-html": "2.16.0",
|
||||
"@types/sax": "1.2.7",
|
||||
"@types/serve-favicon": "2.5.7",
|
||||
"@types/serve-static": "2.2.0",
|
||||
@@ -68,7 +70,7 @@
|
||||
"@types/xml2js": "0.4.14",
|
||||
"archiver": "7.0.1",
|
||||
"async-mutex": "0.5.0",
|
||||
"axios": "1.13.4",
|
||||
"axios": "1.13.5",
|
||||
"bindings": "1.5.0",
|
||||
"bootstrap": "5.3.8",
|
||||
"chardet": "2.1.1",
|
||||
@@ -84,6 +86,7 @@
|
||||
"electron": "40.2.1",
|
||||
"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",
|
||||
@@ -96,7 +99,7 @@
|
||||
"html2plaintext": "2.1.4",
|
||||
"http-proxy-agent": "7.0.2",
|
||||
"https-proxy-agent": "7.0.6",
|
||||
"i18next": "25.8.0",
|
||||
"i18next": "25.8.4",
|
||||
"i18next-fs-backend": "2.6.1",
|
||||
"image-type": "6.0.0",
|
||||
"ini": "6.0.0",
|
||||
@@ -105,12 +108,15 @@
|
||||
"jimp": "1.6.0",
|
||||
"lorem-ipsum": "2.0.8",
|
||||
"marked": "17.0.1",
|
||||
"mime-types": "3.0.2",
|
||||
"multer": "2.0.2",
|
||||
"normalize-strings": "1.1.1",
|
||||
"ollama": "0.6.3",
|
||||
"openai": "6.17.0",
|
||||
"openai": "6.21.0",
|
||||
"rand-token": "1.0.1",
|
||||
"safe-compare": "1.1.4",
|
||||
"safe-compare": "1.1.4",
|
||||
"sanitize-filename": "1.6.3",
|
||||
"sanitize-html": "2.17.0",
|
||||
"sax": "1.4.4",
|
||||
"serve-favicon": "2.5.1",
|
||||
"stream-throttle": "0.1.3",
|
||||
@@ -121,6 +127,8 @@
|
||||
"time2fa": "1.4.2",
|
||||
"tmp": "0.2.5",
|
||||
"turnish": "1.8.0",
|
||||
"unescape": "1.0.1",
|
||||
"vite": "7.3.1",
|
||||
"ws": "8.19.0",
|
||||
"xml2js": "0.6.2",
|
||||
"yauzl": "3.2.0"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import anonymizationService from "./services/anonymization.js";
|
||||
import sqlInit from "./services/sql_init.js";
|
||||
await import("@triliumnext/core");
|
||||
await import("./becca/entity_constructor.js");
|
||||
|
||||
sqlInit.dbReady.then(async () => {
|
||||
try {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import("@triliumnext/core");
|
||||
import "./services/handlers.js";
|
||||
import "./becca/becca_loader.js";
|
||||
|
||||
import { erase } from "@triliumnext/core";
|
||||
import compression from "compression";
|
||||
import cookieParser from "cookie-parser";
|
||||
import ejs from "ejs";
|
||||
@@ -16,6 +16,7 @@ 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";
|
||||
@@ -108,7 +109,7 @@ export default async function buildApp() {
|
||||
|
||||
await import("./services/scheduler.js");
|
||||
|
||||
erase.startScheduledCleanup();
|
||||
startScheduledCleanup();
|
||||
|
||||
if (utils.isElectron) {
|
||||
(await import("@electron/remote/main/index.js")).initialize();
|
||||
|
||||
32
apps/server/src/assets/views/print.ejs
Normal file
32
apps/server/src/assets/views/print.ejs
Normal file
@@ -0,0 +1,32 @@
|
||||
<!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,4 +1,6 @@
|
||||
import { NotFoundError } from "../errors.js";
|
||||
import sql from "../services/sql.js";
|
||||
import NoteSet from "../services/search/note_set.js";
|
||||
import NotFoundError from "../errors/not_found_error.js";
|
||||
import type BOption from "./entities/boption.js";
|
||||
import type BNote from "./entities/bnote.js";
|
||||
import type BEtapiToken from "./entities/betapi_token.js";
|
||||
@@ -10,8 +12,6 @@ import type { AttachmentRow, BlobRow, RevisionRow } from "@triliumnext/commons";
|
||||
import BBlob from "./entities/bblob.js";
|
||||
import BRecentNote from "./entities/brecent_note.js";
|
||||
import type AbstractBeccaEntity from "./entities/abstract_becca_entity.js";
|
||||
import { getSql } from "../services/sql/index.js";
|
||||
import NoteSet from "../services/search/note_set.js";
|
||||
|
||||
/**
|
||||
* Becca is a backend cache of all notes, branches, and attributes.
|
||||
@@ -151,7 +151,7 @@ export default class Becca {
|
||||
}
|
||||
|
||||
getRevision(revisionId: string): BRevision | null {
|
||||
const row = getSql().getRow<RevisionRow | null>("SELECT * FROM revisions WHERE revisionId = ?", [revisionId]);
|
||||
const row = sql.getRow<RevisionRow | null>("SELECT * FROM revisions WHERE revisionId = ?", [revisionId]);
|
||||
return row ? new BRevision(row) : null;
|
||||
}
|
||||
|
||||
@@ -170,7 +170,7 @@ export default class Becca {
|
||||
JOIN blobs USING (blobId)
|
||||
WHERE attachmentId = ? AND isDeleted = 0`;
|
||||
|
||||
return getSql().getRows<AttachmentRow>(query, [attachmentId]).map((row) => new BAttachment(row))[0];
|
||||
return sql.getRows<AttachmentRow>(query, [attachmentId]).map((row) => new BAttachment(row))[0];
|
||||
}
|
||||
|
||||
getAttachmentOrThrow(attachmentId: string): BAttachment {
|
||||
@@ -182,7 +182,7 @@ export default class Becca {
|
||||
}
|
||||
|
||||
getAttachments(attachmentIds: string[]): BAttachment[] {
|
||||
return getSql().getManyRows<AttachmentRow>("SELECT * FROM attachments WHERE attachmentId IN (???) AND isDeleted = 0", attachmentIds).map((row) => new BAttachment(row));
|
||||
return sql.getManyRows<AttachmentRow>("SELECT * FROM attachments WHERE attachmentId IN (???) AND isDeleted = 0", attachmentIds).map((row) => new BAttachment(row));
|
||||
}
|
||||
|
||||
getBlob(entity: { blobId?: string }): BBlob | null {
|
||||
@@ -190,7 +190,7 @@ export default class Becca {
|
||||
return null;
|
||||
}
|
||||
|
||||
const row = getSql().getRow<BlobRow | null>("SELECT *, LENGTH(content) AS contentLength FROM blobs WHERE blobId = ?", [entity.blobId]);
|
||||
const row = sql.getRow<BlobRow | null>("SELECT *, LENGTH(content) AS contentLength FROM blobs WHERE blobId = ?", [entity.blobId]);
|
||||
return row ? new BBlob(row) : null;
|
||||
}
|
||||
|
||||
@@ -227,12 +227,12 @@ export default class Becca {
|
||||
}
|
||||
|
||||
getRecentNotesFromQuery(query: string, params: string[] = []): BRecentNote[] {
|
||||
const rows = getSql().getRows<BRecentNote>(query, params);
|
||||
const rows = sql.getRows<BRecentNote>(query, params);
|
||||
return rows.map((row) => new BRecentNote(row));
|
||||
}
|
||||
|
||||
getRevisionsFromQuery(query: string, params: string[] = []): BRevision[] {
|
||||
const rows = getSql().getRows<RevisionRow>(query, params);
|
||||
const rows = sql.getRows<RevisionRow>(query, params);
|
||||
return rows.map((row) => new BRevision(row));
|
||||
}
|
||||
|
||||
@@ -1,2 +1,7 @@
|
||||
import { becca } from "@triliumnext/core";
|
||||
"use strict";
|
||||
|
||||
import Becca from "./becca-interface.js";
|
||||
|
||||
const becca = new Becca();
|
||||
|
||||
export default becca;
|
||||
|
||||
@@ -1,26 +1,27 @@
|
||||
import type { AttributeRow, BranchRow, EtapiTokenRow, NoteRow, OptionRow } from "@triliumnext/commons";
|
||||
import eventService from "../services/events";
|
||||
"use strict";
|
||||
|
||||
import entityConstructor from "../becca/entity_constructor.js";
|
||||
import { getLog } from "../services/log.js";
|
||||
import { dbReady } from "../services/sql_init.js";
|
||||
import ws from "../services/ws.js";
|
||||
import sql from "../services/sql.js";
|
||||
import eventService from "../services/events.js";
|
||||
import becca from "./becca.js";
|
||||
import type AbstractBeccaEntity from "./entities/abstract_becca_entity.js";
|
||||
import BAttribute from "./entities/battribute.js";
|
||||
import BBranch from "./entities/bbranch.js";
|
||||
import BEtapiToken from "./entities/betapi_token.js";
|
||||
import log from "../services/log.js";
|
||||
import BNote from "./entities/bnote.js";
|
||||
import BBranch from "./entities/bbranch.js";
|
||||
import BAttribute from "./entities/battribute.js";
|
||||
import BOption from "./entities/boption.js";
|
||||
import { getSql } from "../services/sql";
|
||||
import { getContext } from "../services/context.js";
|
||||
import BEtapiToken from "./entities/betapi_token.js";
|
||||
import cls from "../services/cls.js";
|
||||
import entityConstructor from "../becca/entity_constructor.js";
|
||||
import type { AttributeRow, BranchRow, EtapiTokenRow, NoteRow, OptionRow } from "@triliumnext/commons";
|
||||
import type AbstractBeccaEntity from "./entities/abstract_becca_entity.js";
|
||||
import ws from "../services/ws.js";
|
||||
import { dbReady } from "../services/sql_init.js";
|
||||
|
||||
export const beccaLoaded = new Promise<void>(async (res, rej) => {
|
||||
// We have to import async since options init requires keyboard actions which require translations.
|
||||
const options_init = (await import("../services/options_init.js")).default;
|
||||
|
||||
dbReady.then(() => {
|
||||
getContext().init(() => {
|
||||
cls.init(() => {
|
||||
load();
|
||||
|
||||
options_init.initStartupOptions();
|
||||
@@ -35,7 +36,6 @@ function load() {
|
||||
becca.reset();
|
||||
|
||||
// we know this is slow and the total becca load time is logged
|
||||
const sql = getSql();
|
||||
sql.disableSlowQueryLogging(() => {
|
||||
// using a raw query and passing arrays to avoid allocating new objects,
|
||||
// this is worth it for the becca load since it happens every run and blocks the app until finished
|
||||
@@ -72,7 +72,7 @@ function load() {
|
||||
|
||||
becca.loaded = true;
|
||||
|
||||
getLog().info(`Becca (note cache) load took ${Date.now() - start}ms`);
|
||||
log.info(`Becca (note cache) load took ${Date.now() - start}ms`);
|
||||
}
|
||||
|
||||
function reload(reason: string) {
|
||||
@@ -284,7 +284,7 @@ eventService.subscribeBeccaLoader(eventService.ENTER_PROTECTED_SESSION, () => {
|
||||
try {
|
||||
becca.decryptProtectedNotes();
|
||||
} catch (e: any) {
|
||||
getLog().error(`Could not decrypt protected notes: ${e.message} ${e.stack}`);
|
||||
log.error(`Could not decrypt protected notes: ${e.message} ${e.stack}`);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
"use strict";
|
||||
|
||||
import becca from "./becca.js";
|
||||
import { getLog } from "../services/log.js";
|
||||
import { getHoistedNoteId } from "../services/context.js";
|
||||
import cls from "../services/cls.js";
|
||||
import log from "../services/log.js";
|
||||
|
||||
function isNotePathArchived(notePath: string[]) {
|
||||
const noteId = notePath[notePath.length - 1];
|
||||
@@ -29,7 +29,7 @@ function getNoteTitle(childNoteId: string, parentNoteId?: string) {
|
||||
const parentNote = parentNoteId ? becca.notes[parentNoteId] : null;
|
||||
|
||||
if (!childNote) {
|
||||
getLog().info(`Cannot find note '${childNoteId}'`);
|
||||
log.info(`Cannot find note '${childNoteId}'`);
|
||||
return "[error fetching title]";
|
||||
}
|
||||
|
||||
@@ -50,7 +50,7 @@ function getNoteTitleAndIcon(childNoteId: string, parentNoteId?: string) {
|
||||
const parentNote = parentNoteId ? becca.notes[parentNoteId] : null;
|
||||
|
||||
if (!childNote) {
|
||||
getLog().info(`Cannot find note '${childNoteId}'`);
|
||||
log.info(`Cannot find note '${childNoteId}'`);
|
||||
return {
|
||||
title: "[error fetching title]"
|
||||
}
|
||||
@@ -82,7 +82,7 @@ function getNoteTitleArrayForPath(notePathArray: string[]) {
|
||||
let hoistedNotePassed = false;
|
||||
|
||||
// this is a notePath from outside of hoisted subtree, so the full title path needs to be returned
|
||||
const hoistedNoteId = getHoistedNoteId();
|
||||
const hoistedNoteId = cls.getHoistedNoteId();
|
||||
const outsideOfHoistedSubtree = !notePathArray.includes(hoistedNoteId);
|
||||
|
||||
for (const noteId of notePathArray) {
|
||||
@@ -1,16 +1,16 @@
|
||||
import eventService from "../../services/events";
|
||||
"use strict";
|
||||
|
||||
import blobService from "../../services/blob.js";
|
||||
import * as cls from "../../services/context";
|
||||
import dateUtils from "../../services/utils/date";
|
||||
import utils from "../../services/utils.js";
|
||||
import sql from "../../services/sql.js";
|
||||
import entityChangesService from "../../services/entity_changes.js";
|
||||
import { getLog } from "../../services/log.js";
|
||||
import eventService from "../../services/events.js";
|
||||
import dateUtils from "../../services/date_utils.js";
|
||||
import cls from "../../services/cls.js";
|
||||
import log from "../../services/log.js";
|
||||
import protectedSessionService from "../../services/protected_session.js";
|
||||
import blobService from "../../services/blob.js";
|
||||
import type { default as Becca, ConstructorData } from "../becca-interface.js";
|
||||
import becca from "../becca.js";
|
||||
import type { ConstructorData,default as Becca } from "../becca-interface.js";
|
||||
import { getSql } from "../../services/sql";
|
||||
import { concat2, encodeUtf8, unwrapStringOrBuffer, wrapStringOrBuffer } from "../../services/utils/binary";
|
||||
import { hash, hashedBlobId, newEntityId, randomString } from "../../services/utils";
|
||||
|
||||
interface ContentOpts {
|
||||
forceSave?: boolean;
|
||||
@@ -36,7 +36,7 @@ abstract class AbstractBeccaEntity<T extends AbstractBeccaEntity<T>> {
|
||||
protected beforeSaving(opts?: {}) {
|
||||
const constructorData = this.constructor as unknown as ConstructorData<T>;
|
||||
if (!(this as any)[constructorData.primaryKeyName]) {
|
||||
(this as any)[constructorData.primaryKeyName] = newEntityId();
|
||||
(this as any)[constructorData.primaryKeyName] = utils.newEntityId();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -72,7 +72,7 @@ abstract class AbstractBeccaEntity<T extends AbstractBeccaEntity<T>> {
|
||||
contentToHash += "|deleted";
|
||||
}
|
||||
|
||||
return hash(contentToHash).substr(0, 10);
|
||||
return utils.hash(contentToHash).substr(0, 10);
|
||||
}
|
||||
|
||||
protected getPojoToSave() {
|
||||
@@ -111,7 +111,6 @@ abstract class AbstractBeccaEntity<T extends AbstractBeccaEntity<T>> {
|
||||
|
||||
const pojo = this.getPojoToSave();
|
||||
|
||||
const sql = getSql();
|
||||
sql.transactional(() => {
|
||||
sql.upsert(entityName, primaryKeyName, pojo);
|
||||
|
||||
@@ -138,7 +137,7 @@ abstract class AbstractBeccaEntity<T extends AbstractBeccaEntity<T>> {
|
||||
return this;
|
||||
}
|
||||
|
||||
protected _setContent(content: string | Uint8Array, opts: ContentOpts = {}) {
|
||||
protected _setContent(content: string | Buffer, opts: ContentOpts = {}) {
|
||||
// client code asks to save entity even if blobId didn't change (something else was changed)
|
||||
opts.forceSave = !!opts.forceSave;
|
||||
opts.forceFrontendReload = !!opts.forceFrontendReload;
|
||||
@@ -149,9 +148,9 @@ abstract class AbstractBeccaEntity<T extends AbstractBeccaEntity<T>> {
|
||||
}
|
||||
|
||||
if (this.hasStringContent()) {
|
||||
content = unwrapStringOrBuffer(content);
|
||||
content = content.toString();
|
||||
} else {
|
||||
content = wrapStringOrBuffer(content);
|
||||
content = Buffer.isBuffer(content) ? content : Buffer.from(content);
|
||||
}
|
||||
|
||||
const unencryptedContentForHashCalculation = this.getUnencryptedContentForHashCalculation(content);
|
||||
@@ -168,7 +167,7 @@ abstract class AbstractBeccaEntity<T extends AbstractBeccaEntity<T>> {
|
||||
}
|
||||
}
|
||||
|
||||
getSql().transactional(() => {
|
||||
sql.transactional(() => {
|
||||
const newBlobId = this.saveBlob(content, unencryptedContentForHashCalculation, opts);
|
||||
const oldBlobId = this.blobId;
|
||||
|
||||
@@ -184,7 +183,6 @@ abstract class AbstractBeccaEntity<T extends AbstractBeccaEntity<T>> {
|
||||
}
|
||||
|
||||
private deleteBlobIfNotUsed(oldBlobId: string) {
|
||||
const sql = getSql();
|
||||
if (sql.getValue("SELECT 1 FROM notes WHERE blobId = ? LIMIT 1", [oldBlobId])) {
|
||||
return;
|
||||
}
|
||||
@@ -203,29 +201,24 @@ abstract class AbstractBeccaEntity<T extends AbstractBeccaEntity<T>> {
|
||||
sql.execute("DELETE FROM entity_changes WHERE entityName = 'blobs' AND entityId = ?", [oldBlobId]);
|
||||
}
|
||||
|
||||
private getUnencryptedContentForHashCalculation(unencryptedContent: Uint8Array | string) {
|
||||
private getUnencryptedContentForHashCalculation(unencryptedContent: Buffer | string) {
|
||||
if (this.isProtected) {
|
||||
// a "random" prefix makes sure that the calculated hash/blobId is different for a decrypted/encrypted content
|
||||
const encryptedPrefixSuffix = "t$[nvQg7q)&_ENCRYPTED_?M:Bf&j3jr_";
|
||||
if (typeof unencryptedContent === "string") {
|
||||
return `${encryptedPrefixSuffix}${unencryptedContent}`;
|
||||
} else {
|
||||
return concat2(encodeUtf8(encryptedPrefixSuffix), unencryptedContent)
|
||||
}
|
||||
return Buffer.isBuffer(unencryptedContent) ? Buffer.concat([Buffer.from(encryptedPrefixSuffix), unencryptedContent]) : `${encryptedPrefixSuffix}${unencryptedContent}`;
|
||||
} else {
|
||||
return unencryptedContent;
|
||||
}
|
||||
return unencryptedContent;
|
||||
|
||||
}
|
||||
|
||||
private saveBlob(content: string | Uint8Array, unencryptedContentForHashCalculation: string | Uint8Array, opts: ContentOpts = {}) {
|
||||
private saveBlob(content: string | Buffer, unencryptedContentForHashCalculation: string | Buffer, opts: ContentOpts = {}) {
|
||||
/*
|
||||
* We're using the unencrypted blob for the hash calculation, because otherwise the random IV would
|
||||
* cause every content blob to be unique which would balloon the database size (esp. with revisioning).
|
||||
* This has minor security implications (it's easy to infer that given content is shared between different
|
||||
* notes/attachments), but the trade-off comes out clearly positive.
|
||||
*/
|
||||
const newBlobId = hashedBlobId(unencryptedContentForHashCalculation);
|
||||
const sql = getSql();
|
||||
const newBlobId = utils.hashedBlobId(unencryptedContentForHashCalculation);
|
||||
const blobNeedsInsert = !sql.getValue("SELECT 1 FROM blobs WHERE blobId = ?", [newBlobId]);
|
||||
|
||||
if (!blobNeedsInsert) {
|
||||
@@ -234,7 +227,7 @@ abstract class AbstractBeccaEntity<T extends AbstractBeccaEntity<T>> {
|
||||
|
||||
const pojo = {
|
||||
blobId: newBlobId,
|
||||
content,
|
||||
content: content,
|
||||
dateModified: dateUtils.localNowDateTime(),
|
||||
utcDateModified: dateUtils.utcNowDateTime()
|
||||
};
|
||||
@@ -248,13 +241,13 @@ abstract class AbstractBeccaEntity<T extends AbstractBeccaEntity<T>> {
|
||||
entityChangesService.putEntityChange({
|
||||
entityName: "blobs",
|
||||
entityId: newBlobId,
|
||||
hash,
|
||||
hash: hash,
|
||||
isErased: false,
|
||||
utcDateChanged: pojo.utcDateModified,
|
||||
isSynced: true,
|
||||
// overriding componentId will cause the frontend to think the change is coming from a different component
|
||||
// and thus reload
|
||||
componentId: opts.forceFrontendReload ? randomString(10) : null
|
||||
componentId: opts.forceFrontendReload ? utils.randomString(10) : null
|
||||
});
|
||||
|
||||
eventService.emit(eventService.ENTITY_CHANGED, {
|
||||
@@ -265,16 +258,15 @@ abstract class AbstractBeccaEntity<T extends AbstractBeccaEntity<T>> {
|
||||
return newBlobId;
|
||||
}
|
||||
|
||||
protected _getContent(): string | Uint8Array {
|
||||
const sql = getSql();
|
||||
const row = sql.getRow<{ content: string | Uint8Array }>(/*sql*/`SELECT content FROM blobs WHERE blobId = ?`, [this.blobId]);
|
||||
protected _getContent(): string | Buffer {
|
||||
const row = sql.getRow<{ content: string | Buffer }>(/*sql*/`SELECT content FROM blobs WHERE blobId = ?`, [this.blobId]);
|
||||
|
||||
if (!row) {
|
||||
const constructorData = this.constructor as unknown as ConstructorData<T>;
|
||||
throw new Error(`Cannot find content for ${constructorData.primaryKeyName} '${(this as any)[constructorData.primaryKeyName]}', blobId '${this.blobId}'`);
|
||||
}
|
||||
|
||||
return blobService.processContent(row.content, this.isProtected || false, this.hasStringContent()) as string | Uint8Array;
|
||||
return blobService.processContent(row.content, this.isProtected || false, this.hasStringContent());
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -289,7 +281,6 @@ abstract class AbstractBeccaEntity<T extends AbstractBeccaEntity<T>> {
|
||||
|
||||
this.utcDateModified = dateUtils.utcNowDateTime();
|
||||
|
||||
const sql = getSql();
|
||||
sql.execute(
|
||||
/*sql*/`UPDATE ${entityName} SET isDeleted = 1, deleteId = ?, utcDateModified = ?
|
||||
WHERE ${constructorData.primaryKeyName} = ?`,
|
||||
@@ -302,7 +293,7 @@ abstract class AbstractBeccaEntity<T extends AbstractBeccaEntity<T>> {
|
||||
sql.execute(/*sql*/`UPDATE ${entityName} SET dateModified = ? WHERE ${constructorData.primaryKeyName} = ?`, [this.dateModified, entityId]);
|
||||
}
|
||||
|
||||
getLog().info(`Marking ${entityName} ${entityId} as deleted`);
|
||||
log.info(`Marking ${entityName} ${entityId} as deleted`);
|
||||
|
||||
this.putEntityChange(true);
|
||||
|
||||
@@ -316,14 +307,13 @@ abstract class AbstractBeccaEntity<T extends AbstractBeccaEntity<T>> {
|
||||
|
||||
this.utcDateModified = dateUtils.utcNowDateTime();
|
||||
|
||||
const sql = getSql();
|
||||
sql.execute(
|
||||
/*sql*/`UPDATE ${entityName} SET isDeleted = 1, utcDateModified = ?
|
||||
WHERE ${constructorData.primaryKeyName} = ?`,
|
||||
[this.utcDateModified, entityId]
|
||||
);
|
||||
|
||||
getLog().info(`Marking ${entityName} ${entityId} as deleted`);
|
||||
log.info(`Marking ${entityName} ${entityId} as deleted`);
|
||||
|
||||
this.putEntityChange(true);
|
||||
|
||||
@@ -1,2 +1,260 @@
|
||||
import { BAttachment } from "@triliumnext/core";
|
||||
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
export default BAttachment;
|
||||
|
||||
@@ -1,2 +1,227 @@
|
||||
import { BAttribute } from "@triliumnext/core";
|
||||
"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
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export default BAttribute;
|
||||
|
||||
@@ -13,7 +13,7 @@ class BBlob extends AbstractBeccaEntity<BBlob> {
|
||||
return ["blobId", "content"];
|
||||
}
|
||||
|
||||
content!: string | Uint8Array;
|
||||
content!: string | Buffer;
|
||||
contentLength!: number;
|
||||
|
||||
constructor(row: BlobRow) {
|
||||
@@ -1,2 +1,288 @@
|
||||
import { BBranch } from "@triliumnext/core";
|
||||
"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;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export default BBranch;
|
||||
|
||||
@@ -1,2 +1,89 @@
|
||||
import { BEtapiToken } from "@triliumnext/core";
|
||||
"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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default BEtapiToken;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,2 +1,56 @@
|
||||
import { BOption } from "@triliumnext/core";
|
||||
"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
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export default BOption;
|
||||
|
||||
@@ -1,2 +1,46 @@
|
||||
import { BRecentNote } from "@triliumnext/core";
|
||||
"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
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export default BRecentNote;
|
||||
|
||||
@@ -1,2 +1,225 @@
|
||||
import { BRevision } from "@triliumnext/core";
|
||||
"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;
|
||||
}
|
||||
}
|
||||
|
||||
export default BRevision;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import becca from "./becca.js";
|
||||
import { getLog } from "../services/log.js";
|
||||
import log from "../services/log.js";
|
||||
import beccaService from "./becca_service.js";
|
||||
import dateUtils from "../services/utils/date";
|
||||
import dateUtils from "../services/date_utils.js";
|
||||
import { parse } from "node-html-parser";
|
||||
import type BNote from "./entities/bnote.js";
|
||||
import { SimilarNote } from "@triliumnext/commons";
|
||||
@@ -359,7 +359,7 @@ async function findSimilarNotes(noteId: string): Promise<SimilarNote[] | undefin
|
||||
let factor = 1;
|
||||
|
||||
if (!value.startsWith) {
|
||||
getLog().info(`Unexpected falsy value for attribute ${JSON.stringify(attr.getPojo())}`);
|
||||
log.info(`Unexpected falsy value for attribute ${JSON.stringify(attr.getPojo())}`);
|
||||
continue;
|
||||
} else if (value.startsWith("http")) {
|
||||
value = filterUrlValue(value);
|
||||
@@ -1,24 +0,0 @@
|
||||
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);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
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);
|
||||
}
|
||||
|
||||
}
|
||||
12
apps/server/src/errors/forbidden_error.ts
Normal file
12
apps/server/src/errors/forbidden_error.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import HttpError from "./http_error.js";
|
||||
|
||||
class ForbiddenError extends HttpError {
|
||||
|
||||
constructor(message: string) {
|
||||
super(message, 403);
|
||||
this.name = "ForbiddenError";
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export default ForbiddenError;
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user