mirror of
https://github.com/zadam/trilium.git
synced 2026-05-06 07:57:23 +02:00
Lightweight/browser api (#8287)
This commit is contained in:
65
.github/workflows/deploy-app.yml
vendored
Normal file
65
.github/workflows/deploy-app.yml
vendored
Normal file
@@ -0,0 +1,65 @@
|
||||
name: Deploy Standalone App
|
||||
|
||||
on:
|
||||
# Trigger on push to main branch
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
# Only run when docs files change
|
||||
paths:
|
||||
- 'apps/client/**'
|
||||
- 'apps/client-standalone/**'
|
||||
- 'packages/trilium-core/**'
|
||||
|
||||
# Allow manual triggering from Actions tab
|
||||
workflow_dispatch:
|
||||
|
||||
# Run on pull requests for preview deployments
|
||||
pull_request:
|
||||
paths:
|
||||
- 'apps/client/**'
|
||||
- 'apps/client-standalone/**'
|
||||
- 'packages/trilium-core/**'
|
||||
jobs:
|
||||
build-and-deploy:
|
||||
name: Build and Deploy App
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
|
||||
# Required permissions for deployment
|
||||
permissions:
|
||||
contents: read
|
||||
deployments: write
|
||||
pull-requests: write # For PR preview comments
|
||||
id-token: write # For OIDC authentication (if needed)
|
||||
|
||||
steps:
|
||||
- name: Checkout Repository
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: '24'
|
||||
cache: 'pnpm'
|
||||
|
||||
- name: Install Dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Trigger build of app
|
||||
run: pnpm --filter=client-standalone build
|
||||
|
||||
- name: Deploy
|
||||
uses: ./.github/actions/deploy-to-cloudflare-pages
|
||||
if: github.repository == vars.REPO_MAIN
|
||||
with:
|
||||
project_name: "trilium-app"
|
||||
comment_body: "🖥️ App preview is ready"
|
||||
production_url: "https://app.triliumnotes.org"
|
||||
deploy_dir: "apps/client-standalone/dist"
|
||||
cloudflare_api_token: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
cloudflare_account_id: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
||||
github_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
4
apps/client-standalone/.env
Normal file
4
apps/client-standalone/.env
Normal file
@@ -0,0 +1,4 @@
|
||||
# The development license key for premium CKEditor features.
|
||||
# Note: This key must only be used for the Trilium Notes project.
|
||||
VITE_CKEDITOR_KEY=eyJhbGciOiJFUzI1NiJ9.eyJleHAiOjE3ODcyNzA0MDAsImp0aSI6IjkyMWE1MWNlLTliNDMtNGRlMC1iOTQwLTc5ZjM2MDBkYjg1NyIsImRpc3RyaWJ1dGlvbkNoYW5uZWwiOiJ0cmlsaXVtIiwiZmVhdHVyZXMiOlsiVFJJTElVTSJdLCJ2YyI6ImU4YzRhMjBkIn0.hny77p-U4-jTkoqbwPytrEar5ylGCWBN7Ez3SlB8i6_mJCBIeCSTOlVQk_JMiOEq3AGykUMHzWXzjdMFwgniOw
|
||||
VITE_CKEDITOR_ENABLE_INSPECTOR=false
|
||||
1
apps/client-standalone/.env.production
Normal file
1
apps/client-standalone/.env.production
Normal file
@@ -0,0 +1 @@
|
||||
VITE_CKEDITOR_ENABLE_INSPECTOR=false
|
||||
86
apps/client-standalone/package.json
Normal file
86
apps/client-standalone/package.json
Normal file
@@ -0,0 +1,86 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
}
|
||||
3
apps/client-standalone/public/_headers
Normal file
3
apps/client-standalone/public/_headers
Normal file
@@ -0,0 +1,3 @@
|
||||
/*
|
||||
Cross-Origin-Opener-Policy: same-origin
|
||||
Cross-Origin-Embedder-Policy: require-corp
|
||||
BIN
apps/client-standalone/public/favicon.ico
Normal file
BIN
apps/client-standalone/public/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 112 KiB |
20
apps/client-standalone/public/manifest.webmanifest
Normal file
20
apps/client-standalone/public/manifest.webmanifest
Normal file
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"name": "Trilium Notes",
|
||||
"short_name": "Trilium",
|
||||
"description": "Trilium Notes is a hierarchical note taking application with focus on building large personal knowledge bases.",
|
||||
"theme_color": "#333333",
|
||||
"background_color": "#1F1F1F",
|
||||
"display": "standalone",
|
||||
"scope": "/",
|
||||
"start_url": "/",
|
||||
"display_override": [
|
||||
"window-controls-overlay"
|
||||
],
|
||||
"icons": [
|
||||
{
|
||||
"src": "assets/icon.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png"
|
||||
}
|
||||
]
|
||||
}
|
||||
2
apps/client-standalone/src/desktop.ts
Normal file
2
apps/client-standalone/src/desktop.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
// Re-export desktop from client
|
||||
export * from "../../client/src/desktop";
|
||||
35
apps/client-standalone/src/index.html
Normal file
35
apps/client-standalone/src/index.html
Normal file
@@ -0,0 +1,35 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<link rel="shortcut icon" href="favicon.ico">
|
||||
<meta name="mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, viewport-fit=cover" />
|
||||
<link rel="manifest" crossorigin="use-credentials" href="manifest.webmanifest">
|
||||
<title>Trilium Notes</title>
|
||||
</head>
|
||||
|
||||
<body id="trilium-app">
|
||||
<noscript>Trilium requires JavaScript to be enabled.</noscript>
|
||||
|
||||
<script>
|
||||
// hide body to reduce flickering on the startup. This is done through JS and not CSS to not hide <noscript>
|
||||
document.getElementsByTagName("body")[0].style.display = "none";
|
||||
</script>
|
||||
|
||||
<div class="dropdown-menu dropdown-menu-sm" id="context-menu-container" style="display: none"></div>
|
||||
|
||||
<!-- Required for match the PWA's top bar color with the theme -->
|
||||
<!-- This works even when the user directly changes --root-background in CSS -->
|
||||
<div id="background-color-tracker" style="position: absolute; visibility: hidden; color: var(--root-background); transition: color 1ms;"></div>
|
||||
|
||||
<!-- Bootstrap (request server for required information) -->
|
||||
<script src="./main.ts" type="module"></script>
|
||||
|
||||
<!-- Required for correct loading of scripts in Electron -->
|
||||
<script>
|
||||
if (typeof module === 'object') {window.module = module; module = undefined;}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
254
apps/client-standalone/src/lightweight/browser_router.ts
Normal file
254
apps/client-standalone/src/lightweight/browser_router.ts
Normal file
@@ -0,0 +1,254 @@
|
||||
/**
|
||||
* Browser-compatible router that mimics Express routing patterns.
|
||||
* Supports path parameters (e.g., /api/notes/:noteId) and query strings.
|
||||
*/
|
||||
|
||||
import { getContext, routes } from "@triliumnext/core";
|
||||
|
||||
export interface BrowserRequest {
|
||||
method: string;
|
||||
url: string;
|
||||
path: string;
|
||||
params: Record<string, string>;
|
||||
query: Record<string, string | undefined>;
|
||||
body?: unknown;
|
||||
}
|
||||
|
||||
export interface BrowserResponse {
|
||||
status: number;
|
||||
headers: Record<string, string>;
|
||||
body: ArrayBuffer | null;
|
||||
}
|
||||
|
||||
export type RouteHandler = (req: BrowserRequest) => unknown | Promise<unknown>;
|
||||
|
||||
interface Route {
|
||||
method: string;
|
||||
pattern: RegExp;
|
||||
paramNames: string[];
|
||||
handler: RouteHandler;
|
||||
}
|
||||
|
||||
const encoder = new TextEncoder();
|
||||
|
||||
/**
|
||||
* Convert an Express-style path pattern to a RegExp.
|
||||
* Supports :param syntax for path parameters.
|
||||
*
|
||||
* Examples:
|
||||
* /api/notes/:noteId -> /^\/api\/notes\/([^\/]+)$/
|
||||
* /api/notes/:noteId/revisions -> /^\/api\/notes\/([^\/]+)\/revisions$/
|
||||
*/
|
||||
function pathToRegex(path: string): { pattern: RegExp; paramNames: string[] } {
|
||||
const paramNames: string[] = [];
|
||||
|
||||
// Escape special regex characters except for :param patterns
|
||||
const regexPattern = path
|
||||
.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') // Escape special chars
|
||||
.replace(/:([a-zA-Z_][a-zA-Z0-9_]*)/g, (_, paramName) => {
|
||||
paramNames.push(paramName);
|
||||
return '([^/]+)';
|
||||
});
|
||||
|
||||
return {
|
||||
pattern: new RegExp(`^${regexPattern}$`),
|
||||
paramNames
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse query string into an object.
|
||||
*/
|
||||
function parseQuery(search: string): Record<string, string | undefined> {
|
||||
const query: Record<string, string | undefined> = {};
|
||||
if (!search || search === '?') return query;
|
||||
|
||||
const params = new URLSearchParams(search);
|
||||
for (const [key, value] of params) {
|
||||
query[key] = value;
|
||||
}
|
||||
return query;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a result to a JSON response.
|
||||
*/
|
||||
function jsonResponse(obj: unknown, status = 200, extraHeaders: Record<string, string> = {}): BrowserResponse {
|
||||
const parsedObj = routes.convertEntitiesToPojo(obj);
|
||||
const body = encoder.encode(JSON.stringify(parsedObj)).buffer as ArrayBuffer;
|
||||
return {
|
||||
status,
|
||||
headers: { "content-type": "application/json; charset=utf-8", ...extraHeaders },
|
||||
body
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a string to a text response.
|
||||
*/
|
||||
function textResponse(text: string, status = 200, extraHeaders: Record<string, string> = {}): BrowserResponse {
|
||||
const body = encoder.encode(text).buffer as ArrayBuffer;
|
||||
return {
|
||||
status,
|
||||
headers: { "content-type": "text/plain; charset=utf-8", ...extraHeaders },
|
||||
body
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Browser router class that handles route registration and dispatching.
|
||||
*/
|
||||
export class BrowserRouter {
|
||||
private routes: Route[] = [];
|
||||
|
||||
/**
|
||||
* Register a route handler.
|
||||
*/
|
||||
register(method: string, path: string, handler: RouteHandler): void {
|
||||
const { pattern, paramNames } = pathToRegex(path);
|
||||
this.routes.push({
|
||||
method: method.toUpperCase(),
|
||||
pattern,
|
||||
paramNames,
|
||||
handler
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience methods for common HTTP methods.
|
||||
*/
|
||||
get(path: string, handler: RouteHandler): void {
|
||||
this.register('GET', path, handler);
|
||||
}
|
||||
|
||||
post(path: string, handler: RouteHandler): void {
|
||||
this.register('POST', path, handler);
|
||||
}
|
||||
|
||||
put(path: string, handler: RouteHandler): void {
|
||||
this.register('PUT', path, handler);
|
||||
}
|
||||
|
||||
patch(path: string, handler: RouteHandler): void {
|
||||
this.register('PATCH', path, handler);
|
||||
}
|
||||
|
||||
delete(path: string, handler: RouteHandler): void {
|
||||
this.register('DELETE', path, handler);
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispatch a request to the appropriate handler.
|
||||
*/
|
||||
async dispatch(method: string, urlString: string, body?: unknown, headers?: Record<string, string>): Promise<BrowserResponse> {
|
||||
const url = new URL(urlString);
|
||||
const path = url.pathname;
|
||||
const query = parseQuery(url.search);
|
||||
const upperMethod = method.toUpperCase();
|
||||
|
||||
// Parse JSON body if it's an ArrayBuffer and content-type suggests JSON
|
||||
let parsedBody = body;
|
||||
if (body instanceof ArrayBuffer && headers) {
|
||||
const contentType = headers['content-type'] || headers['Content-Type'] || '';
|
||||
if (contentType.includes('application/json')) {
|
||||
try {
|
||||
const text = new TextDecoder().decode(body);
|
||||
if (text.trim()) {
|
||||
parsedBody = JSON.parse(text);
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[Router] Failed to parse JSON body:', e);
|
||||
// Keep original body if JSON parsing fails
|
||||
parsedBody = body;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Find matching route
|
||||
for (const route of this.routes) {
|
||||
if (route.method !== upperMethod) continue;
|
||||
|
||||
const match = path.match(route.pattern);
|
||||
if (!match) continue;
|
||||
|
||||
// Extract path parameters
|
||||
const params: Record<string, string> = {};
|
||||
for (let i = 0; i < route.paramNames.length; i++) {
|
||||
params[route.paramNames[i]] = decodeURIComponent(match[i + 1]);
|
||||
}
|
||||
|
||||
const request: BrowserRequest = {
|
||||
method: upperMethod,
|
||||
url: urlString,
|
||||
path,
|
||||
params,
|
||||
query,
|
||||
body: parsedBody
|
||||
};
|
||||
|
||||
try {
|
||||
const result = await getContext().init(async () => await route.handler(request));
|
||||
return this.formatResult(result);
|
||||
} catch (error) {
|
||||
return this.formatError(error, `Error handling ${method} ${path}`);
|
||||
}
|
||||
}
|
||||
|
||||
// No route matched
|
||||
return textResponse(`Not found: ${method} ${path}`, 404);
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a handler result into a response.
|
||||
* Follows the same patterns as the server's apiResultHandler.
|
||||
*/
|
||||
private formatResult(result: unknown): BrowserResponse {
|
||||
// Handle [statusCode, response] format
|
||||
if (Array.isArray(result) && result.length > 0 && Number.isInteger(result[0])) {
|
||||
const [statusCode, response] = result;
|
||||
return jsonResponse(response, statusCode);
|
||||
}
|
||||
|
||||
// Handle undefined (no content) - 204 should have no body
|
||||
if (result === undefined) {
|
||||
return {
|
||||
status: 204,
|
||||
headers: {},
|
||||
body: null
|
||||
};
|
||||
}
|
||||
|
||||
// Default: JSON response with 200
|
||||
return jsonResponse(result, 200);
|
||||
}
|
||||
|
||||
/**
|
||||
* Format an error into a response.
|
||||
*/
|
||||
private formatError(error: unknown, context: string): BrowserResponse {
|
||||
console.error('[Router] Handler error:', context, error);
|
||||
|
||||
// Check for known error types
|
||||
if (error && typeof error === 'object') {
|
||||
const err = error as { constructor?: { name?: string }; message?: string };
|
||||
|
||||
if (err.constructor?.name === 'NotFoundError') {
|
||||
return jsonResponse({ message: err.message || 'Not found' }, 404);
|
||||
}
|
||||
|
||||
if (err.constructor?.name === 'ValidationError') {
|
||||
return jsonResponse({ message: err.message || 'Validation error' }, 400);
|
||||
}
|
||||
}
|
||||
|
||||
// Generic error
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
return jsonResponse({ message }, 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new router instance.
|
||||
*/
|
||||
export function createRouter(): BrowserRouter {
|
||||
return new BrowserRouter();
|
||||
}
|
||||
98
apps/client-standalone/src/lightweight/browser_routes.ts
Normal file
98
apps/client-standalone/src/lightweight/browser_routes.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
/**
|
||||
* Browser route definitions.
|
||||
* This integrates with the shared route builder from @triliumnext/core.
|
||||
*/
|
||||
|
||||
import { BootstrapDefinition } from '@triliumnext/commons';
|
||||
import { getSharedBootstrapItems, 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) {
|
||||
return (req: BrowserRequest) => {
|
||||
// Create an Express-like request object
|
||||
const expressLikeReq = {
|
||||
params: req.params,
|
||||
query: req.query,
|
||||
body: req.body
|
||||
};
|
||||
return handler(expressLikeReq);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an apiRoute function compatible with buildSharedApiRoutes.
|
||||
* This bridges the core's route registration to the BrowserRouter.
|
||||
*/
|
||||
function createApiRoute(router: BrowserRouter) {
|
||||
return (method: HttpMethod, path: string, handler: (req: any) => unknown) => {
|
||||
router.register(method, path, wrapHandler(handler));
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Register all API routes on the browser router using the shared builder.
|
||||
*
|
||||
* @param router - The browser router instance
|
||||
*/
|
||||
export function registerRoutes(router: BrowserRouter): void {
|
||||
const apiRoute = createApiRoute(router);
|
||||
routes.buildSharedApiRoutes(apiRoute);
|
||||
apiRoute('get', '/bootstrap', bootstrapRoute);
|
||||
|
||||
// Dummy routes for compatibility.
|
||||
apiRoute("get", "/api/script/widgets", () => []);
|
||||
apiRoute("get", "/api/script/startup", () => []);
|
||||
apiRoute("get", "/api/system-checks", () => ({ isCpuArchMismatch: false }));
|
||||
apiRoute("get", "/api/search/:searchString", () => []);
|
||||
apiRoute("get", "/api/search-templates", () => []);
|
||||
apiRoute("get", "/api/autocomplete", () => []);
|
||||
}
|
||||
|
||||
function bootstrapRoute() {
|
||||
|
||||
const 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: true,
|
||||
|
||||
// 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;
|
||||
}
|
||||
46
apps/client-standalone/src/lightweight/cls_provider.ts
Normal file
46
apps/client-standalone/src/lightweight/cls_provider.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { ExecutionContext } from "@triliumnext/core";
|
||||
|
||||
export default class BrowserExecutionContext implements ExecutionContext {
|
||||
private store: Map<string, any> | null = null;
|
||||
|
||||
get<T = any>(key: string): T | undefined {
|
||||
return this.store?.get(key);
|
||||
}
|
||||
|
||||
set(key: string, value: any): void {
|
||||
if (!this.store) {
|
||||
throw new Error("ExecutionContext not initialized");
|
||||
}
|
||||
this.store.set(key, value);
|
||||
}
|
||||
|
||||
reset(): void {
|
||||
this.store = null;
|
||||
}
|
||||
|
||||
init<T>(callback: () => T): T {
|
||||
// Create a fresh context for this request
|
||||
const prev = this.store;
|
||||
this.store = new Map();
|
||||
|
||||
try {
|
||||
const result = callback();
|
||||
|
||||
// If the result is a Promise, we need to handle cleanup after it resolves
|
||||
if (result && typeof result === 'object' && 'then' in result && 'catch' in result) {
|
||||
const promise = result as unknown as Promise<any>;
|
||||
return promise.finally(() => {
|
||||
this.store = prev;
|
||||
}) as T;
|
||||
} else {
|
||||
// Synchronous result, clean up immediately
|
||||
this.store = prev;
|
||||
return result;
|
||||
}
|
||||
} catch (error) {
|
||||
// Always clean up on error (for synchronous errors)
|
||||
this.store = prev;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
145
apps/client-standalone/src/lightweight/crypto_provider.ts
Normal file
145
apps/client-standalone/src/lightweight/crypto_provider.ts
Normal file
@@ -0,0 +1,145 @@
|
||||
import type { CryptoProvider } from "@triliumnext/core";
|
||||
import { sha1 } from "js-sha1";
|
||||
import { sha512 } from "js-sha512";
|
||||
|
||||
interface Cipher {
|
||||
update(data: Uint8Array): Uint8Array;
|
||||
final(): Uint8Array;
|
||||
}
|
||||
|
||||
const CHARS = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
|
||||
|
||||
/**
|
||||
* Crypto provider for browser environments using the Web Crypto API.
|
||||
*/
|
||||
export default class BrowserCryptoProvider implements CryptoProvider {
|
||||
|
||||
createHash(algorithm: "sha1" | "sha512", content: string | Uint8Array): Uint8Array {
|
||||
const data = typeof content === "string" ? content :
|
||||
new TextDecoder().decode(content);
|
||||
|
||||
const hexHash = algorithm === "sha1" ? sha1(data) : sha512(data);
|
||||
|
||||
// Convert hex string to Uint8Array
|
||||
const bytes = new Uint8Array(hexHash.length / 2);
|
||||
for (let i = 0; i < hexHash.length; i += 2) {
|
||||
bytes[i / 2] = parseInt(hexHash.substr(i, 2), 16);
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
|
||||
createCipheriv(algorithm: "aes-128-cbc", key: Uint8Array, iv: Uint8Array): Cipher {
|
||||
// Web Crypto API doesn't support streaming cipher like Node.js
|
||||
// We need to implement a wrapper that collects data and encrypts on final()
|
||||
return new WebCryptoCipher(algorithm, key, iv, "encrypt");
|
||||
}
|
||||
|
||||
createDecipheriv(algorithm: "aes-128-cbc", key: Uint8Array, iv: Uint8Array): Cipher {
|
||||
return new WebCryptoCipher(algorithm, key, iv, "decrypt");
|
||||
}
|
||||
|
||||
randomBytes(size: number): Uint8Array {
|
||||
const bytes = new Uint8Array(size);
|
||||
crypto.getRandomValues(bytes);
|
||||
return bytes;
|
||||
}
|
||||
|
||||
randomString(length: number): string {
|
||||
const bytes = this.randomBytes(length);
|
||||
let result = "";
|
||||
for (let i = 0; i < length; i++) {
|
||||
result += CHARS[bytes[i] % CHARS.length];
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A cipher implementation that wraps Web Crypto API.
|
||||
* Note: This buffers all data until final() is called, which differs from
|
||||
* Node.js's streaming cipher behavior.
|
||||
*/
|
||||
class WebCryptoCipher implements Cipher {
|
||||
private chunks: Uint8Array[] = [];
|
||||
private algorithm: string;
|
||||
private key: Uint8Array;
|
||||
private iv: Uint8Array;
|
||||
private mode: "encrypt" | "decrypt";
|
||||
private finalized = false;
|
||||
|
||||
constructor(
|
||||
algorithm: "aes-128-cbc",
|
||||
key: Uint8Array,
|
||||
iv: Uint8Array,
|
||||
mode: "encrypt" | "decrypt"
|
||||
) {
|
||||
this.algorithm = algorithm;
|
||||
this.key = key;
|
||||
this.iv = iv;
|
||||
this.mode = mode;
|
||||
}
|
||||
|
||||
update(data: Uint8Array): Uint8Array {
|
||||
if (this.finalized) {
|
||||
throw new Error("Cipher has already been finalized");
|
||||
}
|
||||
// Buffer the data - Web Crypto doesn't support streaming
|
||||
this.chunks.push(data);
|
||||
// Return empty array since we process everything in final()
|
||||
return new Uint8Array(0);
|
||||
}
|
||||
|
||||
final(): Uint8Array {
|
||||
if (this.finalized) {
|
||||
throw new Error("Cipher has already been finalized");
|
||||
}
|
||||
this.finalized = true;
|
||||
|
||||
// Web Crypto API is async, but we need sync behavior
|
||||
// This is a fundamental limitation that requires architectural changes
|
||||
// For now, throw an error directing users to use async methods
|
||||
throw new Error(
|
||||
"Synchronous cipher finalization not available in browser. " +
|
||||
"The Web Crypto API is async-only. Use finalizeAsync() instead."
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Async version that actually performs the encryption/decryption.
|
||||
*/
|
||||
async finalizeAsync(): Promise<Uint8Array> {
|
||||
if (this.finalized) {
|
||||
throw new Error("Cipher has already been finalized");
|
||||
}
|
||||
this.finalized = true;
|
||||
|
||||
// Concatenate all chunks
|
||||
const totalLength = this.chunks.reduce((sum, chunk) => sum + chunk.length, 0);
|
||||
const data = new Uint8Array(totalLength);
|
||||
let offset = 0;
|
||||
for (const chunk of this.chunks) {
|
||||
data.set(chunk, offset);
|
||||
offset += chunk.length;
|
||||
}
|
||||
|
||||
// Copy key and iv to ensure they're plain ArrayBuffer-backed
|
||||
const keyBuffer = new Uint8Array(this.key);
|
||||
const ivBuffer = new Uint8Array(this.iv);
|
||||
|
||||
// Import the key
|
||||
const cryptoKey = await crypto.subtle.importKey(
|
||||
"raw",
|
||||
keyBuffer,
|
||||
{ name: "AES-CBC" },
|
||||
false,
|
||||
[this.mode]
|
||||
);
|
||||
|
||||
// Perform encryption/decryption
|
||||
const result = this.mode === "encrypt"
|
||||
? await crypto.subtle.encrypt({ name: "AES-CBC", iv: ivBuffer }, cryptoKey, data)
|
||||
: await crypto.subtle.decrypt({ name: "AES-CBC", iv: ivBuffer }, cryptoKey, data);
|
||||
|
||||
return new Uint8Array(result);
|
||||
}
|
||||
}
|
||||
6538
apps/client-standalone/src/lightweight/db.sql
Normal file
6538
apps/client-standalone/src/lightweight/db.sql
Normal file
File diff suppressed because one or more lines are too long
90
apps/client-standalone/src/lightweight/messaging_provider.ts
Normal file
90
apps/client-standalone/src/lightweight/messaging_provider.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
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 = [];
|
||||
}
|
||||
}
|
||||
618
apps/client-standalone/src/lightweight/sql_provider.ts
Normal file
618
apps/client-standalone/src/lightweight/sql_provider.ts
Normal file
@@ -0,0 +1,618 @@
|
||||
import type { DatabaseProvider, RunResult, Statement, Transaction } from "@triliumnext/core";
|
||||
import sqlite3InitModule from "@sqlite.org/sqlite-wasm";
|
||||
import type { BindableValue } from "@sqlite.org/sqlite-wasm";
|
||||
import demoDbSql from "./db.sql?raw";
|
||||
|
||||
// Type definitions for SQLite WASM (the library doesn't export these directly)
|
||||
type Sqlite3Module = Awaited<ReturnType<typeof sqlite3InitModule>>;
|
||||
type Sqlite3Database = InstanceType<Sqlite3Module["oo1"]["DB"]>;
|
||||
type Sqlite3PreparedStatement = ReturnType<Sqlite3Database["prepare"]>;
|
||||
|
||||
/**
|
||||
* Wraps an SQLite WASM PreparedStatement to match the Statement interface
|
||||
* expected by trilium-core.
|
||||
*/
|
||||
class WasmStatement implements Statement {
|
||||
private isRawMode = false;
|
||||
private isPluckMode = false;
|
||||
private isFinalized = false;
|
||||
|
||||
constructor(
|
||||
private stmt: Sqlite3PreparedStatement,
|
||||
private db: Sqlite3Database,
|
||||
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
|
||||
};
|
||||
} 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.");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { LOCALE_IDS } from "@triliumnext/commons";
|
||||
import i18next from "i18next";
|
||||
import I18NextHttpBackend from "i18next-http-backend";
|
||||
|
||||
export default async function translationProvider(locale: LOCALE_IDS) {
|
||||
await i18next.use(I18NextHttpBackend).init({
|
||||
lng: locale,
|
||||
fallbackLng: "en",
|
||||
ns: "server",
|
||||
backend: {
|
||||
loadPath: "server-assets/translations/{{lng}}/{{ns}}.json"
|
||||
},
|
||||
returnEmptyString: false,
|
||||
debug: true
|
||||
});
|
||||
}
|
||||
88
apps/client-standalone/src/local-bridge.ts
Normal file
88
apps/client-standalone/src/local-bridge.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
let localWorker: Worker | null = null;
|
||||
const pending = new Map();
|
||||
|
||||
export function startLocalServerWorker() {
|
||||
if (localWorker) return localWorker;
|
||||
|
||||
localWorker = new Worker(new URL("./local-server-worker.js", import.meta.url), { type: "module" });
|
||||
|
||||
// Handle worker errors during initialization
|
||||
localWorker.onerror = (event) => {
|
||||
console.error("[LocalBridge] Worker error:", event);
|
||||
// Reject all pending requests
|
||||
for (const [, 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
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
207
apps/client-standalone/src/local-server-worker.ts
Normal file
207
apps/client-standalone/src/local-server-worker.ts
Normal file
@@ -0,0 +1,207 @@
|
||||
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");
|
||||
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)
|
||||
});
|
||||
}
|
||||
};
|
||||
84
apps/client-standalone/src/main.ts
Normal file
84
apps/client-standalone/src/main.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
import { attachServiceWorkerBridge, startLocalServerWorker } from "./local-bridge.js";
|
||||
|
||||
async function waitForServiceWorkerControl(): Promise<void> {
|
||||
if (!("serviceWorker" in navigator)) {
|
||||
throw new Error("Service Worker not supported in this browser");
|
||||
}
|
||||
|
||||
// If already controlling, we're good
|
||||
if (navigator.serviceWorker.controller) {
|
||||
console.log("[Bootstrap] Service worker already controlling");
|
||||
return;
|
||||
}
|
||||
|
||||
console.log("[Bootstrap] Waiting for service worker to take control...");
|
||||
|
||||
// Register service worker
|
||||
await navigator.serviceWorker.register("./sw.js", { scope: "/" });
|
||||
|
||||
// Wait for it to be ready (installed + activated)
|
||||
await navigator.serviceWorker.ready;
|
||||
|
||||
// Check if we're now controlling
|
||||
if (navigator.serviceWorker.controller) {
|
||||
console.log("[Bootstrap] Service worker now controlling");
|
||||
return;
|
||||
}
|
||||
|
||||
// If not controlling yet, we need to reload the page for SW to take control
|
||||
// This is standard PWA behavior on first install
|
||||
console.log("[Bootstrap] Service worker installed but not controlling yet - reloading page");
|
||||
|
||||
// Wait a tiny bit for SW to fully activate
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
|
||||
// Reload to let SW take control
|
||||
window.location.reload();
|
||||
|
||||
// Throw to stop execution (page will reload)
|
||||
throw new Error("Reloading for service worker activation");
|
||||
}
|
||||
|
||||
async function bootstrap() {
|
||||
/* fixes https://github.com/webpack/webpack/issues/10035 */
|
||||
window.global = globalThis;
|
||||
|
||||
try {
|
||||
// 1) Start local worker ASAP (so /bootstrap is fast)
|
||||
startLocalServerWorker();
|
||||
|
||||
// 2) Bridge SW -> local worker
|
||||
attachServiceWorkerBridge();
|
||||
|
||||
// 3) Wait for service worker to control the page (may reload on first install)
|
||||
await waitForServiceWorkerControl();
|
||||
|
||||
await loadScripts();
|
||||
} catch (err) {
|
||||
// If error is from reload, it will stop here (page reloads)
|
||||
// Otherwise, show error to user
|
||||
if (err instanceof Error && err.message.includes("Reloading")) {
|
||||
// Page is reloading, do nothing
|
||||
return;
|
||||
}
|
||||
|
||||
console.error("[Bootstrap] Fatal error:", err);
|
||||
document.body.innerHTML = `
|
||||
<div style="padding: 40px; max-width: 600px; margin: 0 auto; font-family: system-ui, sans-serif;">
|
||||
<h1 style="color: #d32f2f;">Failed to Initialize</h1>
|
||||
<p>The application failed to start. Please check the browser console for details.</p>
|
||||
<pre style="background: #f5f5f5; padding: 16px; border-radius: 4px; overflow: auto;">${err instanceof Error ? err.message : String(err)}</pre>
|
||||
<button onclick="location.reload()" style="padding: 12px 24px; background: #1976d2; color: white; border: none; border-radius: 4px; cursor: pointer; font-size: 16px;">
|
||||
Reload Page
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
document.body.style.display = "block";
|
||||
}
|
||||
}
|
||||
|
||||
async function loadScripts() {
|
||||
await import("../../client/src/index.js");
|
||||
}
|
||||
|
||||
bootstrap();
|
||||
185
apps/client-standalone/src/sw.ts
Normal file
185
apps/client-standalone/src/sw.ts
Normal file
@@ -0,0 +1,185 @@
|
||||
// public/sw.js
|
||||
const VERSION = "localserver-v1.4";
|
||||
const STATIC_CACHE = `static-${VERSION}`;
|
||||
|
||||
// Check if running in dev mode (passed via URL parameter)
|
||||
const isDev = true;
|
||||
|
||||
if (isDev) {
|
||||
console.log('[Service Worker] Running in DEV mode - caching disabled');
|
||||
}
|
||||
|
||||
// Adjust these to your routes:
|
||||
const LOCAL_FIRST_PREFIXES = [
|
||||
"/bootstrap",
|
||||
"/api/",
|
||||
"/sync/",
|
||||
"/search/"
|
||||
];
|
||||
|
||||
// Optional: basic precache list (keep small; you can expand later)
|
||||
const PRECACHE_URLS = [
|
||||
// "/",
|
||||
// "/index.html",
|
||||
// "/manifest.webmanifest",
|
||||
// "/favicon.ico",
|
||||
];
|
||||
|
||||
self.addEventListener("install", (event) => {
|
||||
event.waitUntil((async () => {
|
||||
// Skip precaching in dev mode
|
||||
if (!isDev) {
|
||||
const cache = await caches.open(STATIC_CACHE);
|
||||
await cache.addAll(PRECACHE_URLS);
|
||||
}
|
||||
self.skipWaiting();
|
||||
})());
|
||||
});
|
||||
|
||||
self.addEventListener("activate", (event) => {
|
||||
event.waitUntil((async () => {
|
||||
// Cleanup old caches
|
||||
const keys = await caches.keys();
|
||||
await Promise.all(keys.map((k) => (k === STATIC_CACHE ? Promise.resolve() : caches.delete(k))));
|
||||
await self.clients.claim();
|
||||
})());
|
||||
});
|
||||
|
||||
function isLocalFirst(url) {
|
||||
return LOCAL_FIRST_PREFIXES.some((p) => url.pathname.startsWith(p));
|
||||
}
|
||||
|
||||
async function cacheFirst(request) {
|
||||
// In dev mode, always bypass cache
|
||||
if (isDev) {
|
||||
return fetch(request);
|
||||
}
|
||||
|
||||
const cache = await caches.open(STATIC_CACHE);
|
||||
const cached = await cache.match(request);
|
||||
if (cached) return cached;
|
||||
|
||||
const fresh = await fetch(request);
|
||||
// Cache only successful GETs
|
||||
if (request.method === "GET" && fresh.ok) cache.put(request, fresh.clone());
|
||||
return fresh;
|
||||
}
|
||||
|
||||
async function networkFirst(request) {
|
||||
// In dev mode, always bypass cache
|
||||
if (isDev) {
|
||||
return fetch(request);
|
||||
}
|
||||
|
||||
const cache = await caches.open(STATIC_CACHE);
|
||||
try {
|
||||
const fresh = await fetch(request);
|
||||
// Cache only successful GETs
|
||||
if (request.method === "GET" && fresh.ok) cache.put(request, fresh.clone());
|
||||
return fresh;
|
||||
} catch (error) {
|
||||
// Fallback to cache if network fails
|
||||
const cached = await cache.match(request);
|
||||
if (cached) return cached;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function forwardToClientLocalServer(request, clientId) {
|
||||
// Find a client to handle the request (prefer the initiating client if available)
|
||||
let client = clientId ? await self.clients.get(clientId) : null;
|
||||
|
||||
if (!client) {
|
||||
const all = await self.clients.matchAll({ type: "window", includeUncontrolled: true });
|
||||
client = all[0] || null;
|
||||
}
|
||||
|
||||
// If no page is available, fall back to network
|
||||
if (!client) return fetch(request);
|
||||
|
||||
const reqUrl = request.url;
|
||||
const headersObj = {};
|
||||
for (const [k, v] of request.headers.entries()) headersObj[k] = v;
|
||||
|
||||
const body = (request.method === "GET" || request.method === "HEAD")
|
||||
? null
|
||||
: await request.arrayBuffer();
|
||||
|
||||
const id = crypto.randomUUID();
|
||||
const channel = new MessageChannel();
|
||||
|
||||
const responsePromise = new Promise((resolve, reject) => {
|
||||
const timeout = setTimeout(() => {
|
||||
reject(new Error("Local server timeout"));
|
||||
}, 30_000);
|
||||
|
||||
channel.port1.onmessage = (event) => {
|
||||
clearTimeout(timeout);
|
||||
resolve(event.data);
|
||||
};
|
||||
channel.port1.onmessageerror = () => {
|
||||
clearTimeout(timeout);
|
||||
reject(new Error("Local server message error"));
|
||||
};
|
||||
});
|
||||
|
||||
// Send to the client with a reply port
|
||||
client.postMessage({
|
||||
type: "LOCAL_FETCH",
|
||||
id,
|
||||
request: {
|
||||
url: reqUrl,
|
||||
method: request.method,
|
||||
headers: headersObj,
|
||||
body // ArrayBuffer or null
|
||||
}
|
||||
}, [channel.port2]);
|
||||
|
||||
const localResp = await responsePromise;
|
||||
|
||||
if (!localResp || localResp.type !== "LOCAL_FETCH_RESPONSE" || localResp.id !== id) {
|
||||
// Protocol mismatch; fall back
|
||||
return fetch(request);
|
||||
}
|
||||
|
||||
// localResp.response: { status, headers, body }
|
||||
const { status, headers, body: respBody } = localResp.response;
|
||||
|
||||
const respHeaders = new Headers();
|
||||
if (headers) {
|
||||
for (const [k, v] of Object.entries(headers)) respHeaders.set(k, String(v));
|
||||
}
|
||||
|
||||
return new Response(respBody ? respBody : null, {
|
||||
status: status || 200,
|
||||
headers: respHeaders
|
||||
});
|
||||
}
|
||||
|
||||
self.addEventListener("fetch", (event) => {
|
||||
const url = new URL(event.request.url);
|
||||
|
||||
// Only handle same-origin
|
||||
if (url.origin !== self.location.origin) return;
|
||||
|
||||
// HTML files: network-first to ensure updates are reflected immediately
|
||||
if (event.request.mode === "navigate" || url.pathname.endsWith(".html")) {
|
||||
event.respondWith(networkFirst(event.request));
|
||||
return;
|
||||
}
|
||||
|
||||
// Static assets: cache-first for performance
|
||||
if (event.request.method === "GET" && !isLocalFirst(url)) {
|
||||
event.respondWith(cacheFirst(event.request));
|
||||
return;
|
||||
}
|
||||
|
||||
// API-ish: local-first via bridge
|
||||
if (isLocalFirst(url)) {
|
||||
event.respondWith(forwardToClientLocalServer(event.request, event.clientId));
|
||||
return;
|
||||
}
|
||||
|
||||
// Default
|
||||
event.respondWith(fetch(event.request));
|
||||
});
|
||||
31
apps/client-standalone/src/vite-env.d.ts
vendored
Normal file
31
apps/client-standalone/src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1,31 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
interface ImportMetaEnv {
|
||||
readonly VITE_APP_TITLE: string
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
readonly env: ImportMetaEnv
|
||||
}
|
||||
|
||||
interface Window {
|
||||
glob: {
|
||||
assetPath: string;
|
||||
themeCssUrl?: string;
|
||||
themeUseNextAsBase?: string;
|
||||
iconPackCss: string;
|
||||
device: string;
|
||||
headingStyle: string;
|
||||
layoutOrientation: string;
|
||||
platform: string;
|
||||
isElectron: boolean;
|
||||
hasNativeTitleBar: boolean;
|
||||
hasBackgroundEffects: boolean;
|
||||
currentLocale: {
|
||||
id: string;
|
||||
rtl: boolean;
|
||||
};
|
||||
activeDialog: any;
|
||||
};
|
||||
global: typeof globalThis;
|
||||
}
|
||||
26
apps/client-standalone/tsconfig.app.json
Normal file
26
apps/client-standalone/tsconfig.app.json
Normal file
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"lib": [
|
||||
"ES2022",
|
||||
"dom",
|
||||
"dom.iterable"
|
||||
],
|
||||
"skipLibCheck": true,
|
||||
"types": [
|
||||
"vite/client"
|
||||
],
|
||||
"jsx": "react-jsx",
|
||||
"jsxImportSource": "preact"
|
||||
},
|
||||
"include": [
|
||||
"src/**/*",
|
||||
"../client/src/**/*"
|
||||
],
|
||||
"exclude": [
|
||||
"src/**/*.spec.ts",
|
||||
"src/**/*.test.ts",
|
||||
"../client/src/**/*.spec.ts",
|
||||
"../client/src/**/*.test.ts"
|
||||
]
|
||||
}
|
||||
7
apps/client-standalone/tsconfig.json
Normal file
7
apps/client-standalone/tsconfig.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.spec.json" }
|
||||
]
|
||||
}
|
||||
18
apps/client-standalone/tsconfig.spec.json
Normal file
18
apps/client-standalone/tsconfig.spec.json
Normal file
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"lib": [
|
||||
"ES2022",
|
||||
"dom",
|
||||
"dom.iterable"
|
||||
],
|
||||
"types": [
|
||||
"vitest/globals",
|
||||
"happy-dom"
|
||||
]
|
||||
},
|
||||
"include": [
|
||||
"src/**/*.spec.ts",
|
||||
"src/**/*.test.ts"
|
||||
]
|
||||
}
|
||||
189
apps/client-standalone/vite.config.mts
Normal file
189
apps/client-standalone/vite.config.mts
Normal file
@@ -0,0 +1,189 @@
|
||||
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'),
|
||||
'local-server-worker': join(__dirname, 'src', 'local-server-worker.ts')
|
||||
},
|
||||
output: {
|
||||
entryFileNames: (chunkInfo) => {
|
||||
// Service worker and other workers should be at root level
|
||||
if (chunkInfo.name === 'sw' || chunkInfo.name === 'local-server-worker') {
|
||||
return '[name].js';
|
||||
}
|
||||
return 'src/[name].js';
|
||||
},
|
||||
chunkFileNames: "src/[name].js",
|
||||
assetFileNames: "src/[name].[ext]"
|
||||
}
|
||||
}
|
||||
},
|
||||
test: {
|
||||
environment: "happy-dom"
|
||||
},
|
||||
define: {
|
||||
"process.env.IS_PREACT": JSON.stringify("true"),
|
||||
}
|
||||
}));
|
||||
@@ -25,10 +25,37 @@ async function setupGlob() {
|
||||
window.global = globalThis; /* fixes https://github.com/webpack/webpack/issues/10035 */
|
||||
window.glob = {
|
||||
...json,
|
||||
activeDialog: null
|
||||
activeDialog: null,
|
||||
device: json.device || getDevice()
|
||||
};
|
||||
}
|
||||
|
||||
function getDevice() {
|
||||
// Respect user's manual override via URL.
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
if (urlParams.has("print")) {
|
||||
return "print";
|
||||
} else if (urlParams.has("desktop")) {
|
||||
return "desktop";
|
||||
} else if (urlParams.has("mobile")) {
|
||||
return "mobile";
|
||||
}
|
||||
|
||||
const deviceCookie = document.cookie.split("; ").find(row => row.startsWith("trilium-device="))?.split("=")[1];
|
||||
if (deviceCookie === "desktop" || deviceCookie === "mobile") return deviceCookie;
|
||||
return isMobile() ? "mobile" : "desktop";
|
||||
}
|
||||
|
||||
// https://stackoverflow.com/a/73731646/944162
|
||||
function isMobile() {
|
||||
const mQ = matchMedia?.("(pointer:coarse)");
|
||||
if (mQ?.media === "(pointer:coarse)") return !!mQ.matches;
|
||||
|
||||
if ("orientation" in window) return true;
|
||||
const userAgentsRegEx = /\b(Android|iPhone|iPad|iPod|Windows Phone|BlackBerry|webOS|IEMobile)\b/i;
|
||||
return userAgentsRegEx.test(navigator.userAgent);
|
||||
}
|
||||
|
||||
async function loadBootstrapCss() {
|
||||
// We have to selectively import Bootstrap CSS based on text direction.
|
||||
if (glob.isRtl) {
|
||||
@@ -91,10 +118,16 @@ function setBodyAttributes() {
|
||||
}
|
||||
|
||||
async function loadScripts() {
|
||||
if (glob.device === "mobile") {
|
||||
await import("./mobile.js");
|
||||
} else {
|
||||
await import("./desktop.js");
|
||||
switch (glob.device) {
|
||||
case "mobile":
|
||||
await import("./mobile.js");
|
||||
break;
|
||||
case "print":
|
||||
await import("./print.js");
|
||||
break;
|
||||
case "desktop":
|
||||
default:
|
||||
await import("./desktop.js");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ import { formatCodeBlocks } from "./syntax_highlight.js";
|
||||
|
||||
export default function renderDoc(note: FNote) {
|
||||
return new Promise<JQuery<HTMLElement>>((resolve) => {
|
||||
let docName = note.getLabelValue("docName");
|
||||
const docName = note.getLabelValue("docName");
|
||||
const $content = $("<div>");
|
||||
|
||||
if (docName) {
|
||||
@@ -16,7 +16,7 @@ export default function renderDoc(note: FNote) {
|
||||
if (status === "error") {
|
||||
const fallbackUrl = getUrl(docName, "en");
|
||||
$content.load(fallbackUrl, async () => {
|
||||
await processContent(fallbackUrl, $content)
|
||||
await processContent(fallbackUrl, $content);
|
||||
resolve($content);
|
||||
});
|
||||
return;
|
||||
@@ -37,9 +37,9 @@ async function processContent(url: string, $content: JQuery<HTMLElement>) {
|
||||
const dir = url.substring(0, url.lastIndexOf("/"));
|
||||
|
||||
// Images are relative to the docnote but that will not work when rendered in the application since the path breaks.
|
||||
$content.find("img").each((i, el) => {
|
||||
$content.find("img").each((_i, el) => {
|
||||
const $img = $(el);
|
||||
$img.attr("src", dir + "/" + $img.attr("src"));
|
||||
$img.attr("src", `${dir}/${$img.attr("src")}`);
|
||||
});
|
||||
|
||||
formatCodeBlocks($content);
|
||||
@@ -51,7 +51,17 @@ async function processContent(url: string, $content: JQuery<HTMLElement>) {
|
||||
function getUrl(docNameValue: string, language: string) {
|
||||
// Cannot have spaces in the URL due to how JQuery.load works.
|
||||
docNameValue = docNameValue.replaceAll(" ", "%20");
|
||||
|
||||
const basePath = window.glob.isDev ? window.glob.assetPath + "/.." : window.glob.assetPath;
|
||||
return `${basePath}/doc_notes/${language}/${docNameValue}.html`;
|
||||
// The user guide is available only in English, so make sure we are requesting correctly since 404s in standalone client are treated differently.
|
||||
if (docNameValue.includes("User%20Guide")) language = "en";
|
||||
return `${getBasePath()}/doc_notes/${language}/${docNameValue}.html`;
|
||||
}
|
||||
|
||||
function getBasePath() {
|
||||
if (window.glob.isStandalone) {
|
||||
return `server-assets`;
|
||||
}
|
||||
if (window.glob.isDev) {
|
||||
return `${window.glob.assetPath }/..`;
|
||||
}
|
||||
return window.glob.assetPath;
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ const SELECTED_NOTE_PATH_KEY = "data-note-path";
|
||||
const SELECTED_EXTERNAL_LINK_KEY = "data-external-link";
|
||||
|
||||
// To prevent search lag when there are a large number of notes, set a delay based on the number of notes to avoid jitter.
|
||||
const notesCount = await server.get<number>(`autocomplete/notesCount`);
|
||||
const notesCount = 10000; // TODO: Replace with dynamic count from becca once available.
|
||||
let debounceTimeoutId: ReturnType<typeof setTimeout>;
|
||||
|
||||
function getSearchDelay(notesCount: number): number {
|
||||
|
||||
@@ -133,6 +133,8 @@ export function isElectron() {
|
||||
return !!(window && window.process && window.process.type);
|
||||
}
|
||||
|
||||
export const isStandalone = window.glob.isStandalone;
|
||||
|
||||
/**
|
||||
* Returns `true` if the client is running as a PWA, otherwise `false`.
|
||||
*/
|
||||
@@ -814,7 +816,7 @@ function compareVersions(v1: string, v2: string): number {
|
||||
/**
|
||||
* Compares two semantic version strings and returns `true` if the latest version is greater than the current version.
|
||||
*/
|
||||
function isUpdateAvailable(latestVersion: string | null | undefined, currentVersion: string): boolean {
|
||||
export function isUpdateAvailable(latestVersion: string | null | undefined, currentVersion: string): boolean {
|
||||
if (!latestVersion) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -304,7 +304,7 @@ async function sendPing() {
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
if (glob.device === "print") return;
|
||||
if (glob.device === "print" || glob.isStandalone) return;
|
||||
|
||||
ws = connectWebSocket();
|
||||
|
||||
|
||||
30
apps/client/src/types.d.ts
vendored
30
apps/client/src/types.d.ts
vendored
@@ -1,4 +1,4 @@
|
||||
import { IconRegistry, Locale } from "@triliumnext/commons";
|
||||
import { BootstrapDefinition } from "@triliumnext/commons";
|
||||
|
||||
import appContext, { AppContext } from "./components/app_context";
|
||||
import type FNote from "./entities/fnote";
|
||||
@@ -15,10 +15,9 @@ interface ElectronProcess {
|
||||
platform: string;
|
||||
}
|
||||
|
||||
interface CustomGlobals {
|
||||
interface CustomGlobals extends BootstrapDefinition {
|
||||
isDesktop: typeof utils.isDesktop;
|
||||
isMobile: typeof utils.isMobile;
|
||||
device: "mobile" | "desktop" | "print";
|
||||
getComponentByEl: typeof appContext.getComponentByEl;
|
||||
getHeaders: typeof server.getHeaders;
|
||||
getReferenceLinkTitle: (href: string) => Promise<string>;
|
||||
@@ -31,32 +30,7 @@ interface CustomGlobals {
|
||||
SEARCH_HELP_TEXT: string;
|
||||
activeDialog: JQuery<HTMLElement> | null;
|
||||
componentId: string;
|
||||
csrfToken: string;
|
||||
baseApiUrl: string;
|
||||
isProtectedSessionAvailable: boolean;
|
||||
isDev: boolean;
|
||||
isMainWindow: boolean;
|
||||
maxEntityChangeIdAtLoad: number;
|
||||
maxEntityChangeSyncIdAtLoad: number;
|
||||
assetPath: string;
|
||||
appPath: string;
|
||||
instanceName: string;
|
||||
appCssNoteIds: string[];
|
||||
triliumVersion: string;
|
||||
TRILIUM_SAFE_MODE: boolean;
|
||||
platform?: typeof process.platform;
|
||||
linter: typeof lint;
|
||||
hasNativeTitleBar: boolean;
|
||||
hasBackgroundEffects: boolean;
|
||||
isElectron: boolean;
|
||||
isRtl: boolean;
|
||||
iconRegistry: IconRegistry;
|
||||
themeCssUrl: string;
|
||||
themeUseNextAsBase?: "next" | "next-light" | "next-dark";
|
||||
iconPackCss: string;
|
||||
headingStyle: "plain" | "underline" | "markdown";
|
||||
layoutOrientation: "vertical" | "horizontal";
|
||||
currentLocale: Locale;
|
||||
}
|
||||
|
||||
type RequireMethod = (moduleName: string) => any;
|
||||
|
||||
@@ -8,7 +8,7 @@ import { CommandNames } from "../../components/app_context";
|
||||
import Component from "../../components/component";
|
||||
import { ExperimentalFeature, ExperimentalFeatureId, experimentalFeatures, isExperimentalFeatureEnabled, toggleExperimentalFeature } from "../../services/experimental_features";
|
||||
import { t } from "../../services/i18n";
|
||||
import utils, { dynamicRequire, isElectron, isMobile, reloadFrontendApp } from "../../services/utils";
|
||||
import utils, { dynamicRequire, isElectron, isMobile, isStandalone, reloadFrontendApp } from "../../services/utils";
|
||||
import Dropdown from "../react/Dropdown";
|
||||
import { FormDropdownDivider, FormDropdownSubmenu, FormListHeader, FormListItem } from "../react/FormList";
|
||||
import { useStaticTooltip, useStaticTooltipWithKeyboardShortcut, useTriliumOption, useTriliumOptionBool, useTriliumOptionInt } from "../react/hooks";
|
||||
@@ -249,7 +249,7 @@ function ToggleWindowOnTop() {
|
||||
function useTriliumUpdateStatus() {
|
||||
const [ latestVersion, setLatestVersion ] = useState<string>();
|
||||
const [ checkForUpdates ] = useTriliumOptionBool("checkForUpdates");
|
||||
const isUpdateAvailable = utils.isUpdateAvailable(latestVersion, glob.triliumVersion);
|
||||
const isUpdateAvailable = utils.isUpdateAvailable(latestVersion, window.glob.triliumVersion);
|
||||
|
||||
async function updateVersionStatus() {
|
||||
const RELEASES_API_URL = "https://api.github.com/repos/TriliumNext/Trilium/releases/latest";
|
||||
@@ -267,7 +267,7 @@ function useTriliumUpdateStatus() {
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!checkForUpdates) {
|
||||
if (!checkForUpdates || !isStandalone) {
|
||||
setLatestVersion(undefined);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
import Modal from "../react/Modal.js";
|
||||
import type { AppInfo } from "@triliumnext/commons";
|
||||
import type { CSSProperties } from "preact/compat";
|
||||
import { useState } from "preact/hooks";
|
||||
|
||||
import { t } from "../../services/i18n.js";
|
||||
import { formatDateTime } from "../../utils/formatters.js";
|
||||
import openService from "../../services/open.js";
|
||||
import server from "../../services/server.js";
|
||||
import utils from "../../services/utils.js";
|
||||
import openService from "../../services/open.js";
|
||||
import { useState } from "preact/hooks";
|
||||
import type { CSSProperties } from "preact/compat";
|
||||
import type { AppInfo } from "@triliumnext/commons";
|
||||
import { formatDateTime } from "../../utils/formatters.js";
|
||||
import { useTriliumEvent } from "../react/hooks.jsx";
|
||||
import Modal from "../react/Modal.js";
|
||||
|
||||
export default function AboutDialog() {
|
||||
const [appInfo, setAppInfo] = useState<AppInfo | null>(null);
|
||||
@@ -54,15 +55,15 @@ export default function AboutDialog() {
|
||||
<tr>
|
||||
<th>{t("about.build_revision")}</th>
|
||||
<td className="selectable-text">
|
||||
{appInfo?.buildRevision && <a className="tn-link build-revision external" href={`https://github.com/TriliumNext/Trilium/commit/${appInfo.buildRevision}`} target="_blank" style={forceWordBreak}>{appInfo.buildRevision}</a>}
|
||||
{appInfo?.buildRevision && <a className="tn-link build-revision external" href={`https://github.com/TriliumNext/Trilium/commit/${appInfo.buildRevision}`} target="_blank" style={forceWordBreak} rel="noreferrer">{appInfo.buildRevision}</a>}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
{ appInfo?.dataDirectory && <tr>
|
||||
<th>{t("about.data_directory")}</th>
|
||||
<td className="data-directory">
|
||||
{appInfo?.dataDirectory && (<DirectoryLink directory={appInfo.dataDirectory} style={forceWordBreak} />)}
|
||||
</td>
|
||||
</tr>
|
||||
</tr>}
|
||||
</tbody>
|
||||
</table>
|
||||
</Modal>
|
||||
@@ -76,8 +77,8 @@ function DirectoryLink({ directory, style }: { directory: string, style?: CSSPro
|
||||
openService.openDirectory(directory);
|
||||
};
|
||||
|
||||
return <a className="tn-link selectable-text" href="#" onClick={onClick} style={style}>{directory}</a>
|
||||
} else {
|
||||
return <span className="selectable-text" style={style}>{directory}</span>;
|
||||
}
|
||||
return <a className="tn-link selectable-text" href="#" onClick={onClick} style={style}>{directory}</a>;
|
||||
}
|
||||
return <span className="selectable-text" style={style}>{directory}</span>;
|
||||
|
||||
}
|
||||
|
||||
@@ -27,6 +27,7 @@ export default function RecentChangesDialog() {
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!ancestorNoteId) return;
|
||||
server.get<RecentChangeRow[]>(`recent-changes/${ancestorNoteId}`)
|
||||
.then(async (recentChanges) => {
|
||||
// preload all notes into cache
|
||||
|
||||
@@ -8,39 +8,44 @@ import Dropdown from "./react/Dropdown";
|
||||
|
||||
export default function SqlTableSchemas() {
|
||||
const { note } = useNoteContext();
|
||||
|
||||
const isEnabled = note?.mime === "text/x-sqlite;schema=trilium";
|
||||
return (
|
||||
<div className={`sql-table-schemas-widget ${!isEnabled ? "hidden-ext" : ""}`}>
|
||||
{isEnabled && <SqlTableSchemasContent />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SqlTableSchemasContent() {
|
||||
const [ schemas, setSchemas ] = useState<SchemaResponse[]>();
|
||||
|
||||
useEffect(() => {
|
||||
server.get<SchemaResponse[]>("sql/schema").then(setSchemas);
|
||||
}, []);
|
||||
|
||||
const isEnabled = note?.mime === "text/x-sqlite;schema=trilium" && schemas;
|
||||
return (
|
||||
<div className={`sql-table-schemas-widget ${!isEnabled ? "hidden-ext" : ""}`}>
|
||||
{isEnabled && (
|
||||
<>
|
||||
{t("sql_table_schemas.tables")}{": "}
|
||||
return schemas && (
|
||||
<>
|
||||
{t("sql_table_schemas.tables")}{": "}
|
||||
|
||||
<span class="sql-table-schemas">
|
||||
{schemas.map(({ name, columns }) => (
|
||||
<>
|
||||
<Dropdown text={name} noSelectButtonStyle hideToggleArrow
|
||||
>
|
||||
<table className="table-schema">
|
||||
{columns.map(column => (
|
||||
<tr>
|
||||
<td>{column.name}</td>
|
||||
<td>{column.type}</td>
|
||||
</tr>
|
||||
))}
|
||||
</table>
|
||||
</Dropdown>
|
||||
{" "}
|
||||
</>
|
||||
))}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<span className="sql-table-schemas">
|
||||
{schemas.map(({ name, columns }) => (
|
||||
<>
|
||||
<Dropdown text={name} noSelectButtonStyle hideToggleArrow
|
||||
>
|
||||
<table className="table-schema">
|
||||
{columns.map(column => (
|
||||
<tr>
|
||||
<td>{column.name}</td>
|
||||
<td>{column.type}</td>
|
||||
</tr>
|
||||
))}
|
||||
</table>
|
||||
</Dropdown>
|
||||
{" "}
|
||||
</>
|
||||
))}
|
||||
</span>
|
||||
</>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@ import froca from "../../../services/froca.js";
|
||||
import type LoadResults from "../../../services/load_results.js";
|
||||
import search from "../../../services/search.js";
|
||||
import type { TemplateDefinition } from "@triliumnext/ckeditor5";
|
||||
import appContext from "../../../components/app_context.js";
|
||||
import type FNote from "../../../entities/fnote.js";
|
||||
|
||||
interface TemplateData {
|
||||
@@ -21,20 +20,25 @@ const debouncedHandleContentUpdate = debounce(handleContentUpdate, 1000);
|
||||
* @returns the list of templates.
|
||||
*/
|
||||
export default async function getTemplates() {
|
||||
// Build the definitions and populate the cache.
|
||||
const snippets = await search.searchForNotes("#textSnippet");
|
||||
const definitions: TemplateDefinition[] = [];
|
||||
for (const snippet of snippets) {
|
||||
const { description } = await invalidateCacheFor(snippet);
|
||||
try {
|
||||
// Build the definitions and populate the cache.
|
||||
const snippets = await search.searchForNotes("#textSnippet");
|
||||
const definitions: TemplateDefinition[] = [];
|
||||
for (const snippet of snippets) {
|
||||
const { description } = await invalidateCacheFor(snippet);
|
||||
|
||||
definitions.push({
|
||||
title: snippet.title,
|
||||
data: () => templateCache.get(snippet.noteId)?.content ?? "",
|
||||
icon: buildIcon(snippet),
|
||||
description
|
||||
});
|
||||
definitions.push({
|
||||
title: snippet.title,
|
||||
data: () => templateCache.get(snippet.noteId)?.content ?? "",
|
||||
icon: buildIcon(snippet),
|
||||
description
|
||||
});
|
||||
}
|
||||
return definitions;
|
||||
} catch (e) {
|
||||
logError("Error while building text snippet templates: ", e);
|
||||
return [];
|
||||
}
|
||||
return definitions;
|
||||
}
|
||||
|
||||
async function invalidateCacheFor(snippet: FNote) {
|
||||
|
||||
@@ -35,7 +35,7 @@
|
||||
"sucrase": "3.35.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@anthropic-ai/sdk": "0.71.2",
|
||||
"@anthropic-ai/sdk": "0.71.2",
|
||||
"@electron/remote": "2.1.3",
|
||||
"@triliumnext/commons": "workspace:*",
|
||||
"@triliumnext/core": "workspace:*",
|
||||
@@ -48,14 +48,14 @@
|
||||
"@types/compression": "1.8.1",
|
||||
"@types/cookie-parser": "1.4.10",
|
||||
"@types/debounce": "1.2.4",
|
||||
"@types/ejs": "3.1.5",
|
||||
"@types/ejs": "3.1.5",
|
||||
"@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/ini": "4.1.1",
|
||||
"@types/multer": "2.0.0",
|
||||
"@types/safe-compare": "1.1.2",
|
||||
"@types/safe-compare": "1.1.2",
|
||||
"@types/sax": "1.2.7",
|
||||
"@types/serve-favicon": "2.5.7",
|
||||
"@types/serve-static": "2.2.0",
|
||||
@@ -82,7 +82,7 @@
|
||||
"ejs": "3.1.10",
|
||||
"electron": "39.2.7",
|
||||
"electron-debug": "4.1.0",
|
||||
"electron-window-state": "5.0.3",
|
||||
"electron-window-state": "5.0.3",
|
||||
"express": "5.2.1",
|
||||
"express-http-proxy": "2.1.2",
|
||||
"express-openid-connect": "2.19.4",
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<link rel="shortcut icon" href="favicon.ico">
|
||||
<meta name="mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, viewport-fit=cover" />
|
||||
<link rel="manifest" crossorigin="use-credentials" href="../manifest.webmanifest">
|
||||
<title>Trilium Notes</title>
|
||||
<script src="../<%= appPath %>/runtime.js" crossorigin type="module"></script>
|
||||
</head>
|
||||
<body
|
||||
id="trilium-print"
|
||||
lang="<%= currentLocale.id %>" dir="<%= currentLocale.rtl ? 'rtl' : 'ltr' %>"
|
||||
>
|
||||
<noscript><%= t("javascript-required") %></noscript>
|
||||
|
||||
<script>
|
||||
// hide body to reduce flickering on the startup. This is done through JS and not CSS to not hide <noscript>
|
||||
document.getElementsByTagName("body")[0].style.display = "none";
|
||||
</script>
|
||||
|
||||
<%- include("./partials/windowGlobal.ejs", locals) %>
|
||||
|
||||
<!-- Required for correct loading of scripts in Electron -->
|
||||
<script>if (typeof module === 'object') {window.module = module; module = undefined;}</script>
|
||||
|
||||
<script src="../<%= appPath %>/print.js" crossorigin type="module"></script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
@@ -4,9 +4,11 @@
|
||||
*/
|
||||
|
||||
import { initializeCore } from "@triliumnext/core";
|
||||
import path from "path";
|
||||
|
||||
import ClsHookedExecutionContext from "./cls_provider.js";
|
||||
import NodejsCryptoProvider from "./crypto_provider.js";
|
||||
import dataDirs from "./services/data_dir.js";
|
||||
import BetterSqlite3Provider from "./sql_provider.js";
|
||||
|
||||
async function startApplication() {
|
||||
@@ -40,13 +42,16 @@ async function startApplication() {
|
||||
|
||||
// the maxEntityChangeId has been incremented during failed transaction, need to recalculate
|
||||
entity_changes.recalculateMaxEntityChangeId();
|
||||
}
|
||||
},
|
||||
},
|
||||
crypto: new NodejsCryptoProvider(),
|
||||
executionContext: new ClsHookedExecutionContext()
|
||||
executionContext: new ClsHookedExecutionContext(),
|
||||
translations: (await import("./services/i18n.js")).initializeTranslations,
|
||||
extraAppInfo: {
|
||||
nodeVersion: process.version,
|
||||
dataDirectory: path.resolve(dataDirs.TRILIUM_DATA_DIR)
|
||||
}
|
||||
});
|
||||
const { initializeTranslations } = (await import("./services/i18n.js"));
|
||||
await initializeTranslations();
|
||||
const startTriliumServer = (await import("./www.js")).default;
|
||||
await startTriliumServer();
|
||||
}
|
||||
|
||||
@@ -341,6 +341,7 @@ export function findExcerpts(sourceNote: BNote, referencedNoteId: string) {
|
||||
return excerpts;
|
||||
}
|
||||
|
||||
// TODO: Deduplicate with core
|
||||
function getFilteredBacklinks(note: BNote): BAttribute[] {
|
||||
return (
|
||||
note
|
||||
@@ -350,16 +351,6 @@ function getFilteredBacklinks(note: BNote): BAttribute[] {
|
||||
);
|
||||
}
|
||||
|
||||
function getBacklinkCount(req: Request) {
|
||||
const { noteId } = req.params;
|
||||
|
||||
const note = becca.getNoteOrThrow(noteId);
|
||||
|
||||
return {
|
||||
count: getFilteredBacklinks(note).length
|
||||
} satisfies BacklinkCountResponse;
|
||||
}
|
||||
|
||||
function getBacklinks(req: Request): BacklinksResponse {
|
||||
const { noteId } = req.params;
|
||||
const note = becca.getNoteOrThrow(noteId);
|
||||
@@ -389,6 +380,5 @@ function getBacklinks(req: Request): BacklinksResponse {
|
||||
export default {
|
||||
getLinkMap,
|
||||
getTreeMap,
|
||||
getBacklinkCount,
|
||||
getBacklinks
|
||||
};
|
||||
|
||||
@@ -1,34 +1,9 @@
|
||||
import type { Request } from "express";
|
||||
|
||||
import becca from "../../becca/becca.js";
|
||||
import markdownService from "../../services/import/markdown.js";
|
||||
import markdown from "../../services/export/markdown.js";
|
||||
import { RenderMarkdownResponse, ToMarkdownResponse } from "@triliumnext/commons";
|
||||
|
||||
function getIconUsage() {
|
||||
const iconClassToCountMap: Record<string, number> = {};
|
||||
|
||||
for (const { value: iconClass, noteId } of becca.findAttributes("label", "iconClass")) {
|
||||
if (noteId.startsWith("_")) {
|
||||
continue; // ignore icons of "system" notes since they were not set by the user
|
||||
}
|
||||
|
||||
if (!iconClass?.trim()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const clazz of iconClass.trim().split(/\s+/)) {
|
||||
if (clazz === "bx") {
|
||||
continue;
|
||||
}
|
||||
|
||||
iconClassToCountMap[clazz] = (iconClassToCountMap[clazz] || 0) + 1;
|
||||
}
|
||||
}
|
||||
|
||||
return { iconClassToCountMap };
|
||||
}
|
||||
|
||||
function renderMarkdown(req: Request) {
|
||||
const { markdownContent } = req.body;
|
||||
if (!markdownContent || typeof markdownContent !== 'string') {
|
||||
@@ -50,7 +25,6 @@ function toMarkdown(req: Request) {
|
||||
}
|
||||
|
||||
export default {
|
||||
getIconUsage,
|
||||
renderMarkdown,
|
||||
toMarkdown
|
||||
};
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { BootstrapDefinition } from "@triliumnext/commons";
|
||||
import { getSharedBootstrapItems, icon_packs as iconPackService } from "@triliumnext/core";
|
||||
import type { Request, Response } from "express";
|
||||
|
||||
import packageJson from "../../package.json" with { type: "json" };
|
||||
@@ -6,12 +8,8 @@ import appPath from "../services/app_path.js";
|
||||
import assetPath from "../services/asset_path.js";
|
||||
import attributeService from "../services/attributes.js";
|
||||
import config from "../services/config.js";
|
||||
import { getCurrentLocale } from "../services/i18n.js";
|
||||
import { generateCss, generateIconRegistry, getIconPacks, MIME_TO_EXTENSION_MAPPINGS } from "../services/icon_packs.js";
|
||||
import log from "../services/log.js";
|
||||
import optionService from "../services/options.js";
|
||||
import protectedSessionService from "../services/protected_session.js";
|
||||
import sql from "../services/sql.js";
|
||||
import { isDev, isElectron, isWindows11 } from "../services/utils.js";
|
||||
import { generateToken as generateCsrfToken } from "./csrf_protection.js";
|
||||
|
||||
@@ -30,42 +28,33 @@ export function bootstrap(req: Request, res: Response) {
|
||||
const theme = options.theme;
|
||||
const themeNote = attributeService.getNoteWithLabel("appTheme", theme);
|
||||
const nativeTitleBarVisible = options.nativeTitleBarVisible === "true";
|
||||
const iconPacks = getIconPacks();
|
||||
const currentLocale = getCurrentLocale();
|
||||
const iconPacks = iconPackService.getIconPacks();
|
||||
|
||||
res.send({
|
||||
...getSharedBootstrapItems(assetPath),
|
||||
device: view,
|
||||
csrfToken,
|
||||
themeCssUrl: getThemeCssUrl(theme, themeNote),
|
||||
themeUseNextAsBase: themeNote?.getAttributeValue("label", "appThemeBase"),
|
||||
headingStyle: options.headingStyle,
|
||||
layoutOrientation: options.layoutOrientation,
|
||||
themeUseNextAsBase: themeNote?.getAttributeValue("label", "appThemeBase") as "next" | "next-light" | "next-dark",
|
||||
platform: process.platform,
|
||||
isElectron,
|
||||
hasNativeTitleBar: isElectron && nativeTitleBarVisible,
|
||||
hasBackgroundEffects: isElectron && isWindows11 && !nativeTitleBarVisible && options.backgroundEffects === "true",
|
||||
maxEntityChangeIdAtLoad: sql.getValue("SELECT COALESCE(MAX(id), 0) FROM entity_changes"),
|
||||
maxEntityChangeSyncIdAtLoad: sql.getValue("SELECT COALESCE(MAX(id), 0) FROM entity_changes WHERE isSynced = 1"),
|
||||
instanceName: config.General ? config.General.instanceName : null,
|
||||
appCssNoteIds: getAppCssNoteIds(),
|
||||
isDev,
|
||||
isMainWindow: view === "mobile" ? true : !req.query.extraWindow,
|
||||
isProtectedSessionAvailable: protectedSessionService.isProtectedSessionAvailable(),
|
||||
triliumVersion: packageJson.version,
|
||||
assetPath,
|
||||
appPath,
|
||||
baseApiUrl: 'api/',
|
||||
currentLocale,
|
||||
isRtl: !!currentLocale.rtl,
|
||||
iconPackCss: iconPacks
|
||||
.map(p => generateCss(p, p.builtin
|
||||
? `${assetPath}/fonts/${p.fontAttachmentId}.${MIME_TO_EXTENSION_MAPPINGS[p.fontMime]}`
|
||||
.map((p: iconPackService.ProcessedIconPack) => iconPackService.generateCss(p, p.builtin
|
||||
? `${assetPath}/fonts/${p.fontAttachmentId}.${iconPackService.MIME_TO_EXTENSION_MAPPINGS[p.fontMime]}`
|
||||
: `api/attachments/download/${p.fontAttachmentId}`))
|
||||
.filter(Boolean)
|
||||
.join("\n\n"),
|
||||
iconRegistry: generateIconRegistry(iconPacks),
|
||||
TRILIUM_SAFE_MODE: !!process.env.TRILIUM_SAFE_MODE
|
||||
});
|
||||
} satisfies BootstrapDefinition);
|
||||
}
|
||||
|
||||
function getView(req: Request): View {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { ValidationError } from "@triliumnext/core";
|
||||
import { i18n } from "@triliumnext/core";
|
||||
import crypto from "crypto";
|
||||
import type { Request, Response } from 'express';
|
||||
|
||||
@@ -8,7 +9,6 @@ import myScryptService from "../services/encryption/my_scrypt.js";
|
||||
import openIDEncryption from '../services/encryption/open_id_encryption.js';
|
||||
import passwordService from "../services/encryption/password.js";
|
||||
import recoveryCodeService from '../services/encryption/recovery_codes.js';
|
||||
import { getCurrentLocale } from "../services/i18n.js";
|
||||
import log from "../services/log.js";
|
||||
import openID from '../services/open_id.js';
|
||||
import optionService from "../services/options.js";
|
||||
@@ -27,7 +27,7 @@ function loginPage(req: Request, res: Response) {
|
||||
assetPath,
|
||||
assetPathFragment: assetUrlFragment,
|
||||
appPath,
|
||||
currentLocale: getCurrentLocale()
|
||||
currentLocale: i18n.getCurrentLocale()
|
||||
});
|
||||
}
|
||||
|
||||
@@ -36,7 +36,7 @@ function setPasswordPage(req: Request, res: Response) {
|
||||
error: false,
|
||||
assetPath,
|
||||
appPath,
|
||||
currentLocale: getCurrentLocale()
|
||||
currentLocale: i18n.getCurrentLocale()
|
||||
});
|
||||
}
|
||||
|
||||
@@ -62,7 +62,7 @@ function setPassword(req: Request, res: Response) {
|
||||
error,
|
||||
assetPath,
|
||||
appPath,
|
||||
currentLocale: getCurrentLocale()
|
||||
currentLocale: i18n.getCurrentLocale()
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -185,7 +185,7 @@ function sendLoginError(req: Request, res: Response, errorType: 'password' | 'to
|
||||
assetPath,
|
||||
assetPathFragment: assetUrlFragment,
|
||||
appPath,
|
||||
currentLocale: getCurrentLocale()
|
||||
currentLocale: i18n.getCurrentLocale()
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { AbstractBeccaEntity,NotFoundError, ValidationError } from "@triliumnext/core";
|
||||
import { routes, NotFoundError, ValidationError } from "@triliumnext/core";
|
||||
import express, { type RequestHandler } from "express";
|
||||
import multer from "multer";
|
||||
|
||||
@@ -23,38 +23,10 @@ type NotAPromise<T> = T & { then?: void };
|
||||
export type ApiRequestHandler = (req: express.Request, res: express.Response, next: express.NextFunction) => unknown;
|
||||
export type SyncRouteRequestHandler = (req: express.Request, res: express.Response, next: express.NextFunction) => NotAPromise<object> | number | string | void | null;
|
||||
|
||||
/** Handling common patterns. If entity is not caught, serialization to JSON will fail */
|
||||
function convertEntitiesToPojo(result: unknown) {
|
||||
if (result instanceof AbstractBeccaEntity) {
|
||||
result = result.getPojo();
|
||||
} else if (Array.isArray(result)) {
|
||||
for (const idx in result) {
|
||||
if (result[idx] instanceof AbstractBeccaEntity) {
|
||||
result[idx] = result[idx].getPojo();
|
||||
}
|
||||
}
|
||||
} else if (result && typeof result === "object") {
|
||||
if ("note" in result && result.note instanceof AbstractBeccaEntity) {
|
||||
result.note = result.note.getPojo();
|
||||
}
|
||||
|
||||
if ("branch" in result && result.branch instanceof AbstractBeccaEntity) {
|
||||
result.branch = result.branch.getPojo();
|
||||
}
|
||||
}
|
||||
|
||||
if (result && typeof result === "object" && "executionResult" in result) {
|
||||
// from runOnBackend()
|
||||
result.executionResult = convertEntitiesToPojo(result.executionResult);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export function apiResultHandler(req: express.Request, res: express.Response, result: unknown) {
|
||||
res.setHeader("trilium-max-entity-change-id", entityChangesService.getMaxEntityChangeId());
|
||||
|
||||
result = convertEntitiesToPojo(result);
|
||||
result = routes.convertEntitiesToPojo(result);
|
||||
|
||||
// if it's an array and the first element is integer, then we consider this to be [statusCode, response] format
|
||||
if (Array.isArray(result) && result.length > 0 && Number.isInteger(result[0])) {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { routes } from "@triliumnext/core";
|
||||
import { createPartialContentHandler } from "@triliumnext/express-partial-content";
|
||||
import express from "express";
|
||||
import rateLimit from "express-rate-limit";
|
||||
@@ -17,12 +18,9 @@ import openID from '../services/open_id.js';
|
||||
import { isElectron } from "../services/utils.js";
|
||||
import shareRoutes from "../share/routes.js";
|
||||
import anthropicRoute from "./api/anthropic.js";
|
||||
import appInfoRoute from "./api/app_info.js";
|
||||
import attachmentsApiRoute from "./api/attachments.js";
|
||||
import attributesRoute from "./api/attributes.js";
|
||||
import autocompleteApiRoute from "./api/autocomplete.js";
|
||||
import backendLogRoute from "./api/backend_log.js";
|
||||
import branchesApiRoute from "./api/branches.js";
|
||||
import bulkActionRoute from "./api/bulk_action.js";
|
||||
import clipperRoute from "./api/clipper.js";
|
||||
import cloningApiRoute from "./api/cloning.js";
|
||||
@@ -33,19 +31,15 @@ import filesRoute from "./api/files.js";
|
||||
import fontsRoute from "./api/fonts.js";
|
||||
import imageRoute from "./api/image.js";
|
||||
import importRoute from "./api/import.js";
|
||||
import keysRoute from "./api/keys.js";
|
||||
import llmRoute from "./api/llm.js";
|
||||
import loginApiRoute from "./api/login.js";
|
||||
import metricsRoute from "./api/metrics.js";
|
||||
import noteMapRoute from "./api/note_map.js";
|
||||
import notesApiRoute from "./api/notes.js";
|
||||
import ollamaRoute from "./api/ollama.js";
|
||||
import openaiRoute from "./api/openai.js";
|
||||
import optionsApiRoute from "./api/options.js";
|
||||
import otherRoute from "./api/other.js";
|
||||
import passwordApiRoute from "./api/password.js";
|
||||
import recentChangesApiRoute from "./api/recent_changes.js";
|
||||
import recentNotesRoute from "./api/recent_notes.js";
|
||||
import recoveryCodes from './api/recovery_codes.js';
|
||||
import relationMapApiRoute from "./api/relation-map.js";
|
||||
import revisionsApiRoute from "./api/revisions.js";
|
||||
@@ -61,7 +55,6 @@ import syncApiRoute from "./api/sync.js";
|
||||
import systemInfoRoute from "./api/system_info.js";
|
||||
import totp from './api/totp.js';
|
||||
// API routes
|
||||
import treeApiRoute from "./api/tree.js";
|
||||
import { doubleCsrfProtection as csrfMiddleware } from "./csrf_protection.js";
|
||||
import * as indexRoute from "./index.js";
|
||||
import loginRoute from "./login.js";
|
||||
@@ -105,22 +98,8 @@ function register(app: express.Application) {
|
||||
apiRoute(GET, '/api/totp_recovery/enabled', recoveryCodes.checkForRecoveryKeys);
|
||||
apiRoute(GET, '/api/totp_recovery/used', recoveryCodes.getUsedRecoveryCodes);
|
||||
|
||||
apiRoute(GET, '/api/tree', treeApiRoute.getTree);
|
||||
apiRoute(PST, '/api/tree/load', treeApiRoute.load);
|
||||
routes.buildSharedApiRoutes(apiRoute);
|
||||
|
||||
apiRoute(GET, "/api/notes/:noteId", notesApiRoute.getNote);
|
||||
apiRoute(GET, "/api/notes/:noteId/blob", notesApiRoute.getNoteBlob);
|
||||
apiRoute(GET, "/api/notes/:noteId/metadata", notesApiRoute.getNoteMetadata);
|
||||
apiRoute(PUT, "/api/notes/:noteId/data", notesApiRoute.updateNoteData);
|
||||
apiRoute(DEL, "/api/notes/:noteId", notesApiRoute.deleteNote);
|
||||
apiRoute(PUT, "/api/notes/:noteId/undelete", notesApiRoute.undeleteNote);
|
||||
apiRoute(PST, "/api/notes/:noteId/revision", notesApiRoute.forceSaveRevision);
|
||||
apiRoute(PST, "/api/notes/:parentNoteId/children", notesApiRoute.createNote);
|
||||
apiRoute(PUT, "/api/notes/:noteId/sort-children", notesApiRoute.sortChildNotes);
|
||||
apiRoute(PUT, "/api/notes/:noteId/protect/:isProtected", notesApiRoute.protectNote);
|
||||
apiRoute(PUT, "/api/notes/:noteId/type", notesApiRoute.setNoteTypeMime);
|
||||
apiRoute(PUT, "/api/notes/:noteId/title", notesApiRoute.changeTitle);
|
||||
apiRoute(PST, "/api/notes/:noteId/duplicate/:parentNoteId", notesApiRoute.duplicateSubtree);
|
||||
apiRoute(PUT, "/api/notes/:noteId/clone-to-branch/:parentBranchId", cloningApiRoute.cloneNoteToBranch);
|
||||
apiRoute(PUT, "/api/notes/:noteId/toggle-in-parent/:parentNoteId/:present", cloningApiRoute.toggleNoteInParent);
|
||||
apiRoute(PUT, "/api/notes/:noteId/clone-to-note/:parentNoteId", cloningApiRoute.cloneNoteToParentNote);
|
||||
@@ -142,26 +121,9 @@ function register(app: express.Application) {
|
||||
route(GET, "/api/notes/download/:noteId", [auth.checkApiAuthOrElectron], filesRoute.downloadFile);
|
||||
apiRoute(PST, "/api/notes/:noteId/save-to-tmp-dir", filesRoute.saveNoteToTmpDir);
|
||||
apiRoute(PST, "/api/notes/:noteId/upload-modified-file", filesRoute.uploadModifiedFileToNote);
|
||||
apiRoute(PST, "/api/notes/:noteId/convert-to-attachment", notesApiRoute.convertNoteToAttachment);
|
||||
|
||||
apiRoute(PUT, "/api/branches/:branchId/move-to/:parentBranchId", branchesApiRoute.moveBranchToParent);
|
||||
apiRoute(PUT, "/api/branches/:branchId/move-before/:beforeBranchId", branchesApiRoute.moveBranchBeforeNote);
|
||||
apiRoute(PUT, "/api/branches/:branchId/move-after/:afterBranchId", branchesApiRoute.moveBranchAfterNote);
|
||||
apiRoute(PUT, "/api/branches/:branchId/expanded/:expanded", branchesApiRoute.setExpanded);
|
||||
apiRoute(PUT, "/api/branches/:branchId/expanded-subtree/:expanded", branchesApiRoute.setExpandedForSubtree);
|
||||
apiRoute(DEL, "/api/branches/:branchId", branchesApiRoute.deleteBranch);
|
||||
apiRoute(PUT, "/api/branches/:branchId/set-prefix", branchesApiRoute.setPrefix);
|
||||
apiRoute(PUT, "/api/branches/set-prefix-batch", branchesApiRoute.setPrefixBatch);
|
||||
|
||||
apiRoute(GET, "/api/notes/:noteId/attachments", attachmentsApiRoute.getAttachments);
|
||||
apiRoute(PST, "/api/notes/:noteId/attachments", attachmentsApiRoute.saveAttachment);
|
||||
route(PST, "/api/notes/:noteId/attachments/upload", [auth.checkApiAuthOrElectron, uploadMiddlewareWithErrorHandling, csrfMiddleware], attachmentsApiRoute.uploadAttachment, apiResultHandler);
|
||||
apiRoute(GET, "/api/attachments/:attachmentId", attachmentsApiRoute.getAttachment);
|
||||
apiRoute(GET, "/api/attachments/:attachmentId/all", attachmentsApiRoute.getAllAttachments);
|
||||
apiRoute(PST, "/api/attachments/:attachmentId/convert-to-note", attachmentsApiRoute.convertAttachmentToNote);
|
||||
apiRoute(DEL, "/api/attachments/:attachmentId", attachmentsApiRoute.deleteAttachment);
|
||||
apiRoute(PUT, "/api/attachments/:attachmentId/rename", attachmentsApiRoute.renameAttachment);
|
||||
apiRoute(GET, "/api/attachments/:attachmentId/blob", attachmentsApiRoute.getAttachmentBlob);
|
||||
// TODO: Bring back attachment uploading
|
||||
// route(PST, "/api/notes/:noteId/attachments/upload", [auth.checkApiAuthOrElectron, uploadMiddlewareWithErrorHandling, csrfMiddleware], attachmentsApiRoute.uploadAttachment, apiResultHandler);
|
||||
route(GET, "/api/attachments/:attachmentId/image/:filename", [auth.checkApiAuthOrElectron], imageRoute.returnAttachedImage);
|
||||
route(GET, "/api/attachments/:attachmentId/open", [auth.checkApiAuthOrElectron], filesRoute.openAttachment);
|
||||
asyncRoute(
|
||||
@@ -211,13 +173,6 @@ function register(app: express.Application) {
|
||||
route(GET, "/api/images/:noteId/:filename", [auth.checkApiAuthOrElectron], imageRoute.returnImageFromNote);
|
||||
route(PUT, "/api/images/:noteId", [auth.checkApiAuthOrElectron, uploadMiddlewareWithErrorHandling, csrfMiddleware], imageRoute.updateImage, apiResultHandler);
|
||||
|
||||
apiRoute(GET, "/api/options", optionsApiRoute.getOptions);
|
||||
// FIXME: possibly change to sending value in the body to avoid host of HTTP server issues with slashes
|
||||
apiRoute(PUT, "/api/options/:name/:value", optionsApiRoute.updateOption);
|
||||
apiRoute(PUT, "/api/options", optionsApiRoute.updateOptions);
|
||||
apiRoute(GET, "/api/options/user-themes", optionsApiRoute.getUserThemes);
|
||||
apiRoute(GET, "/api/options/locales", optionsApiRoute.getSupportedLocales);
|
||||
|
||||
apiRoute(PST, "/api/password/change", passwordApiRoute.changePassword);
|
||||
apiRoute(PST, "/api/password/reset", passwordApiRoute.resetPassword);
|
||||
|
||||
@@ -233,8 +188,6 @@ function register(app: express.Application) {
|
||||
route(PST, "/api/sync/queue-sector/:entityName/:sector", [auth.checkApiAuth], syncApiRoute.queueSector, apiResultHandler);
|
||||
route(GET, "/api/sync/stats", [], syncApiRoute.getStats, apiResultHandler);
|
||||
|
||||
apiRoute(PST, "/api/recent-notes", recentNotesRoute.addRecentNote);
|
||||
apiRoute(GET, "/api/app-info", appInfoRoute.getAppInfo);
|
||||
apiRoute(GET, "/api/metrics", metricsRoute.getMetrics);
|
||||
apiRoute(GET, "/api/system-checks", systemInfoRoute.systemChecks);
|
||||
|
||||
@@ -331,19 +284,12 @@ function register(app: express.Application) {
|
||||
asyncRoute(PST, "/api/sender/image", [auth.checkEtapiToken, uploadMiddlewareWithErrorHandling], senderRoute.uploadImage, apiResultHandler);
|
||||
asyncRoute(PST, "/api/sender/note", [auth.checkEtapiToken], senderRoute.saveNote, apiResultHandler);
|
||||
|
||||
apiRoute(GET, "/api/keyboard-actions", keysRoute.getKeyboardActions);
|
||||
apiRoute(GET, "/api/keyboard-shortcuts-for-notes", keysRoute.getShortcutsForNotes);
|
||||
|
||||
apiRoute(PST, "/api/relation-map", relationMapApiRoute.getRelationMap);
|
||||
apiRoute(PST, "/api/notes/erase-deleted-notes-now", notesApiRoute.eraseDeletedNotesNow);
|
||||
apiRoute(PST, "/api/notes/erase-unused-attachments-now", notesApiRoute.eraseUnusedAttachmentsNow);
|
||||
asyncApiRoute(GET, "/api/similar-notes/:noteId", similarNotesRoute.getSimilarNotes);
|
||||
asyncApiRoute(GET, "/api/backend-log", backendLogRoute.getBackendLog);
|
||||
apiRoute(GET, "/api/stats/note-size/:noteId", statsRoute.getNoteSize);
|
||||
apiRoute(GET, "/api/stats/subtree-size/:noteId", statsRoute.getSubtreeSize);
|
||||
apiRoute(PST, "/api/delete-notes-preview", notesApiRoute.getDeleteNotesPreview);
|
||||
route(GET, "/api/fonts", [auth.checkApiAuthOrElectron], fontsRoute.getFontCss);
|
||||
apiRoute(GET, "/api/other/icon-usage", otherRoute.getIconUsage);
|
||||
apiRoute(PST, "/api/other/render-markdown", otherRoute.renderMarkdown);
|
||||
apiRoute(PST, "/api/other/to-markdown", otherRoute.toMarkdown);
|
||||
apiRoute(GET, "/api/recent-changes/:ancestorNoteId", recentChangesApiRoute.getRecentChanges);
|
||||
@@ -351,7 +297,6 @@ function register(app: express.Application) {
|
||||
|
||||
apiRoute(PST, "/api/note-map/:noteId/tree", noteMapRoute.getTreeMap);
|
||||
apiRoute(PST, "/api/note-map/:noteId/link", noteMapRoute.getLinkMap);
|
||||
apiRoute(GET, "/api/note-map/:noteId/backlink-count", noteMapRoute.getBacklinkCount);
|
||||
apiRoute(GET, "/api/note-map/:noteId/backlinks", noteMapRoute.getBacklinks);
|
||||
|
||||
shareRoutes.register(router);
|
||||
|
||||
@@ -1,11 +1,3 @@
|
||||
import { AppInfo } from "@triliumnext/commons";
|
||||
import { app_info as coreAppInfo } from "@triliumnext/core";
|
||||
import path from "path";
|
||||
import { app_info } from "@triliumnext/core";
|
||||
|
||||
import dataDir from "./data_dir.js";
|
||||
|
||||
export default {
|
||||
...coreAppInfo,
|
||||
nodeVersion: process.version,
|
||||
dataDirectory: path.resolve(dataDir.TRILIUM_DATA_DIR),
|
||||
} satisfies AppInfo;
|
||||
export default app_info;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { type AttributeRow, dayjs, formatLogMessage } from "@triliumnext/commons";
|
||||
import { type AbstractBeccaEntity, Becca, NoteParams } from "@triliumnext/core";
|
||||
import { type AbstractBeccaEntity, Becca, branches as branchService, NoteParams } from "@triliumnext/core";
|
||||
import axios from "axios";
|
||||
import * as cheerio from "cheerio";
|
||||
import xml2js from "xml2js";
|
||||
@@ -16,7 +16,6 @@ import appInfo from "./app_info.js";
|
||||
import attributeService from "./attributes.js";
|
||||
import type { ApiParams } from "./backend_script_api_interface.js";
|
||||
import backupService from "./backup.js";
|
||||
import branchService from "./branches.js";
|
||||
import cloningService from "./cloning.js";
|
||||
import config from "./config.js";
|
||||
import dateNoteService from "./date_notes.js";
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import { ActionHandlers, BulkAction, BulkActionData } from "@triliumnext/commons";
|
||||
import { erase as eraseService } from "@triliumnext/core";
|
||||
import { branches as branchService, erase as eraseService } from "@triliumnext/core";
|
||||
|
||||
import becca from "../becca/becca.js";
|
||||
import type BNote from "../becca/entities/bnote.js";
|
||||
import branchService from "./branches.js";
|
||||
import cloningService from "./cloning.js";
|
||||
import log from "./log.js";
|
||||
import { randomString } from "./utils.js";
|
||||
|
||||
@@ -9,7 +9,7 @@ import type BBranch from "../../../becca/entities/bbranch.js";
|
||||
import type BNote from "../../../becca/entities/bnote.js";
|
||||
import { getClientDir, getShareThemeAssetDir } from "../../../routes/assets";
|
||||
import { getDefaultTemplatePath, readTemplate, renderNoteForExport } from "../../../share/content_renderer";
|
||||
import { getIconPacks, MIME_TO_EXTENSION_MAPPINGS, ProcessedIconPack } from "../../icon_packs";
|
||||
import { icon_packs as iconPackService } from "@triliumnext/core";
|
||||
import log from "../../log";
|
||||
import NoteMeta, { NoteMetaFile } from "../../meta/note_meta";
|
||||
import { RESOURCE_DIR } from "../../resource_dir";
|
||||
@@ -31,7 +31,7 @@ export default class ShareThemeExportProvider extends ZipExportProvider {
|
||||
private indexMeta: NoteMeta | null = null;
|
||||
private searchIndex: Map<string, SearchIndexEntry> = new Map();
|
||||
private rootMeta: NoteMeta | null = null;
|
||||
private iconPacks: ProcessedIconPack[] = [];
|
||||
private iconPacks: iconPackService.ProcessedIconPack[] = [];
|
||||
|
||||
prepareMeta(metaFile: NoteMetaFile): void {
|
||||
const assets = [
|
||||
@@ -56,7 +56,7 @@ export default class ShareThemeExportProvider extends ZipExportProvider {
|
||||
dataFileName: "index.html"
|
||||
};
|
||||
this.rootMeta = metaFile.files[0];
|
||||
this.iconPacks = getIconPacks();
|
||||
this.iconPacks = iconPackService.getIconPacks();
|
||||
|
||||
metaFile.files.push(this.indexMeta);
|
||||
}
|
||||
@@ -165,7 +165,7 @@ export default class ShareThemeExportProvider extends ZipExportProvider {
|
||||
|
||||
// Inject the custom fonts.
|
||||
for (const iconPack of this.iconPacks) {
|
||||
const extension = MIME_TO_EXTENSION_MAPPINGS[iconPack.fontMime];
|
||||
const extension = iconPackService.MIME_TO_EXTENSION_MAPPINGS[iconPack.fontMime];
|
||||
let fontData: Uint8Array | undefined;
|
||||
if (iconPack.builtin) {
|
||||
fontData = readFileSync(join(getClientDir(), "fonts", `${iconPack.fontAttachmentId}.${extension}`));
|
||||
|
||||
@@ -1,15 +1,12 @@
|
||||
import { LOCALE_IDS, setDayjsLocale } from "@triliumnext/commons";
|
||||
import i18next from "i18next";
|
||||
import options from "./options.js";
|
||||
import sql_init from "./sql_init.js";
|
||||
import { join } from "path";
|
||||
import { getResourceDir } from "./utils.js";
|
||||
import hidden_subtree from "./hidden_subtree.js";
|
||||
import { dayjs, LOCALES, setDayjsLocale, type Dayjs, type Locale, type LOCALE_IDS } from "@triliumnext/commons";
|
||||
|
||||
export async function initializeTranslations() {
|
||||
import { getResourceDir } from "./utils";
|
||||
|
||||
export async function initializeTranslations(locale: LOCALE_IDS) {
|
||||
const resourceDir = getResourceDir();
|
||||
const Backend = (await import("i18next-fs-backend/cjs")).default;
|
||||
const locale = getCurrentLanguage();
|
||||
|
||||
// Initialize translations
|
||||
await i18next.use(Backend).init({
|
||||
@@ -24,38 +21,3 @@ export async function initializeTranslations() {
|
||||
// Initialize dayjs locale.
|
||||
await setDayjsLocale(locale);
|
||||
}
|
||||
|
||||
export function ordinal(date: Dayjs) {
|
||||
return dayjs(date)
|
||||
.format("Do");
|
||||
}
|
||||
|
||||
export function getLocales(): Locale[] {
|
||||
return LOCALES;
|
||||
}
|
||||
|
||||
function getCurrentLanguage(): LOCALE_IDS {
|
||||
let language: string | null = null;
|
||||
if (sql_init.isDbInitialized()) {
|
||||
language = options.getOptionOrNull("locale");
|
||||
}
|
||||
|
||||
if (!language) {
|
||||
console.info("Language option not found, falling back to en.");
|
||||
language = "en";
|
||||
}
|
||||
|
||||
return language as LOCALE_IDS;
|
||||
}
|
||||
|
||||
export async function changeLanguage(locale: string) {
|
||||
await i18next.changeLanguage(locale);
|
||||
hidden_subtree.checkHiddenSubtree(true, { restoreNames: true });
|
||||
}
|
||||
|
||||
export function getCurrentLocale() {
|
||||
const localeId = options.getOptionOrNull("locale") ?? "en";
|
||||
const currentLocale = LOCALES.find(l => l.id === localeId);
|
||||
if (!currentLocale) return LOCALES.find(l => l.id === "en")!;
|
||||
return currentLocale;
|
||||
}
|
||||
|
||||
@@ -87,11 +87,6 @@ export function constantTimeCompare(a: string | null | undefined, b: string | nu
|
||||
return crypto.timingSafeEqual(bufA, bufB);
|
||||
}
|
||||
|
||||
export function isEmptyOrWhitespace(str: string | null | undefined) {
|
||||
if (!str) return true;
|
||||
return str.match(/^ *$/) !== null;
|
||||
}
|
||||
|
||||
export function sanitizeSqlIdentifier(str: string) {
|
||||
return str.replace(/[^A-Za-z0-9_]/g, "");
|
||||
}
|
||||
@@ -343,10 +338,6 @@ export function processStringOrBuffer(data: string | Buffer | null) {
|
||||
}
|
||||
}
|
||||
|
||||
export function safeExtractMessageAndStackFromError(err: unknown): [errMessage: string, errStack: string | undefined] {
|
||||
return (err instanceof Error) ? [err.message, err.stack] as const : ["Unknown Error", undefined] as const;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalizes URL by removing trailing slashes and fixing double slashes.
|
||||
* Preserves the protocol (http://, https://) but removes trailing slashes from the rest.
|
||||
@@ -453,9 +444,16 @@ function slugify(text: string) {
|
||||
.replace(/(^-|-$)+/g, ""); // trim dashes
|
||||
}
|
||||
|
||||
/** @deprecated */
|
||||
export const escapeHtml = coreUtils.escapeHtml;
|
||||
/** @deprecated */
|
||||
export const unescapeHtml = coreUtils.unescapeHtml;
|
||||
/** @deprecated */
|
||||
export const randomSecureToken = coreUtils.randomSecureToken;
|
||||
/** @deprecated */
|
||||
export const safeExtractMessageAndStackFromError = coreUtils.safeExtractMessageAndStackFromError;
|
||||
/** @deprecated */
|
||||
export const isEmptyOrWhitespace = coreUtils.isEmptyOrWhitespace;
|
||||
|
||||
export default {
|
||||
compareVersions,
|
||||
|
||||
@@ -12,7 +12,6 @@ import BAttachment from '../becca/entities/battachment.js';
|
||||
import type BBranch from "../becca/entities/bbranch.js";
|
||||
import BNote from "../becca/entities/bnote.js";
|
||||
import assetPath, { assetUrlFragment } from "../services/asset_path.js";
|
||||
import { generateCss, getIconPacks, MIME_TO_EXTENSION_MAPPINGS, ProcessedIconPack } from "../services/icon_packs.js";
|
||||
import log from "../services/log.js";
|
||||
import options from "../services/options.js";
|
||||
import utils, { getResourceDir, isDev, safeExtractMessageAndStackFromError } from "../services/utils.js";
|
||||
@@ -21,6 +20,7 @@ import SBranch from "./shaca/entities/sbranch.js";
|
||||
import type SNote from "./shaca/entities/snote.js";
|
||||
import shaca from "./shaca/shaca.js";
|
||||
import shareRoot from "./share_root.js";
|
||||
import { icon_packs as iconPackService } from "@triliumnext/core";
|
||||
|
||||
const shareAdjustedAssetPath = isDev ? assetPath : `../${assetPath}`;
|
||||
const templateCache: Map<string, string> = new Map();
|
||||
@@ -69,7 +69,7 @@ function getSharedSubTreeRoot(note: SNote | BNote | undefined): Subroot {
|
||||
return getSharedSubTreeRoot(parentBranch.getParentNote());
|
||||
}
|
||||
|
||||
export function renderNoteForExport(note: BNote, parentBranch: BBranch, basePath: string, ancestors: string[], iconPacks: ProcessedIconPack[]) {
|
||||
export function renderNoteForExport(note: BNote, parentBranch: BBranch, basePath: string, ancestors: string[], iconPacks: iconPackService.ProcessedIconPack[]) {
|
||||
const subRoot: Subroot = {
|
||||
branch: parentBranch,
|
||||
note: parentBranch.getNote()
|
||||
@@ -95,7 +95,7 @@ export function renderNoteForExport(note: BNote, parentBranch: BBranch, basePath
|
||||
faviconUrl: `${basePath}favicon.ico`,
|
||||
ancestors,
|
||||
isStatic: true,
|
||||
iconPackCss: iconPacks.map(p => generateCss(p, `${basePath}assets/icon-pack-${p.prefix.toLowerCase()}.${MIME_TO_EXTENSION_MAPPINGS[p.fontMime]}`))
|
||||
iconPackCss: iconPacks.map(p => iconPackService.generateCss(p, `${basePath}assets/icon-pack-${p.prefix.toLowerCase()}.${iconPackService.MIME_TO_EXTENSION_MAPPINGS[p.fontMime]}`))
|
||||
.filter(Boolean)
|
||||
.join("\n\n"),
|
||||
iconPackSupportedPrefixes: iconPacks.map(p => p.prefix)
|
||||
@@ -136,7 +136,7 @@ export function renderNoteContent(note: SNote) {
|
||||
|
||||
const customLogoId = note.getRelation("shareLogo")?.value;
|
||||
const logoUrl = customLogoId ? `api/images/${customLogoId}/image.png` : `../${assetUrlFragment}/images/icon-color.svg`;
|
||||
const iconPacks = getIconPacks().filter(p => p.builtin || !!shaca.notes[p.manifestNoteId]);
|
||||
const iconPacks = iconPackService.getIconPacks().filter(p => p.builtin || !!shaca.notes[p.manifestNoteId]);
|
||||
|
||||
return renderNoteContentInternal(note, {
|
||||
subRoot,
|
||||
@@ -147,8 +147,8 @@ export function renderNoteContent(note: SNote) {
|
||||
ancestors,
|
||||
isStatic: false,
|
||||
faviconUrl: note.hasRelation("shareFavicon") ? `api/notes/${note.getRelationValue("shareFavicon")}/download` : `../favicon.ico`,
|
||||
iconPackCss: iconPacks.map(p => generateCss(p, p.builtin
|
||||
? `/share/assets/fonts/${p.fontAttachmentId}.${MIME_TO_EXTENSION_MAPPINGS[p.fontMime]}`
|
||||
iconPackCss: iconPacks.map(p => iconPackService.generateCss(p, p.builtin
|
||||
? `/share/assets/fonts/${p.fontAttachmentId}.${iconPackService.MIME_TO_EXTENSION_MAPPINGS[p.fontMime]}`
|
||||
: `/share/api/attachments/${p.fontAttachmentId}/download`
|
||||
))
|
||||
.filter(Boolean)
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { Locale } from "./i18n.js";
|
||||
import { AttachmentRow, AttributeRow, BranchRow, NoteRow, NoteType } from "./rows.js";
|
||||
|
||||
type Response = {
|
||||
@@ -310,3 +311,33 @@ export interface DefinitionObject {
|
||||
promotedAlias?: string;
|
||||
inverseRelation?: string;
|
||||
}
|
||||
|
||||
export interface BootstrapDefinition {
|
||||
device: "mobile" | "desktop" | "print" | false;
|
||||
csrfToken: string;
|
||||
themeCssUrl: string | false;
|
||||
themeUseNextAsBase?: "next" | "next-light" | "next-dark";
|
||||
headingStyle: "plain" | "underline" | "markdown";
|
||||
layoutOrientation: "vertical" | "horizontal";
|
||||
platform?: typeof process.platform | "web";
|
||||
isElectron: boolean;
|
||||
isStandalone?: boolean;
|
||||
hasNativeTitleBar: boolean;
|
||||
hasBackgroundEffects: boolean;
|
||||
maxEntityChangeIdAtLoad: number;
|
||||
maxEntityChangeSyncIdAtLoad: number;
|
||||
instanceName: string | null;
|
||||
appCssNoteIds: string[];
|
||||
isDev: boolean;
|
||||
isMainWindow: boolean;
|
||||
isProtectedSessionAvailable: boolean;
|
||||
triliumVersion: string;
|
||||
assetPath: string;
|
||||
appPath: string;
|
||||
baseApiUrl: string;
|
||||
currentLocale: Locale;
|
||||
isRtl: boolean;
|
||||
iconPackCss: string;
|
||||
iconRegistry: IconRegistry;
|
||||
TRILIUM_SAFE_MODE: boolean;
|
||||
}
|
||||
|
||||
@@ -7,17 +7,18 @@
|
||||
"test": "echo \"Error: no test specified\" && exit 1"
|
||||
},
|
||||
"dependencies": {
|
||||
"@triliumnext/commons": "workspace:*",
|
||||
"sanitize-html": "2.17.0",
|
||||
"@braintree/sanitize-url": "7.1.1",
|
||||
"sanitize-filename": "1.6.3",
|
||||
"@triliumnext/commons": "workspace:*",
|
||||
"escape-html": "1.0.3",
|
||||
"i18next": "25.7.3",
|
||||
"mime-types": "3.0.2",
|
||||
"unescape": "1.0.1",
|
||||
"escape-html": "1.0.3"
|
||||
"sanitize-filename": "1.6.3",
|
||||
"sanitize-html": "2.17.0",
|
||||
"unescape": "1.0.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/sanitize-html": "2.16.0",
|
||||
"@types/escape-html": "1.0.4",
|
||||
"@types/mime-types": "3.0.1",
|
||||
"@types/escape-html": "1.0.4"
|
||||
"@types/sanitize-html": "2.16.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,9 @@ import { CryptoProvider, initCrypto } from "./services/encryption/crypto";
|
||||
import { getLog, initLog } from "./services/log";
|
||||
import { initSql } from "./services/sql/index";
|
||||
import { SqlService, SqlServiceParams } from "./services/sql/sql";
|
||||
import { initMessaging, MessagingProvider } from "./services/messaging/index";
|
||||
import { initTranslations, TranslationProvider } from "./services/i18n";
|
||||
import appInfo from "./services/app_info";
|
||||
|
||||
export type * from "./services/sql/types";
|
||||
export * from "./services/sql/index";
|
||||
@@ -20,8 +23,10 @@ export { default as app_info } from "./services/app_info";
|
||||
export { default as keyboard_actions } from "./services/keyboard_actions";
|
||||
export { default as entity_changes } from "./services/entity_changes";
|
||||
export { default as hidden_subtree } from "./services/hidden_subtree";
|
||||
export * as icon_packs from "./services/icon_packs";
|
||||
export { getContext, type ExecutionContext } from "./services/context";
|
||||
export * as cls from "./services/context";
|
||||
export * as i18n from "./services/i18n";
|
||||
export * from "./errors";
|
||||
export { default as getInstanceId } from "./services/instance_id";
|
||||
export type { CryptoProvider } from "./services/encryption/crypto";
|
||||
@@ -32,6 +37,12 @@ export { default as handlers } from "./services/handlers";
|
||||
export { default as TaskContext } from "./services/task_context";
|
||||
export { default as revisions } from "./services/revisions";
|
||||
export { default as erase } from "./services/erase";
|
||||
export { default as getSharedBootstrapItems } from "./services/bootstrap_utils";
|
||||
export { default as branches } from "./services/branches";
|
||||
|
||||
// Messaging system
|
||||
export * from "./services/messaging/index";
|
||||
export type { MessagingProvider, ServerMessagingProvider, MessageClient, MessageHandler } from "./services/messaging/types";
|
||||
|
||||
export { default as becca } from "./becca/becca";
|
||||
export { default as becca_loader } from "./becca/becca_loader";
|
||||
@@ -52,16 +63,29 @@ export { default as Becca } from "./becca/becca-interface";
|
||||
export type { NotePojo } from "./becca/becca-interface";
|
||||
|
||||
export { default as NoteSet } from "./services/search/note_set";
|
||||
export { default as note_service, NoteParams } from "./services/notes";
|
||||
export { default as note_service } from "./services/notes";
|
||||
export type { NoteParams } from "./services/notes";
|
||||
export * as sanitize from "./services/sanitizer";
|
||||
export * as routes from "./routes";
|
||||
|
||||
export function initializeCore({ dbConfig, executionContext, crypto }: {
|
||||
export function initializeCore({ dbConfig, executionContext, crypto, translations, messaging, extraAppInfo }: {
|
||||
dbConfig: SqlServiceParams,
|
||||
executionContext: ExecutionContext,
|
||||
crypto: CryptoProvider
|
||||
crypto: CryptoProvider,
|
||||
translations: TranslationProvider,
|
||||
messaging?: MessagingProvider,
|
||||
extraAppInfo?: {
|
||||
nodeVersion: string;
|
||||
dataDirectory: string;
|
||||
};
|
||||
}) {
|
||||
initLog();
|
||||
initCrypto(crypto);
|
||||
initSql(new SqlService(dbConfig, getLog()));
|
||||
initContext(executionContext);
|
||||
initTranslations(translations);
|
||||
Object.assign(appInfo, extraAppInfo);
|
||||
if (messaging) {
|
||||
initMessaging(messaging);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { ConvertAttachmentToNoteResponse } from "@triliumnext/commons";
|
||||
import { blob as blobService, ValidationError } from "@triliumnext/core";
|
||||
import blobService from "../../services/blob";
|
||||
import { ValidationError } from "../../errors";
|
||||
import type { Request } from "express";
|
||||
|
||||
import becca from "../../becca/becca.js";
|
||||
@@ -1,14 +1,16 @@
|
||||
import { erase as eraseService, events as eventService, ValidationError } from "@triliumnext/core";
|
||||
import branchService from "../../services/branches.js";
|
||||
import eraseService from "../../services/erase.js";
|
||||
import eventService from "../../services/events.js";
|
||||
import type { Request } from "express";
|
||||
|
||||
import becca from "../../becca/becca.js";
|
||||
import branchService from "../../services/branches.js";
|
||||
import entityChangesService from "../../services/entity_changes.js";
|
||||
import log from "../../services/log.js";
|
||||
import sql from "../../services/sql.js";
|
||||
import { getLog } from "../../services/log.js";
|
||||
import TaskContext from "../../services/task_context.js";
|
||||
import treeService from "../../services/tree.js";
|
||||
import utils from "../../services/utils.js";
|
||||
import { isEmptyOrWhitespace, randomString } from "../../services/utils/index.js";
|
||||
import { getSql } from "../../services/sql/index.js";
|
||||
import { ValidationError } from "../../errors.js";
|
||||
|
||||
/**
|
||||
* Code in this file deals with moving and cloning branches. The relationship between note and parent note is unique
|
||||
@@ -45,7 +47,7 @@ function moveBranchBeforeNote(req: Request) {
|
||||
// we don't change utcDateModified, so other changes are prioritized in case of conflict
|
||||
// also we would have to sync all those modified branches otherwise hash checks would fail
|
||||
|
||||
sql.execute("UPDATE branches SET notePosition = notePosition + 10 WHERE parentNoteId = ? AND notePosition >= ? AND isDeleted = 0", [beforeBranch.parentNoteId, originalBeforeNotePosition]);
|
||||
getSql().execute("UPDATE branches SET notePosition = notePosition + 10 WHERE parentNoteId = ? AND notePosition >= ? AND isDeleted = 0", [beforeBranch.parentNoteId, originalBeforeNotePosition]);
|
||||
|
||||
// also need to update becca positions
|
||||
const parentNote = becca.getNoteOrThrow(beforeBranch.parentNoteId);
|
||||
@@ -71,7 +73,7 @@ function moveBranchBeforeNote(req: Request) {
|
||||
// if sorting is not needed, then still the ordering might have changed above manually
|
||||
entityChangesService.putNoteReorderingEntityChange(parentNote.noteId);
|
||||
|
||||
log.info(`Moved note ${branchToMove.noteId}, branch ${branchId} before note ${beforeBranch.noteId}, branch ${beforeBranchId}`);
|
||||
getLog().info(`Moved note ${branchToMove.noteId}, branch ${branchId} before note ${beforeBranch.noteId}, branch ${beforeBranchId}`);
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
@@ -92,7 +94,7 @@ function moveBranchAfterNote(req: Request) {
|
||||
|
||||
// we don't change utcDateModified, so other changes are prioritized in case of conflict
|
||||
// also we would have to sync all those modified branches otherwise hash checks would fail
|
||||
sql.execute("UPDATE branches SET notePosition = notePosition + 10 WHERE parentNoteId = ? AND notePosition > ? AND isDeleted = 0", [afterNote.parentNoteId, originalAfterNotePosition]);
|
||||
getSql().execute("UPDATE branches SET notePosition = notePosition + 10 WHERE parentNoteId = ? AND notePosition > ? AND isDeleted = 0", [afterNote.parentNoteId, originalAfterNotePosition]);
|
||||
|
||||
// also need to update becca positions
|
||||
const parentNote = becca.getNoteOrThrow(afterNote.parentNoteId);
|
||||
@@ -120,7 +122,7 @@ function moveBranchAfterNote(req: Request) {
|
||||
// if sorting is not needed, then still the ordering might have changed above manually
|
||||
entityChangesService.putNoteReorderingEntityChange(parentNote.noteId);
|
||||
|
||||
log.info(`Moved note ${branchToMove.noteId}, branch ${branchId} after note ${afterNote.noteId}, branch ${afterBranchId}`);
|
||||
getLog().info(`Moved note ${branchToMove.noteId}, branch ${branchId} after note ${afterNote.noteId}, branch ${afterBranchId}`);
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
@@ -130,7 +132,7 @@ function setExpanded(req: Request) {
|
||||
const expanded = parseInt(req.params.expanded);
|
||||
|
||||
if (branchId !== "none_root") {
|
||||
sql.execute("UPDATE branches SET isExpanded = ? WHERE branchId = ?", [expanded, branchId]);
|
||||
getSql().execute("UPDATE branches SET isExpanded = ? WHERE branchId = ?", [expanded, branchId]);
|
||||
// we don't sync expanded label
|
||||
// also this does not trigger updates to the frontend, this would trigger too many reloads
|
||||
|
||||
@@ -150,6 +152,7 @@ function setExpanded(req: Request) {
|
||||
function setExpandedForSubtree(req: Request) {
|
||||
const { branchId } = req.params;
|
||||
const expanded = parseInt(req.params.expanded);
|
||||
const sql = getSql();
|
||||
|
||||
let branchIds = sql.getColumn<string>(
|
||||
`
|
||||
@@ -236,7 +239,7 @@ function deleteBranch(req: Request) {
|
||||
|
||||
const taskContext = TaskContext.getInstance(req.query.taskId as string, "deleteNotes", null);
|
||||
|
||||
const deleteId = utils.randomString(10);
|
||||
const deleteId = randomString(10);
|
||||
let noteDeleted;
|
||||
|
||||
if (eraseNotes) {
|
||||
@@ -260,7 +263,7 @@ function deleteBranch(req: Request) {
|
||||
function setPrefix(req: Request) {
|
||||
const branchId = req.params.branchId;
|
||||
//TriliumNextTODO: req.body arrives as string, so req.body.prefix will be undefined – did the code below ever even work?
|
||||
const prefix = utils.isEmptyOrWhitespace(req.body.prefix) ? null : req.body.prefix;
|
||||
const prefix = isEmptyOrWhitespace(req.body.prefix) ? null : req.body.prefix;
|
||||
|
||||
const branch = becca.getBranchOrThrow(branchId);
|
||||
branch.prefix = prefix;
|
||||
@@ -279,7 +282,7 @@ function setPrefixBatch(req: Request) {
|
||||
throw new ValidationError("prefix must be a string or null");
|
||||
}
|
||||
|
||||
const normalizedPrefix = utils.isEmptyOrWhitespace(prefix) ? null : prefix;
|
||||
const normalizedPrefix = isEmptyOrWhitespace(prefix) ? null : prefix;
|
||||
let updatedCount = 0;
|
||||
|
||||
for (const branchId of branchIds) {
|
||||
@@ -289,7 +292,7 @@ function setPrefixBatch(req: Request) {
|
||||
branch.save();
|
||||
updatedCount++;
|
||||
} else {
|
||||
log.info(`Branch ${branchId} not found, skipping prefix update`);
|
||||
getLog().info(`Branch ${branchId} not found, skipping prefix update`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
"use strict";
|
||||
|
||||
import keyboardActions from "../../services/keyboard_actions.js";
|
||||
import becca from "../../becca/becca.js";
|
||||
import becca from "../../becca/becca";
|
||||
import keyboard_actions from "../../services/keyboard_actions";
|
||||
|
||||
function getKeyboardActions() {
|
||||
return keyboardActions.getKeyboardActions();
|
||||
return keyboard_actions.getKeyboardActions();
|
||||
}
|
||||
|
||||
function getShortcutsForNotes() {
|
||||
28
packages/trilium-core/src/routes/api/note_map.ts
Normal file
28
packages/trilium-core/src/routes/api/note_map.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import type { Request } from "express";
|
||||
import BAttribute from "../../becca/entities/battribute";
|
||||
import BNote from "../../becca/entities/bnote";
|
||||
import becca from "../../becca/becca";
|
||||
import type { BacklinkCountResponse } from "@triliumnext/commons";
|
||||
|
||||
function getFilteredBacklinks(note: BNote): BAttribute[] {
|
||||
return (
|
||||
note
|
||||
.getTargetRelations()
|
||||
// search notes have "ancestor" relations which are not interesting
|
||||
.filter((relation) => !!relation.getNote() && relation.getNote().type !== "search")
|
||||
);
|
||||
}
|
||||
|
||||
function getBacklinkCount(req: Request) {
|
||||
const { noteId } = req.params;
|
||||
|
||||
const note = becca.getNoteOrThrow(noteId);
|
||||
|
||||
return {
|
||||
count: getFilteredBacklinks(note).length
|
||||
} satisfies BacklinkCountResponse;
|
||||
}
|
||||
|
||||
export default {
|
||||
getBacklinkCount
|
||||
}
|
||||
@@ -1,15 +1,17 @@
|
||||
import type { AttributeRow, CreateChildrenResponse, DeleteNotesPreview, MetadataResponse } from "@triliumnext/commons";
|
||||
import { blob as blobService, erase as eraseService, ValidationError } from "@triliumnext/core";
|
||||
import type { Request } from "express";
|
||||
|
||||
import blobService from "../../services/blob";
|
||||
import eraseService from "../../services/erase.js";
|
||||
import { ValidationError } from "../../errors.js";
|
||||
import becca from "../../becca/becca.js";
|
||||
import type BBranch from "../../becca/entities/bbranch.js";
|
||||
import log from "../../services/log.js";
|
||||
import { getLog } from "../../services/log.js";
|
||||
import noteService from "../../services/notes.js";
|
||||
import sql from "../../services/sql.js";
|
||||
import { getSql } from "../../services/sql/index";
|
||||
import TaskContext from "../../services/task_context.js";
|
||||
import treeService from "../../services/tree.js";
|
||||
import utils from "../../services/utils.js";
|
||||
import { randomString } from "../../services/utils/index";
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
@@ -174,7 +176,7 @@ function deleteNote(req: Request) {
|
||||
const last = req.query.last === "true";
|
||||
|
||||
// note how deleteId is separate from taskId - single taskId produces separate deleteId for each "top level" deleted note
|
||||
const deleteId = utils.randomString(10);
|
||||
const deleteId = randomString(10);
|
||||
|
||||
const note = becca.getNoteOrThrow(noteId);
|
||||
|
||||
@@ -195,7 +197,7 @@ function deleteNote(req: Request) {
|
||||
}
|
||||
|
||||
function undeleteNote(req: Request) {
|
||||
const taskContext = TaskContext.getInstance(utils.randomString(10), "undeleteNotes", null);
|
||||
const taskContext = TaskContext.getInstance(randomString(10), "undeleteNotes", null);
|
||||
|
||||
noteService.undeleteNote(req.params.noteId, taskContext);
|
||||
|
||||
@@ -206,7 +208,7 @@ function sortChildNotes(req: Request) {
|
||||
const noteId = req.params.noteId;
|
||||
const { sortBy, sortDirection, foldersFirst, sortNatural, sortLocale } = req.body;
|
||||
|
||||
log.info(`Sorting '${noteId}' children with ${sortBy} ${sortDirection}, foldersFirst=${foldersFirst}, sortNatural=${sortNatural}, sortLocale=${sortLocale}`);
|
||||
getLog().info(`Sorting '${noteId}' children with ${sortBy} ${sortDirection}, foldersFirst=${foldersFirst}, sortNatural=${sortNatural}, sortLocale=${sortLocale}`);
|
||||
|
||||
const reverse = sortDirection === "desc";
|
||||
|
||||
@@ -219,7 +221,7 @@ function protectNote(req: Request) {
|
||||
const protect = !!parseInt(req.params.isProtected);
|
||||
const includingSubTree = !!parseInt(req.query?.subtree as string);
|
||||
|
||||
const taskContext = new TaskContext(utils.randomString(10), "protectNotes", { protect });
|
||||
const taskContext = new TaskContext(randomString(10), "protectNotes", { protect });
|
||||
|
||||
noteService.protectNoteRecursively(note, protect, includingSubTree, taskContext);
|
||||
|
||||
@@ -307,7 +309,7 @@ function getDeleteNotesPreview(req: Request) {
|
||||
const branch = becca.getBranch(branchId);
|
||||
|
||||
if (!branch) {
|
||||
log.error(`Branch ${branchId} was not found and delete preview can't be calculated for this note.`);
|
||||
getLog().error(`Branch ${branchId} was not found and delete preview can't be calculated for this note.`);
|
||||
|
||||
continue;
|
||||
}
|
||||
@@ -318,6 +320,7 @@ function getDeleteNotesPreview(req: Request) {
|
||||
let brokenRelations: AttributeRow[] = [];
|
||||
|
||||
if (noteIdsToBeDeleted.size > 0) {
|
||||
const sql = getSql();
|
||||
sql.fillParamList(noteIdsToBeDeleted);
|
||||
|
||||
// FIXME: No need to do this in database, can be done with becca data
|
||||
@@ -1,14 +1,14 @@
|
||||
|
||||
|
||||
import type { OptionNames } from "@triliumnext/commons";
|
||||
import { ValidationError } from "@triliumnext/core";
|
||||
import type { Request } from "express";
|
||||
|
||||
import config from "../../services/config.js";
|
||||
import { changeLanguage, getLocales } from "../../services/i18n.js";
|
||||
import log from "../../services/log.js";
|
||||
import { getLog } from "../../services/log.js";
|
||||
import optionService from "../../services/options.js";
|
||||
import searchService from "../../services/search/services/search.js";
|
||||
import { ValidationError } from "../../errors.js";
|
||||
|
||||
interface UserTheme {
|
||||
val: string; // value of the theme, used in the URL
|
||||
@@ -122,6 +122,7 @@ const ALLOWED_OPTIONS = new Set<OptionNames>([
|
||||
]);
|
||||
|
||||
function getOptions() {
|
||||
console.log("Got opts");
|
||||
const optionMap = optionService.getOptionMap();
|
||||
const resultMap: Record<string, string> = {};
|
||||
|
||||
@@ -166,7 +167,7 @@ function update(name: string, value: string) {
|
||||
}
|
||||
|
||||
if (name !== "openNoteContexts") {
|
||||
log.info(`Updating option '${name}' to '${value}'`);
|
||||
getLog().info(`Updating option '${name}' to '${value}'`);
|
||||
}
|
||||
|
||||
optionService.setOption(name as OptionNames, value);
|
||||
29
packages/trilium-core/src/routes/api/others.ts
Normal file
29
packages/trilium-core/src/routes/api/others.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import becca from "../../becca/becca";
|
||||
|
||||
function getIconUsage() {
|
||||
const iconClassToCountMap: Record<string, number> = {};
|
||||
|
||||
for (const { value: iconClass, noteId } of becca.findAttributes("label", "iconClass")) {
|
||||
if (noteId.startsWith("_")) {
|
||||
continue; // ignore icons of "system" notes since they were not set by the user
|
||||
}
|
||||
|
||||
if (!iconClass?.trim()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const clazz of iconClass.trim().split(/\s+/)) {
|
||||
if (clazz === "bx") {
|
||||
continue;
|
||||
}
|
||||
|
||||
iconClassToCountMap[clazz] = (iconClassToCountMap[clazz] || 0) + 1;
|
||||
}
|
||||
}
|
||||
|
||||
return { iconClassToCountMap };
|
||||
}
|
||||
|
||||
export default {
|
||||
getIconUsage
|
||||
}
|
||||
@@ -1,8 +1,9 @@
|
||||
"use strict";
|
||||
|
||||
import BRecentNote from "../../becca/entities/brecent_note.js";
|
||||
import sql from "../../services/sql.js";
|
||||
import dateUtils from "../../services/date_utils.js";
|
||||
import { getSql } from "../../services/sql/index.js";
|
||||
import dateUtils from "../../services/utils/date.js";
|
||||
|
||||
import type { Request } from "express";
|
||||
|
||||
function addRecentNote(req: Request) {
|
||||
@@ -15,7 +16,7 @@ function addRecentNote(req: Request) {
|
||||
// it's not necessary to run this every time ...
|
||||
const cutOffDate = dateUtils.utcDateTimeStr(new Date(Date.now() - 24 * 3600 * 1000));
|
||||
|
||||
sql.execute(/*sql*/`DELETE FROM recent_notes WHERE utcDateCreated < ?`, [cutOffDate]);
|
||||
getSql().execute(/*sql*/`DELETE FROM recent_notes WHERE utcDateCreated < ?`, [cutOffDate]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import type { AttributeRow, BranchRow, NoteRow } from "@triliumnext/commons";
|
||||
import { NotFoundError } from "@triliumnext/core";
|
||||
import type { Request } from "express";
|
||||
|
||||
import becca from "../../becca/becca.js";
|
||||
import { NotFoundError } from "../../errors.js";
|
||||
import { getLog } from "../../services/log.js";
|
||||
import type BNote from "../../becca/entities/bnote.js";
|
||||
import log from "../../services/log.js";
|
||||
|
||||
function getNotesAndBranchesAndAttributes(_noteIds: string[] | Set<string>) {
|
||||
const noteIds = new Set(_noteIds);
|
||||
@@ -85,7 +85,7 @@ function getNotesAndBranchesAndAttributes(_noteIds: string[] | Set<string>) {
|
||||
const branch = becca.branches[branchId];
|
||||
|
||||
if (!branch) {
|
||||
log.error(`Could not find branch for branchId=${branchId}`);
|
||||
getLog().error(`Could not find branch for branchId=${branchId}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -105,7 +105,7 @@ function getNotesAndBranchesAndAttributes(_noteIds: string[] | Set<string>) {
|
||||
const attribute = becca.attributes[attributeId];
|
||||
|
||||
if (!attribute) {
|
||||
log.error(`Could not find attribute for attributeId=${attributeId}`);
|
||||
getLog().error(`Could not find attribute for attributeId=${attributeId}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
104
packages/trilium-core/src/routes/index.ts
Normal file
104
packages/trilium-core/src/routes/index.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
import optionsApiRoute from "./api/options";
|
||||
import treeApiRoute from "./api/tree";
|
||||
import keysApiRoute from "./api/keys";
|
||||
import notesApiRoute from "./api/notes";
|
||||
import attachmentsApiRoute from "./api/attachments";
|
||||
import noteMapRoute from "./api/note_map";
|
||||
import recentNotesRoute from "./api/recent_notes";
|
||||
import otherRoute from "./api/others";
|
||||
import branchesApiRoute from "./api/branches";
|
||||
import appInfoRoute from "./api/app_info";
|
||||
import AbstractBeccaEntity from "../becca/entities/abstract_becca_entity";
|
||||
|
||||
// TODO: Deduplicate with routes.ts
|
||||
const GET = "get",
|
||||
PST = "post",
|
||||
PUT = "put",
|
||||
PATCH = "patch",
|
||||
DEL = "delete";
|
||||
|
||||
export function buildSharedApiRoutes(apiRoute: any) {
|
||||
apiRoute(GET, '/api/tree', treeApiRoute.getTree);
|
||||
apiRoute(PST, '/api/tree/load', treeApiRoute.load);
|
||||
|
||||
apiRoute(GET, "/api/options", optionsApiRoute.getOptions);
|
||||
// FIXME: possibly change to sending value in the body to avoid host of HTTP server issues with slashes
|
||||
apiRoute(PUT, "/api/options/:name/:value", optionsApiRoute.updateOption);
|
||||
apiRoute(PUT, "/api/options", optionsApiRoute.updateOptions);
|
||||
apiRoute(GET, "/api/options/user-themes", optionsApiRoute.getUserThemes);
|
||||
apiRoute(GET, "/api/options/locales", optionsApiRoute.getSupportedLocales);
|
||||
|
||||
apiRoute(PST, "/api/notes/:noteId/convert-to-attachment", notesApiRoute.convertNoteToAttachment);
|
||||
apiRoute(GET, "/api/notes/:noteId", notesApiRoute.getNote);
|
||||
apiRoute(GET, "/api/notes/:noteId/blob", notesApiRoute.getNoteBlob);
|
||||
apiRoute(GET, "/api/notes/:noteId/metadata", notesApiRoute.getNoteMetadata);
|
||||
apiRoute(PUT, "/api/notes/:noteId/data", notesApiRoute.updateNoteData);
|
||||
apiRoute(DEL, "/api/notes/:noteId", notesApiRoute.deleteNote);
|
||||
apiRoute(PUT, "/api/notes/:noteId/undelete", notesApiRoute.undeleteNote);
|
||||
apiRoute(PST, "/api/notes/:noteId/revision", notesApiRoute.forceSaveRevision);
|
||||
apiRoute(PST, "/api/notes/:parentNoteId/children", notesApiRoute.createNote);
|
||||
apiRoute(PUT, "/api/notes/:noteId/sort-children", notesApiRoute.sortChildNotes);
|
||||
apiRoute(PUT, "/api/notes/:noteId/protect/:isProtected", notesApiRoute.protectNote);
|
||||
apiRoute(PUT, "/api/notes/:noteId/type", notesApiRoute.setNoteTypeMime);
|
||||
apiRoute(PUT, "/api/notes/:noteId/title", notesApiRoute.changeTitle);
|
||||
apiRoute(PST, "/api/notes/:noteId/duplicate/:parentNoteId", notesApiRoute.duplicateSubtree);
|
||||
apiRoute(PST, "/api/notes/erase-deleted-notes-now", notesApiRoute.eraseDeletedNotesNow);
|
||||
apiRoute(PST, "/api/notes/erase-unused-attachments-now", notesApiRoute.eraseUnusedAttachmentsNow);
|
||||
apiRoute(PST, "/api/delete-notes-preview", notesApiRoute.getDeleteNotesPreview);
|
||||
|
||||
apiRoute(GET, "/api/notes/:noteId/attachments", attachmentsApiRoute.getAttachments);
|
||||
apiRoute(PST, "/api/notes/:noteId/attachments", attachmentsApiRoute.saveAttachment);
|
||||
apiRoute(GET, "/api/attachments/:attachmentId", attachmentsApiRoute.getAttachment);
|
||||
apiRoute(GET, "/api/attachments/:attachmentId/all", attachmentsApiRoute.getAllAttachments);
|
||||
apiRoute(PST, "/api/attachments/:attachmentId/convert-to-note", attachmentsApiRoute.convertAttachmentToNote);
|
||||
apiRoute(DEL, "/api/attachments/:attachmentId", attachmentsApiRoute.deleteAttachment);
|
||||
apiRoute(PUT, "/api/attachments/:attachmentId/rename", attachmentsApiRoute.renameAttachment);
|
||||
apiRoute(GET, "/api/attachments/:attachmentId/blob", attachmentsApiRoute.getAttachmentBlob);
|
||||
|
||||
apiRoute(PUT, "/api/branches/:branchId/move-to/:parentBranchId", branchesApiRoute.moveBranchToParent);
|
||||
apiRoute(PUT, "/api/branches/:branchId/move-before/:beforeBranchId", branchesApiRoute.moveBranchBeforeNote);
|
||||
apiRoute(PUT, "/api/branches/:branchId/move-after/:afterBranchId", branchesApiRoute.moveBranchAfterNote);
|
||||
apiRoute(PUT, "/api/branches/:branchId/expanded/:expanded", branchesApiRoute.setExpanded);
|
||||
apiRoute(PUT, "/api/branches/:branchId/expanded-subtree/:expanded", branchesApiRoute.setExpandedForSubtree);
|
||||
apiRoute(DEL, "/api/branches/:branchId", branchesApiRoute.deleteBranch);
|
||||
apiRoute(PUT, "/api/branches/:branchId/set-prefix", branchesApiRoute.setPrefix);
|
||||
apiRoute(PUT, "/api/branches/set-prefix-batch", branchesApiRoute.setPrefixBatch);
|
||||
|
||||
apiRoute(GET, "/api/note-map/:noteId/backlink-count", noteMapRoute.getBacklinkCount);
|
||||
|
||||
apiRoute(PST, "/api/recent-notes", recentNotesRoute.addRecentNote);
|
||||
|
||||
apiRoute(GET, "/api/keyboard-actions", keysApiRoute.getKeyboardActions);
|
||||
apiRoute(GET, "/api/keyboard-shortcuts-for-notes", keysApiRoute.getShortcutsForNotes);
|
||||
|
||||
apiRoute(GET, "/api/app-info", appInfoRoute.getAppInfo);
|
||||
apiRoute(GET, "/api/other/icon-usage", otherRoute.getIconUsage);
|
||||
}
|
||||
|
||||
/** Handling common patterns. If entity is not caught, serialization to JSON will fail */
|
||||
export function convertEntitiesToPojo(result: unknown) {
|
||||
if (result instanceof AbstractBeccaEntity) {
|
||||
result = result.getPojo();
|
||||
} else if (Array.isArray(result)) {
|
||||
for (const idx in result) {
|
||||
if (result[idx] instanceof AbstractBeccaEntity) {
|
||||
result[idx] = result[idx].getPojo();
|
||||
}
|
||||
}
|
||||
} else if (result && typeof result === "object") {
|
||||
if ("note" in result && result.note instanceof AbstractBeccaEntity) {
|
||||
result.note = result.note.getPojo();
|
||||
}
|
||||
|
||||
if ("branch" in result && result.branch instanceof AbstractBeccaEntity) {
|
||||
result.branch = result.branch.getPojo();
|
||||
}
|
||||
}
|
||||
|
||||
if (result && typeof result === "object" && "executionResult" in result) {
|
||||
// from runOnBackend()
|
||||
result.executionResult = convertEntitiesToPojo(result.executionResult);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
@@ -6,7 +6,7 @@ const APP_DB_VERSION = 233;
|
||||
const SYNC_VERSION = 36;
|
||||
const CLIPPER_PROTOCOL_VERSION = "1.0";
|
||||
|
||||
export default {
|
||||
const appInfo: AppInfo = {
|
||||
appVersion: packageJson.version,
|
||||
dbVersion: APP_DB_VERSION,
|
||||
syncVersion: SYNC_VERSION,
|
||||
@@ -14,4 +14,6 @@ export default {
|
||||
buildRevision: build.buildRevision,
|
||||
clipperProtocolVersion: CLIPPER_PROTOCOL_VERSION,
|
||||
utcDateTime: new Date().toISOString()
|
||||
} satisfies AppInfo;
|
||||
}
|
||||
|
||||
export default appInfo;
|
||||
|
||||
30
packages/trilium-core/src/services/bootstrap_utils.ts
Normal file
30
packages/trilium-core/src/services/bootstrap_utils.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { BootstrapDefinition } from "@triliumnext/commons";
|
||||
import { getSql } from "./sql";
|
||||
import protected_session from "./protected_session";
|
||||
import { generateCss, generateIconRegistry, getIconPacks, MIME_TO_EXTENSION_MAPPINGS } from "./icon_packs";
|
||||
import options from "./options";
|
||||
import { getCurrentLocale } from "./i18n";
|
||||
|
||||
export default function getSharedBootstrapItems(assetPath: string): Pick<BootstrapDefinition, "assetPath" | "headingStyle" | "layoutOrientation" | "maxEntityChangeIdAtLoad" | "maxEntityChangeSyncIdAtLoad" | "isProtectedSessionAvailable" | "iconRegistry" | "iconPackCss" | "currentLocale" | "isRtl"> {
|
||||
const sql = getSql();
|
||||
const iconPacks = getIconPacks();
|
||||
const currentLocale = getCurrentLocale();
|
||||
|
||||
return {
|
||||
assetPath,
|
||||
headingStyle: options.getOption("headingStyle") as "plain" | "underline" | "markdown",
|
||||
layoutOrientation: options.getOption("layoutOrientation") as "vertical" | "horizontal",
|
||||
maxEntityChangeIdAtLoad: sql.getValue("SELECT COALESCE(MAX(id), 0) FROM entity_changes"),
|
||||
maxEntityChangeSyncIdAtLoad: sql.getValue("SELECT COALESCE(MAX(id), 0) FROM entity_changes WHERE isSynced = 1"),
|
||||
isProtectedSessionAvailable: protected_session.isProtectedSessionAvailable(),
|
||||
currentLocale,
|
||||
isRtl: !!currentLocale.rtl,
|
||||
iconRegistry: generateIconRegistry(iconPacks),
|
||||
iconPackCss: iconPacks
|
||||
.map(p => generateCss(p, p.builtin
|
||||
? `${assetPath}/fonts/${p.fontAttachmentId}.${MIME_TO_EXTENSION_MAPPINGS[p.fontMime]}`
|
||||
: `api/attachments/download/${p.fontAttachmentId}`))
|
||||
.filter(Boolean)
|
||||
.join("\n\n"),
|
||||
}
|
||||
}
|
||||
50
packages/trilium-core/src/services/branches.ts
Normal file
50
packages/trilium-core/src/services/branches.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import treeService from "./tree.js";
|
||||
import type BBranch from "../becca/entities/bbranch.js";
|
||||
import { getSql } from "./sql/index.js";
|
||||
|
||||
function moveBranchToNote(branchToMove: BBranch, targetParentNoteId: string) {
|
||||
if (branchToMove.parentNoteId === targetParentNoteId) {
|
||||
return { success: true }; // no-op
|
||||
}
|
||||
|
||||
const validationResult = treeService.validateParentChild(targetParentNoteId, branchToMove.noteId, branchToMove.branchId);
|
||||
|
||||
if (!validationResult.success) {
|
||||
return [200, validationResult];
|
||||
}
|
||||
|
||||
const maxNotePos = getSql().getValue<number | null>("SELECT MAX(notePosition) FROM branches WHERE parentNoteId = ? AND isDeleted = 0", [targetParentNoteId]);
|
||||
const newNotePos = !maxNotePos ? 0 : maxNotePos + 10;
|
||||
|
||||
const newBranch = branchToMove.createClone(targetParentNoteId, newNotePos);
|
||||
newBranch.save();
|
||||
|
||||
branchToMove.markAsDeleted();
|
||||
|
||||
return {
|
||||
success: true,
|
||||
branch: newBranch
|
||||
};
|
||||
}
|
||||
|
||||
function moveBranchToBranch(branchToMove: BBranch, targetParentBranch: BBranch, branchId: string) {
|
||||
// TODO: Unused branch ID argument.
|
||||
const res = moveBranchToNote(branchToMove, targetParentBranch.noteId);
|
||||
|
||||
if (!("success" in res) || !res.success) {
|
||||
return res;
|
||||
}
|
||||
|
||||
// expanding so that the new placement of the branch is immediately visible
|
||||
if (!targetParentBranch.isExpanded) {
|
||||
targetParentBranch.isExpanded = true;
|
||||
targetParentBranch.save();
|
||||
}
|
||||
|
||||
return res;
|
||||
}
|
||||
|
||||
export default {
|
||||
moveBranchToBranch,
|
||||
moveBranchToNote
|
||||
};
|
||||
6
packages/trilium-core/src/services/config.ts
Normal file
6
packages/trilium-core/src/services/config.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
// TODO: Real implementation.
|
||||
export default {
|
||||
General: {
|
||||
readOnly: false
|
||||
}
|
||||
};
|
||||
51
packages/trilium-core/src/services/i18n.ts
Normal file
51
packages/trilium-core/src/services/i18n.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { dayjs, Dayjs, Locale, LOCALE_IDS, LOCALES, setDayjsLocale } from "@triliumnext/commons";
|
||||
import sql_init from "./sql_init";
|
||||
import options from "./options";
|
||||
import i18next from "i18next";
|
||||
import hidden_subtree from "./hidden_subtree";
|
||||
|
||||
export type TranslationProvider = (locale: LOCALE_IDS) => Promise<void>;
|
||||
|
||||
export async function initTranslations(translationProvider: TranslationProvider) {
|
||||
const locale = getCurrentLanguage();
|
||||
|
||||
await translationProvider(locale);
|
||||
|
||||
// Initialize dayjs locale.
|
||||
await setDayjsLocale(locale);
|
||||
}
|
||||
|
||||
export function ordinal(date: Dayjs) {
|
||||
return dayjs(date)
|
||||
.format("Do");
|
||||
}
|
||||
|
||||
export function getLocales(): Locale[] {
|
||||
return LOCALES;
|
||||
}
|
||||
|
||||
function getCurrentLanguage(): LOCALE_IDS {
|
||||
let language: string | null = null;
|
||||
if (sql_init.isDbInitialized()) {
|
||||
language = options.getOptionOrNull("locale");
|
||||
}
|
||||
|
||||
if (!language) {
|
||||
console.info("Language option not found, falling back to en.");
|
||||
language = "en";
|
||||
}
|
||||
|
||||
return language as LOCALE_IDS;
|
||||
}
|
||||
|
||||
export async function changeLanguage(locale: string) {
|
||||
await i18next.changeLanguage(locale);
|
||||
hidden_subtree.checkHiddenSubtree(true, { restoreNames: true });
|
||||
}
|
||||
|
||||
export function getCurrentLocale() {
|
||||
const localeId = options.getOptionOrNull("locale") ?? "en";
|
||||
const currentLocale = LOCALES.find(l => l.id === localeId);
|
||||
if (!currentLocale) return LOCALES.find(l => l.id === "en")!;
|
||||
return currentLocale;
|
||||
}
|
||||
@@ -3,9 +3,9 @@ import { IconRegistry } from "@triliumnext/commons";
|
||||
import type BAttachment from "../becca/entities/battachment";
|
||||
import type BNote from "../becca/entities/bnote";
|
||||
import boxiconsManifest from "./icon_pack_boxicons-v2.json" with { type: "json" };
|
||||
import log from "./log";
|
||||
import { getLog } from "./log";
|
||||
import search from "./search/services/search";
|
||||
import { safeExtractMessageAndStackFromError } from "./utils";
|
||||
import { safeExtractMessageAndStackFromError } from "./utils/index";
|
||||
|
||||
const PREFERRED_MIME_TYPE = [
|
||||
"font/woff2",
|
||||
@@ -64,7 +64,7 @@ export function getIconPacks() {
|
||||
if (!iconPack) return false;
|
||||
|
||||
if (iconPack.prefix === "bx" || usedPrefixes.has(iconPack.prefix)) {
|
||||
log.info(`Skipping icon pack with duplicate prefix '${iconPack.prefix}': ${iconPack.title} (${iconPack.manifestNoteId})`);
|
||||
getLog().info(`Skipping icon pack with duplicate prefix '${iconPack.prefix}': ${iconPack.title} (${iconPack.manifestNoteId})`);
|
||||
return false;
|
||||
}
|
||||
usedPrefixes.add(iconPack.prefix);
|
||||
@@ -103,25 +103,25 @@ export function generateIconRegistry(iconPacks: ProcessedIconPack[]): IconRegist
|
||||
export function processIconPack(iconPackNote: BNote): ProcessedIconPack | undefined {
|
||||
const manifest = iconPackNote.getJsonContentSafely() as IconPackManifest;
|
||||
if (!manifest) {
|
||||
log.error(`Icon pack is missing JSON manifest (or has syntax errors): ${iconPackNote.title} (${iconPackNote.noteId})`);
|
||||
getLog().error(`Icon pack is missing JSON manifest (or has syntax errors): ${iconPackNote.title} (${iconPackNote.noteId})`);
|
||||
return;
|
||||
}
|
||||
|
||||
const attachment = determineBestFontAttachment(iconPackNote);
|
||||
if (!attachment || !attachment.attachmentId) {
|
||||
log.error(`Icon pack is missing WOFF/WOFF2/TTF attachment: ${iconPackNote.title} (${iconPackNote.noteId})`);
|
||||
getLog().error(`Icon pack is missing WOFF/WOFF2/TTF attachment: ${iconPackNote.title} (${iconPackNote.noteId})`);
|
||||
return;
|
||||
}
|
||||
|
||||
const prefix = iconPackNote.getLabelValue("iconPack");
|
||||
if (!prefix) {
|
||||
log.error(`Icon pack is missing 'iconPack' label defining its prefix: ${iconPackNote.title} (${iconPackNote.noteId})`);
|
||||
getLog().error(`Icon pack is missing 'iconPack' label defining its prefix: ${iconPackNote.title} (${iconPackNote.noteId})`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Ensure prefix is alphanumeric only, dashes and underscores.
|
||||
if (!/^[a-zA-Z0-9-_]+$/.test(prefix)) {
|
||||
log.error(`Icon pack has invalid 'iconPack' prefix (only alphanumeric characters, dashes and underscores are allowed): ${iconPackNote.title} (${iconPackNote.noteId})`);
|
||||
getLog().error(`Icon pack has invalid 'iconPack' prefix (only alphanumeric characters, dashes and underscores are allowed): ${iconPackNote.title} (${iconPackNote.noteId})`);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -185,7 +185,7 @@ export function generateCss({ manifest, fontMime, builtin, fontAttachmentId, pre
|
||||
${iconDeclarations.join("\n")}
|
||||
`;
|
||||
} catch (e) {
|
||||
log.error(safeExtractMessageAndStackFromError(e));
|
||||
getLog().error(safeExtractMessageAndStackFromError(e));
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -8,8 +8,7 @@ import { t } from "i18next";
|
||||
|
||||
function getDefaultKeyboardActions() {
|
||||
if (!t("keyboard_actions.note-navigation")) {
|
||||
// TODO: Re-enable.
|
||||
// throw new Error("Keyboard actions loaded before translations.");
|
||||
throw new Error("Keyboard actions loaded before translations.");
|
||||
}
|
||||
|
||||
const DEFAULT_KEYBOARD_ACTIONS: KeyboardShortcut[] = [
|
||||
|
||||
46
packages/trilium-core/src/services/messaging/index.ts
Normal file
46
packages/trilium-core/src/services/messaging/index.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import type { WebSocketMessage } from "@triliumnext/commons";
|
||||
import type { MessagingProvider } from "./types.js";
|
||||
|
||||
let messagingProvider: MessagingProvider | null = null;
|
||||
|
||||
/**
|
||||
* Initialize the messaging system with a provider.
|
||||
* This should be called during application startup.
|
||||
*/
|
||||
export function initMessaging(provider: MessagingProvider): void {
|
||||
messagingProvider = provider;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current messaging provider.
|
||||
* Throws if messaging hasn't been initialized.
|
||||
*/
|
||||
export function getMessagingProvider(): MessagingProvider {
|
||||
if (!messagingProvider) {
|
||||
throw new Error("Messaging provider not initialized. Call initMessaging() first.");
|
||||
}
|
||||
return messagingProvider;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if messaging has been initialized.
|
||||
*/
|
||||
export function isMessagingInitialized(): boolean {
|
||||
return messagingProvider !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a message to all connected clients.
|
||||
* This is a convenience function that uses the current provider.
|
||||
*/
|
||||
export function sendMessageToAllClients(message: WebSocketMessage): void {
|
||||
if (!messagingProvider) {
|
||||
// Silently ignore if no provider - allows core to work without messaging
|
||||
console.debug("[Messaging] No provider initialized, message not sent:", message.type);
|
||||
return;
|
||||
}
|
||||
messagingProvider.sendMessageToAllClients(message);
|
||||
}
|
||||
|
||||
// Re-export types
|
||||
export * from "./types.js";
|
||||
97
packages/trilium-core/src/services/messaging/types.ts
Normal file
97
packages/trilium-core/src/services/messaging/types.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
import type { EntityChange, WebSocketMessage } from "@triliumnext/commons";
|
||||
|
||||
/**
|
||||
* Handler function for incoming messages from clients.
|
||||
*/
|
||||
export type MessageHandler = (message: WebSocketMessage) => void | Promise<void>;
|
||||
|
||||
/**
|
||||
* Represents a connected client that can receive messages.
|
||||
*/
|
||||
export interface MessageClient {
|
||||
/** Unique identifier for this client */
|
||||
readonly id: string;
|
||||
/** Send a message to this specific client */
|
||||
send(message: WebSocketMessage): void;
|
||||
/** Check if the client is still connected */
|
||||
isConnected(): boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Provider interface for server-to-client messaging.
|
||||
*
|
||||
* This abstraction allows different transport mechanisms:
|
||||
* - WebSocket for traditional server environments
|
||||
* - Worker postMessage for browser environments
|
||||
* - Mock implementations for testing
|
||||
*/
|
||||
export interface MessagingProvider {
|
||||
/**
|
||||
* Send a message to all connected clients.
|
||||
* This is the primary method used by core services like TaskContext.
|
||||
*/
|
||||
sendMessageToAllClients(message: WebSocketMessage): void;
|
||||
|
||||
/**
|
||||
* Send a message to a specific client by ID.
|
||||
* Returns false if the client is not found or disconnected.
|
||||
*/
|
||||
sendMessageToClient?(clientId: string, message: WebSocketMessage): boolean;
|
||||
|
||||
/**
|
||||
* Subscribe to incoming messages from clients.
|
||||
* Returns an unsubscribe function.
|
||||
*/
|
||||
onMessage?(handler: MessageHandler): () => void;
|
||||
|
||||
/**
|
||||
* Get the number of connected clients.
|
||||
*/
|
||||
getClientCount?(): number;
|
||||
|
||||
/**
|
||||
* Called when the provider should clean up resources.
|
||||
*/
|
||||
dispose?(): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extended interface for server-side messaging with entity change support.
|
||||
* This is used by the WebSocket implementation to handle entity sync.
|
||||
*/
|
||||
export interface ServerMessagingProvider extends MessagingProvider {
|
||||
/**
|
||||
* Send entity changes to all clients (for frontend-update messages).
|
||||
*/
|
||||
sendEntityChangesToAllClients(entityChanges: EntityChange[]): void;
|
||||
|
||||
/**
|
||||
* Set the last synced push ID for sync status messages.
|
||||
*/
|
||||
setLastSyncedPush(entityChangeId: number): void;
|
||||
|
||||
/**
|
||||
* Notify clients that sync pull is in progress.
|
||||
*/
|
||||
syncPullInProgress(): void;
|
||||
|
||||
/**
|
||||
* Notify clients that sync push is in progress.
|
||||
*/
|
||||
syncPushInProgress(): void;
|
||||
|
||||
/**
|
||||
* Notify clients that sync has finished.
|
||||
*/
|
||||
syncFinished(): void;
|
||||
|
||||
/**
|
||||
* Notify clients that sync has failed.
|
||||
*/
|
||||
syncFailed(): void;
|
||||
|
||||
/**
|
||||
* Request all clients to reload their frontend.
|
||||
*/
|
||||
reloadFrontend(reason: string): void;
|
||||
}
|
||||
@@ -5,7 +5,7 @@ export default {
|
||||
console.warn("Ignore search ", note.title);
|
||||
},
|
||||
|
||||
searchNotes(searchString: string) {
|
||||
searchNotes(searchString: string, opts?: {}): BNote[] {
|
||||
console.warn("Ignore search", searchString);
|
||||
return [];
|
||||
}
|
||||
|
||||
@@ -277,6 +277,8 @@ export class SqlService {
|
||||
return null;
|
||||
}
|
||||
|
||||
console.error(`Error executing query: ${query} with parameters ${JSON.stringify((e as any).params || [])}`);
|
||||
|
||||
throw e;
|
||||
}
|
||||
|
||||
|
||||
@@ -6,3 +6,9 @@ export const dbReady = deferred<void>();
|
||||
setTimeout(() => {
|
||||
dbReady.resolve();
|
||||
}, 850);
|
||||
|
||||
function isDbInitialized() {
|
||||
return true;
|
||||
}
|
||||
|
||||
export default { isDbInitialized };
|
||||
|
||||
@@ -126,3 +126,12 @@ export const unescapeHtml = unescape;
|
||||
export function randomSecureToken(bytes = 32) {
|
||||
return encodeBase64(getCrypto().randomBytes(32));
|
||||
}
|
||||
|
||||
export function safeExtractMessageAndStackFromError(err: unknown): [errMessage: string, errStack: string | undefined] {
|
||||
return (err instanceof Error) ? [err.message, err.stack] as const : ["Unknown Error", undefined] as const;
|
||||
}
|
||||
|
||||
export function isEmptyOrWhitespace(str: string | null | undefined) {
|
||||
if (!str) return true;
|
||||
return str.match(/^ *$/) !== null;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,20 @@
|
||||
import type { WebSocketMessage } from "@triliumnext/commons";
|
||||
import { sendMessageToAllClients as sendMessage } from "./messaging/index.js";
|
||||
|
||||
/**
|
||||
* WebSocket service abstraction for core.
|
||||
*
|
||||
* This module provides a simple interface for sending messages to clients.
|
||||
* The actual transport mechanism is provided by the messaging provider
|
||||
* configured during initialization.
|
||||
*
|
||||
* @deprecated Use the messaging module directly instead.
|
||||
*/
|
||||
export default {
|
||||
sendMessageToAllClients(message: object) {
|
||||
console.warn("Ignored ws", message);
|
||||
/**
|
||||
* Send a message to all connected clients.
|
||||
*/
|
||||
sendMessageToAllClients(message: WebSocketMessage) {
|
||||
sendMessage(message);
|
||||
}
|
||||
}
|
||||
|
||||
347
pnpm-lock.yaml
generated
347
pnpm-lock.yaml
generated
@@ -351,6 +351,214 @@ importers:
|
||||
specifier: 3.1.4
|
||||
version: 3.1.4(vite@7.3.1(@types/node@24.10.7)(jiti@2.6.1)(less@4.1.3)(lightningcss@1.30.2)(sass-embedded@1.91.0)(sass@1.91.0)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.1))
|
||||
|
||||
apps/client-standalone:
|
||||
dependencies:
|
||||
'@excalidraw/excalidraw':
|
||||
specifier: 0.18.0
|
||||
version: 0.18.0(@types/react-dom@19.1.6(@types/react@19.1.7))(@types/react@19.1.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||
'@fullcalendar/core':
|
||||
specifier: 6.1.20
|
||||
version: 6.1.20
|
||||
'@fullcalendar/daygrid':
|
||||
specifier: 6.1.20
|
||||
version: 6.1.20(@fullcalendar/core@6.1.20)
|
||||
'@fullcalendar/interaction':
|
||||
specifier: 6.1.20
|
||||
version: 6.1.20(@fullcalendar/core@6.1.20)
|
||||
'@fullcalendar/list':
|
||||
specifier: 6.1.20
|
||||
version: 6.1.20(@fullcalendar/core@6.1.20)
|
||||
'@fullcalendar/multimonth':
|
||||
specifier: 6.1.20
|
||||
version: 6.1.20(@fullcalendar/core@6.1.20)
|
||||
'@fullcalendar/timegrid':
|
||||
specifier: 6.1.20
|
||||
version: 6.1.20(@fullcalendar/core@6.1.20)
|
||||
'@maplibre/maplibre-gl-leaflet':
|
||||
specifier: 0.1.3
|
||||
version: 0.1.3(@types/leaflet@1.9.21)(leaflet@1.9.4)(maplibre-gl@5.6.1)
|
||||
'@mermaid-js/layout-elk':
|
||||
specifier: 0.2.0
|
||||
version: 0.2.0(mermaid@11.12.2)
|
||||
'@mind-elixir/node-menu':
|
||||
specifier: 5.0.1
|
||||
version: 5.0.1(mind-elixir@5.4.0)
|
||||
'@popperjs/core':
|
||||
specifier: 2.11.8
|
||||
version: 2.11.8
|
||||
'@preact/signals':
|
||||
specifier: 2.5.1
|
||||
version: 2.5.1(preact@10.28.2)
|
||||
'@sqlite.org/sqlite-wasm':
|
||||
specifier: 3.51.1-build2
|
||||
version: 3.51.1-build2
|
||||
'@triliumnext/ckeditor5':
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/ckeditor5
|
||||
'@triliumnext/codemirror':
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/codemirror
|
||||
'@triliumnext/commons':
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/commons
|
||||
'@triliumnext/core':
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/trilium-core
|
||||
'@triliumnext/highlightjs':
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/highlightjs
|
||||
'@triliumnext/share-theme':
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/share-theme
|
||||
'@triliumnext/split.js':
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/splitjs
|
||||
'@zumer/snapdom':
|
||||
specifier: 2.0.1
|
||||
version: 2.0.1
|
||||
autocomplete.js:
|
||||
specifier: 0.38.1
|
||||
version: 0.38.1
|
||||
bootstrap:
|
||||
specifier: 5.3.8
|
||||
version: 5.3.8(@popperjs/core@2.11.8)
|
||||
boxicons:
|
||||
specifier: 2.1.4
|
||||
version: 2.1.4
|
||||
clsx:
|
||||
specifier: 2.1.1
|
||||
version: 2.1.1
|
||||
color:
|
||||
specifier: 5.0.3
|
||||
version: 5.0.3
|
||||
debounce:
|
||||
specifier: 3.0.0
|
||||
version: 3.0.0
|
||||
draggabilly:
|
||||
specifier: 3.0.0
|
||||
version: 3.0.0
|
||||
force-graph:
|
||||
specifier: 1.51.0
|
||||
version: 1.51.0
|
||||
globals:
|
||||
specifier: 17.0.0
|
||||
version: 17.0.0
|
||||
i18next:
|
||||
specifier: 25.7.3
|
||||
version: 25.7.3(typescript@5.9.3)
|
||||
i18next-http-backend:
|
||||
specifier: 3.0.2
|
||||
version: 3.0.2(encoding@0.1.13)
|
||||
jquery:
|
||||
specifier: 3.7.1
|
||||
version: 3.7.1
|
||||
jquery.fancytree:
|
||||
specifier: 2.38.5
|
||||
version: 2.38.5(jquery@3.7.1)
|
||||
js-sha1:
|
||||
specifier: 0.7.0
|
||||
version: 0.7.0
|
||||
js-sha512:
|
||||
specifier: 0.9.0
|
||||
version: 0.9.0
|
||||
jsplumb:
|
||||
specifier: 2.15.6
|
||||
version: 2.15.6
|
||||
katex:
|
||||
specifier: 0.16.27
|
||||
version: 0.16.27
|
||||
knockout:
|
||||
specifier: 3.5.1
|
||||
version: 3.5.1
|
||||
leaflet:
|
||||
specifier: 1.9.4
|
||||
version: 1.9.4
|
||||
leaflet-gpx:
|
||||
specifier: 2.2.0
|
||||
version: 2.2.0
|
||||
mark.js:
|
||||
specifier: 8.11.1
|
||||
version: 8.11.1
|
||||
marked:
|
||||
specifier: 17.0.1
|
||||
version: 17.0.1
|
||||
mermaid:
|
||||
specifier: 11.12.2
|
||||
version: 11.12.2
|
||||
mind-elixir:
|
||||
specifier: 5.4.0
|
||||
version: 5.4.0
|
||||
normalize.css:
|
||||
specifier: 8.0.1
|
||||
version: 8.0.1
|
||||
panzoom:
|
||||
specifier: 9.4.3
|
||||
version: 9.4.3
|
||||
preact:
|
||||
specifier: 10.28.2
|
||||
version: 10.28.2
|
||||
react-i18next:
|
||||
specifier: 16.5.1
|
||||
version: 16.5.1(i18next@25.7.3(typescript@5.9.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3)
|
||||
react-window:
|
||||
specifier: 2.2.3
|
||||
version: 2.2.3(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||
reveal.js:
|
||||
specifier: 5.2.1
|
||||
version: 5.2.1
|
||||
svg-pan-zoom:
|
||||
specifier: 3.6.2
|
||||
version: 3.6.2
|
||||
tabulator-tables:
|
||||
specifier: 6.3.1
|
||||
version: 6.3.1
|
||||
vanilla-js-wheel-zoom:
|
||||
specifier: 9.0.4
|
||||
version: 9.0.4
|
||||
devDependencies:
|
||||
'@ckeditor/ckeditor5-inspector':
|
||||
specifier: 5.0.0
|
||||
version: 5.0.0
|
||||
'@preact/preset-vite':
|
||||
specifier: 2.10.2
|
||||
version: 2.10.2(@babel/core@7.28.0)(preact@10.28.2)(vite@7.3.1(@types/node@24.10.7)(jiti@2.6.1)(less@4.1.3)(lightningcss@1.30.2)(sass-embedded@1.91.0)(sass@1.91.0)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.1))
|
||||
'@types/bootstrap':
|
||||
specifier: 5.2.10
|
||||
version: 5.2.10
|
||||
'@types/jquery':
|
||||
specifier: 3.5.33
|
||||
version: 3.5.33
|
||||
'@types/leaflet':
|
||||
specifier: 1.9.21
|
||||
version: 1.9.21
|
||||
'@types/leaflet-gpx':
|
||||
specifier: 1.3.8
|
||||
version: 1.3.8
|
||||
'@types/mark.js':
|
||||
specifier: 8.11.12
|
||||
version: 8.11.12
|
||||
'@types/reveal.js':
|
||||
specifier: 5.2.2
|
||||
version: 5.2.2
|
||||
'@types/tabulator-tables':
|
||||
specifier: 6.3.1
|
||||
version: 6.3.1
|
||||
copy-webpack-plugin:
|
||||
specifier: 13.0.1
|
||||
version: 13.0.1(webpack@5.101.3(esbuild@0.27.2))
|
||||
cross-env:
|
||||
specifier: 7.0.3
|
||||
version: 7.0.3
|
||||
happy-dom:
|
||||
specifier: 20.0.11
|
||||
version: 20.0.11
|
||||
script-loader:
|
||||
specifier: 0.7.2
|
||||
version: 0.7.2
|
||||
vite-plugin-static-copy:
|
||||
specifier: 3.1.4
|
||||
version: 3.1.4(vite@7.3.1(@types/node@24.10.7)(jiti@2.6.1)(less@4.1.3)(lightningcss@1.30.2)(sass-embedded@1.91.0)(sass@1.91.0)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.1))
|
||||
|
||||
apps/db-compare:
|
||||
dependencies:
|
||||
colors:
|
||||
@@ -1431,6 +1639,9 @@ importers:
|
||||
escape-html:
|
||||
specifier: 1.0.3
|
||||
version: 1.0.3
|
||||
i18next:
|
||||
specifier: 25.7.3
|
||||
version: 25.7.3(typescript@5.9.3)
|
||||
mime-types:
|
||||
specifier: 3.0.2
|
||||
version: 3.0.2
|
||||
@@ -1733,18 +1944,10 @@ packages:
|
||||
resolution: {integrity: sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
|
||||
'@babel/traverse@7.28.0':
|
||||
resolution: {integrity: sha512-mGe7UK5wWyh0bKRfupsUchrQGqvDbZDbKJw+kcRGSmdHVYrv+ltd0pnpDTVpiTqnaBru9iEvA8pz8W46v0Amwg==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
|
||||
'@babel/traverse@7.28.4':
|
||||
resolution: {integrity: sha512-YEzuboP2qvQavAcjgQNVgsvHIDv6ZpwXvcvjmyySP2DIMuByS/6ioU5G9pYrWHM6T2YDfc7xga9iNzYOs12CFQ==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
|
||||
'@babel/types@7.28.1':
|
||||
resolution: {integrity: sha512-x0LvFTekgSX+83TI28Y9wYPUfzrnl2aT5+5QLnO6v7mSJYtEEevuDRN0F0uSHRk1G1IWZC43o00Y0xDDrpBGPQ==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
|
||||
'@babel/types@7.28.4':
|
||||
resolution: {integrity: sha512-bkFqkLhh3pMBUQQkpVgWDWq/lqzc2678eUyDlTBhRqhCHFguYYGM0Efga7tYk4TogG/3x0EEl66/OQ+WGbWB/Q==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
@@ -4040,12 +4243,6 @@ packages:
|
||||
preact: 10.28.2
|
||||
vite: '>=2.0.0'
|
||||
|
||||
'@prefresh/vite@2.4.8':
|
||||
resolution: {integrity: sha512-H7vlo9UbJInuRbZhRQrdgVqLP7qKjDoX7TgYWWwIVhEHeHO0hZ4zyicvwBrV1wX5A3EPOmArgRkUaN7cPI2VXQ==}
|
||||
peerDependencies:
|
||||
preact: 10.28.2
|
||||
vite: '>=2.0.0'
|
||||
|
||||
'@promptbook/utils@0.69.5':
|
||||
resolution: {integrity: sha512-xm5Ti/Hp3o4xHrsK9Yy3MS6KbDxYbq485hDsFvxqaNA7equHLPdo8H8faTitTeb14QCDfLW4iwCxdVYu5sn6YQ==}
|
||||
|
||||
@@ -4995,6 +5192,10 @@ packages:
|
||||
'@socket.io/component-emitter@3.1.2':
|
||||
resolution: {integrity: sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==}
|
||||
|
||||
'@sqlite.org/sqlite-wasm@3.51.1-build2':
|
||||
resolution: {integrity: sha512-lVPTBlFsEijJ3wuoIbMfC9QMZKfL8huHN8D/lijNKoVxPqUDNvDtXse0wafe7USSmyfKAMb1JZ3ISSr/Vgbn5w==}
|
||||
hasBin: true
|
||||
|
||||
'@ssddanbrown/codemirror-lang-smarty@1.0.0':
|
||||
resolution: {integrity: sha512-F0ut1kmdbT3eORk3xVIKfQsGCZiQdh+6sLayBa0+FTex2gyIQlVQZRRA7bPSlchI3uZtWwNnqGNz5O/QLWRlFg==}
|
||||
|
||||
@@ -7443,15 +7644,6 @@ packages:
|
||||
supports-color:
|
||||
optional: true
|
||||
|
||||
debug@4.4.1:
|
||||
resolution: {integrity: sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==}
|
||||
engines: {node: '>=6.0'}
|
||||
peerDependencies:
|
||||
supports-color: '*'
|
||||
peerDependenciesMeta:
|
||||
supports-color:
|
||||
optional: true
|
||||
|
||||
debug@4.4.3:
|
||||
resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==}
|
||||
engines: {node: '>=6.0'}
|
||||
@@ -9513,6 +9705,12 @@ packages:
|
||||
resolution: {integrity: sha512-X2BB11YZtrRqY4EnQcLX5Rh373zbK4alC1FW7D7MBhL2gtcC17cTnr6DmfHZeS0s2rTHjUTMMHfG7gO8SSdw+g==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
js-sha1@0.7.0:
|
||||
resolution: {integrity: sha512-oQZ1Mo7440BfLSv9TX87VNEyU52pXPVG19F9PL3gTgNt0tVxlZ8F4O6yze3CLuLx28TxotxvlyepCNaaV0ZjMw==}
|
||||
|
||||
js-sha512@0.9.0:
|
||||
resolution: {integrity: sha512-mirki9WS/SUahm+1TbAPkqvbCiCfOAAsyXeHxK1UkullnJVVqoJG2pL9ObvT05CN+tM7fxhfYm0NbXn+1hWoZg==}
|
||||
|
||||
js-tokens@4.0.0:
|
||||
resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
|
||||
|
||||
@@ -10003,9 +10201,6 @@ packages:
|
||||
magic-string@0.25.9:
|
||||
resolution: {integrity: sha512-RmF0AsMzgt25qzqqLc1+MbHmhdx0ojF2Fvs4XnOqz2ZOBXzzkEwc/dJQZCYHAn7v1jbVOjAZfK8msRn4BxO4VQ==}
|
||||
|
||||
magic-string@0.30.18:
|
||||
resolution: {integrity: sha512-yi8swmWbO17qHhwIBNeeZxTceJMeBvWJaId6dyvTSOwTipqeHhMhOrz6513r1sOKnpvQ7zkhlG8tPrpilwTxHQ==}
|
||||
|
||||
magic-string@0.30.21:
|
||||
resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
|
||||
|
||||
@@ -10322,6 +10517,9 @@ packages:
|
||||
resolution: {integrity: sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==}
|
||||
engines: {node: '>=10'}
|
||||
|
||||
mind-elixir@5.4.0:
|
||||
resolution: {integrity: sha512-yxXajDWoSF6id8b2LKxlhXidxH/v6mx4JV+isrtsZ62RGCMsRbjUMFO9xOfTVH8vyxWhsbCkiAP6/i5hqbyk6w==}
|
||||
|
||||
mind-elixir@5.5.0:
|
||||
resolution: {integrity: sha512-a/bOTp3wJrK/vTm2/Vn5+9kYL0fNqxWvm8SsVojJO/tltLPPU8yMPzFCZHzGRz1Aoj6bpLxN+ExfIbc28nrNxQ==}
|
||||
|
||||
@@ -10542,11 +10740,6 @@ packages:
|
||||
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
|
||||
hasBin: true
|
||||
|
||||
nanoid@4.0.2:
|
||||
resolution: {integrity: sha512-7ZtY5KTCNheRGfEFxnedV5zFiORN1+Y1N6zvPTnHQd8ENUvfaDBeuJDZb2bN/oXwXxu3qkTXDzy57W5vAmDTBw==}
|
||||
engines: {node: ^14 || ^16 || >=18}
|
||||
hasBin: true
|
||||
|
||||
nanoid@5.1.5:
|
||||
resolution: {integrity: sha512-Ir/+ZpE9fDsNH0hQ3C68uyThDXzYcim2EqcZ8zn8Chtt1iylPT9xXJB0kPCnqzgcEGikO9RxSrh63MsmVCU7Fw==}
|
||||
engines: {node: ^18 || >=20}
|
||||
@@ -11952,6 +12145,12 @@ packages:
|
||||
peerDependencies:
|
||||
react: ^18.0.0 || ^19.0.0
|
||||
|
||||
react-window@2.2.3:
|
||||
resolution: {integrity: sha512-gTRqQYC8ojbiXyd9duYFiSn2TJw0ROXCgYjenOvNKITWzK0m0eCvkUsEUM08xvydkMh7ncp+LE0uS3DeNGZxnQ==}
|
||||
peerDependencies:
|
||||
react: ^18.0.0 || ^19.0.0
|
||||
react-dom: ^18.0.0 || ^19.0.0
|
||||
|
||||
react-window@2.2.5:
|
||||
resolution: {integrity: sha512-6viWvPSZvVuMIe9hrl4IIZoVfO/npiqOb03m4Z9w+VihmVzBbiudUrtUqDpsWdKvd/Ai31TCR25CBcFFAUm28w==}
|
||||
peerDependencies:
|
||||
@@ -14824,14 +15023,14 @@ snapshots:
|
||||
'@babel/generator@7.28.3':
|
||||
dependencies:
|
||||
'@babel/parser': 7.28.4
|
||||
'@babel/types': 7.28.4
|
||||
'@babel/types': 7.28.5
|
||||
'@jridgewell/gen-mapping': 0.3.13
|
||||
'@jridgewell/trace-mapping': 0.3.31
|
||||
jsesc: 3.1.0
|
||||
|
||||
'@babel/helper-annotate-as-pure@7.27.3':
|
||||
dependencies:
|
||||
'@babel/types': 7.28.1
|
||||
'@babel/types': 7.28.5
|
||||
|
||||
'@babel/helper-compilation-targets@7.27.2':
|
||||
dependencies:
|
||||
@@ -14845,8 +15044,8 @@ snapshots:
|
||||
|
||||
'@babel/helper-module-imports@7.27.1':
|
||||
dependencies:
|
||||
'@babel/traverse': 7.28.0
|
||||
'@babel/types': 7.28.1
|
||||
'@babel/traverse': 7.28.4
|
||||
'@babel/types': 7.28.5
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
@@ -14872,11 +15071,11 @@ snapshots:
|
||||
'@babel/helpers@7.27.6':
|
||||
dependencies:
|
||||
'@babel/template': 7.27.2
|
||||
'@babel/types': 7.28.4
|
||||
'@babel/types': 7.28.5
|
||||
|
||||
'@babel/parser@7.28.4':
|
||||
dependencies:
|
||||
'@babel/types': 7.28.4
|
||||
'@babel/types': 7.28.5
|
||||
|
||||
'@babel/parser@7.28.5':
|
||||
dependencies:
|
||||
@@ -14906,7 +15105,7 @@ snapshots:
|
||||
'@babel/helper-module-imports': 7.27.1
|
||||
'@babel/helper-plugin-utils': 7.27.1
|
||||
'@babel/plugin-syntax-jsx': 7.27.1(@babel/core@7.28.0)
|
||||
'@babel/types': 7.28.1
|
||||
'@babel/types': 7.28.5
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
@@ -14916,19 +15115,7 @@ snapshots:
|
||||
dependencies:
|
||||
'@babel/code-frame': 7.27.1
|
||||
'@babel/parser': 7.28.4
|
||||
'@babel/types': 7.28.4
|
||||
|
||||
'@babel/traverse@7.28.0':
|
||||
dependencies:
|
||||
'@babel/code-frame': 7.27.1
|
||||
'@babel/generator': 7.28.3
|
||||
'@babel/helper-globals': 7.28.0
|
||||
'@babel/parser': 7.28.5
|
||||
'@babel/template': 7.27.2
|
||||
'@babel/types': 7.28.5
|
||||
debug: 4.4.3(supports-color@8.1.1)
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@babel/traverse@7.28.4':
|
||||
dependencies:
|
||||
@@ -14937,16 +15124,11 @@ snapshots:
|
||||
'@babel/helper-globals': 7.28.0
|
||||
'@babel/parser': 7.28.4
|
||||
'@babel/template': 7.27.2
|
||||
'@babel/types': 7.28.4
|
||||
'@babel/types': 7.28.5
|
||||
debug: 4.4.3(supports-color@8.1.1)
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@babel/types@7.28.1':
|
||||
dependencies:
|
||||
'@babel/helper-string-parser': 7.27.1
|
||||
'@babel/helper-validator-identifier': 7.27.1
|
||||
|
||||
'@babel/types@7.28.4':
|
||||
dependencies:
|
||||
'@babel/helper-string-parser': 7.27.1
|
||||
@@ -15659,8 +15841,6 @@ snapshots:
|
||||
'@ckeditor/ckeditor5-ui': 47.3.0
|
||||
'@ckeditor/ckeditor5-utils': 47.3.0
|
||||
ckeditor5: 47.3.0
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@ckeditor/ckeditor5-inspector@5.0.0': {}
|
||||
|
||||
@@ -15763,8 +15943,6 @@ snapshots:
|
||||
'@ckeditor/ckeditor5-utils': 47.3.0
|
||||
ckeditor5: 47.3.0
|
||||
es-toolkit: 1.39.5
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@ckeditor/ckeditor5-merge-fields@47.3.0':
|
||||
dependencies:
|
||||
@@ -15785,6 +15963,8 @@ snapshots:
|
||||
'@ckeditor/ckeditor5-ui': 47.3.0
|
||||
'@ckeditor/ckeditor5-utils': 47.3.0
|
||||
ckeditor5: 47.3.0
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@ckeditor/ckeditor5-operations-compressor@47.3.0':
|
||||
dependencies:
|
||||
@@ -17129,7 +17309,7 @@ snapshots:
|
||||
jotai-scope: 0.7.2(jotai@2.11.0(@types/react@19.1.7)(react@19.2.3))(react@19.2.3)
|
||||
lodash.debounce: 4.0.8
|
||||
lodash.throttle: 4.1.1
|
||||
nanoid: 4.0.2
|
||||
nanoid: 5.1.5
|
||||
open-color: 1.9.1
|
||||
pako: 2.0.3
|
||||
perfect-freehand: 1.2.0
|
||||
@@ -18042,6 +18222,10 @@ snapshots:
|
||||
|
||||
'@microsoft/tsdoc@0.15.1': {}
|
||||
|
||||
'@mind-elixir/node-menu@5.0.1(mind-elixir@5.4.0)':
|
||||
dependencies:
|
||||
mind-elixir: 5.4.0
|
||||
|
||||
'@mind-elixir/node-menu@5.0.1(mind-elixir@5.5.0)':
|
||||
dependencies:
|
||||
mind-elixir: 5.5.0
|
||||
@@ -18401,10 +18585,10 @@ snapshots:
|
||||
'@babel/core': 7.28.0
|
||||
'@babel/plugin-transform-react-jsx': 7.27.1(@babel/core@7.28.0)
|
||||
'@babel/plugin-transform-react-jsx-development': 7.27.1(@babel/core@7.28.0)
|
||||
'@prefresh/vite': 2.4.8(preact@10.28.2)(vite@7.3.1(@types/node@24.10.7)(jiti@2.6.1)(less@4.1.3)(lightningcss@1.30.2)(sass-embedded@1.91.0)(sass@1.91.0)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.1))
|
||||
'@prefresh/vite': 2.4.11(preact@10.28.2)(vite@7.3.1(@types/node@24.10.7)(jiti@2.6.1)(less@4.1.3)(lightningcss@1.30.2)(sass-embedded@1.91.0)(sass@1.91.0)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.1))
|
||||
'@rollup/pluginutils': 4.2.1
|
||||
babel-plugin-transform-hook-names: 1.0.2(@babel/core@7.28.0)
|
||||
debug: 4.4.1
|
||||
debug: 4.4.3(supports-color@8.1.1)
|
||||
picocolors: 1.1.1
|
||||
vite: 7.3.1(@types/node@24.10.7)(jiti@2.6.1)(less@4.1.3)(lightningcss@1.30.2)(sass-embedded@1.91.0)(sass@1.91.0)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.1)
|
||||
vite-prerender-plugin: 0.5.11(vite@7.3.1(@types/node@24.10.7)(jiti@2.6.1)(less@4.1.3)(lightningcss@1.30.2)(sass-embedded@1.91.0)(sass@1.91.0)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.1))
|
||||
@@ -18439,18 +18623,6 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@prefresh/vite@2.4.8(preact@10.28.2)(vite@7.3.1(@types/node@24.10.7)(jiti@2.6.1)(less@4.1.3)(lightningcss@1.30.2)(sass-embedded@1.91.0)(sass@1.91.0)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.1))':
|
||||
dependencies:
|
||||
'@babel/core': 7.28.0
|
||||
'@prefresh/babel-plugin': 0.5.2
|
||||
'@prefresh/core': 1.5.5(preact@10.28.2)
|
||||
'@prefresh/utils': 1.2.1
|
||||
'@rollup/pluginutils': 4.2.1
|
||||
preact: 10.28.2
|
||||
vite: 7.3.1(@types/node@24.10.7)(jiti@2.6.1)(less@4.1.3)(lightningcss@1.30.2)(sass-embedded@1.91.0)(sass@1.91.0)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.1)
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@promptbook/utils@0.69.5':
|
||||
dependencies:
|
||||
spacetrim: 0.11.59
|
||||
@@ -19562,6 +19734,8 @@ snapshots:
|
||||
|
||||
'@socket.io/component-emitter@3.1.2': {}
|
||||
|
||||
'@sqlite.org/sqlite-wasm@3.51.1-build2': {}
|
||||
|
||||
'@ssddanbrown/codemirror-lang-smarty@1.0.0': {}
|
||||
|
||||
'@ssddanbrown/codemirror-lang-twig@1.0.0':
|
||||
@@ -22605,10 +22779,6 @@ snapshots:
|
||||
dependencies:
|
||||
ms: 2.1.3
|
||||
|
||||
debug@4.4.1:
|
||||
dependencies:
|
||||
ms: 2.1.3
|
||||
|
||||
debug@4.4.3(supports-color@8.1.1):
|
||||
dependencies:
|
||||
ms: 2.1.3
|
||||
@@ -25254,6 +25424,10 @@ snapshots:
|
||||
|
||||
js-levenshtein@1.1.6: {}
|
||||
|
||||
js-sha1@0.7.0: {}
|
||||
|
||||
js-sha512@0.9.0: {}
|
||||
|
||||
js-tokens@4.0.0: {}
|
||||
|
||||
js-tokens@9.0.1: {}
|
||||
@@ -25787,10 +25961,6 @@ snapshots:
|
||||
dependencies:
|
||||
sourcemap-codec: 1.4.8
|
||||
|
||||
magic-string@0.30.18:
|
||||
dependencies:
|
||||
'@jridgewell/sourcemap-codec': 1.5.5
|
||||
|
||||
magic-string@0.30.21:
|
||||
dependencies:
|
||||
'@jridgewell/sourcemap-codec': 1.5.5
|
||||
@@ -26380,6 +26550,8 @@ snapshots:
|
||||
|
||||
mimic-response@3.1.0: {}
|
||||
|
||||
mind-elixir@5.4.0: {}
|
||||
|
||||
mind-elixir@5.5.0: {}
|
||||
|
||||
mini-css-extract-plugin@2.9.4(webpack@5.101.3(@swc/core@1.11.29(@swc/helpers@0.5.17))(esbuild@0.27.2)):
|
||||
@@ -26636,8 +26808,6 @@ snapshots:
|
||||
|
||||
nanoid@3.3.11: {}
|
||||
|
||||
nanoid@4.0.2: {}
|
||||
|
||||
nanoid@5.1.5: {}
|
||||
|
||||
napi-build-utils@2.0.0: {}
|
||||
@@ -28075,6 +28245,11 @@ snapshots:
|
||||
prop-types: 15.8.1
|
||||
react: 19.2.3
|
||||
|
||||
react-window@2.2.3(react-dom@19.2.3(react@19.2.3))(react@19.2.3):
|
||||
dependencies:
|
||||
react: 19.2.3
|
||||
react-dom: 19.2.3(react@19.2.3)
|
||||
|
||||
react-window@2.2.5(react-dom@19.2.3(react@19.2.3))(react@19.2.3):
|
||||
dependencies:
|
||||
react: 19.2.3
|
||||
@@ -30410,7 +30585,7 @@ snapshots:
|
||||
vite-prerender-plugin@0.5.11(vite@7.3.1(@types/node@24.10.7)(jiti@2.6.1)(less@4.1.3)(lightningcss@1.30.2)(sass-embedded@1.91.0)(sass@1.91.0)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.1)):
|
||||
dependencies:
|
||||
kolorist: 1.8.0
|
||||
magic-string: 0.30.18
|
||||
magic-string: 0.30.21
|
||||
node-html-parser: 6.1.13
|
||||
simple-code-frame: 1.3.0
|
||||
source-map: 0.7.6
|
||||
|
||||
@@ -46,7 +46,7 @@ function main() {
|
||||
const rootPackageJson = join(scriptDir, "..", "package.json");
|
||||
patchPackageJson(rootPackageJson);
|
||||
|
||||
for (const app of ["server", "client"]) {
|
||||
for (const app of ["server", "client", "client-standalone"]) {
|
||||
const appPackageJsonPath = join(scriptDir, "..", "apps", app, "package.json");
|
||||
patchPackageJson(appPackageJsonPath);
|
||||
}
|
||||
|
||||
@@ -26,7 +26,7 @@ function getVersion(packageJsonPath: string) {
|
||||
function main() {
|
||||
const version = getVersion(join(__dirname, "..", "package.json"));
|
||||
|
||||
for (const appName of ["server", "client", "desktop"]) {
|
||||
for (const appName of ["server", "client", "client-standalone", "desktop"]) {
|
||||
patchPackageJson(join(__dirname, "..", "apps", appName, "package.json"), version);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user