mirror of
https://github.com/zadam/trilium.git
synced 2026-01-10 17:32:15 +01:00
Compare commits
136 Commits
lightweigh
...
lightweigh
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4f0021e44e | ||
|
|
2546e4c0dc | ||
|
|
eac5dbb210 | ||
|
|
8b6da981f7 | ||
|
|
7433ca069f | ||
|
|
128049b672 | ||
|
|
0eb3cb1118 | ||
|
|
8fc28716a7 | ||
|
|
af346f455a | ||
|
|
3e5a6c1e51 | ||
|
|
9e3b4435cd | ||
|
|
3a793a3549 | ||
|
|
4f139552f4 | ||
|
|
13f25e9fed | ||
|
|
91db73703b | ||
|
|
d690985b58 | ||
|
|
b5bcf73531 | ||
|
|
2e905c8292 | ||
|
|
4374c92032 | ||
|
|
edde0d0f90 | ||
|
|
32c39384ff | ||
|
|
807ab4be8c | ||
|
|
4da20f4829 | ||
|
|
cb5b491633 | ||
|
|
e76c33c37a | ||
|
|
89fc89603e | ||
|
|
c0bf294457 | ||
|
|
24e076cacf | ||
|
|
1e381b13ca | ||
|
|
f83121ce1d | ||
|
|
b32480f1d3 | ||
|
|
d4468bd97b | ||
|
|
e8711d7cd5 | ||
|
|
35f4d2aaad | ||
|
|
b1f3fe5345 | ||
|
|
9f1b0ac449 | ||
|
|
a84e804fc3 | ||
|
|
3371a31c70 | ||
|
|
724af8e103 | ||
|
|
c5803a2650 | ||
|
|
baf18835be | ||
|
|
3d1c93e58c | ||
|
|
ab0800a9f3 | ||
|
|
dd58eac4b0 | ||
|
|
c6d1457ad7 | ||
|
|
f05fda871c | ||
|
|
22590596da | ||
|
|
8274f9a220 | ||
|
|
b19bf62d7e | ||
|
|
7b436bdf70 | ||
|
|
a1c4a17d64 | ||
|
|
7966cfd09c | ||
|
|
0fe299250e | ||
|
|
adfe490480 | ||
|
|
872ab0864b | ||
|
|
6633b4233d | ||
|
|
a2d873d16f | ||
|
|
a6f52fff3e | ||
|
|
7832f20c89 | ||
|
|
405db7cedb | ||
|
|
ccf4df8e86 | ||
|
|
1beda05e6c | ||
|
|
18a3d9d71a | ||
|
|
25dc9201bf | ||
|
|
b60501dd3f | ||
|
|
cbd2fc3966 | ||
|
|
9bce12a85b | ||
|
|
aaca18003d | ||
|
|
5ec521b024 | ||
|
|
b3c0be7559 | ||
|
|
d52b735b99 | ||
|
|
639b1f2863 | ||
|
|
7f2cc885fe | ||
|
|
19a365a370 | ||
|
|
9a50da328e | ||
|
|
181e36a7c1 | ||
|
|
178508d245 | ||
|
|
d132d084cf | ||
|
|
494b55d685 | ||
|
|
51513d3779 | ||
|
|
5d474150da | ||
|
|
d3941752f1 | ||
|
|
56b305b1de | ||
|
|
bde472d649 | ||
|
|
458398f2ca | ||
|
|
7a6cc4f51e | ||
|
|
f4ccce7de5 | ||
|
|
f8b5417d6c | ||
|
|
87ab41c80c | ||
|
|
d2391f94c0 | ||
|
|
050ddb8c55 | ||
|
|
bc23e0984a | ||
|
|
07de353207 | ||
|
|
c02491d2e6 | ||
|
|
a6ede8f905 | ||
|
|
22941a9ce0 | ||
|
|
633a09d414 | ||
|
|
29f0881c5a | ||
|
|
60debca37b | ||
|
|
30ea81d0fb | ||
|
|
b1d92c4fe6 | ||
|
|
70f46de2d8 | ||
|
|
f1b2d0b870 | ||
|
|
8a385972fc | ||
|
|
28dd85c1d1 | ||
|
|
827c8e0e72 | ||
|
|
162c076a14 | ||
|
|
9386465de7 | ||
|
|
acca22f3a1 | ||
|
|
f8d84814e0 | ||
|
|
c46cf41842 | ||
|
|
64ab1c4116 | ||
|
|
a6de1041c7 | ||
|
|
c8d34e65ea | ||
|
|
51db729546 | ||
|
|
d2052ad236 | ||
|
|
9c4301467f | ||
|
|
e7355dc0e4 | ||
|
|
4110fec94f | ||
|
|
d5e601eae9 | ||
|
|
4f044c4a57 | ||
|
|
5821c350e1 | ||
|
|
edba8188fe | ||
|
|
1471a72633 | ||
|
|
56834cb88a | ||
|
|
a0f16f9184 | ||
|
|
de80eb4806 | ||
|
|
48a4b81fbe | ||
|
|
e225794f72 | ||
|
|
4eef30f8b5 | ||
|
|
569b09609d | ||
|
|
39838c25c2 | ||
|
|
49e90c08a9 | ||
|
|
e777b06fb8 | ||
|
|
497ec2ac74 | ||
|
|
c5d282d203 |
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 }}
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -51,4 +51,4 @@ upload
|
||||
# docs
|
||||
site/
|
||||
apps/*/coverage
|
||||
scripts/translation/.language*.json
|
||||
scripts/translation/.language*.json
|
||||
|
||||
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": "1.0.0",
|
||||
"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.1",
|
||||
"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
|
||||
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><%= 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>
|
||||
|
||||
<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();
|
||||
}
|
||||
92
apps/client-standalone/src/lightweight/browser_routes.ts
Normal file
92
apps/client-standalone/src/lightweight/browser_routes.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
/**
|
||||
* Browser route definitions.
|
||||
* This integrates with the shared route builder from @triliumnext/core.
|
||||
*/
|
||||
|
||||
import { routes, icon_packs as iconPackService } from '@triliumnext/core';
|
||||
import { BrowserRouter, type BrowserRequest } 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 iconPacks = iconPackService.getIconPacks();
|
||||
const assetPath = ".";
|
||||
|
||||
return {
|
||||
triliumVersion: "1.2.3",
|
||||
assetPath,
|
||||
baseApiUrl: "../api/",
|
||||
themeCssUrl: null,
|
||||
themeUseNextAsBase: "next",
|
||||
device: "desktop",
|
||||
headingStyle: "default",
|
||||
layoutOrientation: "vertical",
|
||||
platform: "web",
|
||||
isElectron: false,
|
||||
isStandalone: true,
|
||||
hasNativeTitleBar: false,
|
||||
hasBackgroundEffects: true,
|
||||
currentLocale: { id: "en", rtl: false },
|
||||
iconPackCss: iconPacks
|
||||
.map(p => 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: iconPackService.generateIconRegistry(iconPacks),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
92
apps/client-standalone/src/lightweight/messaging_provider.ts
Normal file
92
apps/client-standalone/src/lightweight/messaging_provider.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
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);
|
||||
console.log("[WorkerMessagingProvider] Initialized");
|
||||
}
|
||||
|
||||
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 = [];
|
||||
console.log("[WorkerMessagingProvider] Disposed");
|
||||
}
|
||||
}
|
||||
615
apps/client-standalone/src/lightweight/sql_provider.ts
Normal file
615
apps/client-standalone/src/lightweight/sql_provider.ts
Normal file
@@ -0,0 +1,615 @@
|
||||
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
|
||||
) {}
|
||||
|
||||
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();
|
||||
this.stmt.reset();
|
||||
return {
|
||||
changes,
|
||||
lastInsertRowid: 0 // Would need sqlite3_last_insert_rowid for this
|
||||
};
|
||||
} 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.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.");
|
||||
}
|
||||
}
|
||||
}
|
||||
89
apps/client-standalone/src/local-bridge.ts
Normal file
89
apps/client-standalone/src/local-bridge.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
// public/local-bridge.js
|
||||
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 [id, 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 [id, 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
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
222
apps/client-standalone/src/local-server-worker.ts
Normal file
222
apps/client-standalone/src/local-server-worker.ts
Normal file
@@ -0,0 +1,222 @@
|
||||
// public/local-server-worker.js
|
||||
// This will eventually import your core server and DB provider.
|
||||
// import { createCoreServer } from "@trilium/core"; (bundled)
|
||||
|
||||
import BrowserExecutionContext from './lightweight/cls_provider';
|
||||
import BrowserCryptoProvider from './lightweight/crypto_provider';
|
||||
import BrowserSqlProvider from './lightweight/sql_provider';
|
||||
import WorkerMessagingProvider from './lightweight/messaging_provider';
|
||||
import { BrowserRouter } from './lightweight/browser_router';
|
||||
import { createConfiguredRouter } from './lightweight/browser_routes';
|
||||
|
||||
// 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,
|
||||
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;
|
||||
}
|
||||
|
||||
const encoder = new TextEncoder();
|
||||
|
||||
function jsonResponse(obj: unknown, status = 200, extraHeaders = {}) {
|
||||
const body = encoder.encode(JSON.stringify(obj)).buffer;
|
||||
return {
|
||||
status,
|
||||
headers: { "content-type": "application/json; charset=utf-8", ...extraHeaders },
|
||||
body
|
||||
};
|
||||
}
|
||||
|
||||
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;
|
||||
console.log("[Worker] Received LOCAL_REQUEST:", id, request.method, request.url);
|
||||
|
||||
try {
|
||||
const response = await dispatch(request);
|
||||
console.log("[Worker] Dispatch completed, sending response:", id);
|
||||
|
||||
// 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)
|
||||
});
|
||||
}
|
||||
};
|
||||
189
apps/client-standalone/src/main.ts
Normal file
189
apps/client-standalone/src/main.ts
Normal file
@@ -0,0 +1,189 @@
|
||||
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
|
||||
const registration = 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 fetchWithRetry(url: string, maxRetries = 3, delayMs = 500): Promise<Response> {
|
||||
let lastError: Error | null = null;
|
||||
|
||||
for (let attempt = 0; attempt < maxRetries; attempt++) {
|
||||
try {
|
||||
console.log(`[Bootstrap] Fetching ${url} (attempt ${attempt + 1}/${maxRetries})`);
|
||||
const response = await fetch(url);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||||
}
|
||||
|
||||
// Check if response has content
|
||||
const contentType = response.headers.get("content-type");
|
||||
if (!contentType || !contentType.includes("application/json")) {
|
||||
throw new Error(`Invalid content-type: ${contentType || "none"}`);
|
||||
}
|
||||
|
||||
return response;
|
||||
} catch (err) {
|
||||
lastError = err as Error;
|
||||
console.warn(`[Bootstrap] Fetch attempt ${attempt + 1} failed:`, err);
|
||||
|
||||
if (attempt < maxRetries - 1) {
|
||||
// Exponential backoff
|
||||
const delay = delayMs * Math.pow(2, attempt);
|
||||
console.log(`[Bootstrap] Retrying in ${delay}ms...`);
|
||||
await new Promise(resolve => setTimeout(resolve, delay));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(`Failed to fetch ${url} after ${maxRetries} attempts: ${lastError?.message}`);
|
||||
}
|
||||
|
||||
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();
|
||||
|
||||
// 4) Now fetch bootstrap - SW is guaranteed to intercept this
|
||||
await setupGlob();
|
||||
|
||||
loadStylesheets();
|
||||
loadIcons();
|
||||
setBodyAttributes();
|
||||
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 setupGlob() {
|
||||
const response = await fetchWithRetry("/bootstrap");
|
||||
console.log("Service worker state", navigator.serviceWorker.controller);
|
||||
console.log("Resp", response);
|
||||
const json = await response.json();
|
||||
console.log("Bootstrap", json);
|
||||
|
||||
window.glob = {
|
||||
...json,
|
||||
activeDialog: null
|
||||
};
|
||||
}
|
||||
|
||||
function loadStylesheets() {
|
||||
const { assetPath, themeCssUrl, themeUseNextAsBase } = window.glob;
|
||||
const cssToLoad = [];
|
||||
cssToLoad.push(`${assetPath}/stylesheets/theme-light.css`);
|
||||
if (themeCssUrl) {
|
||||
cssToLoad.push(themeCssUrl);
|
||||
}
|
||||
if (themeUseNextAsBase === "next") {
|
||||
cssToLoad.push(`${assetPath}/stylesheets/theme-next.css`)
|
||||
} else if (themeUseNextAsBase === "next-dark") {
|
||||
cssToLoad.push(`${assetPath}/stylesheets/theme-next-dark.css`)
|
||||
} else if (themeUseNextAsBase === "next-light") {
|
||||
cssToLoad.push(`${assetPath}/stylesheets/theme-next-light.css`)
|
||||
}
|
||||
cssToLoad.push(`${assetPath}/stylesheets/style.css`);
|
||||
|
||||
for (const href of cssToLoad) {
|
||||
const linkEl = document.createElement("link");
|
||||
linkEl.href = href;
|
||||
linkEl.rel = "stylesheet";
|
||||
document.body.appendChild(linkEl);
|
||||
}
|
||||
}
|
||||
|
||||
function loadIcons() {
|
||||
const styleEl = document.createElement("style");
|
||||
styleEl.innerText = window.glob.iconPackCss;
|
||||
document.head.appendChild(styleEl);
|
||||
}
|
||||
|
||||
function setBodyAttributes() {
|
||||
const { device, headingStyle, layoutOrientation, platform, isElectron, hasNativeTitleBar, hasBackgroundEffects, currentLocale } = window.glob;
|
||||
const classesToSet = [
|
||||
device,
|
||||
`heading-style-${headingStyle}`,
|
||||
`layout-${layoutOrientation}`,
|
||||
`platform-${platform}`,
|
||||
isElectron && "isElectron",
|
||||
hasNativeTitleBar && "native-titlebar",
|
||||
hasBackgroundEffects && "background-effects"
|
||||
].filter(Boolean);
|
||||
|
||||
for (const classToSet of classesToSet) {
|
||||
document.body.classList.add(classToSet);
|
||||
}
|
||||
|
||||
document.body.lang = currentLocale.id;
|
||||
document.body.dir = currentLocale.rtl ? "rtl" : "ltr";
|
||||
}
|
||||
|
||||
async function loadScripts() {
|
||||
await import("./runtime.js");
|
||||
await import("./desktop.js");
|
||||
}
|
||||
|
||||
bootstrap();
|
||||
2
apps/client-standalone/src/runtime.ts
Normal file
2
apps/client-standalone/src/runtime.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
// Re-export runtime from client
|
||||
export * from "../../client/src/runtime";
|
||||
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"
|
||||
]
|
||||
}
|
||||
173
apps/client-standalone/vite.config.mts
Normal file
173
apps/client-standalone/vite.config.mts
Normal file
@@ -0,0 +1,173 @@
|
||||
import preact from "@preact/preset-vite";
|
||||
import { defineConfig } from 'vite';
|
||||
import { join } from 'path';
|
||||
import { viteStaticCopy } from 'vite-plugin-static-copy';
|
||||
|
||||
const assets = ["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 = [
|
||||
preact({
|
||||
babel: {
|
||||
compact: !isDev
|
||||
}
|
||||
}),
|
||||
sqliteWasmPlugin, // Always include SQLite WASM files
|
||||
viteStaticCopy({
|
||||
targets: assets.map((asset) => ({
|
||||
src: `../../client/src/${asset}/*`,
|
||||
dest: asset
|
||||
})),
|
||||
// Enable watching in development
|
||||
...(isDev && {
|
||||
watch: {
|
||||
reloadPageOnChange: true
|
||||
}
|
||||
})
|
||||
}),
|
||||
// Watch client files for changes in development
|
||||
...(isDev ? [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 /
|
||||
cacheDir: '../../../node_modules/.vite/apps/client-standalone',
|
||||
base: "",
|
||||
plugins,
|
||||
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"),
|
||||
}
|
||||
}));
|
||||
@@ -47,7 +47,7 @@
|
||||
"i18next": "25.7.3",
|
||||
"i18next-http-backend": "3.0.2",
|
||||
"jquery": "3.7.1",
|
||||
"jquery.fancytree": "2.38.5",
|
||||
"jquery.fancytree": "2.38.5",
|
||||
"jsplumb": "2.15.6",
|
||||
"katex": "0.16.27",
|
||||
"knockout": "3.5.1",
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
<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">
|
||||
<!-- <link rel="manifest" crossorigin="use-credentials" href="manifest.webmanifest"> -->
|
||||
<title>Trilium Notes</title>
|
||||
</head>
|
||||
|
||||
@@ -35,8 +35,11 @@
|
||||
}
|
||||
|
||||
async function setupGlob() {
|
||||
const response = await fetch("./bootstrap");
|
||||
const response = await fetch("/bootstrap");
|
||||
console.log("Service worker state", navigator.serviceWorker.controller);
|
||||
console.log("Resp", response);
|
||||
const json = await response.json();
|
||||
console.log("Bootstrap", json);
|
||||
|
||||
global = globalThis; /* fixes https://github.com/webpack/webpack/issues/10035 */
|
||||
window.glob = {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -21,7 +21,13 @@
|
||||
},
|
||||
"bundle-error": {
|
||||
"title": "Nem sikerült betölteni az egyéni szkriptet",
|
||||
"message": "A(z) \"{{id}}\" azonosítójú, \"{{title}}\" című jegyzetből származó szkript nem hajtható végre a következő ok miatt:\n\n{{message}}"
|
||||
"message": "A skript nem hajtható végre a következő ok miatt:\n\n{{message}}"
|
||||
},
|
||||
"widget-list-error": {
|
||||
"title": "A Widget-ek letöltése sikertelen volt"
|
||||
},
|
||||
"widget-render-error": {
|
||||
"title": "Nem sikerült renderelni a React widget-et"
|
||||
}
|
||||
},
|
||||
"add_link": {
|
||||
|
||||
1
apps/client/src/types.d.ts
vendored
1
apps/client/src/types.d.ts
vendored
@@ -36,6 +36,7 @@ interface CustomGlobals {
|
||||
isProtectedSessionAvailable: boolean;
|
||||
isDev: boolean;
|
||||
isMainWindow: boolean;
|
||||
isStandalone?: boolean;
|
||||
maxEntityChangeIdAtLoad: number;
|
||||
maxEntityChangeSyncIdAtLoad: number;
|
||||
assetPath: string;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -23,7 +23,7 @@ export default function SqlResults() {
|
||||
{t("sql_result.no_rows")}
|
||||
</Alert>
|
||||
) : (
|
||||
<div class="sql-console-result-container">
|
||||
<div className="sql-console-result-container selectable-text">
|
||||
{results?.map(rows => {
|
||||
// inserts, updates
|
||||
if (typeof rows === "object" && !Array.isArray(rows)) {
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -134,11 +134,12 @@ export async function buildConfig(opts: BuildEditorOptions): Promise<EditorConfi
|
||||
defaultProtocol: "https://",
|
||||
allowedProtocols: ALLOWED_PROTOCOLS
|
||||
},
|
||||
emoji: {
|
||||
definitionsUrl: window.glob.isDev
|
||||
? new URL(import.meta.url).origin + emojiDefinitionsUrl
|
||||
: emojiDefinitionsUrl
|
||||
},
|
||||
// TODO: Disabled for now
|
||||
// emoji: {
|
||||
// definitionsUrl: window.glob.isDev
|
||||
// ? new URL(import.meta.url).origin + emojiDefinitionsUrl
|
||||
// : emojiDefinitionsUrl
|
||||
// },
|
||||
syntaxHighlighting: {
|
||||
loadHighlightJs: async () => {
|
||||
await ensureMimeTypesForHighlighting();
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -8,12 +8,25 @@ import { viteStaticCopy } from 'vite-plugin-static-copy'
|
||||
const assets = [ "assets", "stylesheets", "fonts", "translations" ];
|
||||
|
||||
const isDev = process.env.NODE_ENV === "development";
|
||||
|
||||
// Always copy SQLite WASM files so they're available to the module
|
||||
const sqliteWasmPlugin = viteStaticCopy({
|
||||
targets: [
|
||||
{
|
||||
// Copy the entire jswasm directory to maintain the module's expected structure
|
||||
src: "../../node_modules/@sqlite.org/sqlite-wasm/sqlite-wasm/jswasm/*",
|
||||
dest: "node_modules/@sqlite.org/sqlite-wasm/sqlite-wasm/jswasm"
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
let plugins: any = [
|
||||
preact({
|
||||
babel: {
|
||||
compact: !isDev
|
||||
}
|
||||
})
|
||||
}),
|
||||
sqliteWasmPlugin // Always include SQLite WASM files
|
||||
];
|
||||
|
||||
if (!isDev) {
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
"creating-and-moving-notes": "नोट्स बनाना और स्थानांतरित करना",
|
||||
"move-note-up": "नोट को ऊपर ले जाएं",
|
||||
"move-note-down": "नोट को नीचे ले जाएं",
|
||||
"note-clipboard": "नोट क्लिपबोर्ड"
|
||||
"note-clipboard": "नोट क्लिपबोर्ड",
|
||||
"duplicate-subtree": "डुप्लिकेट सबट्री"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
};
|
||||
|
||||
@@ -7,7 +7,7 @@ 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 { icon_packs as iconPackService } from "@triliumnext/core";
|
||||
import log from "../services/log.js";
|
||||
import optionService from "../services/options.js";
|
||||
import protectedSessionService from "../services/protected_session.js";
|
||||
@@ -30,7 +30,7 @@ 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 iconPacks = iconPackService.getIconPacks();
|
||||
const currentLocale = getCurrentLocale();
|
||||
|
||||
res.send({
|
||||
@@ -62,12 +62,12 @@ export function bootstrap(req: Request, res: Response) {
|
||||
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.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),
|
||||
iconRegistry: iconPackService.generateIconRegistry(iconPacks),
|
||||
TRILIUM_SAFE_MODE: !!process.env.TRILIUM_SAFE_MODE
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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])) {
|
||||
|
||||
@@ -18,7 +18,6 @@ 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";
|
||||
@@ -33,19 +32,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,13 +56,13 @@ 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";
|
||||
import { apiResultHandler, apiRoute, asyncApiRoute, asyncRoute, route, router, uploadMiddlewareWithErrorHandling } from "./route_api.js";
|
||||
// page routes
|
||||
import setupRoute from "./setup.js";
|
||||
import { routes } from "@triliumnext/core";
|
||||
|
||||
const GET = "get",
|
||||
PST = "post",
|
||||
@@ -105,22 +100,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,7 +123,6 @@ 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);
|
||||
@@ -153,15 +133,8 @@ function register(app: express.Application) {
|
||||
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 +184,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,7 +199,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 +296,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 +309,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);
|
||||
|
||||
@@ -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,10 +1,11 @@
|
||||
import { type Dayjs, dayjs, type Locale, type LOCALE_IDS,LOCALES, setDayjsLocale } from "@triliumnext/commons";
|
||||
import i18next from "i18next";
|
||||
import { join } from "path";
|
||||
|
||||
import hidden_subtree from "./hidden_subtree.js";
|
||||
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() {
|
||||
const resourceDir = getResourceDir();
|
||||
|
||||
@@ -343,10 +343,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 +449,14 @@ 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;
|
||||
|
||||
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)
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
"note_structure_description": "नोटों को पदानुक्रमिक रूप से व्यवस्थित किया जा सकता है। फ़ोल्डर्स की कोई आवश्यकता नहीं है, क्योंकि प्रत्येक नोट में उप-नोट हो सकते हैं। एक एकल नोट को पदानुक्रम में कई स्थानों पर जोड़ा जा सकता है।"
|
||||
},
|
||||
"productivity_benefits": {
|
||||
"protected_notes_title": "संरक्षित नोट्स"
|
||||
"protected_notes_title": "संरक्षित नोट्स",
|
||||
"web_clipper_title": "वेब क्लिपर"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,13 +10,18 @@
|
||||
"title": "Organiser tankene dine. Bygg din personlige kunnskapsbase.",
|
||||
"github": "GitHub",
|
||||
"get_started": "Kom i gang",
|
||||
"dockerhub": "Docker Hub"
|
||||
"dockerhub": "Docker Hub",
|
||||
"screenshot_alt": "Screenshot fra Trilium Notes skrivebordsprogram",
|
||||
"subtitle": "Trilium er en open-source-løsning for å ta notater og organisere en personlig kunnskapsbase. Kan brukes lokalt på arbeidsstasjonen din eller synkroniseres med en selv-hostet løsning for å ha dine notater med deg overalt."
|
||||
},
|
||||
"organization_benefits": {
|
||||
"title": "Organisering",
|
||||
"note_structure_title": "Notatstruktur",
|
||||
"hoisting_title": "Arbeidsflate og fokusering",
|
||||
"attributes_description": "Bruk relasjoner mellom notater eller legg til etiketter for enkel kategorisering. Bruk fremhevede attributter for å legge inn strukturert informasjon som kan brukes i tabeller og tavler."
|
||||
"attributes_description": "Bruk relasjoner mellom notater eller legg til etiketter for enkel kategorisering. Bruk fremhevede attributter for å legge inn strukturert informasjon som kan brukes i tabeller og tavler.",
|
||||
"note_structure_description": "Notater kan arrangeres herarkisk. Det trengs ikke mapper, siden alle notater kan inneholde undernotater. Ett notat kan legges inn flere steder i herarkiet.",
|
||||
"attributes_title": "Notatetiketter og -relasjoner",
|
||||
"hoisting_description": "Du kan enkelt skille personlige og arbeidsnotater ved å gruppere de under arbeidsrom, som fokuserer notat-treet ditt på kun ønskede notater."
|
||||
},
|
||||
"productivity_benefits": {
|
||||
"sync_title": "Synkronisering",
|
||||
@@ -26,7 +31,12 @@
|
||||
"protected_notes_title": "Beskyttede notater",
|
||||
"title": "Produktivitet og sikkerhet",
|
||||
"sync_content": "Bruk en selv-hostet eller cloud-instans for å enkelt synkronisere notater på tvers av enheter, og ha de tilgjengelige fra din mobiltelefon ved hjelp av progressiv web-app.",
|
||||
"jump_to_content": "Hopp raskt til notater eller grensesnittkommandoer over hele hierarkiet ved å søke etter tittel, med \"fuzzy\" matching for å ta hensyn til skrivefeil eller små differanser."
|
||||
"jump_to_content": "Hopp raskt til notater eller grensesnittkommandoer over hele hierarkiet ved å søke etter tittel, med \"fuzzy\" matching for å ta hensyn til skrivefeil eller små differanser.",
|
||||
"revisions_content": "Notater lagres periodisk i bakgrunnen og revisjonshistorikk kan brukes for tilbakeblikk eller å omgjøre uønskede endringer. Revisjoner kan også lages manuelt.",
|
||||
"protected_notes_content": "Beskytt sensitiv personlig informasjon ved å kryptere notater og låse de med en passordkryptert sesjon.",
|
||||
"jump_to_title": "Hurtigsøk og kommandoer",
|
||||
"search_content": "Eller søk etter tekst i notatene og finjuster søket ved å filtrere på foreldrenotat eller dybde.",
|
||||
"web_clipper_content": "Hent nettsider (eller screenshots) og legg de direkte i Trilium ved hjelp av web clipper nettleserutvidelse."
|
||||
},
|
||||
"note_types": {
|
||||
"canvas_title": "Kanvas",
|
||||
@@ -34,7 +44,14 @@
|
||||
"text_title": "Tekstnotat",
|
||||
"code_title": "Kodenotat",
|
||||
"file_title": "Filnotat",
|
||||
"mermaid_title": "Mermaid diagrammer"
|
||||
"mermaid_title": "Mermaid diagrammer",
|
||||
"title": "Flere måter å presentere informasjonen din",
|
||||
"text_description": "Notatene redigeres med en visuell editor (WYSIWYG), som støtter tabeller, bilder, matematiske uttrykk og kodeblokker med syntaksutheving. Formater tekst hurtig med Markdown-inspirert syntaks eller \"slash-kommandoer\".",
|
||||
"code_description": "Store samlinger med kildekode eller skript bruker en dedikert editor med syntaksfremheving for mange programmeringsspråk og med flere fargetema.",
|
||||
"file_description": "Integrer multimediafiler som PDFer, bilder og video med forhåndsvisning i programmet.",
|
||||
"mermaid_description": "Lag diagrammer som flytskjema, klasse- og sekvensdiagrammer, Ganttdiagrammer og mye mer ved hjelp av Mermaidsyntaks.",
|
||||
"mindmap_description": "Organiser dine tanker visuelt eller gjør en brainstorming.",
|
||||
"others_list": "og andre: <0>notatkart</0>, <1>relasjonskart</1>, <2>lagrede søk</2>, <3>rendret notat</3>, og <4>web view</4>."
|
||||
},
|
||||
"extensibility_benefits": {
|
||||
"import_export_title": "Import/eksport",
|
||||
|
||||
@@ -66,7 +66,7 @@
|
||||
"jiti": "2.6.1",
|
||||
"jsonc-eslint-parser": "2.4.2",
|
||||
"react-refresh": "0.18.0",
|
||||
"rollup-plugin-webpack-stats": "2.1.8",
|
||||
"rollup-plugin-webpack-stats": "2.1.9",
|
||||
"tslib": "2.8.1",
|
||||
"tsx": "4.21.0",
|
||||
"typescript": "~5.9.0",
|
||||
|
||||
@@ -70,6 +70,7 @@
|
||||
]
|
||||
},
|
||||
"dependencies": {
|
||||
"@ckeditor/ckeditor5-icons": "47.3.0"
|
||||
"@ckeditor/ckeditor5-icons": "47.3.0",
|
||||
"mathlive": "0.108.2"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import ckeditor from './../theme/icons/math.svg?raw';
|
||||
import './augmentation.js';
|
||||
import "../theme/mathform.css";
|
||||
import 'mathlive';
|
||||
import 'mathlive/fonts.css';
|
||||
import 'mathlive/static.css';
|
||||
|
||||
export { default as Math } from './math.js';
|
||||
export { default as MathUI } from './mathui.js';
|
||||
|
||||
@@ -55,9 +55,9 @@ export default class MathUI extends Plugin {
|
||||
|
||||
this._balloon.showStack( 'main' );
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
this.formView?.mathInputView.fieldView.element?.focus();
|
||||
});
|
||||
requestAnimationFrame( () => {
|
||||
this.formView?.mathInputView.focus();
|
||||
} );
|
||||
}
|
||||
|
||||
private _createFormView() {
|
||||
@@ -71,31 +71,37 @@ export default class MathUI extends Plugin {
|
||||
throw new CKEditorError( 'math-command' );
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
const mathConfig = editor.config.get( 'math' )!;
|
||||
|
||||
const formView = new MainFormView(
|
||||
editor.locale,
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
mathConfig.engine!,
|
||||
mathConfig.lazyLoad,
|
||||
{
|
||||
engine: mathConfig.engine!,
|
||||
lazyLoad: mathConfig.lazyLoad,
|
||||
previewUid: this._previewUid,
|
||||
previewClassName: mathConfig.previewClassName!,
|
||||
katexRenderOptions: mathConfig.katexRenderOptions!
|
||||
},
|
||||
mathConfig.enablePreview,
|
||||
this._previewUid,
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
mathConfig.previewClassName!,
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
mathConfig.popupClassName!,
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
mathConfig.katexRenderOptions!
|
||||
mathConfig.popupClassName!
|
||||
);
|
||||
|
||||
formView.mathInputView.bind( 'value' ).to( mathCommand, 'value' );
|
||||
formView.displayButtonView.bind( 'isOn' ).to( mathCommand, 'display' );
|
||||
|
||||
// Form elements should be read-only when corresponding commands are disabled.
|
||||
formView.mathInputView.bind( 'isReadOnly' ).to( mathCommand, 'isEnabled', value => !value );
|
||||
formView.saveButtonView.bind( 'isEnabled' ).to( mathCommand );
|
||||
formView.displayButtonView.bind( 'isEnabled' ).to( mathCommand );
|
||||
formView.mathInputView.bind( 'isReadOnly' ).to( mathCommand, 'isEnabled', ( value: boolean ) => !value );
|
||||
formView.saveButtonView.bind( 'isEnabled' ).to(
|
||||
mathCommand,
|
||||
'isEnabled',
|
||||
formView.mathInputView,
|
||||
'value',
|
||||
( commandEnabled, equation ) => {
|
||||
const normalizedEquation = ( equation ?? '' ).trim();
|
||||
return commandEnabled && normalizedEquation.length > 0;
|
||||
}
|
||||
);
|
||||
formView.displayButtonView.bind( 'isEnabled' ).to( mathCommand, 'isEnabled' );
|
||||
|
||||
// Listen to submit button click
|
||||
this.listenTo( formView, 'submit', () => {
|
||||
@@ -115,24 +121,12 @@ export default class MathUI extends Plugin {
|
||||
} );
|
||||
|
||||
// Allow pressing Enter to submit changes, and use Shift+Enter to insert a new line
|
||||
formView.keystrokes.set('enter', (data, cancel) => {
|
||||
if (!data.shiftKey) {
|
||||
formView.fire('submit');
|
||||
formView.keystrokes.set( 'enter', ( data, cancel ) => {
|
||||
if ( !data.shiftKey ) {
|
||||
formView.fire( 'submit' );
|
||||
cancel();
|
||||
}
|
||||
});
|
||||
|
||||
// Allow the textarea to be resizable
|
||||
formView.mathInputView.fieldView.once('render', () => {
|
||||
const textarea = formView.mathInputView.fieldView.element;
|
||||
if (!textarea) return;
|
||||
Object.assign(textarea.style, {
|
||||
resize: 'both',
|
||||
height: '100px',
|
||||
width: '400px',
|
||||
minWidth: '100%',
|
||||
});
|
||||
});
|
||||
} );
|
||||
|
||||
return formView;
|
||||
}
|
||||
@@ -162,14 +156,12 @@ export default class MathUI extends Plugin {
|
||||
} );
|
||||
|
||||
if ( this._balloon.visibleView === this.formView ) {
|
||||
this.formView.mathInputView.fieldView.element?.select();
|
||||
this.formView.mathInputView.focus();
|
||||
}
|
||||
|
||||
// Show preview element
|
||||
const previewEl = document.getElementById( this._previewUid );
|
||||
if ( previewEl && this.formView.previewEnabled ) {
|
||||
// Force refresh preview
|
||||
this.formView.mathView?.updateMath();
|
||||
if ( previewEl && this.formView.mathView ) {
|
||||
this.formView.mathView.updateMath();
|
||||
}
|
||||
|
||||
this.formView.equation = mathCommand.value ?? '';
|
||||
@@ -206,8 +198,10 @@ export default class MathUI extends Plugin {
|
||||
|
||||
private _removeFormView() {
|
||||
if ( this._isFormInPanel && this.formView ) {
|
||||
this.formView.saveButtonView.focus();
|
||||
// Hide virtual keyboard before removing the form
|
||||
this.formView.hideKeyboard();
|
||||
|
||||
this.formView.saveButtonView.focus();
|
||||
this._balloon.remove( this.formView );
|
||||
|
||||
// Hide preview element
|
||||
|
||||
@@ -1,91 +1,59 @@
|
||||
import { ButtonView, createLabeledTextarea, FocusCycler, LabelView, LabeledFieldView, submitHandler, SwitchButtonView, View, ViewCollection, type TextareaView, type FocusableView, Locale, FocusTracker, KeystrokeHandler } from 'ckeditor5';
|
||||
import { ButtonView, FocusCycler, FocusTracker, KeystrokeHandler, LabelView, submitHandler, SwitchButtonView, View, ViewCollection, type FocusableView, type Locale } from 'ckeditor5';
|
||||
import IconCheck from "@ckeditor/ckeditor5-icons/theme/icons/check.svg?raw";
|
||||
import IconCancel from "@ckeditor/ckeditor5-icons/theme/icons/cancel.svg?raw";
|
||||
import { extractDelimiters, hasDelimiters } from '../utils.js';
|
||||
import MathView from './mathview.js';
|
||||
import MathView, { type MathViewOptions } from './mathview.js';
|
||||
import MathInputView from './mathinputview.js';
|
||||
import '../../theme/mathform.css';
|
||||
import type { KatexOptions } from '../typings-external.js';
|
||||
|
||||
class MathInputView extends LabeledFieldView<TextareaView> {
|
||||
public value: null | string = null;
|
||||
public isReadOnly = false;
|
||||
|
||||
constructor( locale: Locale ) {
|
||||
super( locale, createLabeledTextarea );
|
||||
}
|
||||
}
|
||||
|
||||
export default class MainFormView extends View {
|
||||
public saveButtonView: ButtonView;
|
||||
public mathInputView: MathInputView;
|
||||
public displayButtonView: SwitchButtonView;
|
||||
public cancelButtonView: ButtonView;
|
||||
public previewEnabled: boolean;
|
||||
public previewLabel?: LabelView;
|
||||
public displayButtonView: SwitchButtonView;
|
||||
|
||||
public mathInputView: MathInputView;
|
||||
public mathView?: MathView;
|
||||
public override locale: Locale = new Locale();
|
||||
public lazyLoad: undefined | ( () => Promise<void> );
|
||||
|
||||
public focusTracker = new FocusTracker();
|
||||
public keystrokes = new KeystrokeHandler();
|
||||
private _focusables = new ViewCollection<FocusableView>();
|
||||
private _focusCycler: FocusCycler;
|
||||
|
||||
constructor(
|
||||
locale: Locale,
|
||||
engine:
|
||||
| 'mathjax'
|
||||
| 'katex'
|
||||
| ( (
|
||||
equation: string,
|
||||
element: HTMLElement,
|
||||
display: boolean,
|
||||
) => void ),
|
||||
lazyLoad: undefined | ( () => Promise<void> ),
|
||||
mathViewOptions: MathViewOptions,
|
||||
previewEnabled = false,
|
||||
previewUid: string,
|
||||
previewClassName: Array<string>,
|
||||
popupClassName: Array<string>,
|
||||
katexRenderOptions: KatexOptions
|
||||
popupClassName: Array<string> = []
|
||||
) {
|
||||
super( locale );
|
||||
|
||||
const t = locale.t;
|
||||
|
||||
// Submit button
|
||||
this.saveButtonView = this._createButton( t( 'Save' ), IconCheck, 'ck-button-save', null );
|
||||
this.saveButtonView.type = 'submit';
|
||||
// Create views
|
||||
this.mathInputView = new MathInputView( locale );
|
||||
this.saveButtonView = this._createButton( t( 'Save' ), IconCheck, 'ck-button-save', 'submit' );
|
||||
this.cancelButtonView = this._createButton( t( 'Cancel' ), IconCancel, 'ck-button-cancel' );
|
||||
this.cancelButtonView.delegate( 'execute' ).to( this, 'cancel' );
|
||||
this.displayButtonView = this._createDisplayButton( t );
|
||||
|
||||
// Equation input
|
||||
this.mathInputView = this._createMathInput();
|
||||
// Build children
|
||||
|
||||
// Display button
|
||||
this.displayButtonView = this._createDisplayButton();
|
||||
const children: Array<View> = [
|
||||
this.mathInputView,
|
||||
this.displayButtonView
|
||||
];
|
||||
|
||||
// Cancel button
|
||||
this.cancelButtonView = this._createButton( t( 'Cancel' ), IconCancel, 'ck-button-cancel', 'cancel' );
|
||||
if ( previewEnabled ) {
|
||||
const previewLabel = new LabelView( locale );
|
||||
previewLabel.text = t( 'Equation preview' );
|
||||
|
||||
this.previewEnabled = previewEnabled;
|
||||
|
||||
let children = [];
|
||||
if ( this.previewEnabled ) {
|
||||
// Preview label
|
||||
this.previewLabel = new LabelView( locale );
|
||||
this.previewLabel.text = t( 'Equation preview' );
|
||||
|
||||
// Math element
|
||||
this.mathView = new MathView( engine, lazyLoad, locale, previewUid, previewClassName, katexRenderOptions );
|
||||
this.mathView = new MathView( locale, mathViewOptions );
|
||||
this.mathView.bind( 'display' ).to( this.displayButtonView, 'isOn' );
|
||||
|
||||
children = [
|
||||
this.mathInputView,
|
||||
this.displayButtonView,
|
||||
this.previewLabel,
|
||||
this.mathView
|
||||
];
|
||||
} else {
|
||||
children = [
|
||||
this.mathInputView,
|
||||
this.displayButtonView
|
||||
];
|
||||
children.push( previewLabel, this.mathView );
|
||||
}
|
||||
|
||||
// Add UI elements to template
|
||||
this._setupSync( previewEnabled );
|
||||
|
||||
this.setTemplate( {
|
||||
tag: 'form',
|
||||
attributes: {
|
||||
@@ -107,10 +75,30 @@ export default class MainFormView extends View {
|
||||
},
|
||||
children
|
||||
},
|
||||
this.saveButtonView,
|
||||
this.cancelButtonView
|
||||
{
|
||||
tag: 'div',
|
||||
attributes: {
|
||||
class: [
|
||||
'ck-math-button-row'
|
||||
]
|
||||
},
|
||||
children: [
|
||||
this.saveButtonView,
|
||||
this.cancelButtonView
|
||||
]
|
||||
}
|
||||
]
|
||||
} );
|
||||
|
||||
this._focusCycler = new FocusCycler( {
|
||||
focusables: this._focusables,
|
||||
focusTracker: this.focusTracker,
|
||||
keystrokeHandler: this.keystrokes,
|
||||
actions: {
|
||||
focusPrevious: 'shift + tab',
|
||||
focusNext: 'tab'
|
||||
}
|
||||
} );
|
||||
}
|
||||
|
||||
public override render(): void {
|
||||
@@ -121,103 +109,73 @@ export default class MainFormView extends View {
|
||||
view: this
|
||||
} );
|
||||
|
||||
// Register form elements to focusable elements
|
||||
const childViews = [
|
||||
this.mathInputView,
|
||||
const focusableViews = [
|
||||
this.mathInputView.latexTextAreaView,
|
||||
this.displayButtonView,
|
||||
this.saveButtonView,
|
||||
this.cancelButtonView
|
||||
];
|
||||
|
||||
childViews.forEach( v => {
|
||||
focusableViews.forEach( v => {
|
||||
this._focusables.add( v );
|
||||
if ( v.element ) {
|
||||
this._focusables.add( v );
|
||||
this.focusTracker.add( v.element );
|
||||
}
|
||||
} );
|
||||
|
||||
// Listen to keypresses inside form element
|
||||
this.mathInputView.on( 'mathfieldReady', () => {
|
||||
const mathfieldView = this.mathInputView.mathFieldFocusableView;
|
||||
if ( mathfieldView.element ) {
|
||||
if ( this._focusables.has( mathfieldView ) ) {
|
||||
this._focusables.remove( mathfieldView );
|
||||
}
|
||||
this._focusables.add( mathfieldView, 0 );
|
||||
this.focusTracker.add( mathfieldView.element );
|
||||
}
|
||||
} );
|
||||
|
||||
if ( this.element ) {
|
||||
this.keystrokes.listenTo( this.element );
|
||||
}
|
||||
}
|
||||
|
||||
public get equation(): string {
|
||||
return this.mathInputView.value ?? '';
|
||||
}
|
||||
|
||||
public set equation( equation: string ) {
|
||||
const norm = equation.trim();
|
||||
this.mathInputView.value = norm.length ? norm : null;
|
||||
if ( this.mathView ) {
|
||||
this.mathView.value = norm;
|
||||
}
|
||||
}
|
||||
|
||||
public focus(): void {
|
||||
this._focusCycler.focusFirst();
|
||||
}
|
||||
|
||||
public get equation(): string {
|
||||
return this.mathInputView.fieldView.element?.value ?? '';
|
||||
}
|
||||
private _setupSync( previewEnabled: boolean ): void {
|
||||
this.mathInputView.on( 'change:value', () => {
|
||||
let eq = ( this.mathInputView.value ?? '' ).trim();
|
||||
|
||||
public set equation( equation: string ) {
|
||||
if ( this.mathInputView.fieldView.element ) {
|
||||
this.mathInputView.fieldView.element.value = equation;
|
||||
}
|
||||
if ( this.previewEnabled && this.mathView ) {
|
||||
this.mathView.value = equation;
|
||||
}
|
||||
}
|
||||
if ( hasDelimiters( eq ) ) {
|
||||
const params = extractDelimiters( eq );
|
||||
eq = params.equation;
|
||||
this.displayButtonView.isOn = params.display;
|
||||
|
||||
public focusTracker: FocusTracker = new FocusTracker();
|
||||
public keystrokes: KeystrokeHandler = new KeystrokeHandler();
|
||||
private _focusables = new ViewCollection<FocusableView>();
|
||||
private _focusCycler: FocusCycler = new FocusCycler( {
|
||||
focusables: this._focusables,
|
||||
focusTracker: this.focusTracker,
|
||||
keystrokeHandler: this.keystrokes,
|
||||
actions: {
|
||||
focusPrevious: 'shift + tab',
|
||||
focusNext: 'tab'
|
||||
}
|
||||
} );
|
||||
|
||||
private _createMathInput() {
|
||||
const t = this.locale.t;
|
||||
|
||||
// Create equation input
|
||||
const mathInput = new MathInputView( this.locale );
|
||||
const fieldView = mathInput.fieldView;
|
||||
mathInput.infoText = t( 'Insert equation in TeX format.' );
|
||||
|
||||
const onInput = () => {
|
||||
if ( fieldView.element != null ) {
|
||||
let equationInput = fieldView.element.value.trim();
|
||||
|
||||
// If input has delimiters
|
||||
if ( hasDelimiters( equationInput ) ) {
|
||||
// Get equation without delimiters
|
||||
const params = extractDelimiters( equationInput );
|
||||
|
||||
// Remove delimiters from input field
|
||||
fieldView.element.value = params.equation;
|
||||
|
||||
equationInput = params.equation;
|
||||
|
||||
// update display button and preview
|
||||
this.displayButtonView.isOn = params.display;
|
||||
if ( this.mathInputView.value !== eq ) {
|
||||
this.mathInputView.value = eq.length ? eq : null;
|
||||
}
|
||||
if ( this.previewEnabled && this.mathView ) {
|
||||
// Update preview view
|
||||
this.mathView.value = equationInput;
|
||||
}
|
||||
|
||||
this.saveButtonView.isEnabled = !!equationInput;
|
||||
}
|
||||
};
|
||||
|
||||
fieldView.on( 'render', onInput );
|
||||
fieldView.on( 'input', onInput );
|
||||
|
||||
return mathInput;
|
||||
if ( previewEnabled && this.mathView && this.mathView.value !== eq ) {
|
||||
this.mathView.value = eq;
|
||||
}
|
||||
} );
|
||||
}
|
||||
|
||||
private _createButton(
|
||||
label: string,
|
||||
icon: string,
|
||||
className: string,
|
||||
eventName: string | null
|
||||
) {
|
||||
private _createButton( label: string, icon: string, className: string, type?: 'submit' | 'button' ): ButtonView {
|
||||
const button = new ButtonView( this.locale );
|
||||
|
||||
button.set( {
|
||||
@@ -232,16 +190,14 @@ export default class MainFormView extends View {
|
||||
}
|
||||
} );
|
||||
|
||||
if ( eventName ) {
|
||||
button.delegate( 'execute' ).to( this, eventName );
|
||||
if ( type ) {
|
||||
button.type = type;
|
||||
}
|
||||
|
||||
return button;
|
||||
}
|
||||
|
||||
private _createDisplayButton() {
|
||||
const t = this.locale.t;
|
||||
|
||||
private _createDisplayButton( t: ( str: string ) => string ): SwitchButtonView {
|
||||
const switchButton = new SwitchButtonView( this.locale );
|
||||
|
||||
switchButton.set( {
|
||||
@@ -256,15 +212,13 @@ export default class MainFormView extends View {
|
||||
} );
|
||||
|
||||
switchButton.on( 'execute', () => {
|
||||
// Toggle state
|
||||
switchButton.isOn = !switchButton.isOn;
|
||||
|
||||
if ( this.previewEnabled && this.mathView ) {
|
||||
// Update preview view
|
||||
this.mathView.display = switchButton.isOn;
|
||||
}
|
||||
} );
|
||||
|
||||
return switchButton;
|
||||
}
|
||||
|
||||
public hideKeyboard(): void {
|
||||
this.mathInputView.hideKeyboard();
|
||||
}
|
||||
}
|
||||
|
||||
268
packages/ckeditor5-math/src/ui/mathinputview.ts
Normal file
268
packages/ckeditor5-math/src/ui/mathinputview.ts
Normal file
@@ -0,0 +1,268 @@
|
||||
// Math input widget: wraps a MathLive <math-field> and a LaTeX textarea
|
||||
// and keeps them in sync for the CKEditor 5 math dialog.
|
||||
import { View, type Locale, type FocusableView } from 'ckeditor5';
|
||||
import 'mathlive/fonts.css'; // Auto-bundles offline fonts
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
mathVirtualKeyboard?: {
|
||||
visible: boolean;
|
||||
show: () => void;
|
||||
hide: () => void;
|
||||
addEventListener: ( event: string, cb: () => void ) => void;
|
||||
removeEventListener: ( event: string, cb: () => void ) => void;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
interface MathFieldElement extends HTMLElement {
|
||||
value: string;
|
||||
readOnly: boolean;
|
||||
mathVirtualKeyboardPolicy: string;
|
||||
inlineShortcuts?: Record<string, string>;
|
||||
setValue?: ( value: string, options?: { silenceNotifications?: boolean } ) => void;
|
||||
}
|
||||
|
||||
// Wrapper for the MathLive element to make it focusable in CKEditor's UI system
|
||||
export class MathFieldFocusableView extends View implements FocusableView {
|
||||
public declare element: HTMLElement | null;
|
||||
private _view: MathInputView;
|
||||
constructor( locale: Locale, view: MathInputView ) {
|
||||
super( locale );
|
||||
this._view = view;
|
||||
}
|
||||
public focus(): void {
|
||||
this._view.mathfield?.focus();
|
||||
}
|
||||
public setElement( el: HTMLElement ): void {
|
||||
this.element = el;
|
||||
}
|
||||
}
|
||||
|
||||
// Wrapper for the LaTeX textarea to make it focusable in CKEditor's UI system
|
||||
export class LatexTextAreaView extends View implements FocusableView {
|
||||
declare public element: HTMLTextAreaElement;
|
||||
constructor( locale: Locale ) {
|
||||
super( locale );
|
||||
this.setTemplate( { tag: 'textarea', attributes: {
|
||||
class: [ 'ck', 'ck-textarea', 'ck-latex-textarea' ], spellcheck: 'false', tabindex: 0
|
||||
} } );
|
||||
}
|
||||
public focus(): void {
|
||||
this.element?.focus();
|
||||
}
|
||||
}
|
||||
|
||||
// Main view class for the math input
|
||||
export default class MathInputView extends View {
|
||||
public declare value: string | null;
|
||||
public declare isReadOnly: boolean;
|
||||
public mathfield: MathFieldElement | null = null;
|
||||
public readonly latexTextAreaView: LatexTextAreaView;
|
||||
public readonly mathFieldFocusableView: MathFieldFocusableView;
|
||||
private _destroyed = false;
|
||||
private _vkGeometryHandler?: () => void;
|
||||
private _updating = false;
|
||||
private static _configured = false;
|
||||
|
||||
constructor( locale: Locale ) {
|
||||
super( locale );
|
||||
this.latexTextAreaView = new LatexTextAreaView( locale );
|
||||
this.mathFieldFocusableView = new MathFieldFocusableView( locale, this );
|
||||
this.set( 'value', null );
|
||||
this.set( 'isReadOnly', false );
|
||||
this.setTemplate( {
|
||||
tag: 'div', attributes: { class: [ 'ck', 'ck-math-input' ] },
|
||||
children: [
|
||||
{ tag: 'div', attributes: { class: [ 'ck-mathlive-container' ] } },
|
||||
{ tag: 'label', attributes: { class: [ 'ck-latex-label' ] }, children: [ locale.t( 'LaTeX' ) ] },
|
||||
{ tag: 'div', attributes: { class: [ 'ck-latex-wrapper' ] }, children: [ this.latexTextAreaView ] }
|
||||
]
|
||||
} );
|
||||
}
|
||||
|
||||
public override render(): void {
|
||||
super.render();
|
||||
const textarea = this.latexTextAreaView.element;
|
||||
|
||||
// Sync changes from the LaTeX textarea to the mathfield and model
|
||||
this.listenTo( textarea, 'input', () => {
|
||||
if ( this._updating ) {
|
||||
return;
|
||||
}
|
||||
this._updating = true;
|
||||
const val = textarea.value;
|
||||
this.value = val || null;
|
||||
if ( this.mathfield ) {
|
||||
if ( val === '' ) {
|
||||
this.mathfield.remove();
|
||||
this.mathfield = null;
|
||||
this._initMathField( false );
|
||||
} else if ( this.mathfield.value.trim() !== val.trim() ) {
|
||||
this._setMathfieldValue( val );
|
||||
}
|
||||
}
|
||||
this._updating = false;
|
||||
} );
|
||||
|
||||
// Sync changes from the model (this.value) to the UI elements
|
||||
this.on( 'change:value', ( _e, _n, val ) => {
|
||||
if ( this._updating ) {
|
||||
return;
|
||||
}
|
||||
this._updating = true;
|
||||
const newVal = val ?? '';
|
||||
if ( textarea.value !== newVal ) {
|
||||
textarea.value = newVal;
|
||||
}
|
||||
if ( this.mathfield ) {
|
||||
if ( this.mathfield.value.trim() !== newVal.trim() ) {
|
||||
this._setMathfieldValue( newVal );
|
||||
}
|
||||
} else if ( newVal !== '' ) {
|
||||
this._initMathField( false );
|
||||
}
|
||||
this._updating = false;
|
||||
} );
|
||||
|
||||
// Handle read-only state changes
|
||||
this.on( 'change:isReadOnly', ( _e, _n, val ) => {
|
||||
textarea.readOnly = val;
|
||||
if ( this.mathfield ) {
|
||||
this.mathfield.readOnly = val;
|
||||
}
|
||||
} );
|
||||
|
||||
// Handle virtual keyboard geometry changes
|
||||
const vk = window.mathVirtualKeyboard;
|
||||
if ( vk && !this._vkGeometryHandler ) {
|
||||
this._vkGeometryHandler = () => {
|
||||
if ( vk.visible && this.mathfield ) {
|
||||
this.mathfield.focus();
|
||||
}
|
||||
};
|
||||
vk.addEventListener( 'geometrychange', this._vkGeometryHandler );
|
||||
}
|
||||
|
||||
const initial = this.value ?? '';
|
||||
if ( textarea.value !== initial ) {
|
||||
textarea.value = initial;
|
||||
}
|
||||
this._loadMathLive();
|
||||
}
|
||||
|
||||
// Loads the MathLive library dynamically
|
||||
private async _loadMathLive(): Promise<void> {
|
||||
try {
|
||||
await import( 'mathlive' );
|
||||
await customElements.whenDefined( 'math-field' );
|
||||
if ( this._destroyed ) {
|
||||
return;
|
||||
}
|
||||
if ( !MathInputView._configured ) {
|
||||
const MathfieldClass = customElements.get( 'math-field' ) as any;
|
||||
if ( MathfieldClass ) {
|
||||
MathfieldClass.soundsDirectory = null;
|
||||
MathfieldClass.plonkSound = null;
|
||||
MathInputView._configured = true;
|
||||
}
|
||||
}
|
||||
if ( this.element && !this._destroyed ) {
|
||||
this._initMathField( true );
|
||||
}
|
||||
} catch {
|
||||
const c = this.element?.querySelector( '.ck-mathlive-container' );
|
||||
if ( c ) {
|
||||
c.textContent = 'Math editor unavailable';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Initializes the <math-field> element
|
||||
private _initMathField( shouldFocus: boolean ): void {
|
||||
const container = this.element?.querySelector( '.ck-mathlive-container' );
|
||||
if ( !container ) {
|
||||
return;
|
||||
}
|
||||
if ( this.mathfield ) {
|
||||
this._setMathfieldValue( this.value ?? '' );
|
||||
return;
|
||||
}
|
||||
const mf = document.createElement( 'math-field' ) as MathFieldElement;
|
||||
mf.mathVirtualKeyboardPolicy = 'auto';
|
||||
mf.setAttribute( 'tabindex', '0' );
|
||||
mf.value = this.value ?? '';
|
||||
mf.readOnly = this.isReadOnly;
|
||||
container.appendChild( mf );
|
||||
// Set shortcuts after mounting (accessing inlineShortcuts requires mounted element)
|
||||
try {
|
||||
if ( mf.inlineShortcuts ) {
|
||||
mf.inlineShortcuts = { ...mf.inlineShortcuts, dx: 'dx', dy: 'dy', dt: 'dt' };
|
||||
}
|
||||
} catch {
|
||||
// Inline shortcut configuration is optional; ignore failures to avoid breaking the math field.
|
||||
}
|
||||
mf.addEventListener( 'keydown', ev => {
|
||||
if ( ev.key === 'Tab' ) {
|
||||
if ( ev.shiftKey ) {
|
||||
ev.preventDefault();
|
||||
} else {
|
||||
ev.preventDefault();
|
||||
ev.stopImmediatePropagation();
|
||||
this.latexTextAreaView.focus();
|
||||
}
|
||||
}
|
||||
}, { capture: true } );
|
||||
mf.addEventListener( 'input', () => {
|
||||
if ( this._updating ) {
|
||||
return;
|
||||
}
|
||||
this._updating = true;
|
||||
const textarea = this.latexTextAreaView.element;
|
||||
if ( textarea.value.trim() !== mf.value.trim() ) {
|
||||
textarea.value = mf.value;
|
||||
}
|
||||
this.value = mf.value || null;
|
||||
this._updating = false;
|
||||
} );
|
||||
this.mathfield = mf;
|
||||
this.mathFieldFocusableView.setElement( mf );
|
||||
this.fire( 'mathfieldReady' );
|
||||
if ( shouldFocus ) {
|
||||
requestAnimationFrame( () => mf.focus() );
|
||||
}
|
||||
}
|
||||
|
||||
// Updates the mathfield value without triggering loops
|
||||
private _setMathfieldValue( value: string ): void {
|
||||
if ( !this.mathfield ) {
|
||||
return;
|
||||
}
|
||||
if ( this.mathfield.setValue ) {
|
||||
this.mathfield.setValue( value, { silenceNotifications: true } );
|
||||
} else {
|
||||
this.mathfield.value = value;
|
||||
}
|
||||
}
|
||||
|
||||
public hideKeyboard(): void {
|
||||
window.mathVirtualKeyboard?.hide();
|
||||
}
|
||||
|
||||
public focus(): void {
|
||||
this.mathfield?.focus();
|
||||
}
|
||||
|
||||
public override destroy(): void {
|
||||
this._destroyed = true;
|
||||
const vk = window.mathVirtualKeyboard;
|
||||
if ( vk && this._vkGeometryHandler ) {
|
||||
vk.removeEventListener( 'geometrychange', this._vkGeometryHandler );
|
||||
this._vkGeometryHandler = undefined;
|
||||
}
|
||||
this.hideKeyboard();
|
||||
this.mathfield?.remove();
|
||||
this.mathfield = null;
|
||||
super.destroy();
|
||||
}
|
||||
}
|
||||
@@ -2,44 +2,44 @@ import { View, type Locale } from 'ckeditor5';
|
||||
import type { KatexOptions } from '../typings-external.js';
|
||||
import { renderEquation } from '../utils.js';
|
||||
|
||||
/**
|
||||
* Configuration options for the MathView.
|
||||
*/
|
||||
export interface MathViewOptions {
|
||||
engine: 'mathjax' | 'katex' | ( ( equation: string, element: HTMLElement, display: boolean ) => void );
|
||||
lazyLoad: undefined | ( () => Promise<void> );
|
||||
previewUid: string;
|
||||
previewClassName: Array<string>;
|
||||
katexRenderOptions: KatexOptions;
|
||||
}
|
||||
|
||||
export default class MathView extends View {
|
||||
/**
|
||||
* The LaTeX equation value to render.
|
||||
* @observable
|
||||
*/
|
||||
public declare value: string;
|
||||
|
||||
/**
|
||||
* Whether to render in display mode (centered) or inline.
|
||||
* @observable
|
||||
*/
|
||||
public declare display: boolean;
|
||||
public previewUid: string;
|
||||
public previewClassName: Array<string>;
|
||||
public katexRenderOptions: KatexOptions;
|
||||
public engine:
|
||||
| 'mathjax'
|
||||
| 'katex'
|
||||
| ( ( equation: string, element: HTMLElement, display: boolean ) => void );
|
||||
public lazyLoad: undefined | ( () => Promise<void> );
|
||||
|
||||
constructor(
|
||||
engine:
|
||||
| 'mathjax'
|
||||
| 'katex'
|
||||
| ( (
|
||||
equation: string,
|
||||
element: HTMLElement,
|
||||
display: boolean,
|
||||
) => void ),
|
||||
lazyLoad: undefined | ( () => Promise<void> ),
|
||||
locale: Locale,
|
||||
previewUid: string,
|
||||
previewClassName: Array<string>,
|
||||
katexRenderOptions: KatexOptions
|
||||
) {
|
||||
/**
|
||||
* Configuration options passed during initialization.
|
||||
*/
|
||||
private options: MathViewOptions;
|
||||
|
||||
constructor( locale: Locale, options: MathViewOptions ) {
|
||||
super( locale );
|
||||
|
||||
this.engine = engine;
|
||||
this.lazyLoad = lazyLoad;
|
||||
this.previewUid = previewUid;
|
||||
this.katexRenderOptions = katexRenderOptions;
|
||||
this.previewClassName = previewClassName;
|
||||
this.options = options;
|
||||
|
||||
this.set( 'value', '' );
|
||||
this.set( 'display', false );
|
||||
|
||||
// Update rendering when state changes.
|
||||
// Checking isRendered prevents errors during initialization.
|
||||
this.on( 'change', () => {
|
||||
if ( this.isRendered ) {
|
||||
this.updateMath();
|
||||
@@ -55,19 +55,39 @@ export default class MathView extends View {
|
||||
}
|
||||
|
||||
public updateMath(): void {
|
||||
if ( this.element ) {
|
||||
void renderEquation(
|
||||
this.value,
|
||||
this.element,
|
||||
this.engine,
|
||||
this.lazyLoad,
|
||||
this.display,
|
||||
true,
|
||||
this.previewUid,
|
||||
this.previewClassName,
|
||||
this.katexRenderOptions
|
||||
);
|
||||
if ( !this.element ) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle empty equations
|
||||
if ( !this.value || !this.value.trim() ) {
|
||||
this.element.textContent = '';
|
||||
this.element.classList.remove( 'ck-math-render-error' );
|
||||
return;
|
||||
}
|
||||
|
||||
// Clear previous render
|
||||
this.element.textContent = '';
|
||||
this.element.classList.remove( 'ck-math-render-error' );
|
||||
|
||||
renderEquation(
|
||||
this.value,
|
||||
this.element,
|
||||
this.options.engine,
|
||||
this.options.lazyLoad,
|
||||
this.display,
|
||||
true, // isPreview
|
||||
this.options.previewUid,
|
||||
this.options.previewClassName,
|
||||
this.options.katexRenderOptions
|
||||
).catch( error => {
|
||||
console.error( 'Math rendering failed:', error );
|
||||
|
||||
if ( this.element ) {
|
||||
this.element.textContent = 'Error rendering equation';
|
||||
this.element.classList.add( 'ck-math-render-error' );
|
||||
}
|
||||
} );
|
||||
}
|
||||
|
||||
public override render(): void {
|
||||
|
||||
@@ -3,6 +3,20 @@ import Math from '../src/math';
|
||||
import AutoformatMath from '../src/autoformatmath';
|
||||
import { describe, it, expect } from 'vitest';
|
||||
|
||||
// Suppress MathLive errors during async cleanup in tests
|
||||
if (typeof window !== 'undefined') {
|
||||
window.addEventListener('unhandledrejection', event => {
|
||||
if (event.reason?.message?.includes('options') || event.reason?.message?.includes('mathlive')) {
|
||||
event.preventDefault();
|
||||
}
|
||||
});
|
||||
window.addEventListener('error', event => {
|
||||
if (event.message?.includes('options') || event.message?.includes('mathlive')) {
|
||||
event.preventDefault();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
describe( 'CKEditor5 Math DLL', () => {
|
||||
it( 'exports Math', () => {
|
||||
expect( MathDll ).to.equal( Math );
|
||||
|
||||
@@ -2,6 +2,20 @@ import { ClassicEditor, type EditorConfig } from 'ckeditor5';
|
||||
import MathUI from '../src/mathui';
|
||||
import { describe, beforeEach, it, afterEach, expect } from "vitest";
|
||||
|
||||
// Suppress MathLive errors during async cleanup
|
||||
if (typeof window !== 'undefined') {
|
||||
window.addEventListener('unhandledrejection', event => {
|
||||
if (event.reason?.message?.includes('options') || event.reason?.message?.includes('mathlive')) {
|
||||
event.preventDefault();
|
||||
}
|
||||
});
|
||||
window.addEventListener('error', event => {
|
||||
if (event.message?.includes('options') || event.message?.includes('mathlive')) {
|
||||
event.preventDefault();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
describe( 'Lazy load', () => {
|
||||
let editorElement: HTMLDivElement;
|
||||
let editor: ClassicEditor;
|
||||
@@ -24,11 +38,14 @@ describe( 'Lazy load', () => {
|
||||
beforeEach( () => {
|
||||
editorElement = document.createElement( 'div' );
|
||||
document.body.appendChild( editorElement );
|
||||
|
||||
lazyLoadInvoked = false;
|
||||
} );
|
||||
|
||||
afterEach( () => {
|
||||
afterEach( async () => {
|
||||
if ( mathUIFeature?.formView ) {
|
||||
mathUIFeature._hideUI();
|
||||
}
|
||||
await new Promise( resolve => setTimeout( resolve, 50 ) );
|
||||
editorElement.remove();
|
||||
return editor.destroy();
|
||||
} );
|
||||
@@ -37,6 +54,7 @@ describe( 'Lazy load', () => {
|
||||
await buildEditor( {
|
||||
math: {
|
||||
engine: 'katex',
|
||||
enablePreview: true,
|
||||
lazyLoad: async () => {
|
||||
lazyLoadInvoked = true;
|
||||
}
|
||||
@@ -44,6 +62,15 @@ describe( 'Lazy load', () => {
|
||||
} );
|
||||
|
||||
mathUIFeature._showUI();
|
||||
|
||||
// Trigger render with a non-empty value to bypass empty check optimization
|
||||
if ( mathUIFeature.formView ) {
|
||||
mathUIFeature.formView.equation = 'x^2';
|
||||
}
|
||||
|
||||
// Wait for async rendering and lazy loading
|
||||
await new Promise( resolve => setTimeout( resolve, 100 ) );
|
||||
|
||||
expect( lazyLoadInvoked ).to.be.true;
|
||||
} );
|
||||
} );
|
||||
|
||||
@@ -410,7 +410,7 @@ describe( 'MathUI', () => {
|
||||
it( 'should bind mainFormView.mathInputView#value to math command value', () => {
|
||||
const command = editor.commands.get( 'math' );
|
||||
|
||||
expect( formView!.mathInputView.value ).to.null;
|
||||
expect( formView!.mathInputView.value ).to.be.null;
|
||||
|
||||
command!.value = 'x^2';
|
||||
expect( formView!.mathInputView.value ).to.equal( 'x^2' );
|
||||
@@ -419,10 +419,18 @@ describe( 'MathUI', () => {
|
||||
it( 'should execute math command on mainFormView#submit event', () => {
|
||||
const executeSpy = vi.spyOn( editor, 'execute' );
|
||||
|
||||
formView!.mathInputView.fieldView.element!.value = 'x^2';
|
||||
formView!.mathInputView.value = 'x^2';
|
||||
formView!.fire( 'submit' );
|
||||
|
||||
expect(executeSpy.mock.lastCall?.slice(0, 2)).toMatchObject(['math', 'x^2']);
|
||||
expect( executeSpy.mock.lastCall?.slice( 0, 2 ) ).toMatchObject( [ 'math', 'x^2' ] );
|
||||
} );
|
||||
|
||||
it( 'should update equation value when mathInputView changes', () => {
|
||||
formView!.mathInputView.value = 'x^2';
|
||||
expect( formView!.equation ).to.equal( 'x^2' );
|
||||
|
||||
formView!.mathInputView.value = '\\frac{1}{2}';
|
||||
expect( formView!.equation ).to.equal( '\\frac{1}{2}' );
|
||||
} );
|
||||
|
||||
it( 'should hide the balloon on mainFormView#cancel if math command does not have a value', () => {
|
||||
|
||||
@@ -1,35 +1,220 @@
|
||||
/**
|
||||
* Math Equation Editor Dialog Styles - Compact & Readable
|
||||
*/
|
||||
|
||||
/* === Z-INDEX: MathLive UI above CKEditor === */
|
||||
.ML__keyboard, .ML__popover, .ML__menu, .ML__suggestions, .ML__autocomplete,
|
||||
.ML__tooltip, .ML__sr-only, [data-ml-root], #mathlive-suggestion-popover,
|
||||
.mathlive-suggestions-popover, [data-ml-tooltip], .ML__base {
|
||||
z-index: calc(var(--ck-z-panel) + 1000) !important;
|
||||
}
|
||||
.ML__tooltip, [role="tooltip"], .ML__popover[role="tooltip"], .popover, [data-ml-tooltip] {
|
||||
z-index: calc(var(--ck-z-panel) + 2000) !important;
|
||||
position: fixed !important;
|
||||
}
|
||||
.ck.ck-balloon-panel, .ck.ck-balloon-panel .ck-balloon-panel__content {
|
||||
overflow: visible !important;
|
||||
}
|
||||
|
||||
/* === MAIN DIALOG === */
|
||||
.ck.ck-math-form {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
flex-direction: row;
|
||||
flex-wrap: nowrap;
|
||||
padding: var(--ck-spacing-standard);
|
||||
|
||||
@media screen and (max-width: 600px) {
|
||||
flex-wrap: wrap;
|
||||
|
||||
& .ck-math-view {
|
||||
flex-basis: 100%;
|
||||
|
||||
& .ck-labeled-view {
|
||||
flex-basis: 100%;
|
||||
}
|
||||
|
||||
& .ck-label {
|
||||
flex-basis: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
& .ck-button {
|
||||
flex-basis: 50%;
|
||||
}
|
||||
}
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: var(--ck-spacing-standard);
|
||||
box-sizing: border-box;
|
||||
max-width: 80vw;
|
||||
max-height: 80vh;
|
||||
overflow: visible;
|
||||
user-select: text;
|
||||
}
|
||||
|
||||
.ck-math-tex.ck-placeholder::before {
|
||||
display: none !important;
|
||||
/* Scrollable content - vertical scroll, horizontal visible for tooltips */
|
||||
.ck-math-view {
|
||||
overflow-y: auto;
|
||||
overflow-x: visible;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1 1 auto;
|
||||
gap: var(--ck-spacing-standard);
|
||||
min-height: 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.ck.ck-toolbar-container {
|
||||
z-index: calc(var(--ck-z-panel) + 2);
|
||||
/* === MATH INPUT === */
|
||||
.ck.ck-math-input {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--ck-spacing-standard);
|
||||
width: fit-content;
|
||||
min-width: 100%;
|
||||
max-width: 100%;
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
overflow: visible !important;
|
||||
}
|
||||
|
||||
/* === MATHLIVE EDITOR === */
|
||||
.ck.ck-math-input .ck-mathlive-container {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
min-height: 50px;
|
||||
padding: var(--ck-spacing-small);
|
||||
border: 1px solid var(--ck-color-input-border);
|
||||
border-radius: var(--ck-border-radius);
|
||||
background: var(--ck-color-input-background) !important;
|
||||
transition: border-color 120ms ease;
|
||||
overflow: visible !important;
|
||||
clip-path: none !important;
|
||||
}
|
||||
.ck.ck-math-input .ck-mathlive-container:focus-within {
|
||||
border-color: var(--ck-color-focus-border);
|
||||
}
|
||||
|
||||
/* Position keyboard & menu buttons */
|
||||
.ck-mathlive-container math-field::part(virtual-keyboard-toggle),
|
||||
.ck-mathlive-container math-field::part(menu-toggle) {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
}
|
||||
.ck-mathlive-container math-field::part(virtual-keyboard-toggle) { right: 40px; }
|
||||
.ck-mathlive-container math-field::part(menu-toggle) {
|
||||
right: 8px;
|
||||
display: flex !important;
|
||||
visibility: visible !important;
|
||||
}
|
||||
|
||||
/* Math field element */
|
||||
.ck.ck-math-form math-field {
|
||||
display: block !important;
|
||||
width: 100%;
|
||||
font-size: 1.5em;
|
||||
background: transparent !important;
|
||||
color: var(--ck-color-input-text);
|
||||
border: none !important;
|
||||
padding: 0;
|
||||
outline: none !important;
|
||||
--selection-background-color: rgba(33, 150, 243, 0.2);
|
||||
--selection-color: inherit;
|
||||
--contains-highlight-background-color: rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
/* === LATEX TEXTAREA === */
|
||||
.ck.ck-math-input .ck-latex-wrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: fit-content;
|
||||
min-width: 100%;
|
||||
max-width: 100%;
|
||||
padding: var(--ck-spacing-small);
|
||||
border: 1px solid var(--ck-color-input-border);
|
||||
border-radius: var(--ck-border-radius);
|
||||
background: var(--ck-color-input-background) !important;
|
||||
transition: border-color 120ms ease;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.ck.ck-math-input .ck-latex-wrapper:focus-within {
|
||||
border-color: var(--ck-color-focus-border);
|
||||
}
|
||||
.ck.ck-math-input .ck-latex-label {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--ck-color-text);
|
||||
opacity: 0.8;
|
||||
margin: 0 0 var(--ck-spacing-small) 0;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.ck.ck-math-input .ck-latex-textarea {
|
||||
width: fit-content;
|
||||
min-width: 100%;
|
||||
max-width: 100%;
|
||||
min-height: 60px;
|
||||
max-height: calc(80vh - 300px);
|
||||
resize: both;
|
||||
overflow: auto;
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 0.95em;
|
||||
background: transparent !important;
|
||||
color: var(--ck-color-input-text);
|
||||
border: none !important;
|
||||
padding: 0;
|
||||
outline: none !important;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
/* === DISPLAY TOGGLE === */
|
||||
.ck-button-display-toggle {
|
||||
align-self: flex-start;
|
||||
padding: var(--ck-spacing-small) var(--ck-spacing-standard);
|
||||
background: var(--ck-color-input-background);
|
||||
color: var(--ck-color-text);
|
||||
border: 1px solid var(--ck-color-input-border);
|
||||
border-radius: var(--ck-border-radius);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
.ck-button-display-toggle:hover { background: var(--ck-color-focus-border); }
|
||||
|
||||
/* === PREVIEW === */
|
||||
.ck-math-preview,
|
||||
.ck.ck-math-preview {
|
||||
width: 100%;
|
||||
min-height: 40px;
|
||||
max-height: none !important;
|
||||
height: auto !important;
|
||||
padding: var(--ck-spacing-small);
|
||||
background: transparent !important;
|
||||
border: none !important;
|
||||
display: block;
|
||||
text-align: left;
|
||||
overflow-x: auto !important;
|
||||
overflow-y: visible !important;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Center equation when in display mode */
|
||||
.ck-math-preview[data-display="true"],
|
||||
.ck.ck-math-preview[data-display="true"] {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.ck-math-preview.ck-error, .ck-math-render-error {
|
||||
border-color: var(--ck-color-error-text);
|
||||
background: var(--ck-color-base-background);
|
||||
color: var(--ck-color-error-text);
|
||||
}
|
||||
|
||||
/* === BUTTONS === */
|
||||
.ck-math-button-row {
|
||||
display: flex;
|
||||
gap: var(--ck-spacing-standard);
|
||||
justify-content: flex-end;
|
||||
margin-top: var(--ck-spacing-standard);
|
||||
}
|
||||
.ck-button-save, .ck-button-cancel {
|
||||
padding: var(--ck-spacing-small) var(--ck-spacing-standard);
|
||||
border: 1px solid var(--ck-color-input-border);
|
||||
border-radius: var(--ck-border-radius);
|
||||
cursor: pointer;
|
||||
font-weight: 500;
|
||||
}
|
||||
.ck-button-save {
|
||||
background: var(--ck-color-focus-border);
|
||||
color: white;
|
||||
}
|
||||
.ck-button-cancel {
|
||||
background: var(--ck-color-input-background);
|
||||
color: var(--ck-color-text);
|
||||
}
|
||||
.ck-button-save:hover { opacity: 0.9; }
|
||||
.ck-button-cancel:hover { background: var(--ck-color-base-background); }
|
||||
|
||||
/* === OVERFLOW FIX: Allow tooltips to escape === */
|
||||
.ck.ck-balloon-panel,
|
||||
.ck.ck-balloon-panel .ck-balloon-panel__content,
|
||||
.ck.ck-math-form,
|
||||
.ck-math-view,
|
||||
.ck.ck-math-input,
|
||||
.ck.ck-math-input .ck-mathlive-container {
|
||||
overflow: visible !important;
|
||||
clip-path: none !important;
|
||||
}
|
||||
|
||||
@@ -22,6 +22,9 @@ export default defineConfig( {
|
||||
include: [
|
||||
'tests/**/*.[jt]s'
|
||||
],
|
||||
exclude: [
|
||||
'tests/setup.ts'
|
||||
],
|
||||
globals: true,
|
||||
watch: false,
|
||||
coverage: {
|
||||
|
||||
@@ -50,6 +50,11 @@ const LOCALE_MAPPINGS: Record<DISPLAYABLE_LOCALE_IDS, LocaleMapping | null> = {
|
||||
coreTranslation: () => import("ckeditor5/translations/ja.js"),
|
||||
premiumFeaturesTranslation: () => import("ckeditor5-premium-features/translations/ja.js"),
|
||||
},
|
||||
pl: {
|
||||
languageCode: "pl",
|
||||
coreTranslation: () => import("ckeditor5/translations/pl.js"),
|
||||
premiumFeaturesTranslation: () => import("ckeditor5-premium-features/translations/pl.js"),
|
||||
},
|
||||
pt: {
|
||||
languageCode: "pt",
|
||||
coreTranslation: () => import("ckeditor5/translations/pt.js"),
|
||||
|
||||
@@ -3,6 +3,7 @@ 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";
|
||||
|
||||
export type * from "./services/sql/types";
|
||||
export * from "./services/sql/index";
|
||||
@@ -20,6 +21,7 @@ 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 * from "./errors";
|
||||
@@ -33,6 +35,10 @@ export { default as TaskContext } from "./services/task_context";
|
||||
export { default as revisions } from "./services/revisions";
|
||||
export { default as erase } from "./services/erase";
|
||||
|
||||
// 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";
|
||||
export { default as becca_service } from "./becca/becca_service";
|
||||
@@ -52,16 +58,22 @@ 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, messaging }: {
|
||||
dbConfig: SqlServiceParams,
|
||||
executionContext: ExecutionContext,
|
||||
crypto: CryptoProvider
|
||||
crypto: CryptoProvider,
|
||||
messaging?: MessagingProvider
|
||||
}) {
|
||||
initLog();
|
||||
initCrypto(crypto);
|
||||
initSql(new SqlService(dbConfig, getLog()));
|
||||
initContext(executionContext);
|
||||
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,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;
|
||||
}
|
||||
|
||||
92
packages/trilium-core/src/routes/index.ts
Normal file
92
packages/trilium-core/src/routes/index.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
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 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(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/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
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
|
||||
}
|
||||
};
|
||||
10
packages/trilium-core/src/services/i18n.ts
Normal file
10
packages/trilium-core/src/services/i18n.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { LOCALES } from "@triliumnext/commons";
|
||||
|
||||
// TODO: Real impl.
|
||||
export function changeLanguage(languageCode: string) {
|
||||
console.log("Got request to change language", languageCode);
|
||||
}
|
||||
|
||||
export function getLocales() {
|
||||
return LOCALES;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
@@ -126,3 +126,7 @@ 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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
383
pnpm-lock.yaml
generated
383
pnpm-lock.yaml
generated
@@ -104,8 +104,8 @@ importers:
|
||||
specifier: 0.18.0
|
||||
version: 0.18.0
|
||||
rollup-plugin-webpack-stats:
|
||||
specifier: 2.1.8
|
||||
version: 2.1.8(rolldown@1.0.0-beta.29)(rollup@4.52.0)(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(less@4.1.3)(lightningcss@1.30.1)(sass-embedded@1.91.0)(sass@1.91.0)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.1))
|
||||
specifier: 2.1.9
|
||||
version: 2.1.9(rolldown@1.0.0-beta.29)(rollup@4.52.0)(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(less@4.1.3)(lightningcss@1.30.1)(sass-embedded@1.91.0)(sass@1.91.0)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.1))
|
||||
tslib:
|
||||
specifier: 2.8.1
|
||||
version: 2.8.1
|
||||
@@ -348,6 +348,214 @@ importers:
|
||||
specifier: 3.1.4
|
||||
version: 3.1.4(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(less@4.1.3)(lightningcss@1.30.1)(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.1)
|
||||
'@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.1
|
||||
version: 10.28.1
|
||||
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.1)(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(less@4.1.3)(lightningcss@1.30.1)(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.0(@types/node@24.10.4)(jiti@2.6.1)(less@4.1.3)(lightningcss@1.30.1)(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:
|
||||
@@ -513,9 +721,6 @@ importers:
|
||||
'@anthropic-ai/sdk':
|
||||
specifier: 0.71.2
|
||||
version: 0.71.2(zod@4.1.12)
|
||||
'@braintree/sanitize-url':
|
||||
specifier: 7.1.1
|
||||
version: 7.1.1
|
||||
'@electron/remote':
|
||||
specifier: 2.1.3
|
||||
version: 2.1.3(electron@39.2.7)
|
||||
@@ -558,9 +763,6 @@ importers:
|
||||
'@types/ejs':
|
||||
specifier: 3.1.5
|
||||
version: 3.1.5
|
||||
'@types/escape-html':
|
||||
specifier: 1.0.4
|
||||
version: 1.0.4
|
||||
'@types/express-http-proxy':
|
||||
specifier: 1.6.7
|
||||
version: 1.6.7
|
||||
@@ -576,18 +778,12 @@ importers:
|
||||
'@types/ini':
|
||||
specifier: 4.1.1
|
||||
version: 4.1.1
|
||||
'@types/mime-types':
|
||||
specifier: 3.0.1
|
||||
version: 3.0.1
|
||||
'@types/multer':
|
||||
specifier: 2.0.0
|
||||
version: 2.0.0
|
||||
'@types/safe-compare':
|
||||
specifier: 1.1.2
|
||||
version: 1.1.2
|
||||
'@types/sanitize-html':
|
||||
specifier: 2.16.0
|
||||
version: 2.16.0
|
||||
'@types/sax':
|
||||
specifier: 1.2.7
|
||||
version: 1.2.7
|
||||
@@ -669,9 +865,6 @@ importers:
|
||||
electron-window-state:
|
||||
specifier: 5.0.3
|
||||
version: 5.0.3
|
||||
escape-html:
|
||||
specifier: 1.0.3
|
||||
version: 1.0.3
|
||||
express:
|
||||
specifier: 5.2.1
|
||||
version: 5.2.1
|
||||
@@ -735,9 +928,6 @@ importers:
|
||||
marked:
|
||||
specifier: 17.0.1
|
||||
version: 17.0.1
|
||||
mime-types:
|
||||
specifier: 3.0.2
|
||||
version: 3.0.2
|
||||
multer:
|
||||
specifier: 2.0.2
|
||||
version: 2.0.2
|
||||
@@ -756,12 +946,6 @@ importers:
|
||||
safe-compare:
|
||||
specifier: 1.1.4
|
||||
version: 1.1.4
|
||||
sanitize-filename:
|
||||
specifier: 1.6.3
|
||||
version: 1.6.3
|
||||
sanitize-html:
|
||||
specifier: 2.17.0
|
||||
version: 2.17.0
|
||||
sax:
|
||||
specifier: 1.4.3
|
||||
version: 1.4.3
|
||||
@@ -792,9 +976,6 @@ importers:
|
||||
turndown:
|
||||
specifier: 7.2.2
|
||||
version: 7.2.2
|
||||
unescape:
|
||||
specifier: 1.0.1
|
||||
version: 1.0.1
|
||||
vite:
|
||||
specifier: 7.3.0
|
||||
version: 7.3.0(@types/node@24.10.4)(jiti@2.6.1)(less@4.1.3)(lightningcss@1.30.1)(sass-embedded@1.91.0)(sass@1.91.0)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.1)
|
||||
@@ -1076,6 +1257,9 @@ importers:
|
||||
'@ckeditor/ckeditor5-icons':
|
||||
specifier: 47.3.0
|
||||
version: 47.3.0
|
||||
mathlive:
|
||||
specifier: 0.108.2
|
||||
version: 0.108.2
|
||||
devDependencies:
|
||||
'@ckeditor/ckeditor5-dev-build-tools':
|
||||
specifier: 54.2.3
|
||||
@@ -1446,9 +1630,37 @@ importers:
|
||||
|
||||
packages/trilium-core:
|
||||
dependencies:
|
||||
'@braintree/sanitize-url':
|
||||
specifier: 7.1.1
|
||||
version: 7.1.1
|
||||
'@triliumnext/commons':
|
||||
specifier: workspace:*
|
||||
version: link:../commons
|
||||
escape-html:
|
||||
specifier: 1.0.3
|
||||
version: 1.0.3
|
||||
mime-types:
|
||||
specifier: 3.0.2
|
||||
version: 3.0.2
|
||||
sanitize-filename:
|
||||
specifier: 1.6.3
|
||||
version: 1.6.3
|
||||
sanitize-html:
|
||||
specifier: 2.17.0
|
||||
version: 2.17.0
|
||||
unescape:
|
||||
specifier: 1.0.1
|
||||
version: 1.0.1
|
||||
devDependencies:
|
||||
'@types/escape-html':
|
||||
specifier: 1.0.4
|
||||
version: 1.0.4
|
||||
'@types/mime-types':
|
||||
specifier: 3.0.1
|
||||
version: 3.0.1
|
||||
'@types/sanitize-html':
|
||||
specifier: 2.16.0
|
||||
version: 2.16.0
|
||||
|
||||
packages/turndown-plugin-gfm:
|
||||
devDependencies:
|
||||
@@ -2138,6 +2350,10 @@ packages:
|
||||
resolution: {integrity: sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ==}
|
||||
engines: {node: '>=0.1.90'}
|
||||
|
||||
'@cortex-js/compute-engine@0.30.2':
|
||||
resolution: {integrity: sha512-Zx+iisk9WWdbxjm8EYsneIBszvjfUs7BHNwf1jBtSINIgfWGpHrTTq9vW0J59iGCFt6bOFxbmWyxNMRSmksHMA==}
|
||||
engines: {node: '>=21.7.3', npm: '>=10.5.0'}
|
||||
|
||||
'@cspotcode/source-map-support@0.8.1':
|
||||
resolution: {integrity: sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==}
|
||||
engines: {node: '>=12'}
|
||||
@@ -5199,6 +5415,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==}
|
||||
|
||||
@@ -7085,6 +7305,10 @@ packages:
|
||||
compare-versions@6.1.1:
|
||||
resolution: {integrity: sha512-4hm4VPpIecmlg59CHXnRDnqGplJFrbLG4aFEl5vl6cK1u76ws3LLvX7ikFnTDl5vo39sjWD6AaDPYodJp/NNHg==}
|
||||
|
||||
complex-esm@2.1.1-esm1:
|
||||
resolution: {integrity: sha512-IShBEWHILB9s7MnfyevqNGxV0A1cfcSnewL/4uPFiSxkcQL4Mm3FxJ0pXMtCXuWLjYz3lRRyk6OfkeDZcjD6nw==}
|
||||
engines: {node: '>=16.14.2', npm: '>=8.5.0'}
|
||||
|
||||
component-emitter@1.3.1:
|
||||
resolution: {integrity: sha512-T0+barUSQRTUQASh8bx02dl+DhF54GtIDY13Y3m9oWTklKbb3Wv974meRpeZ3lp1JpLVECWWNHC4vaG2XHXouQ==}
|
||||
|
||||
@@ -9717,6 +9941,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==}
|
||||
|
||||
@@ -10278,6 +10508,9 @@ packages:
|
||||
resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
mathlive@0.108.2:
|
||||
resolution: {integrity: sha512-GIZkfprGTxrbHckOvwo92ZmOOxdD018BHDzlrEwYUU+pzR5KabhqI1s43lxe/vqXdF5RLiQKgDcuk5jxEjhkYg==}
|
||||
|
||||
mathml-tag-names@2.1.3:
|
||||
resolution: {integrity: sha512-APMBEanjybaPzUrfqU0IMU5I0AswKMH7k8OTLs0vvV4KZpExkTkY87nR/zpbuTPj+gARop7aGUbl11pnDfW6xg==}
|
||||
|
||||
@@ -11853,10 +12086,6 @@ packages:
|
||||
resolution: {integrity: sha512-OCVPnIObs4N29kxTjzLfUryOkvZEq+pf8jTF0lg8E7uETuWHA+v7j3c/xJmiqpX450191LlmZfUKkXxkTry7nA==}
|
||||
engines: {node: ^10 || ^12 || >=14}
|
||||
|
||||
postcss@8.5.3:
|
||||
resolution: {integrity: sha512-dle9A3yYxlBSrt8Fu+IpjGT8SY8hN0mlaA6GY8t0P5PjIOZemULz/E2Bnm/2dcUOena75OTNkHI76uZBNUUq3A==}
|
||||
engines: {node: ^10 || ^12 || >=14}
|
||||
|
||||
postcss@8.5.6:
|
||||
resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==}
|
||||
engines: {node: ^10 || ^12 || >=14}
|
||||
@@ -12398,8 +12627,8 @@ packages:
|
||||
resolution: {integrity: sha512-EsoOi8moHN6CAYyTZipxDDVTJn0j2nBCWor4wRU45RQ8ER2qREDykXLr3Ulz6hBh6oBKCFTQIjo21i0FXNo/IA==}
|
||||
hasBin: true
|
||||
|
||||
rollup-plugin-stats@1.5.3:
|
||||
resolution: {integrity: sha512-0IYVGhsFTjcddpqcElzU7Mi4vmDLihCCTH5QgCCgWpNY1VKMXVoEpxmCmGjivtJKLzI6t5QIicsPBC93UWWN2g==}
|
||||
rollup-plugin-stats@1.5.4:
|
||||
resolution: {integrity: sha512-b1hYagYLTyr8mCVUb7e1x9fjxOXFyeWmV9hIr7vYqq/agN+WDaGNzz+KmM3GAx0KGGI2qllOL+zAUi/l39s/Sg==}
|
||||
engines: {node: '>=18'}
|
||||
peerDependencies:
|
||||
rolldown: ^1.0.0-beta.0
|
||||
@@ -12425,8 +12654,8 @@ packages:
|
||||
peerDependencies:
|
||||
rollup: ^3.0.0||^4.0.0
|
||||
|
||||
rollup-plugin-webpack-stats@2.1.8:
|
||||
resolution: {integrity: sha512-agc1OE+QwG3sGeTSdruh16DkxPb6QkgR7I3gntPDFHMXsK1bR2ADHUVod1eoE+epAOqiv3idx/hcSqZAI3a1yg==}
|
||||
rollup-plugin-webpack-stats@2.1.9:
|
||||
resolution: {integrity: sha512-ft1vdp3xPjE+zw8A22yCToo5cpymoWCjNDefWNO1awywsDrSDoRJhkoZTENkhJwmfh6oe5ztpGu7PfnJOMXc2g==}
|
||||
engines: {node: '>=18'}
|
||||
peerDependencies:
|
||||
rolldown: ^1.0.0-beta.0
|
||||
@@ -15294,8 +15523,6 @@ snapshots:
|
||||
'@ckeditor/ckeditor5-ui': 47.3.0
|
||||
'@ckeditor/ckeditor5-utils': 47.3.0
|
||||
ckeditor5: 47.3.0
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@ckeditor/ckeditor5-block-quote@47.3.0':
|
||||
dependencies:
|
||||
@@ -15306,8 +15533,6 @@ snapshots:
|
||||
'@ckeditor/ckeditor5-ui': 47.3.0
|
||||
'@ckeditor/ckeditor5-utils': 47.3.0
|
||||
ckeditor5: 47.3.0
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@ckeditor/ckeditor5-bookmark@47.3.0':
|
||||
dependencies:
|
||||
@@ -15370,8 +15595,6 @@ snapshots:
|
||||
'@ckeditor/ckeditor5-core': 47.3.0
|
||||
'@ckeditor/ckeditor5-utils': 47.3.0
|
||||
ckeditor5: 47.3.0
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@ckeditor/ckeditor5-code-block@47.3.0(patch_hash=2361d8caad7d6b5bddacc3a3b4aa37dbfba260b1c1b22a450413a79c1bb1ce95)':
|
||||
dependencies:
|
||||
@@ -15543,6 +15766,8 @@ snapshots:
|
||||
'@ckeditor/ckeditor5-widget': 47.3.0
|
||||
ckeditor5: 47.3.0
|
||||
es-toolkit: 1.39.5
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@ckeditor/ckeditor5-easy-image@47.3.0':
|
||||
dependencies:
|
||||
@@ -15562,8 +15787,6 @@ snapshots:
|
||||
'@ckeditor/ckeditor5-utils': 47.3.0
|
||||
ckeditor5: 47.3.0
|
||||
es-toolkit: 1.39.5
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@ckeditor/ckeditor5-editor-classic@47.3.0':
|
||||
dependencies:
|
||||
@@ -15582,6 +15805,8 @@ snapshots:
|
||||
'@ckeditor/ckeditor5-utils': 47.3.0
|
||||
ckeditor5: 47.3.0
|
||||
es-toolkit: 1.39.5
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@ckeditor/ckeditor5-editor-inline@47.3.0':
|
||||
dependencies:
|
||||
@@ -15591,8 +15816,6 @@ snapshots:
|
||||
'@ckeditor/ckeditor5-utils': 47.3.0
|
||||
ckeditor5: 47.3.0
|
||||
es-toolkit: 1.39.5
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@ckeditor/ckeditor5-editor-multi-root@47.3.0':
|
||||
dependencies:
|
||||
@@ -15615,8 +15838,6 @@ snapshots:
|
||||
'@ckeditor/ckeditor5-table': 47.3.0
|
||||
'@ckeditor/ckeditor5-utils': 47.3.0
|
||||
ckeditor5: 47.3.0
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@ckeditor/ckeditor5-emoji@47.3.0':
|
||||
dependencies:
|
||||
@@ -15642,6 +15863,8 @@ snapshots:
|
||||
'@ckeditor/ckeditor5-core': 47.3.0
|
||||
'@ckeditor/ckeditor5-engine': 47.3.0
|
||||
'@ckeditor/ckeditor5-utils': 47.3.0
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@ckeditor/ckeditor5-essentials@47.3.0':
|
||||
dependencies:
|
||||
@@ -15673,8 +15896,6 @@ snapshots:
|
||||
'@ckeditor/ckeditor5-ui': 47.3.0
|
||||
'@ckeditor/ckeditor5-utils': 47.3.0
|
||||
ckeditor5: 47.3.0
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@ckeditor/ckeditor5-export-word@47.3.0':
|
||||
dependencies:
|
||||
@@ -15699,6 +15920,8 @@ snapshots:
|
||||
'@ckeditor/ckeditor5-utils': 47.3.0
|
||||
ckeditor5: 47.3.0
|
||||
es-toolkit: 1.39.5
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@ckeditor/ckeditor5-font@47.3.0':
|
||||
dependencies:
|
||||
@@ -15708,8 +15931,6 @@ snapshots:
|
||||
'@ckeditor/ckeditor5-ui': 47.3.0
|
||||
'@ckeditor/ckeditor5-utils': 47.3.0
|
||||
ckeditor5: 47.3.0
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@ckeditor/ckeditor5-footnotes@47.3.0':
|
||||
dependencies:
|
||||
@@ -15740,8 +15961,6 @@ snapshots:
|
||||
'@ckeditor/ckeditor5-ui': 47.3.0
|
||||
'@ckeditor/ckeditor5-utils': 47.3.0
|
||||
ckeditor5: 47.3.0
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@ckeditor/ckeditor5-heading@47.3.0':
|
||||
dependencies:
|
||||
@@ -15773,6 +15992,8 @@ snapshots:
|
||||
'@ckeditor/ckeditor5-utils': 47.3.0
|
||||
'@ckeditor/ckeditor5-widget': 47.3.0
|
||||
ckeditor5: 47.3.0
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@ckeditor/ckeditor5-html-embed@47.3.0':
|
||||
dependencies:
|
||||
@@ -15832,8 +16053,6 @@ snapshots:
|
||||
'@ckeditor/ckeditor5-ui': 47.3.0
|
||||
'@ckeditor/ckeditor5-utils': 47.3.0
|
||||
ckeditor5: 47.3.0
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@ckeditor/ckeditor5-indent@47.3.0':
|
||||
dependencies:
|
||||
@@ -15868,6 +16087,8 @@ snapshots:
|
||||
'@ckeditor/ckeditor5-ui': 47.3.0
|
||||
'@ckeditor/ckeditor5-utils': 47.3.0
|
||||
ckeditor5: 47.3.0
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@ckeditor/ckeditor5-link@47.3.0':
|
||||
dependencies:
|
||||
@@ -15894,6 +16115,8 @@ snapshots:
|
||||
'@ckeditor/ckeditor5-ui': 47.3.0
|
||||
'@ckeditor/ckeditor5-utils': 47.3.0
|
||||
ckeditor5: 47.3.0
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@ckeditor/ckeditor5-list@47.3.0':
|
||||
dependencies:
|
||||
@@ -15946,8 +16169,6 @@ snapshots:
|
||||
'@ckeditor/ckeditor5-utils': 47.3.0
|
||||
'@ckeditor/ckeditor5-widget': 47.3.0
|
||||
ckeditor5: 47.3.0
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@ckeditor/ckeditor5-mention@47.3.0(patch_hash=5981fb59ba35829e4dff1d39cf771000f8a8fdfa7a34b51d8af9549541f2d62d)':
|
||||
dependencies:
|
||||
@@ -15957,8 +16178,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:
|
||||
@@ -15971,8 +16190,6 @@ snapshots:
|
||||
'@ckeditor/ckeditor5-widget': 47.3.0
|
||||
ckeditor5: 47.3.0
|
||||
es-toolkit: 1.39.5
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@ckeditor/ckeditor5-minimap@47.3.0':
|
||||
dependencies:
|
||||
@@ -15981,8 +16198,6 @@ 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:
|
||||
@@ -16102,8 +16317,6 @@ snapshots:
|
||||
'@ckeditor/ckeditor5-ui': 47.3.0
|
||||
'@ckeditor/ckeditor5-utils': 47.3.0
|
||||
ckeditor5: 47.3.0
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@ckeditor/ckeditor5-restricted-editing@47.3.0':
|
||||
dependencies:
|
||||
@@ -16225,8 +16438,6 @@ snapshots:
|
||||
'@ckeditor/ckeditor5-widget': 47.3.0
|
||||
ckeditor5: 47.3.0
|
||||
es-toolkit: 1.39.5
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@ckeditor/ckeditor5-template@47.3.0':
|
||||
dependencies:
|
||||
@@ -16508,6 +16719,11 @@ snapshots:
|
||||
|
||||
'@colors/colors@1.5.0': {}
|
||||
|
||||
'@cortex-js/compute-engine@0.30.2':
|
||||
dependencies:
|
||||
complex-esm: 2.1.1-esm1
|
||||
decimal.js: 10.6.0
|
||||
|
||||
'@cspotcode/source-map-support@0.8.1':
|
||||
dependencies:
|
||||
'@jridgewell/trace-mapping': 0.3.9
|
||||
@@ -19933,6 +20149,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':
|
||||
@@ -22314,6 +22532,8 @@ snapshots:
|
||||
|
||||
compare-versions@6.1.1: {}
|
||||
|
||||
complex-esm@2.1.1-esm1: {}
|
||||
|
||||
component-emitter@1.3.1: {}
|
||||
|
||||
compress-commons@6.0.2:
|
||||
@@ -23000,8 +23220,7 @@ snapshots:
|
||||
|
||||
decimal.js@10.5.0: {}
|
||||
|
||||
decimal.js@10.6.0:
|
||||
optional: true
|
||||
decimal.js@10.6.0: {}
|
||||
|
||||
decko@1.2.0: {}
|
||||
|
||||
@@ -25655,6 +25874,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: {}
|
||||
@@ -26347,6 +26570,10 @@ snapshots:
|
||||
|
||||
math-intrinsics@1.1.0: {}
|
||||
|
||||
mathlive@0.108.2:
|
||||
dependencies:
|
||||
'@cortex-js/compute-engine': 0.30.2
|
||||
|
||||
mathml-tag-names@2.1.3: {}
|
||||
|
||||
mdast-util-find-and-replace@3.0.2:
|
||||
@@ -28159,12 +28386,6 @@ snapshots:
|
||||
picocolors: 1.1.1
|
||||
source-map-js: 1.2.1
|
||||
|
||||
postcss@8.5.3:
|
||||
dependencies:
|
||||
nanoid: 3.3.11
|
||||
picocolors: 1.1.1
|
||||
source-map-js: 1.2.1
|
||||
|
||||
postcss@8.5.6:
|
||||
dependencies:
|
||||
nanoid: 3.3.11
|
||||
@@ -28817,7 +29038,7 @@ snapshots:
|
||||
'@rolldown/binding-win32-x64-msvc': 1.0.0-beta.29
|
||||
optional: true
|
||||
|
||||
rollup-plugin-stats@1.5.3(rolldown@1.0.0-beta.29)(rollup@4.52.0)(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(less@4.1.3)(lightningcss@1.30.1)(sass-embedded@1.91.0)(sass@1.91.0)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.1)):
|
||||
rollup-plugin-stats@1.5.4(rolldown@1.0.0-beta.29)(rollup@4.52.0)(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(less@4.1.3)(lightningcss@1.30.1)(sass-embedded@1.91.0)(sass@1.91.0)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.1)):
|
||||
optionalDependencies:
|
||||
rolldown: 1.0.0-beta.29
|
||||
rollup: 4.52.0
|
||||
@@ -28850,9 +29071,9 @@ snapshots:
|
||||
'@rollup/pluginutils': 5.1.4(rollup@4.52.0)
|
||||
rollup: 4.52.0
|
||||
|
||||
rollup-plugin-webpack-stats@2.1.8(rolldown@1.0.0-beta.29)(rollup@4.52.0)(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(less@4.1.3)(lightningcss@1.30.1)(sass-embedded@1.91.0)(sass@1.91.0)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.1)):
|
||||
rollup-plugin-webpack-stats@2.1.9(rolldown@1.0.0-beta.29)(rollup@4.52.0)(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(less@4.1.3)(lightningcss@1.30.1)(sass-embedded@1.91.0)(sass@1.91.0)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.1)):
|
||||
dependencies:
|
||||
rollup-plugin-stats: 1.5.3(rolldown@1.0.0-beta.29)(rollup@4.52.0)(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(less@4.1.3)(lightningcss@1.30.1)(sass-embedded@1.91.0)(sass@1.91.0)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.1))
|
||||
rollup-plugin-stats: 1.5.4(rolldown@1.0.0-beta.29)(rollup@4.52.0)(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(less@4.1.3)(lightningcss@1.30.1)(sass-embedded@1.91.0)(sass@1.91.0)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.1))
|
||||
optionalDependencies:
|
||||
rolldown: 1.0.0-beta.29
|
||||
rollup: 4.52.0
|
||||
@@ -28965,7 +29186,7 @@ snapshots:
|
||||
htmlparser2: 8.0.2
|
||||
is-plain-object: 5.0.0
|
||||
parse-srcset: 1.0.2
|
||||
postcss: 8.5.3
|
||||
postcss: 8.5.6
|
||||
|
||||
sass-embedded-all-unknown@1.91.0:
|
||||
dependencies:
|
||||
|
||||
Reference in New Issue
Block a user