From 00e74829682c0610089fab4d4b80975cdc0d7faf Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Mon, 5 Jan 2026 12:26:13 +0200 Subject: [PATCH 01/72] chore(core): create empty package --- packages/trilium-core/package.json | 9 +++++++ packages/trilium-core/tsconfig.json | 13 ++++++++++ packages/trilium-core/tsconfig.lib.json | 30 ++++++++++++++++++++++++ packages/trilium-core/tsconfig.spec.json | 30 ++++++++++++++++++++++++ 4 files changed, 82 insertions(+) create mode 100644 packages/trilium-core/package.json create mode 100644 packages/trilium-core/tsconfig.json create mode 100644 packages/trilium-core/tsconfig.lib.json create mode 100644 packages/trilium-core/tsconfig.spec.json diff --git a/packages/trilium-core/package.json b/packages/trilium-core/package.json new file mode 100644 index 0000000000..c34bfce2a2 --- /dev/null +++ b/packages/trilium-core/package.json @@ -0,0 +1,9 @@ +{ + "name": "@triliumnext/core", + "version": "1.0.0", + "description": "", + "main": "index.js", + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1" + } +} diff --git a/packages/trilium-core/tsconfig.json b/packages/trilium-core/tsconfig.json new file mode 100644 index 0000000000..62ebbd9464 --- /dev/null +++ b/packages/trilium-core/tsconfig.json @@ -0,0 +1,13 @@ +{ + "extends": "../../tsconfig.base.json", + "files": [], + "include": [], + "references": [ + { + "path": "./tsconfig.lib.json" + }, + { + "path": "./tsconfig.spec.json" + } + ] +} diff --git a/packages/trilium-core/tsconfig.lib.json b/packages/trilium-core/tsconfig.lib.json new file mode 100644 index 0000000000..43b685ad35 --- /dev/null +++ b/packages/trilium-core/tsconfig.lib.json @@ -0,0 +1,30 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "baseUrl": ".", + "rootDir": "src", + "outDir": "dist", + "tsBuildInfoFile": "dist/tsconfig.lib.tsbuildinfo", + "emitDeclarationOnly": true, + "forceConsistentCasingInFileNames": true, + "types": [] + }, + "include": [ + "src/**/*.ts" + ], + "references": [], + "exclude": [ + "vite.config.ts", + "vite.config.mts", + "vitest.config.ts", + "vitest.config.mts", + "src/**/*.test.ts", + "src/**/*.spec.ts", + "src/**/*.test.tsx", + "src/**/*.spec.tsx", + "src/**/*.test.js", + "src/**/*.spec.js", + "src/**/*.test.jsx", + "src/**/*.spec.jsx" + ] +} diff --git a/packages/trilium-core/tsconfig.spec.json b/packages/trilium-core/tsconfig.spec.json new file mode 100644 index 0000000000..2819b04629 --- /dev/null +++ b/packages/trilium-core/tsconfig.spec.json @@ -0,0 +1,30 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "./out-tsc/vitest", + "types": [ + "vitest" + ], + "forceConsistentCasingInFileNames": true + }, + "include": [ + "vite.config.ts", + "vite.config.mts", + "vitest.config.ts", + "vitest.config.mts", + "src/**/*.test.ts", + "src/**/*.spec.ts", + "src/**/*.test.tsx", + "src/**/*.spec.tsx", + "src/**/*.test.js", + "src/**/*.spec.js", + "src/**/*.test.jsx", + "src/**/*.spec.jsx", + "src/**/*.d.ts" + ], + "references": [ + { + "path": "./tsconfig.lib.json" + } + ] +} From a67464b4a0328ecaac301c33fd13fa6924145a8a Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Mon, 5 Jan 2026 14:03:03 +0200 Subject: [PATCH 02/72] refactor(server): decouple bettersqlite3 from sql service --- apps/server/src/services/sql.ts | 105 +++++++++++++------------ apps/server/src/services/sql_nodejs.ts | 71 +++++++++++++++++ 2 files changed, 126 insertions(+), 50 deletions(-) create mode 100644 apps/server/src/services/sql_nodejs.ts diff --git a/apps/server/src/services/sql.ts b/apps/server/src/services/sql.ts index 206a828d66..61bc9c6d78 100644 --- a/apps/server/src/services/sql.ts +++ b/apps/server/src/services/sql.ts @@ -1,44 +1,65 @@ -"use strict"; - -/** - * @module sql - */ - -import log from "./log.js"; -import type { Statement, Database as DatabaseType, RunResult } from "better-sqlite3"; -import dataDir from "./data_dir.js"; -import cls from "./cls.js"; import fs from "fs"; -import Database from "better-sqlite3"; -import ws from "./ws.js"; + import becca_loader from "../becca/becca_loader.js"; -import entity_changes from "./entity_changes.js"; +import cls from "./cls.js"; import config from "./config.js"; +import dataDir from "./data_dir.js"; +import entity_changes from "./entity_changes.js"; +import log from "./log.js"; +import BetterSqlite3Provider from "./sql_nodejs.js"; +import ws from "./ws.js"; -const dbOpts: Database.Options = { - nativeBinding: process.env.BETTERSQLITE3_NATIVE_PATH || undefined -}; +type Params = any; -let dbConnection: DatabaseType = buildDatabase(); +export interface Statement { + run(...params: Params): RunResult; + get(params: Params): unknown; + all(...params: Params): unknown[]; + iterate(...params: Params): IterableIterator; + raw(toggleState?: boolean): this; + pluck(toggleState?: boolean): this; +} + +export interface Transaction { + deferred(): void; +} + +export interface RunResult { + changes: number; + lastInsertRowid: number | bigint; +} + +export interface DatabaseProvider { + loadFromFile(path: string, isReadOnly: boolean): void; + loadFromMemory(): void; + loadFromBuffer(buffer: NonSharedBuffer): void; + backup(destinationFile: string): void; + prepare(query: string): Statement; + transaction(func: (statement: Statement) => T): Transaction; + get inTransaction(): boolean; + exec(query: string): void; + close(): void; +} + +const dbConnection: DatabaseProvider = new BetterSqlite3Provider(); let statementCache: Record = {}; function buildDatabase() { // for integration tests, ignore the config's readOnly setting if (process.env.TRILIUM_INTEGRATION_TEST === "memory") { - return buildIntegrationTestDatabase(); + buildIntegrationTestDatabase(); } else if (process.env.TRILIUM_INTEGRATION_TEST === "memory-no-store") { - return new Database(":memory:", dbOpts); + dbConnection.loadFromMemory(); } - return new Database(dataDir.DOCUMENT_PATH, { - ...dbOpts, - readonly: config.General.readOnly - }); + dbConnection.loadFromFile(dataDir.DOCUMENT_PATH, config.General.readOnly); } +buildDatabase(); + function buildIntegrationTestDatabase(dbPath?: string) { - const dbBuffer = fs.readFileSync(dbPath ?? dataDir.DOCUMENT_PATH); - return new Database(dbBuffer, dbOpts); + const buffer = fs.readFileSync(dbPath ?? dataDir.DOCUMENT_PATH); + dbConnection.loadFromBuffer(buffer); } function rebuildIntegrationTestDatabase(dbPath?: string) { @@ -47,28 +68,12 @@ function rebuildIntegrationTestDatabase(dbPath?: string) { } // This allows a database that is read normally but is kept in memory and discards all modifications. - dbConnection = buildIntegrationTestDatabase(dbPath); + buildIntegrationTestDatabase(dbPath); statementCache = {}; } -if (!process.env.TRILIUM_INTEGRATION_TEST) { - dbConnection.pragma("journal_mode = WAL"); -} - const LOG_ALL_QUERIES = false; -type Params = any; - -[`exit`, `SIGINT`, `SIGUSR1`, `SIGUSR2`, `SIGTERM`].forEach((eventType) => { - process.on(eventType, () => { - if (dbConnection) { - // closing connection is especially important to fold -wal file into the main DB file - // (see https://sqlite.org/tempfiles.html for details) - dbConnection.close(); - } - }); -}); - function insert(tableName: string, rec: T, replace = false) { const keys = Object.keys(rec || {}); if (keys.length === 0) { @@ -129,7 +134,7 @@ function upsert(tableName: string, primaryKey: string, rec: T) { * @returns the corresponding {@link Statement}. */ function stmt(sql: string, isRaw?: boolean) { - const key = (isRaw ? "raw/" + sql : sql); + const key = (isRaw ? `raw/${sql}` : sql); if (!(key in statementCache)) { statementCache[key] = dbConnection.prepare(sql); @@ -169,11 +174,11 @@ function getManyRows(query: string, params: Params): T[] { let j = 1; for (const param of curParams) { - curParamsObj["param" + j++] = param; + curParamsObj[`param${j++}`] = param; } let i = 1; - const questionMarks = curParams.map(() => ":param" + i++).join(","); + const questionMarks = curParams.map(() => `:param${i++}`).join(","); const curQuery = query.replace(/\?\?\?/g, questionMarks); const statement = curParams.length === PARAM_LIMIT ? stmt(curQuery) : dbConnection.prepare(curQuery); @@ -240,23 +245,23 @@ function executeMany(query: string, params: Params) { let j = 1; for (const param of curParams) { - curParamsObj["param" + j++] = param; + curParamsObj[`param${j++}`] = param; } let i = 1; - const questionMarks = curParams.map(() => ":param" + i++).join(","); + const questionMarks = curParams.map(() => `:param${i++}`).join(","); const curQuery = query.replace(/\?\?\?/g, questionMarks); dbConnection.prepare(curQuery).run(curParamsObj); } } -function executeScript(query: string): DatabaseType { +function executeScript(query: string) { if (LOG_ALL_QUERIES) { console.log(query); } - return dbConnection.exec(query); + dbConnection.exec(query); } /** @@ -350,7 +355,7 @@ function fillParamList(paramIds: string[] | Set, truncate = true) { async function copyDatabase(targetFilePath: string) { try { fs.unlinkSync(targetFilePath); - } catch (e) {} // unlink throws exception if the file did not exist + } catch (e) { } // unlink throws exception if the file did not exist await dbConnection.backup(targetFilePath); } diff --git a/apps/server/src/services/sql_nodejs.ts b/apps/server/src/services/sql_nodejs.ts new file mode 100644 index 0000000000..77cf4b8ed7 --- /dev/null +++ b/apps/server/src/services/sql_nodejs.ts @@ -0,0 +1,71 @@ +import Database, { type Database as DatabaseType } from "better-sqlite3"; +import { readFileSync } from "fs"; + +import dataDirs from "./data_dir"; +import type { DatabaseProvider, Statement, Transaction } from "./sql"; + +const dbOpts: Database.Options = { + nativeBinding: process.env.BETTERSQLITE3_NATIVE_PATH || undefined +}; + +export default class BetterSqlite3Provider implements DatabaseProvider { + + private dbConnection?: DatabaseType; + + constructor() { + [`exit`, `SIGINT`, `SIGUSR1`, `SIGUSR2`, `SIGTERM`].forEach((eventType) => { + // closing connection is especially important to fold -wal file into the main DB file + // (see https://sqlite.org/tempfiles.html for details) + process.on(eventType, () => this.close()); + }); + } + + loadFromFile(path: string, isReadOnly: boolean) { + this.dbConnection = new Database(path, { + ...dbOpts, + readonly: isReadOnly + }); + this.dbConnection.pragma("journal_mode = WAL"); + } + + loadFromMemory() { + this.dbConnection = new Database(":memory:", dbOpts); + } + + loadFromBuffer(buffer: NonSharedBuffer) { + this.dbConnection = new Database(buffer, dbOpts); + } + + backup(destinationFile: string) { + this.dbConnection?.backup(destinationFile); + } + + prepare(query: string): Statement { + if (!this.dbConnection) throw new Error("DB not open."); + return this.dbConnection.prepare(query); + } + + transaction(func: (statement: Statement) => T): Transaction { + if (!this.dbConnection) throw new Error("DB not open."); + return this.dbConnection.transaction(func); + } + + get inTransaction() { + if (!this.dbConnection) throw new Error("DB not open."); + return this.dbConnection.inTransaction; + } + + exec(query: string): void { + this.dbConnection?.exec(query); + } + + close() { + this.dbConnection?.close(); + } + +} + +function buildIntegrationTestDatabase(dbPath?: string) { + const dbBuffer = readFileSync(dbPath ?? dataDirs.DOCUMENT_PATH); + return new Database(dbBuffer, dbOpts); +} From 62803a1817934a542b009091fddae68f73db44e8 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Mon, 5 Jan 2026 14:42:32 +0200 Subject: [PATCH 03/72] chore(server): set up dependency to trilium-core --- apps/server/package.json | 1 + apps/server/tsconfig.app.json | 3 +++ apps/server/tsconfig.json | 3 +++ packages/trilium-core/package.json | 2 +- pnpm-lock.yaml | 7 +++++-- 5 files changed, 13 insertions(+), 3 deletions(-) diff --git a/apps/server/package.json b/apps/server/package.json index ecde429862..0f067c321e 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -40,6 +40,7 @@ "@electron/remote": "2.1.3", "@preact/preset-vite": "2.10.2", "@triliumnext/commons": "workspace:*", + "@triliumnext/core": "workspace:*", "@triliumnext/express-partial-content": "workspace:*", "@triliumnext/highlightjs": "workspace:*", "@triliumnext/turndown-plugin-gfm": "workspace:*", diff --git a/apps/server/tsconfig.app.json b/apps/server/tsconfig.app.json index 79dd0b7709..b416e1af91 100644 --- a/apps/server/tsconfig.app.json +++ b/apps/server/tsconfig.app.json @@ -46,6 +46,9 @@ }, { "path": "../../packages/commons/tsconfig.lib.json" + }, + { + "path": "../../packages/trilium-core/tsconfig.lib.json" } ] } diff --git a/apps/server/tsconfig.json b/apps/server/tsconfig.json index 6bc2242953..ae11c06bcd 100644 --- a/apps/server/tsconfig.json +++ b/apps/server/tsconfig.json @@ -15,6 +15,9 @@ { "path": "../../packages/commons" }, + { + "path": "../../packages/trilium-core" + }, { "path": "./tsconfig.app.json" }, diff --git a/packages/trilium-core/package.json b/packages/trilium-core/package.json index c34bfce2a2..7e6df61d35 100644 --- a/packages/trilium-core/package.json +++ b/packages/trilium-core/package.json @@ -2,7 +2,7 @@ "name": "@triliumnext/core", "version": "1.0.0", "description": "", - "main": "index.js", + "main": "src/index.ts", "scripts": { "test": "echo \"Error: no test specified\" && exit 1" } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 31fc4bd627..fc5d57faf4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -525,6 +525,9 @@ importers: '@triliumnext/commons': specifier: workspace:* version: link:../../packages/commons + '@triliumnext/core': + specifier: workspace:* + version: link:../../packages/trilium-core '@triliumnext/express-partial-content': specifier: workspace:* version: link:../../packages/express-partial-content @@ -1441,6 +1444,8 @@ importers: specifier: 5.1.0 version: 5.1.0(karma@6.4.4(bufferutil@4.0.9)(utf-8-validate@6.0.5)) + packages/trilium-core: {} + packages/turndown-plugin-gfm: devDependencies: turndown: @@ -15428,8 +15433,6 @@ snapshots: '@ckeditor/ckeditor5-utils': 47.3.0 '@ckeditor/ckeditor5-watchdog': 47.3.0 es-toolkit: 1.39.5 - transitivePeerDependencies: - - supports-color '@ckeditor/ckeditor5-dev-build-tools@54.2.3(@swc/helpers@0.5.17)(tslib@2.8.1)(typescript@5.9.3)': dependencies: From ea31d2f4468198bfb414208ccc956acd8eca8826 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Mon, 5 Jan 2026 15:45:45 +0200 Subject: [PATCH 04/72] chore(core): basic integration of SQL + CLS + log --- apps/server/src/cls_provider.ts | 24 + apps/server/src/main.ts | 31 ++ apps/server/src/services/cls.ts | 70 ++- apps/server/src/services/log.ts | 20 +- apps/server/src/services/sql.ts | 444 +----------------- .../sql_nodejs.ts => sql_provider.ts} | 17 +- packages/trilium-core/src/index.ts | 17 + packages/trilium-core/src/services/context.ts | 18 + packages/trilium-core/src/services/log.ts | 26 + .../trilium-core/src/services/sql/index.ts | 13 + packages/trilium-core/src/services/sql/sql.ts | 318 +++++++++++++ .../trilium-core/src/services/sql/types.ts | 31 ++ 12 files changed, 526 insertions(+), 503 deletions(-) create mode 100644 apps/server/src/cls_provider.ts rename apps/server/src/{services/sql_nodejs.ts => sql_provider.ts} (82%) create mode 100644 packages/trilium-core/src/index.ts create mode 100644 packages/trilium-core/src/services/context.ts create mode 100644 packages/trilium-core/src/services/log.ts create mode 100644 packages/trilium-core/src/services/sql/index.ts create mode 100644 packages/trilium-core/src/services/sql/sql.ts create mode 100644 packages/trilium-core/src/services/sql/types.ts diff --git a/apps/server/src/cls_provider.ts b/apps/server/src/cls_provider.ts new file mode 100644 index 0000000000..3bac6352c6 --- /dev/null +++ b/apps/server/src/cls_provider.ts @@ -0,0 +1,24 @@ +import { ExecutionContext } from "@triliumnext/core"; +import clsHooked from "cls-hooked"; + +const namespace = clsHooked.createNamespace("trilium"); + +export default class ClsHookedExecutionContext implements ExecutionContext { + + get(key: string): T | undefined { + return namespace.get(key); + } + + set(key: string, value: any): void { + namespace.set(key, value); + } + + reset(): void { + clsHooked.reset(); + } + + init(callback: () => T): T { + return namespace.runAndReturn(callback); + } + +} diff --git a/apps/server/src/main.ts b/apps/server/src/main.ts index 072ff3229e..98cc544ab1 100644 --- a/apps/server/src/main.ts +++ b/apps/server/src/main.ts @@ -3,10 +3,41 @@ * are loaded later and will result in an empty string. */ +import { initializeCore } from "@triliumnext/core"; + import { initializeTranslations } from "./services/i18n.js"; +import BetterSqlite3Provider from "./sql_provider.js"; async function startApplication() { await initializeTranslations(); + const config = (await import("./services/config.js")).default; + initializeCore({ + dbConfig: { + provider: new BetterSqlite3Provider(), + isReadOnly: config.General.readOnly, + async onTransactionCommit() { + const ws = (await import("./services/ws.js")).default; + ws.sendTransactionEntityChangesToAllClients(); + }, + async onTransactionRollback() { + const cls = (await import("./services/cls.js")).default; + const becca_loader = (await import("./becca/becca_loader.js")).default; + const entity_changes = (await import("./services/entity_changes.js")).default; + const log = (await import("./services/log")).default; + + const entityChangeIds = cls.getAndClearEntityChangeIds(); + + if (entityChangeIds.length > 0) { + log.info("Transaction rollback dirtied the becca, forcing reload."); + + becca_loader.load(); + } + + // the maxEntityChangeId has been incremented during failed transaction, need to recalculate + entity_changes.recalculateMaxEntityChangeId(); + } + } + }); const startTriliumServer = (await import("./www.js")).default; await startTriliumServer(); } diff --git a/apps/server/src/services/cls.ts b/apps/server/src/services/cls.ts index 7636be7dd0..5fae8afbdd 100644 --- a/apps/server/src/services/cls.ts +++ b/apps/server/src/services/cls.ts @@ -1,11 +1,10 @@ -import clsHooked from "cls-hooked"; import type { EntityChange } from "@triliumnext/commons"; -const namespace = clsHooked.createNamespace("trilium"); +import { getContext } from "@triliumnext/core/src/services/context"; type Callback = (...args: any[]) => any; -function init(callback: Callback) { - return namespace.runAndReturn(callback); +function init(callback: () => void) { + getContext().init(callback); } function wrap(callback: Callback) { @@ -18,81 +17,73 @@ function wrap(callback: Callback) { }; } -function get(key: string) { - return namespace.get(key); -} - -function set(key: string, value: any) { - namespace.set(key, value); -} - function getHoistedNoteId() { - return namespace.get("hoistedNoteId") || "root"; + return getContext().get("hoistedNoteId") || "root"; } function getComponentId() { - return namespace.get("componentId"); + return getContext().get("componentId"); } function getLocalNowDateTime() { - return namespace.get("localNowDateTime"); + return getContext().get("localNowDateTime"); } function disableEntityEvents() { - namespace.set("disableEntityEvents", true); + getContext().set("disableEntityEvents", true); } function enableEntityEvents() { - namespace.set("disableEntityEvents", false); + getContext().set("disableEntityEvents", false); } function isEntityEventsDisabled() { - return !!namespace.get("disableEntityEvents"); + return !!getContext().get("disableEntityEvents"); } function setMigrationRunning(running: boolean) { - namespace.set("migrationRunning", !!running); + getContext().set("migrationRunning", !!running); } function isMigrationRunning() { - return !!namespace.get("migrationRunning"); -} - -function disableSlowQueryLogging(disable: boolean) { - namespace.set("disableSlowQueryLogging", disable); -} - -function isSlowQueryLoggingDisabled() { - return !!namespace.get("disableSlowQueryLogging"); + return !!getContext().get("migrationRunning"); } function getAndClearEntityChangeIds() { - const entityChangeIds = namespace.get("entityChangeIds") || []; + const entityChangeIds = getContext().get("entityChangeIds") || []; - namespace.set("entityChangeIds", []); + getContext().set("entityChangeIds", []); return entityChangeIds; } function putEntityChange(entityChange: EntityChange) { - if (namespace.get("ignoreEntityChangeIds")) { + if (getContext().get("ignoreEntityChangeIds")) { return; } - const entityChangeIds = namespace.get("entityChangeIds") || []; + const entityChangeIds = getContext().get("entityChangeIds") || []; // store only ID since the record can be modified (e.g., in erase) entityChangeIds.push(entityChange.id); - namespace.set("entityChangeIds", entityChangeIds); -} - -function reset() { - clsHooked.reset(); + getContext().set("entityChangeIds", entityChangeIds); } function ignoreEntityChangeIds() { - namespace.set("ignoreEntityChangeIds", true); + getContext().set("ignoreEntityChangeIds", true); +} + +function get(key: string) { + return getContext().get(key); +} + +function set(key: string, value: unknown) { + getContext().set(key, value); +} + +function reset() { + getContext().reset(); } export default { @@ -100,7 +91,6 @@ export default { wrap, get, set, - namespace, getHoistedNoteId, getComponentId, getLocalNowDateTime, @@ -111,8 +101,6 @@ export default { getAndClearEntityChangeIds, putEntityChange, ignoreEntityChangeIds, - disableSlowQueryLogging, - isSlowQueryLoggingDisabled, setMigrationRunning, isMigrationRunning }; diff --git a/apps/server/src/services/log.ts b/apps/server/src/services/log.ts index 3d8cdd6b76..247b5de639 100644 --- a/apps/server/src/services/log.ts +++ b/apps/server/src/services/log.ts @@ -1,12 +1,12 @@ -"use strict"; - +import { getLog } from "@triliumnext/core/src/services/log.js"; import type { Request, Response } from "express"; import fs from "fs"; -import path from "path"; import { EOL } from "os"; -import dataDir from "./data_dir.js"; +import path from "path"; + import cls from "./cls.js"; import config, { LOGGING_DEFAULT_RETENTION_DAYS } from "./config.js"; +import dataDir from "./data_dir.js"; if (!fs.existsSync(dataDir.LOG_DIR)) { fs.mkdirSync(dataDir.LOG_DIR, 0o700); @@ -40,7 +40,7 @@ async function cleanupOldLogFiles() { retentionDays = customRetentionDays; } else if (customRetentionDays <= -1){ info(`Log cleanup: keeping all log files, as specified by configuration.`); - return + return; } const cutoffDate = new Date(); @@ -150,11 +150,11 @@ function log(str: string | Error) { } function info(message: string | Error) { - log(message); + getLog().info(message); } function error(message: string | Error | unknown) { - log(`ERROR: ${message}`); + getLog().error(message); } const requestBlacklist = ["/app", "/images", "/stylesheets", "/api/recent-notes"]; @@ -170,7 +170,7 @@ function request(req: Request, res: Response, timeMs: number, responseLength: nu return; } - info((timeMs >= 10 ? "Slow " : "") + `${res.statusCode} ${req.method} ${req.url} with ${responseLength} bytes took ${timeMs}ms`); + info(`${timeMs >= 10 ? "Slow " : "" }${res.statusCode} ${req.method} ${req.url} with ${responseLength} bytes took ${timeMs}ms`); } function pad(num: number) { @@ -184,9 +184,9 @@ function padMilli(num: number) { return `00${num}`; } else if (num < 100) { return `0${num}`; - } else { - return num.toString(); } + return num.toString(); + } function formatTime(millisSinceMidnight: number) { diff --git a/apps/server/src/services/sql.ts b/apps/server/src/services/sql.ts index 61bc9c6d78..ceff5a8917 100644 --- a/apps/server/src/services/sql.ts +++ b/apps/server/src/services/sql.ts @@ -1,443 +1,3 @@ -import fs from "fs"; +import { getSql } from "@triliumnext/core"; -import becca_loader from "../becca/becca_loader.js"; -import cls from "./cls.js"; -import config from "./config.js"; -import dataDir from "./data_dir.js"; -import entity_changes from "./entity_changes.js"; -import log from "./log.js"; -import BetterSqlite3Provider from "./sql_nodejs.js"; -import ws from "./ws.js"; - -type Params = any; - -export interface Statement { - run(...params: Params): RunResult; - get(params: Params): unknown; - all(...params: Params): unknown[]; - iterate(...params: Params): IterableIterator; - raw(toggleState?: boolean): this; - pluck(toggleState?: boolean): this; -} - -export interface Transaction { - deferred(): void; -} - -export interface RunResult { - changes: number; - lastInsertRowid: number | bigint; -} - -export interface DatabaseProvider { - loadFromFile(path: string, isReadOnly: boolean): void; - loadFromMemory(): void; - loadFromBuffer(buffer: NonSharedBuffer): void; - backup(destinationFile: string): void; - prepare(query: string): Statement; - transaction(func: (statement: Statement) => T): Transaction; - get inTransaction(): boolean; - exec(query: string): void; - close(): void; -} - -const dbConnection: DatabaseProvider = new BetterSqlite3Provider(); -let statementCache: Record = {}; - -function buildDatabase() { - // for integration tests, ignore the config's readOnly setting - if (process.env.TRILIUM_INTEGRATION_TEST === "memory") { - buildIntegrationTestDatabase(); - } else if (process.env.TRILIUM_INTEGRATION_TEST === "memory-no-store") { - dbConnection.loadFromMemory(); - } - - dbConnection.loadFromFile(dataDir.DOCUMENT_PATH, config.General.readOnly); -} - -buildDatabase(); - -function buildIntegrationTestDatabase(dbPath?: string) { - const buffer = fs.readFileSync(dbPath ?? dataDir.DOCUMENT_PATH); - dbConnection.loadFromBuffer(buffer); -} - -function rebuildIntegrationTestDatabase(dbPath?: string) { - if (dbConnection) { - dbConnection.close(); - } - - // This allows a database that is read normally but is kept in memory and discards all modifications. - buildIntegrationTestDatabase(dbPath); - statementCache = {}; -} - -const LOG_ALL_QUERIES = false; - -function insert(tableName: string, rec: T, replace = false) { - const keys = Object.keys(rec || {}); - if (keys.length === 0) { - log.error(`Can't insert empty object into table ${tableName}`); - return; - } - - const columns = keys.join(", "); - const questionMarks = keys.map((p) => "?").join(", "); - - const query = `INSERT - ${replace ? "OR REPLACE" : ""} INTO - ${tableName} - ( - ${columns} - ) - VALUES (${questionMarks})`; - - const res = execute(query, Object.values(rec)); - - return res ? res.lastInsertRowid : null; -} - -function replace(tableName: string, rec: T): number | null { - return insert(tableName, rec, true) as number | null; -} - -function upsert(tableName: string, primaryKey: string, rec: T) { - const keys = Object.keys(rec || {}); - if (keys.length === 0) { - log.error(`Can't upsert empty object into table ${tableName}`); - return; - } - - const columns = keys.join(", "); - - const questionMarks = keys.map((colName) => `@${colName}`).join(", "); - - const updateMarks = keys.map((colName) => `${colName} = @${colName}`).join(", "); - - const query = `INSERT INTO ${tableName} (${columns}) VALUES (${questionMarks}) - ON CONFLICT (${primaryKey}) DO UPDATE SET ${updateMarks}`; - - for (const idx in rec) { - if (rec[idx] === true || rec[idx] === false) { - (rec as any)[idx] = rec[idx] ? 1 : 0; - } - } - - execute(query, rec); -} - -/** - * For the given SQL query, returns a prepared statement. For the same query (string comparison), the same statement is returned. - * - * @param sql the SQL query for which to return a prepared statement. - * @param isRaw indicates whether `.raw()` is going to be called on the prepared statement in order to return the raw rows (e.g. via {@link getRawRows()}). The reason is that the raw state is preserved in the saved statement and would break non-raw calls for the same query. - * @returns the corresponding {@link Statement}. - */ -function stmt(sql: string, isRaw?: boolean) { - const key = (isRaw ? `raw/${sql}` : sql); - - if (!(key in statementCache)) { - statementCache[key] = dbConnection.prepare(sql); - } - - return statementCache[key]; -} - -function getRow(query: string, params: Params = []): T { - return wrap(query, (s) => s.get(params)) as T; -} - -function getRowOrNull(query: string, params: Params = []): T | null { - const all = getRows(query, params); - if (!all) { - return null; - } - - return (all.length > 0 ? all[0] : null) as T | null; -} - -function getValue(query: string, params: Params = []): T { - return wrap(query, (s) => s.pluck().get(params)) as T; -} - -// smaller values can result in better performance due to better usage of statement cache -const PARAM_LIMIT = 100; - -function getManyRows(query: string, params: Params): T[] { - let results: unknown[] = []; - - while (params.length > 0) { - const curParams = params.slice(0, Math.min(params.length, PARAM_LIMIT)); - params = params.slice(curParams.length); - - const curParamsObj: Record = {}; - - let j = 1; - for (const param of curParams) { - curParamsObj[`param${j++}`] = param; - } - - let i = 1; - const questionMarks = curParams.map(() => `:param${i++}`).join(","); - const curQuery = query.replace(/\?\?\?/g, questionMarks); - - const statement = curParams.length === PARAM_LIMIT ? stmt(curQuery) : dbConnection.prepare(curQuery); - - const subResults = statement.all(curParamsObj); - results = results.concat(subResults); - } - - return (results as T[] | null) || []; -} - -function getRows(query: string, params: Params = []): T[] { - return wrap(query, (s) => s.all(params)) as T[]; -} - -function getRawRows(query: string, params: Params = []): T[] { - return (wrap(query, (s) => s.raw().all(params), true) as T[]) || []; -} - -function iterateRows(query: string, params: Params = []): IterableIterator { - if (LOG_ALL_QUERIES) { - console.log(query); - } - - return stmt(query).iterate(params) as IterableIterator; -} - -function getMap(query: string, params: Params = []) { - const map: Record = {} as Record; - const results = getRawRows<[K, V]>(query, params); - - for (const row of results || []) { - map[row[0]] = row[1]; - } - - return map; -} - -function getColumn(query: string, params: Params = []): T[] { - return wrap(query, (s) => s.pluck().all(params)) as T[]; -} - -function execute(query: string, params: Params = []): RunResult { - if (config.General.readOnly && (query.startsWith("UPDATE") || query.startsWith("INSERT") || query.startsWith("DELETE"))) { - log.error(`read-only DB ignored: ${query} with parameters ${JSON.stringify(params)}`); - return { - changes: 0, - lastInsertRowid: 0 - }; - } - return wrap(query, (s) => s.run(params)) as RunResult; -} - -function executeMany(query: string, params: Params) { - if (LOG_ALL_QUERIES) { - console.log(query); - } - - while (params.length > 0) { - const curParams = params.slice(0, Math.min(params.length, PARAM_LIMIT)); - params = params.slice(curParams.length); - - const curParamsObj: Record = {}; - - let j = 1; - for (const param of curParams) { - curParamsObj[`param${j++}`] = param; - } - - let i = 1; - const questionMarks = curParams.map(() => `:param${i++}`).join(","); - const curQuery = query.replace(/\?\?\?/g, questionMarks); - - dbConnection.prepare(curQuery).run(curParamsObj); - } -} - -function executeScript(query: string) { - if (LOG_ALL_QUERIES) { - console.log(query); - } - - dbConnection.exec(query); -} - -/** - * @param isRaw indicates whether `.raw()` is going to be called on the prepared statement in order to return the raw rows (e.g. via {@link getRawRows()}). The reason is that the raw state is preserved in the saved statement and would break non-raw calls for the same query. - */ -function wrap(query: string, func: (statement: Statement) => unknown, isRaw?: boolean): unknown { - const startTimestamp = Date.now(); - let result; - - if (LOG_ALL_QUERIES) { - console.log(query); - } - - try { - result = func(stmt(query, isRaw)); - } catch (e: any) { - if (e.message.includes("The database connection is not open")) { - // this often happens on killing the app which puts these alerts in front of user - // in these cases error should be simply ignored. - console.log(e.message); - - return null; - } - - throw e; - } - - const milliseconds = Date.now() - startTimestamp; - - if (milliseconds >= 20 && !cls.isSlowQueryLoggingDisabled()) { - if (query.includes("WITH RECURSIVE")) { - log.info(`Slow recursive query took ${milliseconds}ms.`); - } else { - log.info(`Slow query took ${milliseconds}ms: ${query.trim().replace(/\s+/g, " ")}`); - } - } - - return result; -} - -function transactional(func: (statement: Statement) => T) { - try { - const ret = (dbConnection.transaction(func) as any).deferred(); - - if (!dbConnection.inTransaction) { - // i.e. transaction was really committed (and not just savepoint released) - ws.sendTransactionEntityChangesToAllClients(); - } - - return ret as T; - } catch (e) { - console.warn("Got error ", e); - const entityChangeIds = cls.getAndClearEntityChangeIds(); - - if (entityChangeIds.length > 0) { - log.info("Transaction rollback dirtied the becca, forcing reload."); - - becca_loader.load(); - } - - // the maxEntityChangeId has been incremented during failed transaction, need to recalculate - entity_changes.recalculateMaxEntityChangeId(); - - throw e; - } -} - -function fillParamList(paramIds: string[] | Set, truncate = true) { - if ("length" in paramIds && paramIds.length === 0) { - return; - } - - if (truncate) { - execute("DELETE FROM param_list"); - } - - paramIds = Array.from(new Set(paramIds)); - - if (paramIds.length > 30000) { - fillParamList(paramIds.slice(30000), false); - - paramIds = paramIds.slice(0, 30000); - } - - // doing it manually to avoid this showing up on the slow query list - const s = stmt(`INSERT INTO param_list VALUES ${paramIds.map((paramId) => `(?)`).join(",")}`); - - s.run(paramIds); -} - -async function copyDatabase(targetFilePath: string) { - try { - fs.unlinkSync(targetFilePath); - } catch (e) { } // unlink throws exception if the file did not exist - - await dbConnection.backup(targetFilePath); -} - -function disableSlowQueryLogging(cb: () => T) { - const orig = cls.isSlowQueryLoggingDisabled(); - - try { - cls.disableSlowQueryLogging(true); - - return cb(); - } finally { - cls.disableSlowQueryLogging(orig); - } -} - -export default { - insert, - replace, - - /** - * Get single value from the given query - first column from first returned row. - * - * @param query - SQL query with ? used as parameter placeholder - * @param params - array of params if needed - * @returns single value - */ - getValue, - - /** - * Get first returned row. - * - * @param query - SQL query with ? used as parameter placeholder - * @param params - array of params if needed - * @returns - map of column name to column value - */ - getRow, - getRowOrNull, - - /** - * Get all returned rows. - * - * @param query - SQL query with ? used as parameter placeholder - * @param params - array of params if needed - * @returns - array of all rows, each row is a map of column name to column value - */ - getRows, - getRawRows, - iterateRows, - getManyRows, - - /** - * Get a map of first column mapping to second column. - * - * @param query - SQL query with ? used as parameter placeholder - * @param params - array of params if needed - * @returns - map of first column to second column - */ - getMap, - - /** - * Get a first column in an array. - * - * @param query - SQL query with ? used as parameter placeholder - * @param params - array of params if needed - * @returns array of first column of all returned rows - */ - getColumn, - - /** - * Execute SQL - * - * @param query - SQL query with ? used as parameter placeholder - * @param params - array of params if needed - */ - execute, - executeMany, - executeScript, - transactional, - upsert, - fillParamList, - copyDatabase, - disableSlowQueryLogging, - rebuildIntegrationTestDatabase -}; +export default getSql(); diff --git a/apps/server/src/services/sql_nodejs.ts b/apps/server/src/sql_provider.ts similarity index 82% rename from apps/server/src/services/sql_nodejs.ts rename to apps/server/src/sql_provider.ts index 77cf4b8ed7..da1604d44e 100644 --- a/apps/server/src/services/sql_nodejs.ts +++ b/apps/server/src/sql_provider.ts @@ -1,8 +1,6 @@ +import type { DatabaseProvider, Statement, Transaction } from "@triliumnext/core"; import Database, { type Database as DatabaseType } from "better-sqlite3"; -import { readFileSync } from "fs"; - -import dataDirs from "./data_dir"; -import type { DatabaseProvider, Statement, Transaction } from "./sql"; +import { unlinkSync } from "fs"; const dbOpts: Database.Options = { nativeBinding: process.env.BETTERSQLITE3_NATIVE_PATH || undefined @@ -37,6 +35,10 @@ export default class BetterSqlite3Provider implements DatabaseProvider { } backup(destinationFile: string) { + try { + unlinkSync(destinationFile); + } catch (e) { } // unlink throws exception if the file did not exist + this.dbConnection?.backup(destinationFile); } @@ -47,7 +49,7 @@ export default class BetterSqlite3Provider implements DatabaseProvider { transaction(func: (statement: Statement) => T): Transaction { if (!this.dbConnection) throw new Error("DB not open."); - return this.dbConnection.transaction(func); + return this.dbConnection.transaction(func) as any; } get inTransaction() { @@ -64,8 +66,3 @@ export default class BetterSqlite3Provider implements DatabaseProvider { } } - -function buildIntegrationTestDatabase(dbPath?: string) { - const dbBuffer = readFileSync(dbPath ?? dataDirs.DOCUMENT_PATH); - return new Database(dbBuffer, dbOpts); -} diff --git a/packages/trilium-core/src/index.ts b/packages/trilium-core/src/index.ts new file mode 100644 index 0000000000..733e1faf1e --- /dev/null +++ b/packages/trilium-core/src/index.ts @@ -0,0 +1,17 @@ +import { ExecutionContext, initContext } from "./services/context"; +import { getLog, initLog } from "./services/log"; +import { initSql } from "./services/sql/index"; +import { SqlService, SqlServiceParams } from "./services/sql/sql"; + +export type * from "./services/sql/types"; +export * from "./services/sql/index"; +export type { ExecutionContext } from "./services/context"; + +export function initializeCore({ dbConfig, executionContext }: { + dbConfig: SqlServiceParams, + executionContext: ExecutionContext +}) { + initLog(); + initSql(new SqlService(dbConfig, getLog())); + initContext(executionContext); +}; diff --git a/packages/trilium-core/src/services/context.ts b/packages/trilium-core/src/services/context.ts new file mode 100644 index 0000000000..99cf356653 --- /dev/null +++ b/packages/trilium-core/src/services/context.ts @@ -0,0 +1,18 @@ +export interface ExecutionContext { + init(fn: () => T): T; + get(key: string): T | undefined; + set(key: string, value: any): void; + reset(): void; +} + +let ctx: ExecutionContext | null = null; + +export function initContext(context: ExecutionContext) { + if (ctx) throw new Error("Context already initialized"); + ctx = context; +} + +export function getContext(): ExecutionContext { + if (!ctx) throw new Error("Context not initialized"); + return ctx; +} diff --git a/packages/trilium-core/src/services/log.ts b/packages/trilium-core/src/services/log.ts new file mode 100644 index 0000000000..540cdc87a7 --- /dev/null +++ b/packages/trilium-core/src/services/log.ts @@ -0,0 +1,26 @@ +export default class LogService { + + log(message: string | Error) { + console.log(message); + } + + info(message: string | Error) { + this.log(message); + } + + error(message: string | Error | unknown) { + this.log(`ERROR: ${message}`); + } + +} + +let log: LogService; + +export function initLog() { + log = new LogService(); +} + +export function getLog() { + if (!log) throw new Error("Log service not initialized."); + return log; +} diff --git a/packages/trilium-core/src/services/sql/index.ts b/packages/trilium-core/src/services/sql/index.ts new file mode 100644 index 0000000000..ecabce498c --- /dev/null +++ b/packages/trilium-core/src/services/sql/index.ts @@ -0,0 +1,13 @@ +import type { SqlService } from "./sql"; + +let sql: SqlService | null = null; + +export function initSql(instance: SqlService) { + if (sql) throw new Error("SQL already initialized"); + sql = instance; +} + +export function getSql(): SqlService { + if (!sql) throw new Error("SQL not initialized"); + return sql; +} diff --git a/packages/trilium-core/src/services/sql/sql.ts b/packages/trilium-core/src/services/sql/sql.ts new file mode 100644 index 0000000000..2daf428b5d --- /dev/null +++ b/packages/trilium-core/src/services/sql/sql.ts @@ -0,0 +1,318 @@ +import { getContext } from "../context.js"; +import type LogService from "../log.js"; +import type { DatabaseProvider, Params, RunResult, Statement } from "./types.js"; + +const LOG_ALL_QUERIES = false; + +// smaller values can result in better performance due to better usage of statement cache +const PARAM_LIMIT = 100; + +export interface SqlServiceParams { + provider: DatabaseProvider; + onTransactionRollback: () => void; + onTransactionCommit: () => void; + isReadOnly: boolean; +} + +export class SqlService { + + private dbConnection: DatabaseProvider; + private statementCache: Record = {}; + private params: Omit; + + constructor({ provider, ...restParams }: SqlServiceParams, + private log: LogService + ) { + this.dbConnection = provider; + this.params = restParams; + } + + insert(tableName: string, rec: T, replace = false) { + const keys = Object.keys(rec || {}); + if (keys.length === 0) { + this.log.error(`Can't insert empty object into table ${tableName}`); + return; + } + + const columns = keys.join(", "); + const questionMarks = keys.map((p) => "?").join(", "); + + const query = `INSERT + ${replace ? "OR REPLACE" : ""} INTO + ${tableName} + ( + ${columns} + ) + VALUES (${questionMarks})`; + + const res = this.execute(query, Object.values(rec)); + + return res ? res.lastInsertRowid : null; + } + + replace(tableName: string, rec: T): number | null { + return this.insert(tableName, rec, true) as number | null; + } + + upsert(tableName: string, primaryKey: string, rec: T) { + const keys = Object.keys(rec || {}); + if (keys.length === 0) { + this.log.error(`Can't upsert empty object into table ${tableName}`); + return; + } + + const columns = keys.join(", "); + + const questionMarks = keys.map((colName) => `@${colName}`).join(", "); + + const updateMarks = keys.map((colName) => `${colName} = @${colName}`).join(", "); + + const query = `INSERT INTO ${tableName} (${columns}) VALUES (${questionMarks}) + ON CONFLICT (${primaryKey}) DO UPDATE SET ${updateMarks}`; + + for (const idx in rec) { + if (rec[idx] === true || rec[idx] === false) { + (rec as any)[idx] = rec[idx] ? 1 : 0; + } + } + + this.execute(query, rec); + } + + /** + * For the given SQL query, returns a prepared statement. For the same query (string comparison), the same statement is returned. + * + * @param sql the SQL query for which to return a prepared statement. + * @param isRaw indicates whether `.raw()` is going to be called on the prepared statement in order to return the raw rows (e.g. via {@link getRawRows()}). The reason is that the raw state is preserved in the saved statement and would break non-raw calls for the same query. + * @returns the corresponding {@link Statement}. + */ + stmt(sql: string, isRaw?: boolean) { + const key = (isRaw ? `raw/${sql}` : sql); + + if (!(key in this.statementCache)) { + this.statementCache[key] = this.dbConnection.prepare(sql); + } + + return this.statementCache[key]; + } + + getRow(query: string, params: Params = []): T { + return this.wrap(query, (s) => s.get(params)) as T; + } + + getRowOrNull(query: string, params: Params = []): T | null { + const all = this.getRows(query, params); + if (!all) { + return null; + } + + return (all.length > 0 ? all[0] : null) as T | null; + } + + getValue(query: string, params: Params = []): T { + return this.wrap(query, (s) => s.pluck().get(params)) as T; + } + + getManyRows(query: string, params: Params): T[] { + let results: unknown[] = []; + + while (params.length > 0) { + const curParams = params.slice(0, Math.min(params.length, PARAM_LIMIT)); + params = params.slice(curParams.length); + + const curParamsObj: Record = {}; + + let j = 1; + for (const param of curParams) { + curParamsObj[`param${j++}`] = param; + } + + let i = 1; + const questionMarks = curParams.map(() => `:param${i++}`).join(","); + const curQuery = query.replace(/\?\?\?/g, questionMarks); + + const statement = curParams.length === PARAM_LIMIT ? this.stmt(curQuery) : this.dbConnection.prepare(curQuery); + + const subResults = statement.all(curParamsObj); + results = results.concat(subResults); + } + + return (results as T[] | null) || []; + } + + getRows(query: string, params: Params = []): T[] { + return this.wrap(query, (s) => s.all(params)) as T[]; + } + + getRawRows(query: string, params: Params = []): T[] { + return (this.wrap(query, (s) => s.raw().all(params), true) as T[]) || []; + } + + iterateRows(query: string, params: Params = []): IterableIterator { + if (LOG_ALL_QUERIES) { + console.log(query); + } + + return this.stmt(query).iterate(params) as IterableIterator; + } + + getMap(query: string, params: Params = []) { + const map: Record = {} as Record; + const results = this.getRawRows<[K, V]>(query, params); + + for (const row of results || []) { + map[row[0]] = row[1]; + } + + return map; + } + + getColumn(query: string, params: Params = []): T[] { + return this.wrap(query, (s) => s.pluck().all(params)) as T[]; + } + + execute(query: string, params: Params = []): RunResult { + if (this.params.isReadOnly && (query.startsWith("UPDATE") || query.startsWith("INSERT") || query.startsWith("DELETE"))) { + this.log.error(`read-only DB ignored: ${query} with parameters ${JSON.stringify(params)}`); + return { + changes: 0, + lastInsertRowid: 0 + }; + } + return this.wrap(query, (s) => s.run(params)) as RunResult; + } + + executeMany(query: string, params: Params) { + if (LOG_ALL_QUERIES) { + console.log(query); + } + + while (params.length > 0) { + const curParams = params.slice(0, Math.min(params.length, PARAM_LIMIT)); + params = params.slice(curParams.length); + + const curParamsObj: Record = {}; + + let j = 1; + for (const param of curParams) { + curParamsObj[`param${j++}`] = param; + } + + let i = 1; + const questionMarks = curParams.map(() => `:param${i++}`).join(","); + const curQuery = query.replace(/\?\?\?/g, questionMarks); + + this.dbConnection.prepare(curQuery).run(curParamsObj); + } + } + + executeScript(query: string) { + if (LOG_ALL_QUERIES) { + console.log(query); + } + + this.dbConnection.exec(query); + } + + /** + * @param isRaw indicates whether `.raw()` is going to be called on the prepared statement in order to return the raw rows (e.g. via {@link getRawRows()}). The reason is that the raw state is preserved in the saved statement and would break non-raw calls for the same query. + */ + wrap(query: string, func: (statement: Statement) => unknown, isRaw?: boolean): unknown { + const startTimestamp = Date.now(); + let result; + + if (LOG_ALL_QUERIES) { + console.log(query); + } + + try { + result = func(this.stmt(query, isRaw)); + } catch (e: any) { + if (e.message.includes("The database connection is not open")) { + // this often happens on killing the app which puts these alerts in front of user + // in these cases error should be simply ignored. + console.log(e.message); + + return null; + } + + throw e; + } + + const milliseconds = Date.now() - startTimestamp; + + if (milliseconds >= 20 && !isSlowQueryLoggingDisabled()) { + if (query.includes("WITH RECURSIVE")) { + this.log.info(`Slow recursive query took ${milliseconds}ms.`); + } else { + this.log.info(`Slow query took ${milliseconds}ms: ${query.trim().replace(/\s+/g, " ")}`); + } + } + + return result; + } + + transactional(func: (statement: Statement) => T) { + try { + const ret = (this.dbConnection.transaction(func) as any).deferred(); + + if (!this.dbConnection.inTransaction) { + // i.e. transaction was really committed (and not just savepoint released) + this.params.onTransactionCommit(); + } + + return ret as T; + } catch (e) { + console.warn("Got error ", e); + this.params.onTransactionRollback(); + throw e; + } + } + + fillParamList(paramIds: string[] | Set, truncate = true) { + if ("length" in paramIds && paramIds.length === 0) { + return; + } + + if (truncate) { + this.execute("DELETE FROM param_list"); + } + + paramIds = Array.from(new Set(paramIds)); + + if (paramIds.length > 30000) { + this.fillParamList(paramIds.slice(30000), false); + + paramIds = paramIds.slice(0, 30000); + } + + // doing it manually to avoid this showing up on the slow query list + const s = this.stmt(`INSERT INTO param_list VALUES ${paramIds.map((paramId) => `(?)`).join(",")}`); + + s.run(paramIds); + } + + async copyDatabase(targetFilePath: string) { + await this.dbConnection.backup(targetFilePath); + } + + disableSlowQueryLogging(cb: () => T) { + const orig = isSlowQueryLoggingDisabled(); + + try { + disableSlowQueryLogging(true); + + return cb(); + } finally { + disableSlowQueryLogging(orig); + } + } +} + +function disableSlowQueryLogging(disable: boolean) { + getContext().set("disableSlowQueryLogging", disable); +} + +function isSlowQueryLoggingDisabled() { + return !!getContext().get("disableSlowQueryLogging"); +} diff --git a/packages/trilium-core/src/services/sql/types.ts b/packages/trilium-core/src/services/sql/types.ts new file mode 100644 index 0000000000..eff81ae23b --- /dev/null +++ b/packages/trilium-core/src/services/sql/types.ts @@ -0,0 +1,31 @@ +export type Params = any; + +export interface Statement { + run(...params: Params): RunResult; + get(params: Params): unknown; + all(...params: Params): unknown[]; + iterate(...params: Params): IterableIterator; + raw(toggleState?: boolean): this; + pluck(toggleState?: boolean): this; +} + +export interface Transaction { + deferred(): void; +} + +export interface RunResult { + changes: number; + lastInsertRowid: number | bigint; +} + +export interface DatabaseProvider { + loadFromFile(path: string, isReadOnly: boolean): void; + loadFromMemory(): void; + loadFromBuffer(buffer: NonSharedBuffer): void; + backup(destinationFile: string): void; + prepare(query: string): Statement; + transaction(func: (statement: Statement) => T): Transaction; + get inTransaction(): boolean; + exec(query: string): void; + close(): void; +} From d61e2c6f2cefeedd55619187b37ea9afe9c4663c Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Mon, 5 Jan 2026 15:52:31 +0200 Subject: [PATCH 05/72] chore(server): get DB to be loaded --- apps/server/src/main.ts | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/apps/server/src/main.ts b/apps/server/src/main.ts index 98cc544ab1..38e602e8e4 100644 --- a/apps/server/src/main.ts +++ b/apps/server/src/main.ts @@ -5,15 +5,19 @@ import { initializeCore } from "@triliumnext/core"; -import { initializeTranslations } from "./services/i18n.js"; +import ClsHookedExecutionContext from "./cls_provider.js"; import BetterSqlite3Provider from "./sql_provider.js"; async function startApplication() { - await initializeTranslations(); const config = (await import("./services/config.js")).default; + const { DOCUMENT_PATH } = (await import("./services/data_dir.js")).default; + + const dbProvider = new BetterSqlite3Provider(); + dbProvider.loadFromFile(DOCUMENT_PATH, config.General.readOnly); + initializeCore({ dbConfig: { - provider: new BetterSqlite3Provider(), + provider: dbProvider, isReadOnly: config.General.readOnly, async onTransactionCommit() { const ws = (await import("./services/ws.js")).default; @@ -36,8 +40,11 @@ async function startApplication() { // the maxEntityChangeId has been incremented during failed transaction, need to recalculate entity_changes.recalculateMaxEntityChangeId(); } - } + }, + executionContext: new ClsHookedExecutionContext() }); + const { initializeTranslations } = (await import("./services/i18n.js")); + await initializeTranslations(); const startTriliumServer = (await import("./www.js")).default; await startTriliumServer(); } From 527f5020835b9da54ddc63e379d6416697a1a120 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Mon, 5 Jan 2026 15:58:24 +0200 Subject: [PATCH 06/72] fix(server): requests failing due to cls namespacing issue --- apps/server/src/cls_provider.ts | 2 +- apps/server/src/etapi/etapi_utils.ts | 48 +++++++++++++++------------- apps/server/src/routes/custom.ts | 22 +++++++------ apps/server/src/routes/route_api.ts | 36 +++++++++++---------- 4 files changed, 57 insertions(+), 51 deletions(-) diff --git a/apps/server/src/cls_provider.ts b/apps/server/src/cls_provider.ts index 3bac6352c6..a11f616129 100644 --- a/apps/server/src/cls_provider.ts +++ b/apps/server/src/cls_provider.ts @@ -1,7 +1,7 @@ import { ExecutionContext } from "@triliumnext/core"; import clsHooked from "cls-hooked"; -const namespace = clsHooked.createNamespace("trilium"); +export const namespace = clsHooked.createNamespace("trilium"); export default class ClsHookedExecutionContext implements ExecutionContext { diff --git a/apps/server/src/etapi/etapi_utils.ts b/apps/server/src/etapi/etapi_utils.ts index 1319162575..f018975ecb 100644 --- a/apps/server/src/etapi/etapi_utils.ts +++ b/apps/server/src/etapi/etapi_utils.ts @@ -1,12 +1,14 @@ -import cls from "../services/cls.js"; -import sql from "../services/sql.js"; -import log from "../services/log.js"; -import becca from "../becca/becca.js"; -import etapiTokenService from "../services/etapi_tokens.js"; -import config from "../services/config.js"; import type { NextFunction, Request, RequestHandler, Response, Router } from "express"; -import type { ValidatorMap } from "./etapi-interface.js"; + +import becca from "../becca/becca.js"; +import { namespace } from "../cls_provider.js"; import type { ApiRequestHandler, SyncRouteRequestHandler } from "../routes/route_api.js"; +import cls from "../services/cls.js"; +import config from "../services/config.js"; +import etapiTokenService from "../services/etapi_tokens.js"; +import log from "../services/log.js"; +import sql from "../services/sql.js"; +import type { ValidatorMap } from "./etapi-interface.js"; const GENERIC_CODE = "GENERIC"; type HttpMethod = "all" | "get" | "post" | "put" | "delete" | "patch" | "options" | "head"; @@ -35,8 +37,8 @@ function sendError(res: Response, statusCode: number, code: string, message: str .send( JSON.stringify({ status: statusCode, - code: code, - message: message + code, + message }) ); } @@ -51,8 +53,8 @@ function checkEtapiAuth(req: Request, res: Response, next: NextFunction) { function processRequest(req: Request, res: Response, routeHandler: ApiRequestHandler, next: NextFunction, method: string, path: string) { try { - cls.namespace.bindEmitter(req); - cls.namespace.bindEmitter(res); + namespace.bindEmitter(req); + namespace.bindEmitter(res); cls.init(() => { cls.set("componentId", "etapi"); @@ -86,9 +88,9 @@ function getAndCheckNote(noteId: string) { if (note) { return note; - } else { - throw new EtapiError(404, "NOTE_NOT_FOUND", `Note '${noteId}' not found.`); - } + } + throw new EtapiError(404, "NOTE_NOT_FOUND", `Note '${noteId}' not found.`); + } function getAndCheckAttachment(attachmentId: string) { @@ -96,9 +98,9 @@ function getAndCheckAttachment(attachmentId: string) { if (attachment) { return attachment; - } else { - throw new EtapiError(404, "ATTACHMENT_NOT_FOUND", `Attachment '${attachmentId}' not found.`); - } + } + throw new EtapiError(404, "ATTACHMENT_NOT_FOUND", `Attachment '${attachmentId}' not found.`); + } function getAndCheckBranch(branchId: string) { @@ -106,9 +108,9 @@ function getAndCheckBranch(branchId: string) { if (branch) { return branch; - } else { - throw new EtapiError(404, "BRANCH_NOT_FOUND", `Branch '${branchId}' not found.`); - } + } + throw new EtapiError(404, "BRANCH_NOT_FOUND", `Branch '${branchId}' not found.`); + } function getAndCheckAttribute(attributeId: string) { @@ -116,9 +118,9 @@ function getAndCheckAttribute(attributeId: string) { if (attribute) { return attribute; - } else { - throw new EtapiError(404, "ATTRIBUTE_NOT_FOUND", `Attribute '${attributeId}' not found.`); - } + } + throw new EtapiError(404, "ATTRIBUTE_NOT_FOUND", `Attribute '${attributeId}' not found.`); + } function validateAndPatch(target: any, source: any, allowedProperties: ValidatorMap) { diff --git a/apps/server/src/routes/custom.ts b/apps/server/src/routes/custom.ts index b27a710f2e..5498ef6d06 100644 --- a/apps/server/src/routes/custom.ts +++ b/apps/server/src/routes/custom.ts @@ -1,11 +1,13 @@ -import log from "../services/log.js"; -import fileService from "./api/files.js"; -import scriptService from "../services/script.js"; -import cls from "../services/cls.js"; -import sql from "../services/sql.js"; -import becca from "../becca/becca.js"; import type { Request, Response, Router } from "express"; -import { safeExtractMessageAndStackFromError, normalizeCustomHandlerPattern } from "../services/utils.js"; + +import becca from "../becca/becca.js"; +import { namespace } from "../cls_provider.js"; +import cls from "../services/cls.js"; +import log from "../services/log.js"; +import scriptService from "../services/script.js"; +import sql from "../services/sql.js"; +import { normalizeCustomHandlerPattern,safeExtractMessageAndStackFromError } from "../services/utils.js"; +import fileService from "./api/files.js"; function handleRequest(req: Request, res: Response) { @@ -27,7 +29,7 @@ function handleRequest(req: Request, res: Response) { // splitPath.map(segment => encodeURIComponent(segment)).join("/") // might be safer - const path = splitPath.join("/") + const path = splitPath.join("/"); const attributeIds = sql.getColumn("SELECT attributeId FROM attributes WHERE isDeleted = 0 AND type = 'label' AND name IN ('customRequestHandler', 'customResourceProvider')"); @@ -96,8 +98,8 @@ function register(router: Router) { // explicitly no CSRF middleware since it's meant to allow integration from external services router.all("/custom/*path", (req: Request, res: Response, _next) => { - cls.namespace.bindEmitter(req); - cls.namespace.bindEmitter(res); + namespace.bindEmitter(req); + namespace.bindEmitter(res); cls.init(() => handleRequest(req, res)); }); diff --git a/apps/server/src/routes/route_api.ts b/apps/server/src/routes/route_api.ts index 1b4ea48f24..6f33260740 100644 --- a/apps/server/src/routes/route_api.ts +++ b/apps/server/src/routes/route_api.ts @@ -1,15 +1,17 @@ import express, { type RequestHandler } from "express"; import multer from "multer"; -import log from "../services/log.js"; -import cls from "../services/cls.js"; -import sql from "../services/sql.js"; -import entityChangesService from "../services/entity_changes.js"; + import AbstractBeccaEntity from "../becca/entities/abstract_becca_entity.js"; +import { namespace } from "../cls_provider.js"; import NotFoundError from "../errors/not_found_error.js"; import ValidationError from "../errors/validation_error.js"; import auth from "../services/auth.js"; -import { doubleCsrfProtection as csrfMiddleware } from "./csrf_protection.js"; +import cls from "../services/cls.js"; +import entityChangesService from "../services/entity_changes.js"; +import log from "../services/log.js"; +import sql from "../services/sql.js"; import { safeExtractMessageAndStackFromError } from "../services/utils.js"; +import { doubleCsrfProtection as csrfMiddleware } from "./csrf_protection.js"; const MAX_ALLOWED_FILE_SIZE_MB = 250; export const router = express.Router(); @@ -67,9 +69,9 @@ export function apiResultHandler(req: express.Request, res: express.Response, re return send(res, statusCode, response); } else if (result === undefined) { return send(res, 204, ""); - } else { - return send(res, 200, result); } + return send(res, 200, result); + } function send(res: express.Response, statusCode: number, response: unknown) { @@ -81,14 +83,14 @@ function send(res: express.Response, statusCode: number, response: unknown) { res.status(statusCode).send(response); return response.length; - } else { - const json = JSON.stringify(response); - - res.setHeader("Content-Type", "application/json"); - res.status(statusCode).send(json); - - return json.length; } + const json = JSON.stringify(response); + + res.setHeader("Content-Type", "application/json"); + res.status(statusCode).send(json); + + return json.length; + } export function apiRoute(method: HttpMethod, path: string, routeHandler: SyncRouteRequestHandler) { @@ -112,8 +114,8 @@ function internalRoute(method: HttpMethod, path: string, middleware: express.Han const start = Date.now(); try { - cls.namespace.bindEmitter(req); - cls.namespace.bindEmitter(res); + namespace.bindEmitter(req); + namespace.bindEmitter(res); const result = cls.init(() => { cls.set("componentId", req.headers["trilium-component-id"]); @@ -193,7 +195,7 @@ export function createUploadMiddleware(): RequestHandler { const uploadMiddleware = createUploadMiddleware(); export const uploadMiddlewareWithErrorHandling = function (req: express.Request, res: express.Response, next: express.NextFunction) { - uploadMiddleware(req, res, function (err) { + uploadMiddleware(req, res, (err) => { if (err?.code === "LIMIT_FILE_SIZE") { res.setHeader("Content-Type", "text/plain").status(400).send(`Cannot upload file because it excceeded max allowed file size of ${MAX_ALLOWED_FILE_SIZE_MB} MiB`); } else { From f79af7b0458da0caa30cf63cd6d3332d844dae88 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Mon, 5 Jan 2026 16:01:27 +0200 Subject: [PATCH 07/72] fix(server): request content empty due to CLS --- apps/server/src/services/cls.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/server/src/services/cls.ts b/apps/server/src/services/cls.ts index 5fae8afbdd..5835385bee 100644 --- a/apps/server/src/services/cls.ts +++ b/apps/server/src/services/cls.ts @@ -3,8 +3,8 @@ import { getContext } from "@triliumnext/core/src/services/context"; type Callback = (...args: any[]) => any; -function init(callback: () => void) { - getContext().init(callback); +function init(callback: () => T) { + return getContext().init(callback); } function wrap(callback: Callback) { From 6f0473862972c221e76e954700a694782e7d457c Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Mon, 5 Jan 2026 16:07:17 +0200 Subject: [PATCH 08/72] chore(core): add documentation for SQL --- packages/trilium-core/src/services/sql/sql.ts | 41 +++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/packages/trilium-core/src/services/sql/sql.ts b/packages/trilium-core/src/services/sql/sql.ts index 2daf428b5d..27ab4c2ca0 100644 --- a/packages/trilium-core/src/services/sql/sql.ts +++ b/packages/trilium-core/src/services/sql/sql.ts @@ -96,6 +96,13 @@ export class SqlService { return this.statementCache[key]; } + /** + * Get first returned row. + * + * @param query - SQL query with ? used as parameter placeholder + * @param params - array of params if needed + * @returns - map of column name to column value + */ getRow(query: string, params: Params = []): T { return this.wrap(query, (s) => s.get(params)) as T; } @@ -109,6 +116,13 @@ export class SqlService { return (all.length > 0 ? all[0] : null) as T | null; } + /** + * Get single value from the given query - first column from first returned row. + * + * @param query - SQL query with ? used as parameter placeholder + * @param params - array of params if needed + * @returns single value + */ getValue(query: string, params: Params = []): T { return this.wrap(query, (s) => s.pluck().get(params)) as T; } @@ -140,6 +154,13 @@ export class SqlService { return (results as T[] | null) || []; } + /** + * Get all returned rows. + * + * @param query - SQL query with ? used as parameter placeholder + * @param params - array of params if needed + * @returns - array of all rows, each row is a map of column name to column value + */ getRows(query: string, params: Params = []): T[] { return this.wrap(query, (s) => s.all(params)) as T[]; } @@ -156,6 +177,13 @@ export class SqlService { return this.stmt(query).iterate(params) as IterableIterator; } + /** + * Get a map of first column mapping to second column. + * + * @param query - SQL query with ? used as parameter placeholder + * @param params - array of params if needed + * @returns - map of first column to second column + */ getMap(query: string, params: Params = []) { const map: Record = {} as Record; const results = this.getRawRows<[K, V]>(query, params); @@ -167,10 +195,23 @@ export class SqlService { return map; } + /** + * Get a first column in an array. + * + * @param query - SQL query with ? used as parameter placeholder + * @param params - array of params if needed + * @returns array of first column of all returned rows + */ getColumn(query: string, params: Params = []): T[] { return this.wrap(query, (s) => s.pluck().all(params)) as T[]; } + /** + * Execute SQL + * + * @param query - SQL query with ? used as parameter placeholder + * @param params - array of params if needed + */ execute(query: string, params: Params = []): RunResult { if (this.params.isReadOnly && (query.startsWith("UPDATE") || query.startsWith("INSERT") || query.startsWith("DELETE"))) { this.log.error(`read-only DB ignored: ${query} with parameters ${JSON.stringify(params)}`); From c1548b0f5467957acc6c8cfadfc96855846816bd Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Mon, 5 Jan 2026 17:47:25 +0200 Subject: [PATCH 09/72] chore(server): integrate data_encryption, and protected_session --- .../becca/entities/abstract_becca_entity.ts | 24 ++-- apps/server/src/crypto_provider.ts | 22 ++++ apps/server/src/main.ts | 2 + apps/server/src/services/blob.ts | 29 ++--- .../services/encryption/data_encryption.ts | 114 ----------------- .../services/encryption/open_id_encryption.ts | 21 ++-- .../encryption/password_encryption.ts | 11 +- .../services/encryption/totp_encryption.ts | 13 +- apps/server/src/services/protected_session.ts | 70 +---------- apps/server/src/services/scheduler.ts | 23 ++-- packages/trilium-core/src/index.ts | 11 +- .../src/services/encryption/crypto.ts | 23 ++++ .../services/encryption/data_encryption.ts | 115 ++++++++++++++++++ .../services/encryption/protected_session.ts | 69 +++++++++++ .../trilium-core/src/services/utils/binary.ts | 35 ++++++ 15 files changed, 339 insertions(+), 243 deletions(-) create mode 100644 apps/server/src/crypto_provider.ts delete mode 100644 apps/server/src/services/encryption/data_encryption.ts create mode 100644 packages/trilium-core/src/services/encryption/crypto.ts create mode 100644 packages/trilium-core/src/services/encryption/data_encryption.ts create mode 100644 packages/trilium-core/src/services/encryption/protected_session.ts create mode 100644 packages/trilium-core/src/services/utils/binary.ts diff --git a/apps/server/src/becca/entities/abstract_becca_entity.ts b/apps/server/src/becca/entities/abstract_becca_entity.ts index 1f3bd0d863..3b30eccd82 100644 --- a/apps/server/src/becca/entities/abstract_becca_entity.ts +++ b/apps/server/src/becca/entities/abstract_becca_entity.ts @@ -1,16 +1,14 @@ -"use strict"; - -import utils from "../../services/utils.js"; -import sql from "../../services/sql.js"; +import blobService from "../../services/blob.js"; +import cls from "../../services/cls.js"; +import dateUtils from "../../services/date_utils.js"; import entityChangesService from "../../services/entity_changes.js"; import eventService from "../../services/events.js"; -import dateUtils from "../../services/date_utils.js"; -import cls from "../../services/cls.js"; import log from "../../services/log.js"; import protectedSessionService from "../../services/protected_session.js"; -import blobService from "../../services/blob.js"; -import type { default as Becca, ConstructorData } from "../becca-interface.js"; +import sql from "../../services/sql.js"; +import utils from "../../services/utils.js"; import becca from "../becca.js"; +import type { ConstructorData,default as Becca } from "../becca-interface.js"; interface ContentOpts { forceSave?: boolean; @@ -206,9 +204,9 @@ abstract class AbstractBeccaEntity> { // a "random" prefix makes sure that the calculated hash/blobId is different for a decrypted/encrypted content const encryptedPrefixSuffix = "t$[nvQg7q)&_ENCRYPTED_?M:Bf&j3jr_"; return Buffer.isBuffer(unencryptedContent) ? Buffer.concat([Buffer.from(encryptedPrefixSuffix), unencryptedContent]) : `${encryptedPrefixSuffix}${unencryptedContent}`; - } else { - return unencryptedContent; } + return unencryptedContent; + } private saveBlob(content: string | Buffer, unencryptedContentForHashCalculation: string | Buffer, opts: ContentOpts = {}) { @@ -227,7 +225,7 @@ abstract class AbstractBeccaEntity> { const pojo = { blobId: newBlobId, - content: content, + content, dateModified: dateUtils.localNowDateTime(), utcDateModified: dateUtils.utcNowDateTime() }; @@ -241,7 +239,7 @@ abstract class AbstractBeccaEntity> { entityChangesService.putEntityChange({ entityName: "blobs", entityId: newBlobId, - hash: hash, + hash, isErased: false, utcDateChanged: pojo.utcDateModified, isSynced: true, @@ -266,7 +264,7 @@ abstract class AbstractBeccaEntity> { throw new Error(`Cannot find content for ${constructorData.primaryKeyName} '${(this as any)[constructorData.primaryKeyName]}', blobId '${this.blobId}'`); } - return blobService.processContent(row.content, this.isProtected || false, this.hasStringContent()); + return blobService.processContent(row.content, this.isProtected || false, this.hasStringContent()) as string | Buffer; } /** diff --git a/apps/server/src/crypto_provider.ts b/apps/server/src/crypto_provider.ts new file mode 100644 index 0000000000..c51b7bc9dd --- /dev/null +++ b/apps/server/src/crypto_provider.ts @@ -0,0 +1,22 @@ +import { CryptoProvider } from "@triliumnext/core"; +import crypto from "crypto"; + +export default class NodejsCryptoProvider implements CryptoProvider { + + createHash(algorithm: "sha1", content: string | Uint8Array): Uint8Array { + return crypto.createHash(algorithm).update(content).digest(); + } + + createCipheriv(algorithm: "aes-128-cbc", key: Uint8Array, iv: Uint8Array): { update(data: Uint8Array): Uint8Array; final(): Uint8Array; } { + return crypto.createCipheriv(algorithm, key, iv); + } + + createDecipheriv(algorithm: "aes-128-cbc", key: Uint8Array, iv: Uint8Array) { + return crypto.createDecipheriv(algorithm, key, iv); + } + + randomBytes(size: number): Uint8Array { + return crypto.randomBytes(size); + } + +} diff --git a/apps/server/src/main.ts b/apps/server/src/main.ts index 38e602e8e4..5d9bb8033c 100644 --- a/apps/server/src/main.ts +++ b/apps/server/src/main.ts @@ -6,6 +6,7 @@ import { initializeCore } from "@triliumnext/core"; import ClsHookedExecutionContext from "./cls_provider.js"; +import NodejsCryptoProvider from "./crypto_provider.js"; import BetterSqlite3Provider from "./sql_provider.js"; async function startApplication() { @@ -41,6 +42,7 @@ async function startApplication() { entity_changes.recalculateMaxEntityChangeId(); } }, + crypto: new NodejsCryptoProvider(), executionContext: new ClsHookedExecutionContext() }); const { initializeTranslations } = (await import("./services/i18n.js")); diff --git a/apps/server/src/services/blob.ts b/apps/server/src/services/blob.ts index c4684c2ae5..6804f1ecb5 100644 --- a/apps/server/src/services/blob.ts +++ b/apps/server/src/services/blob.ts @@ -1,8 +1,10 @@ +import { binary_utils } from "@triliumnext/core"; + import becca from "../becca/becca.js"; import NotFoundError from "../errors/not_found_error.js"; +import type { Blob } from "./blob-interface.js"; import protectedSessionService from "./protected_session.js"; import { hash } from "./utils.js"; -import type { Blob } from "./blob-interface.js"; function getBlobPojo(entityName: string, entityId: string, opts?: { preview: boolean }) { // TODO: Unused opts. @@ -21,33 +23,32 @@ function getBlobPojo(entityName: string, entityId: string, opts?: { preview: boo if (!entity.hasStringContent()) { pojo.content = null; } else { - pojo.content = processContent(pojo.content, !!entity.isProtected, true); + pojo.content = processContent(pojo.content, !!entity.isProtected, true) as string | Buffer; } return pojo; } -function processContent(content: Buffer | string | null, isProtected: boolean, isStringContent: boolean) { +function processContent(content: Buffer | Uint8Array | string | null, isProtected: boolean, isStringContent: boolean) { if (isProtected) { if (protectedSessionService.isProtectedSessionAvailable()) { - content = content === null ? null : protectedSessionService.decrypt(content); + content = content === null ? null : protectedSessionService.decrypt(content as Uint8Array); } else { content = ""; } } if (isStringContent) { - return content === null ? "" : content.toString("utf-8"); - } else { - // see https://github.com/zadam/trilium/issues/3523 - // IIRC a zero-sized buffer can be returned as null from the database - if (content === null) { - // this will force de/encryption - content = Buffer.alloc(0); - } - - return content; + return content === null ? "" : binary_utils.decodeUtf8(content as Uint8Array); } + // see https://github.com/zadam/trilium/issues/3523 + // IIRC a zero-sized buffer can be returned as null from the database + if (content === null) { + // this will force de/encryption + content = Buffer.alloc(0); + } + + return content; } function calculateContentHash({ blobId, content }: Blob) { diff --git a/apps/server/src/services/encryption/data_encryption.ts b/apps/server/src/services/encryption/data_encryption.ts deleted file mode 100644 index 250db687b2..0000000000 --- a/apps/server/src/services/encryption/data_encryption.ts +++ /dev/null @@ -1,114 +0,0 @@ -import crypto from "crypto"; -import log from "../log.js"; - -function arraysIdentical(a: any[] | Buffer, b: any[] | Buffer) { - let i = a.length; - if (i !== b.length) return false; - while (i--) { - if (a[i] !== b[i]) return false; - } - return true; -} - -function shaArray(content: crypto.BinaryLike) { - // we use this as a simple checksum and don't rely on its security, so SHA-1 is good enough - return crypto.createHash("sha1").update(content).digest(); -} - -function pad(data: Buffer): Buffer { - if (data.length > 16) { - data = data.slice(0, 16); - } else if (data.length < 16) { - const zeros = Array(16 - data.length).fill(0); - - data = Buffer.concat([data, Buffer.from(zeros)]); - } - - return Buffer.from(data); -} - -function encrypt(key: Buffer, plainText: Buffer | string) { - if (!key) { - throw new Error("No data key!"); - } - - const plainTextBuffer = Buffer.isBuffer(plainText) ? plainText : Buffer.from(plainText); - - const iv = crypto.randomBytes(16); - const cipher = crypto.createCipheriv("aes-128-cbc", pad(key), pad(iv)); - - const digest = shaArray(plainTextBuffer).slice(0, 4); - - const digestWithPayload = Buffer.concat([digest, plainTextBuffer]); - - const encryptedData = Buffer.concat([cipher.update(digestWithPayload), cipher.final()]); - - const encryptedDataWithIv = Buffer.concat([iv, encryptedData]); - - return encryptedDataWithIv.toString("base64"); -} - -function decrypt(key: Buffer, cipherText: string | Buffer): Buffer | false | null { - if (cipherText === null) { - return null; - } - - if (!key) { - return Buffer.from("[protected]"); - } - - try { - const cipherTextBufferWithIv = Buffer.from(cipherText.toString(), "base64"); - - // old encrypted data can have IV of length 13, see some details here: https://github.com/zadam/trilium/issues/3017 - const ivLength = cipherTextBufferWithIv.length % 16 === 0 ? 16 : 13; - - const iv = cipherTextBufferWithIv.slice(0, ivLength); - - const cipherTextBuffer = cipherTextBufferWithIv.slice(ivLength); - - const decipher = crypto.createDecipheriv("aes-128-cbc", pad(key), pad(iv)); - - const decryptedBytes = Buffer.concat([decipher.update(cipherTextBuffer), decipher.final()]); - - const digest = decryptedBytes.slice(0, 4); - const payload = decryptedBytes.slice(4); - - const computedDigest = shaArray(payload).slice(0, 4); - - if (!arraysIdentical(digest, computedDigest)) { - return false; - } - - return payload; - } catch (e: any) { - // recovery from https://github.com/zadam/trilium/issues/510 - if (e.message?.includes("WRONG_FINAL_BLOCK_LENGTH") || e.message?.includes("wrong final block length")) { - log.info("Caught WRONG_FINAL_BLOCK_LENGTH, returning cipherText instead"); - - return (Buffer.isBuffer(cipherText) ? cipherText : Buffer.from(cipherText)); - } else { - throw e; - } - } -} - -function decryptString(dataKey: Buffer, cipherText: string) { - const buffer = decrypt(dataKey, cipherText); - - if (buffer === null) { - return null; - } else if (buffer === false) { - log.error(`Could not decrypt string. Buffer: ${buffer}`); - - throw new Error("Could not decrypt string."); - } - - return buffer.toString("utf-8"); -} - -export default { - encrypt, - decrypt, - decryptString -}; diff --git a/apps/server/src/services/encryption/open_id_encryption.ts b/apps/server/src/services/encryption/open_id_encryption.ts index 5ba14ca829..a191b782b3 100644 --- a/apps/server/src/services/encryption/open_id_encryption.ts +++ b/apps/server/src/services/encryption/open_id_encryption.ts @@ -1,9 +1,10 @@ -import myScryptService from "./my_scrypt.js"; -import utils, { constantTimeCompare } from "../utils.js"; -import dataEncryptionService from "./data_encryption.js"; +import { data_encryption } from "@triliumnext/core"; + +import OpenIdError from "../../errors/open_id_error.js"; import sql from "../sql.js"; import sqlInit from "../sql_init.js"; -import OpenIdError from "../../errors/open_id_error.js"; +import utils, { constantTimeCompare } from "../utils.js"; +import myScryptService from "./my_scrypt.js"; function saveUser(subjectIdentifier: string, name: string, email: string) { if (isUserSaved()) return false; @@ -16,7 +17,7 @@ function saveUser(subjectIdentifier: string, name: string, email: string) { verificationSalt ); if (!verificationHash) { - throw new OpenIdError("Verification hash undefined!") + throw new OpenIdError("Verification hash undefined!"); } const userIDEncryptedDataKey = setDataKey( @@ -35,10 +36,10 @@ function saveUser(subjectIdentifier: string, name: string, email: string) { userIDVerificationHash: utils.toBase64(verificationHash), salt: verificationSalt, derivedKey: derivedKeySalt, - userIDEncryptedDataKey: userIDEncryptedDataKey, + userIDEncryptedDataKey, isSetup: "true", username: name, - email: email + email }; sql.upsert("user_data", "tmpID", data); @@ -53,7 +54,7 @@ function isSubjectIdentifierSaved() { function isUserSaved() { const isSaved = sql.getValue("SELECT isSetup FROM user_data;"); - return isSaved === "true" ? true : false; + return isSaved === "true"; } function verifyOpenIDSubjectIdentifier(subjectIdentifier: string) { @@ -102,7 +103,7 @@ function setDataKey( console.error("SOMETHING WENT WRONG SAVING USER ID DERIVED KEY"); return undefined; } - const newEncryptedDataKey = dataEncryptionService.encrypt( + const newEncryptedDataKey = data_encryption.encrypt( subjectIdentifierDerivedKey, plainTextDataKey ); @@ -127,7 +128,7 @@ function getDataKey(subjectIdentifier: string) { console.error("SOMETHING WENT WRONG SAVING USER ID DERIVED KEY"); return undefined; } - const decryptedDataKey = dataEncryptionService.decrypt( + const decryptedDataKey = data_encryption.decrypt( subjectIdentifierDerivedKey, encryptedDataKey.toString() ); diff --git a/apps/server/src/services/encryption/password_encryption.ts b/apps/server/src/services/encryption/password_encryption.ts index 61e73495ce..7c126f3260 100644 --- a/apps/server/src/services/encryption/password_encryption.ts +++ b/apps/server/src/services/encryption/password_encryption.ts @@ -1,7 +1,8 @@ +import { data_encryption } from "@triliumnext/core"; + import optionService from "../options.js"; +import { constantTimeCompare,toBase64 } from "../utils.js"; import myScryptService from "./my_scrypt.js"; -import { toBase64, constantTimeCompare } from "../utils.js"; -import dataEncryptionService from "./data_encryption.js"; function verifyPassword(password: string) { const givenPasswordHash = toBase64(myScryptService.getVerificationHash(password)); @@ -15,10 +16,10 @@ function verifyPassword(password: string) { return constantTimeCompare(givenPasswordHash, dbPasswordHash); } -function setDataKey(password: string, plainTextDataKey: string | Buffer) { +function setDataKey(password: string, plainTextDataKey: string | Buffer | Uint8Array) { const passwordDerivedKey = myScryptService.getPasswordDerivedKey(password); - const newEncryptedDataKey = dataEncryptionService.encrypt(passwordDerivedKey, plainTextDataKey); + const newEncryptedDataKey = data_encryption.encrypt(passwordDerivedKey, plainTextDataKey); optionService.setOption("encryptedDataKey", newEncryptedDataKey); } @@ -28,7 +29,7 @@ function getDataKey(password: string) { const encryptedDataKey = optionService.getOption("encryptedDataKey"); - const decryptedDataKey = dataEncryptionService.decrypt(passwordDerivedKey, encryptedDataKey); + const decryptedDataKey = data_encryption.decrypt(passwordDerivedKey, encryptedDataKey); return decryptedDataKey; } diff --git a/apps/server/src/services/encryption/totp_encryption.ts b/apps/server/src/services/encryption/totp_encryption.ts index 87f4cfef18..1d1297dc52 100644 --- a/apps/server/src/services/encryption/totp_encryption.ts +++ b/apps/server/src/services/encryption/totp_encryption.ts @@ -1,8 +1,9 @@ -import optionService from "../options.js"; -import myScryptService from "./my_scrypt.js"; -import { randomSecureToken, toBase64, constantTimeCompare } from "../utils.js"; -import dataEncryptionService from "./data_encryption.js"; import type { OptionNames } from "@triliumnext/commons"; +import { data_encryption } from "@triliumnext/core"; + +import optionService from "../options.js"; +import { constantTimeCompare,randomSecureToken, toBase64 } from "../utils.js"; +import myScryptService from "./my_scrypt.js"; const TOTP_OPTIONS: Record = { SALT: "totpEncryptionSalt", @@ -32,7 +33,7 @@ function setTotpSecret(secret: string) { const verificationHash = toBase64(myScryptService.getVerificationHash(secret)); optionService.setOption(TOTP_OPTIONS.VERIFICATION_HASH, verificationHash); - const encryptedSecret = dataEncryptionService.encrypt( + const encryptedSecret = data_encryption.encrypt( Buffer.from(encryptionSalt), secret ); @@ -48,7 +49,7 @@ function getTotpSecret(): string | null { } try { - const decryptedSecret = dataEncryptionService.decrypt( + const decryptedSecret = data_encryption.decrypt( Buffer.from(encryptionSalt), encryptedSecret ); diff --git a/apps/server/src/services/protected_session.ts b/apps/server/src/services/protected_session.ts index d69d318d1b..780b584b27 100644 --- a/apps/server/src/services/protected_session.ts +++ b/apps/server/src/services/protected_session.ts @@ -1,69 +1,3 @@ -"use strict"; +import { protected_session } from "@triliumnext/core"; -import dataEncryptionService from "./encryption/data_encryption.js"; - -let dataKey: Buffer | null = null; - -function setDataKey(decryptedDataKey: Buffer) { - dataKey = Buffer.from(decryptedDataKey); -} - -function getDataKey() { - return dataKey; -} - -export function resetDataKey() { - dataKey = null; -} - -export function isProtectedSessionAvailable() { - return !!dataKey; -} - -function encrypt(plainText: string | Buffer) { - const dataKey = getDataKey(); - if (plainText === null || dataKey === null) { - return null; - } - - return dataEncryptionService.encrypt(dataKey, plainText); -} - -function decrypt(cipherText: string | Buffer): Buffer | null { - const dataKey = getDataKey(); - if (cipherText === null || dataKey === null) { - return null; - } - - return dataEncryptionService.decrypt(dataKey, cipherText) || null; -} - -function decryptString(cipherText: string): string | null { - const dataKey = getDataKey(); - if (dataKey === null) { - return null; - } - return dataEncryptionService.decryptString(dataKey, cipherText); -} - -let lastProtectedSessionOperationDate: number | null = null; - -function touchProtectedSession() { - if (isProtectedSessionAvailable()) { - lastProtectedSessionOperationDate = Date.now(); - } -} - -export function getLastProtectedSessionOperationDate() { - return lastProtectedSessionOperationDate; -} - -export default { - setDataKey, - resetDataKey, - isProtectedSessionAvailable, - encrypt, - decrypt, - decryptString, - touchProtectedSession -}; +export default protected_session.default; diff --git a/apps/server/src/services/scheduler.ts b/apps/server/src/services/scheduler.ts index f44e3aa430..0ed4236513 100644 --- a/apps/server/src/services/scheduler.ts +++ b/apps/server/src/services/scheduler.ts @@ -1,13 +1,14 @@ -import scriptService from "./script.js"; -import cls from "./cls.js"; -import sqlInit from "./sql_init.js"; -import config from "./config.js"; -import log from "./log.js"; -import attributeService from "../services/attributes.js"; -import hiddenSubtreeService from "./hidden_subtree.js"; +import { protected_session } from "@triliumnext/core"; + import type BNote from "../becca/entities/bnote.js"; +import attributeService from "../services/attributes.js"; +import cls from "./cls.js"; +import config from "./config.js"; +import hiddenSubtreeService from "./hidden_subtree.js"; +import log from "./log.js"; import options from "./options.js"; -import { getLastProtectedSessionOperationDate, isProtectedSessionAvailable, resetDataKey } from "./protected_session.js"; +import scriptService from "./script.js"; +import sqlInit from "./sql_init.js"; import ws from "./ws.js"; function getRunAtHours(note: BNote): number[] { @@ -71,9 +72,9 @@ sqlInit.dbReady.then(() => { function checkProtectedSessionExpiration() { const protectedSessionTimeout = options.getOptionInt("protectedSessionTimeout"); - const lastProtectedSessionOperationDate = getLastProtectedSessionOperationDate(); - if (isProtectedSessionAvailable() && lastProtectedSessionOperationDate && Date.now() - lastProtectedSessionOperationDate > protectedSessionTimeout * 1000) { - resetDataKey(); + const lastProtectedSessionOperationDate = protected_session.getLastProtectedSessionOperationDate(); + if (protected_session.isProtectedSessionAvailable() && lastProtectedSessionOperationDate && Date.now() - lastProtectedSessionOperationDate > protectedSessionTimeout * 1000) { + protected_session.resetDataKey(); log.info("Expiring protected session"); ws.reloadFrontend("leaving protected session"); } diff --git a/packages/trilium-core/src/index.ts b/packages/trilium-core/src/index.ts index 733e1faf1e..eee6b2d65b 100644 --- a/packages/trilium-core/src/index.ts +++ b/packages/trilium-core/src/index.ts @@ -1,17 +1,24 @@ import { ExecutionContext, initContext } from "./services/context"; +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"; export type * from "./services/sql/types"; export * from "./services/sql/index"; +export * as protected_session from "./services/encryption/protected_session"; +export { default as data_encryption } from "./services/encryption/data_encryption" +export * as binary_utils from "./services/utils/binary"; export type { ExecutionContext } from "./services/context"; +export type { CryptoProvider } from "./services/encryption/crypto"; -export function initializeCore({ dbConfig, executionContext }: { +export function initializeCore({ dbConfig, executionContext, crypto }: { dbConfig: SqlServiceParams, - executionContext: ExecutionContext + executionContext: ExecutionContext, + crypto: CryptoProvider }) { initLog(); + initCrypto(crypto); initSql(new SqlService(dbConfig, getLog())); initContext(executionContext); }; diff --git a/packages/trilium-core/src/services/encryption/crypto.ts b/packages/trilium-core/src/services/encryption/crypto.ts new file mode 100644 index 0000000000..94ebcb1ad9 --- /dev/null +++ b/packages/trilium-core/src/services/encryption/crypto.ts @@ -0,0 +1,23 @@ +interface Cipher { + update(data: Uint8Array): Uint8Array; + final(): Uint8Array; +} + +export interface CryptoProvider { + + createHash(algorithm: "sha1", content: string | Uint8Array): Uint8Array; + randomBytes(size: number): Uint8Array; + createCipheriv(algorithm: "aes-128-cbc", key: Uint8Array, iv: Uint8Array): Cipher; + createDecipheriv(algorithm: "aes-128-cbc", key: Uint8Array, iv: Uint8Array): Cipher; +} + +let crypto: CryptoProvider | null = null; + +export function initCrypto(cryptoProvider: CryptoProvider) { + crypto = cryptoProvider; +} + +export function getCrypto() { + if (!crypto) throw new Error("Crypto not initialized."); + return crypto; +} diff --git a/packages/trilium-core/src/services/encryption/data_encryption.ts b/packages/trilium-core/src/services/encryption/data_encryption.ts new file mode 100644 index 0000000000..e6bb9d1e84 --- /dev/null +++ b/packages/trilium-core/src/services/encryption/data_encryption.ts @@ -0,0 +1,115 @@ +import { getLog } from "../log.js"; +import { concat2, decodeBase64, decodeUtf8, encodeBase64 } from "../utils/binary.js"; +import { getCrypto } from "./crypto.js"; + +function arraysIdentical(a: any[] | Uint8Array, b: any[] | Uint8Array) { + let i = a.length; + if (i !== b.length) return false; + while (i--) { + if (a[i] !== b[i]) return false; + } + return true; +} + +function shaArray(content: string | Uint8Array) { + // we use this as a simple checksum and don't rely on its security, so SHA-1 is good enough + return getCrypto().createHash("sha1", content); +} + +function pad(data: Uint8Array): Uint8Array { + if (data.length > 16) { + data = data.slice(0, 16); + } else if (data.length < 16) { + const zeros = Array(16 - data.length).fill(0); + + data = concat2(data, Uint8Array.from(zeros)); + } + + return Uint8Array.from(data); +} + +function encrypt(key: Uint8Array, plainText: Uint8Array | string) { + if (!key) { + throw new Error("No data key!"); + } + + const plainTextUint8Array = ArrayBuffer.isView(plainText) ? plainText : Uint8Array.from(plainText); + + const iv = getCrypto().randomBytes(16); + const cipher = getCrypto().createCipheriv("aes-128-cbc", pad(key), pad(iv)); + + const digest = shaArray(plainTextUint8Array).slice(0, 4); + + const digestWithPayload = concat2(digest, plainTextUint8Array); + + const encryptedData = concat2(cipher.update(digestWithPayload), cipher.final()); + + const encryptedDataWithIv = concat2(iv, encryptedData); + + return encodeBase64(encryptedDataWithIv); +} + +function decrypt(key: Uint8Array, cipherText: string | Uint8Array): Uint8Array | false | null { + if (cipherText === null) { + return null; + } + + if (!key) { + return Uint8Array.from("[protected]"); + } + + try { + const cipherTextStr = typeof cipherText === "string" ? cipherText : decodeUtf8(cipherText); + const cipherTextUint8ArrayWithIv = decodeBase64(cipherTextStr); + + // old encrypted data can have IV of length 13, see some details here: https://github.com/zadam/trilium/issues/3017 + const ivLength = cipherTextUint8ArrayWithIv.length % 16 === 0 ? 16 : 13; + + const iv = cipherTextUint8ArrayWithIv.slice(0, ivLength); + + const cipherTextUint8Array = cipherTextUint8ArrayWithIv.slice(ivLength); + + const decipher = getCrypto().createDecipheriv("aes-128-cbc", pad(key), pad(iv)); + + const decryptedBytes = concat2(decipher.update(cipherTextUint8Array), decipher.final()); + + const digest = decryptedBytes.slice(0, 4); + const payload = decryptedBytes.slice(4); + + const computedDigest = shaArray(payload).slice(0, 4); + + if (!arraysIdentical(digest, computedDigest)) { + return false; + } + + return payload; + } catch (e: any) { + // recovery from https://github.com/zadam/trilium/issues/510 + if (e.message?.includes("WRONG_FINAL_BLOCK_LENGTH") || e.message?.includes("wrong final block length")) { + getLog().info("Caught WRONG_FINAL_BLOCK_LENGTH, returning cipherText instead"); + + return (ArrayBuffer.isView(cipherText) ? cipherText : Uint8Array.from(cipherText)); + } + throw e; + } +} + +function decryptString(dataKey: Uint8Array, cipherText: string) { + const buffer = decrypt(dataKey, cipherText); + + if (buffer === null) { + return null; + } else if (buffer === false) { + getLog().error(`Could not decrypt string. Uint8Array: ${buffer}`); + + throw new Error("Could not decrypt string."); + } + + return decodeUtf8(buffer); +} + +export default { + encrypt, + decrypt, + decryptString +}; diff --git a/packages/trilium-core/src/services/encryption/protected_session.ts b/packages/trilium-core/src/services/encryption/protected_session.ts new file mode 100644 index 0000000000..ae4d69029b --- /dev/null +++ b/packages/trilium-core/src/services/encryption/protected_session.ts @@ -0,0 +1,69 @@ +"use strict"; + +import dataEncryptionService from "./data_encryption.js"; + +let dataKey: Uint8Array | null = null; + +function setDataKey(decryptedDataKey: Uint8Array) { + dataKey = Uint8Array.from(decryptedDataKey); +} + +function getDataKey() { + return dataKey; +} + +export function resetDataKey() { + dataKey = null; +} + +export function isProtectedSessionAvailable() { + return !!dataKey; +} + +function encrypt(plainText: string | Uint8Array) { + const dataKey = getDataKey(); + if (plainText === null || dataKey === null) { + return null; + } + + return dataEncryptionService.encrypt(dataKey, plainText); +} + +function decrypt(cipherText: string | Uint8Array): Uint8Array | null { + const dataKey = getDataKey(); + if (cipherText === null || dataKey === null) { + return null; + } + + return dataEncryptionService.decrypt(dataKey, cipherText) || null; +} + +function decryptString(cipherText: string): string | null { + const dataKey = getDataKey(); + if (dataKey === null) { + return null; + } + return dataEncryptionService.decryptString(dataKey, cipherText); +} + +let lastProtectedSessionOperationDate: number | null = null; + +function touchProtectedSession() { + if (isProtectedSessionAvailable()) { + lastProtectedSessionOperationDate = Date.now(); + } +} + +export function getLastProtectedSessionOperationDate() { + return lastProtectedSessionOperationDate; +} + +export default { + setDataKey, + resetDataKey, + isProtectedSessionAvailable, + encrypt, + decrypt, + decryptString, + touchProtectedSession +}; diff --git a/packages/trilium-core/src/services/utils/binary.ts b/packages/trilium-core/src/services/utils/binary.ts new file mode 100644 index 0000000000..5861ac9067 --- /dev/null +++ b/packages/trilium-core/src/services/utils/binary.ts @@ -0,0 +1,35 @@ +const utf8Decoder = new TextDecoder("utf-8"); + +export function concat2(a: Uint8Array, b: Uint8Array): Uint8Array { + const out = new Uint8Array(a.length + b.length); + out.set(a, 0); + out.set(b, a.length); + return out; +} + +export function encodeBase64(bytes: Uint8Array): string { + let binary = ""; + const len = bytes.length; + + for (let i = 0; i < len; i++) { + binary += String.fromCharCode(bytes[i]); + } + + return btoa(binary); +} + +export function decodeBase64(base64: string): Uint8Array { + const binary = atob(base64); + const len = binary.length; + + const bytes = new Uint8Array(len); + for (let i = 0; i < len; i++) { + bytes[i] = binary.charCodeAt(i); + } + + return bytes; +} + +export function decodeUtf8(bytes: Uint8Array) { + return utf8Decoder.decode(bytes); +} From c7f0d541c2de2b88265059334fe455d05507750f Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Tue, 6 Jan 2026 10:41:50 +0200 Subject: [PATCH 10/72] fix(server): blob errors out --- apps/server/src/services/blob.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/apps/server/src/services/blob.ts b/apps/server/src/services/blob.ts index 6804f1ecb5..2f123cb03f 100644 --- a/apps/server/src/services/blob.ts +++ b/apps/server/src/services/blob.ts @@ -39,7 +39,9 @@ function processContent(content: Buffer | Uint8Array | string | null, isProtecte } if (isStringContent) { - return content === null ? "" : binary_utils.decodeUtf8(content as Uint8Array); + if (content === null) return ""; + if (typeof content === "string") return content; + return binary_utils.decodeUtf8(content as Uint8Array); } // see https://github.com/zadam/trilium/issues/3523 // IIRC a zero-sized buffer can be returned as null from the database From 14e2e85da70f9f1dbaba78dd7116fd162fe3f4b8 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Tue, 6 Jan 2026 11:03:33 +0200 Subject: [PATCH 11/72] chore(core): integrate date_utils --- apps/server/src/becca/entities/bbranch.ts | 41 +++---- apps/server/src/services/cls.ts | 5 - apps/server/src/services/date_utils.ts | 109 +---------------- apps/server/src/services/notes.ts | 105 +++++++++-------- packages/trilium-core/package.json | 3 + packages/trilium-core/src/index.ts | 3 +- .../trilium-core/src/services/utils/date.ts | 111 ++++++++++++++++++ pnpm-lock.yaml | 8 +- 8 files changed, 199 insertions(+), 186 deletions(-) create mode 100644 packages/trilium-core/src/services/utils/date.ts diff --git a/apps/server/src/becca/entities/bbranch.ts b/apps/server/src/becca/entities/bbranch.ts index e074430240..d1eb91a9ef 100644 --- a/apps/server/src/becca/entities/bbranch.ts +++ b/apps/server/src/becca/entities/bbranch.ts @@ -1,14 +1,15 @@ -"use strict"; -import BNote from "./bnote.js"; -import AbstractBeccaEntity from "./abstract_becca_entity.js"; -import dateUtils from "../../services/date_utils.js"; -import utils from "../../services/utils.js"; -import TaskContext from "../../services/task_context.js"; -import cls from "../../services/cls.js"; -import log from "../../services/log.js"; + import type { BranchRow } from "@triliumnext/commons"; + +import cls from "../../services/cls.js"; +import dateUtils from "../../services/date_utils.js"; import handlers from "../../services/handlers.js"; +import log from "../../services/log.js"; +import TaskContext from "../../services/task_context.js"; +import utils from "../../services/utils.js"; +import AbstractBeccaEntity from "./abstract_becca_entity.js"; +import BNote from "./bnote.js"; /** * Branch represents a relationship between a child note and its parent note. Trilium allows a note to have multiple @@ -199,9 +200,9 @@ class BBranch extends AbstractBeccaEntity { note.markAsDeleted(deleteId); return true; - } else { - return false; - } + } + return false; + } override beforeSaving() { @@ -268,15 +269,15 @@ class BBranch extends AbstractBeccaEntity { existingBranch.notePosition = notePosition; } return existingBranch; - } else { - return new BBranch({ - noteId: this.noteId, - parentNoteId: parentNoteId, - notePosition: notePosition || null, - prefix: this.prefix, - isExpanded: this.isExpanded - }); - } + } + return new BBranch({ + noteId: this.noteId, + parentNoteId, + notePosition: notePosition || null, + prefix: this.prefix, + isExpanded: this.isExpanded + }); + } getParentNote() { diff --git a/apps/server/src/services/cls.ts b/apps/server/src/services/cls.ts index 5835385bee..4d75ca67d9 100644 --- a/apps/server/src/services/cls.ts +++ b/apps/server/src/services/cls.ts @@ -25,10 +25,6 @@ function getComponentId() { return getContext().get("componentId"); } -function getLocalNowDateTime() { - return getContext().get("localNowDateTime"); -} - function disableEntityEvents() { getContext().set("disableEntityEvents", true); } @@ -93,7 +89,6 @@ export default { set, getHoistedNoteId, getComponentId, - getLocalNowDateTime, disableEntityEvents, enableEntityEvents, isEntityEventsDisabled, diff --git a/apps/server/src/services/date_utils.ts b/apps/server/src/services/date_utils.ts index 0cbe69cfaa..0e38fb1eae 100644 --- a/apps/server/src/services/date_utils.ts +++ b/apps/server/src/services/date_utils.ts @@ -1,107 +1,2 @@ -import { dayjs } from "@triliumnext/commons"; -import cls from "./cls.js"; - -const LOCAL_DATETIME_FORMAT = "YYYY-MM-DD HH:mm:ss.SSSZZ"; -const UTC_DATETIME_FORMAT = "YYYY-MM-DD HH:mm:ssZ"; - -function utcNowDateTime() { - return utcDateTimeStr(new Date()); -} - -// CLS date time is important in web deployments - server often runs in different time zone than user is located in, -// so we'd prefer client timezone to be used to record local dates. For this reason, requests from clients contain -// "trilium-local-now-datetime" header which is then stored in CLS -function localNowDateTime() { - return cls.getLocalNowDateTime() || dayjs().format(LOCAL_DATETIME_FORMAT); -} - -function localNowDate() { - const clsDateTime = cls.getLocalNowDateTime(); - - if (clsDateTime) { - return clsDateTime.substr(0, 10); - } else { - const date = new Date(); - - return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`; - } -} - -function pad(num: number) { - return num <= 9 ? `0${num}` : `${num}`; -} - -function utcDateStr(date: Date) { - return date.toISOString().split("T")[0]; -} - -function utcDateTimeStr(date: Date) { - return date.toISOString().replace("T", " "); -} - -/** - * @param str - needs to be in the ISO 8601 format "YYYY-MM-DDTHH:MM:SS.sssZ" format as outputted by dateStr(). - * also is assumed to be GMT time (as indicated by the "Z" at the end), *not* local time - */ -function parseDateTime(str: string) { - try { - return new Date(Date.parse(str)); - } catch (e: any) { - throw new Error(`Can't parse date from '${str}': ${e.stack}`); - } -} - -function parseLocalDate(str: string) { - const datePart = str.substr(0, 10); - - // not specifying the timezone and specifying the time means Date.parse() will use the local timezone - return parseDateTime(`${datePart} 12:00:00.000`); -} - -function getDateTimeForFile() { - return new Date().toISOString().substr(0, 19).replace(/:/g, ""); -} - -function validateLocalDateTime(str: string | null | undefined) { - if (!str) { - return; - } - - if (!/[0-9]{4}-[0-9]{2}-[0-9]{2} [0-9]{2}:[0-9]{2}:[0-9]{2}\.[0-9]{3}[+-][0-9]{4}/.test(str)) { - return `Invalid local date time format in '${str}'. Correct format shoud follow example: '2023-08-21 23:38:51.110+0200'`; - } - - if (!dayjs(str, LOCAL_DATETIME_FORMAT)) { - return `Date '${str}' appears to be in the correct format, but cannot be parsed. It likely represents an invalid date.`; - } -} - -function validateUtcDateTime(str: string | undefined) { - if (!str) { - return; - } - - if (!/[0-9]{4}-[0-9]{2}-[0-9]{2} [0-9]{2}:[0-9]{2}:[0-9]{2}\.[0-9]{3}Z/.test(str)) { - return `Invalid UTC date time format in '${str}'. Correct format shoud follow example: '2023-08-21 23:38:51.110Z'`; - } - - if (!dayjs(str, UTC_DATETIME_FORMAT)) { - return `Date '${str}' appears to be in the correct format, but cannot be parsed. It likely represents an invalid date.`; - } -} - -export default { - LOCAL_DATETIME_FORMAT, - UTC_DATETIME_FORMAT, - utcNowDateTime, - localNowDateTime, - localNowDate, - - utcDateStr, - utcDateTimeStr, - parseDateTime, - parseLocalDate, - getDateTimeForFile, - validateLocalDateTime, - validateUtcDateTime -}; +import { date_utils } from "@triliumnext/core"; +export default date_utils; diff --git a/apps/server/src/services/notes.ts b/apps/server/src/services/notes.ts index 4964a57976..a4e1ed3cf9 100644 --- a/apps/server/src/services/notes.ts +++ b/apps/server/src/services/notes.ts @@ -1,33 +1,34 @@ -import sql from "./sql.js"; -import optionService from "./options.js"; -import dateUtils from "./date_utils.js"; -import entityChangesService from "./entity_changes.js"; -import eventService from "./events.js"; -import cls from "../services/cls.js"; -import protectedSessionService from "../services/protected_session.js"; -import log from "../services/log.js"; -import { newEntityId, unescapeHtml, quoteRegex, toMap } from "../services/utils.js"; -import revisionService from "./revisions.js"; -import request from "./request.js"; +import type { AttachmentRow, AttributeRow, BranchRow, NoteRow } from "@triliumnext/commons"; +import { dayjs } from "@triliumnext/commons"; +import { date_utils } from "@triliumnext/core"; +import fs from "fs"; +import html2plaintext from "html2plaintext"; +import { t } from "i18next"; import path from "path"; import url from "url"; + import becca from "../becca/becca.js"; +import BAttachment from "../becca/entities/battachment.js"; +import BAttribute from "../becca/entities/battribute.js"; import BBranch from "../becca/entities/bbranch.js"; import BNote from "../becca/entities/bnote.js"; -import BAttribute from "../becca/entities/battribute.js"; -import BAttachment from "../becca/entities/battachment.js"; -import { dayjs } from "@triliumnext/commons"; -import htmlSanitizer from "./html_sanitizer.js"; import ValidationError from "../errors/validation_error.js"; -import noteTypesService from "./note_types.js"; -import fs from "fs"; -import ws from "./ws.js"; -import html2plaintext from "html2plaintext"; -import type { AttachmentRow, AttributeRow, BranchRow, NoteRow } from "@triliumnext/commons"; -import type TaskContext from "./task_context.js"; -import type { NoteParams } from "./note-interface.js"; +import cls from "../services/cls.js"; +import log from "../services/log.js"; +import protectedSessionService from "../services/protected_session.js"; +import { newEntityId, quoteRegex, toMap,unescapeHtml } from "../services/utils.js"; +import entityChangesService from "./entity_changes.js"; +import eventService from "./events.js"; +import htmlSanitizer from "./html_sanitizer.js"; import imageService from "./image.js"; -import { t } from "i18next"; +import noteTypesService from "./note_types.js"; +import type { NoteParams } from "./note-interface.js"; +import optionService from "./options.js"; +import request from "./request.js"; +import revisionService from "./revisions.js"; +import sql from "./sql.js"; +import type TaskContext from "./task_context.js"; +import ws from "./ws.js"; interface FoundLink { name: "imageLink" | "internalLink" | "includeNoteLink" | "relationMapLink"; @@ -47,14 +48,14 @@ function getNewNotePosition(parentNote: BNote) { .reduce((min, note) => Math.min(min, note?.notePosition || 0), 0); return minNotePos - 10; - } else { - const maxNotePos = parentNote - .getChildBranches() - .filter((branch) => branch?.noteId !== "_hidden") // has "always last" note position - .reduce((max, note) => Math.max(max, note?.notePosition || 0), 0); - - return maxNotePos + 10; } + const maxNotePos = parentNote + .getChildBranches() + .filter((branch) => branch?.noteId !== "_hidden") // has "always last" note position + .reduce((max, note) => Math.max(max, note?.notePosition || 0), 0); + + return maxNotePos + 10; + } function triggerNoteTitleChanged(note: BNote) { @@ -88,7 +89,7 @@ function copyChildAttributes(parentNote: BNote, childNote: BNote) { new BAttribute({ noteId: childNote.noteId, type: attr.type, - name: name, + name, value: attr.value, position: attr.position, isInheritable: attr.isInheritable @@ -121,7 +122,7 @@ function getNewNoteTitle(parentNote: BNote) { if (titleTemplate !== null) { try { - const now = dayjs(cls.getLocalNowDateTime() || new Date()); + const now = dayjs(date_utils.localNowDateTime() || new Date()); // "officially" injected values: // - now @@ -189,11 +190,11 @@ function createNewNote(params: NoteParams): { } let error; - if ((error = dateUtils.validateLocalDateTime(params.dateCreated))) { + if ((error = date_utils.validateLocalDateTime(params.dateCreated))) { throw new Error(error); } - if ((error = dateUtils.validateUtcDateTime(params.utcDateCreated))) { + if ((error = date_utils.validateUtcDateTime(params.utcDateCreated))) { throw new Error(error); } @@ -260,7 +261,7 @@ function createNewNote(params: NoteParams): { eventService.emit(eventService.ENTITY_CHANGED, { entityName: "blobs", entity: note }); eventService.emit(eventService.ENTITY_CREATED, { entityName: "branches", entity: branch }); eventService.emit(eventService.ENTITY_CHANGED, { entityName: "branches", entity: branch }); - eventService.emit(eventService.CHILD_NOTE_CREATED, { childNote: note, parentNote: parentNote }); + eventService.emit(eventService.CHILD_NOTE_CREATED, { childNote: note, parentNote }); log.info(`Created new note '${note.noteId}', branch '${branch.branchId}' of type '${note.type}', mime '${note.mime}'`); @@ -308,9 +309,9 @@ function createNewNoteWithTarget(target: "into" | "after" | "before", targetBran entityChangesService.putNoteReorderingEntityChange(params.parentNoteId); return retObject; - } else { - throw new Error(`Unknown target '${target}'`); } + throw new Error(`Unknown target '${target}'`); + } function protectNoteRecursively(note: BNote, protect: boolean, includingSubTree: boolean, taskContext: TaskContext<"protectNotes">) { @@ -384,7 +385,7 @@ function checkImageAttachments(note: BNote, content: string) { attachment.utcDateScheduledForErasureSince = null; attachment.save(); } else if (!attachment.utcDateScheduledForErasureSince && !attachmentInContent) { - attachment.utcDateScheduledForErasureSince = dateUtils.utcNowDateTime(); + attachment.utcDateScheduledForErasureSince = date_utils.utcNowDateTime(); attachment.save(); } } @@ -488,7 +489,7 @@ function findRelationMapLinks(content: string, foundLinks: FoundLink[]) { }); } } catch (e: any) { - log.error("Could not scan for relation map links: " + e.message); + log.error(`Could not scan for relation map links: ${ e.message}`); } } @@ -656,8 +657,8 @@ function saveAttachments(note: BNote, content: string) { const attachment = note.saveAttachment({ role: "file", - mime: mime, - title: title, + mime, + title, content: buffer }); @@ -739,11 +740,11 @@ function saveRevisionIfNeeded(note: BNote) { const now = new Date(); const revisionSnapshotTimeInterval = parseInt(optionService.getOption("revisionSnapshotTimeInterval")); - const revisionCutoff = dateUtils.utcDateTimeStr(new Date(now.getTime() - revisionSnapshotTimeInterval * 1000)); + const revisionCutoff = date_utils.utcDateTimeStr(new Date(now.getTime() - revisionSnapshotTimeInterval * 1000)); const existingRevisionId = sql.getValue("SELECT revisionId FROM revisions WHERE noteId = ? AND utcDateCreated >= ?", [note.noteId, revisionCutoff]); - const msSinceDateCreated = now.getTime() - dateUtils.parseDateTime(note.utcDateCreated).getTime(); + const msSinceDateCreated = now.getTime() - date_utils.parseDateTime(note.utcDateCreated).getTime(); if (!existingRevisionId && msSinceDateCreated >= revisionSnapshotTimeInterval * 1000) { note.saveRevision(); @@ -953,7 +954,7 @@ function duplicateSubtree(origNoteId: string, newParentNoteId: string) { const duplicateNoteSuffix = t("notes.duplicate-note-suffix"); if (!res.note.title.endsWith(duplicateNoteSuffix) && !res.note.title.startsWith(duplicateNoteSuffix)) { - res.note.title = t("notes.duplicate-note-title", { noteTitle: res.note.title, duplicateNoteSuffix: duplicateNoteSuffix }); + res.note.title = t("notes.duplicate-note-title", { noteTitle: res.note.title, duplicateNoteSuffix }); } res.note.save(); @@ -999,8 +1000,8 @@ function duplicateSubtreeInner(origNote: BNote, origBranch: BBranch | null | und const newNote = new BNote({ ...origNote, noteId: newNoteId, - dateCreated: dateUtils.localNowDateTime(), - utcDateCreated: dateUtils.utcNowDateTime() + dateCreated: date_utils.localNowDateTime(), + utcDateCreated: date_utils.utcNowDateTime() }).save(); let content = origNote.getContent(); @@ -1050,13 +1051,13 @@ function duplicateSubtreeInner(origNote: BNote, origBranch: BBranch | null | und note: existingNote, branch: createDuplicatedBranch() }; - } else { - return { - // order here is important, note needs to be created first to not mess up the becca - note: createDuplicatedNote(), - branch: createDuplicatedBranch() - }; } + return { + // order here is important, note needs to be created first to not mess up the becca + note: createDuplicatedNote(), + branch: createDuplicatedBranch() + }; + } function getNoteIdMapping(origNote: BNote) { diff --git a/packages/trilium-core/package.json b/packages/trilium-core/package.json index 7e6df61d35..1bc92db237 100644 --- a/packages/trilium-core/package.json +++ b/packages/trilium-core/package.json @@ -5,5 +5,8 @@ "main": "src/index.ts", "scripts": { "test": "echo \"Error: no test specified\" && exit 1" + }, + "dependencies": { + "@triliumnext/commons": "workspace:*" } } diff --git a/packages/trilium-core/src/index.ts b/packages/trilium-core/src/index.ts index eee6b2d65b..042bde8b21 100644 --- a/packages/trilium-core/src/index.ts +++ b/packages/trilium-core/src/index.ts @@ -9,7 +9,8 @@ export * from "./services/sql/index"; export * as protected_session from "./services/encryption/protected_session"; export { default as data_encryption } from "./services/encryption/data_encryption" export * as binary_utils from "./services/utils/binary"; -export type { ExecutionContext } from "./services/context"; +export { default as date_utils } from "./services/utils/date"; +export { getContext, type ExecutionContext } from "./services/context"; export type { CryptoProvider } from "./services/encryption/crypto"; export function initializeCore({ dbConfig, executionContext, crypto }: { diff --git a/packages/trilium-core/src/services/utils/date.ts b/packages/trilium-core/src/services/utils/date.ts new file mode 100644 index 0000000000..657eea11eb --- /dev/null +++ b/packages/trilium-core/src/services/utils/date.ts @@ -0,0 +1,111 @@ +import { dayjs } from "@triliumnext/commons"; +import { getContext } from "../context.js"; + +const LOCAL_DATETIME_FORMAT = "YYYY-MM-DD HH:mm:ss.SSSZZ"; +const UTC_DATETIME_FORMAT = "YYYY-MM-DD HH:mm:ssZ"; + +function utcNowDateTime() { + return utcDateTimeStr(new Date()); +} + +// CLS date time is important in web deployments - server often runs in different time zone than user is located in, +// so we'd prefer client timezone to be used to record local dates. For this reason, requests from clients contain +// "trilium-local-now-datetime" header which is then stored in CLS +function localNowDateTime() { + return getLocalNowDateTime() || dayjs().format(LOCAL_DATETIME_FORMAT); +} + +function localNowDate() { + const clsDateTime = getLocalNowDateTime(); + + if (clsDateTime) { + return clsDateTime.substr(0, 10); + } else { + const date = new Date(); + + return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`; + } +} + +function pad(num: number) { + return num <= 9 ? `0${num}` : `${num}`; +} + +function utcDateStr(date: Date) { + return date.toISOString().split("T")[0]; +} + +function utcDateTimeStr(date: Date) { + return date.toISOString().replace("T", " "); +} + +/** + * @param str - needs to be in the ISO 8601 format "YYYY-MM-DDTHH:MM:SS.sssZ" format as outputted by dateStr(). + * also is assumed to be GMT time (as indicated by the "Z" at the end), *not* local time + */ +function parseDateTime(str: string) { + try { + return new Date(Date.parse(str)); + } catch (e: any) { + throw new Error(`Can't parse date from '${str}': ${e.stack}`); + } +} + +function parseLocalDate(str: string) { + const datePart = str.substr(0, 10); + + // not specifying the timezone and specifying the time means Date.parse() will use the local timezone + return parseDateTime(`${datePart} 12:00:00.000`); +} + +function getDateTimeForFile() { + return new Date().toISOString().substr(0, 19).replace(/:/g, ""); +} + +function validateLocalDateTime(str: string | null | undefined) { + if (!str) { + return; + } + + if (!/[0-9]{4}-[0-9]{2}-[0-9]{2} [0-9]{2}:[0-9]{2}:[0-9]{2}\.[0-9]{3}[+-][0-9]{4}/.test(str)) { + return `Invalid local date time format in '${str}'. Correct format shoud follow example: '2023-08-21 23:38:51.110+0200'`; + } + + if (!dayjs(str, LOCAL_DATETIME_FORMAT)) { + return `Date '${str}' appears to be in the correct format, but cannot be parsed. It likely represents an invalid date.`; + } +} + +function validateUtcDateTime(str: string | undefined) { + if (!str) { + return; + } + + if (!/[0-9]{4}-[0-9]{2}-[0-9]{2} [0-9]{2}:[0-9]{2}:[0-9]{2}\.[0-9]{3}Z/.test(str)) { + return `Invalid UTC date time format in '${str}'. Correct format shoud follow example: '2023-08-21 23:38:51.110Z'`; + } + + if (!dayjs(str, UTC_DATETIME_FORMAT)) { + return `Date '${str}' appears to be in the correct format, but cannot be parsed. It likely represents an invalid date.`; + } +} + +function getLocalNowDateTime() { + return getContext().get("localNowDateTime"); +} + +export default { + LOCAL_DATETIME_FORMAT, + UTC_DATETIME_FORMAT, + utcNowDateTime, + localNowDateTime, + localNowDate, + + utcDateStr, + utcDateTimeStr, + parseDateTime, + parseLocalDate, + getDateTimeForFile, + validateLocalDateTime, + validateUtcDateTime +}; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index fc5d57faf4..2984cdb3bc 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1444,7 +1444,11 @@ importers: specifier: 5.1.0 version: 5.1.0(karma@6.4.4(bufferutil@4.0.9)(utf-8-validate@6.0.5)) - packages/trilium-core: {} + packages/trilium-core: + dependencies: + '@triliumnext/commons': + specifier: workspace:* + version: link:../commons packages/turndown-plugin-gfm: devDependencies: @@ -15558,6 +15562,8 @@ 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: From c20da77f830f9d7f5fb88d445a9526b361f0c2d1 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Tue, 6 Jan 2026 11:10:09 +0200 Subject: [PATCH 12/72] chore(core): integrate events service --- apps/server/src/app.ts | 39 ++++++++--------- apps/server/src/becca/becca_loader.ts | 29 +++++++------ .../becca/entities/abstract_becca_entity.ts | 3 +- apps/server/src/becca/entities/bnote.ts | 2 +- apps/server/src/routes/api/branches.ts | 29 +++++++------ apps/server/src/routes/api/login.ts | 31 +++++++------- apps/server/src/services/entity_changes.ts | 17 ++++---- apps/server/src/services/handlers.ts | 19 +++++---- .../src/services/llm/ai_service_manager.ts | 42 +++++++++---------- apps/server/src/services/notes.ts | 2 +- apps/server/src/services/sql_init.ts | 33 ++++++++------- apps/server/src/services/sync_update.ts | 13 +++--- apps/server/src/share/shaca/shaca_loader.ts | 15 ++++--- packages/trilium-core/src/index.ts | 1 + .../trilium-core}/src/services/events.ts | 0 15 files changed, 137 insertions(+), 138 deletions(-) rename {apps/server => packages/trilium-core}/src/services/events.ts (100%) diff --git a/apps/server/src/app.ts b/apps/server/src/app.ts index 8023338c97..7f44e60246 100644 --- a/apps/server/src/app.ts +++ b/apps/server/src/app.ts @@ -1,25 +1,26 @@ -import express from "express"; -import path from "path"; -import favicon from "serve-favicon"; -import cookieParser from "cookie-parser"; -import helmet from "helmet"; -import compression from "compression"; -import config from "./services/config.js"; -import utils, { getResourceDir, isDev } from "./services/utils.js"; -import assets from "./routes/assets.js"; -import routes from "./routes/routes.js"; -import custom from "./routes/custom.js"; -import error_handlers from "./routes/error_handlers.js"; -import { startScheduledCleanup } from "./services/erase.js"; -import sql_init from "./services/sql_init.js"; -import { auth } from "express-openid-connect"; -import openID from "./services/open_id.js"; -import { t } from "i18next"; -import eventService from "./services/events.js"; -import log from "./services/log.js"; import "./services/handlers.js"; import "./becca/becca_loader.js"; + +import compression from "compression"; +import cookieParser from "cookie-parser"; +import express from "express"; +import { auth } from "express-openid-connect"; +import helmet from "helmet"; +import { t } from "i18next"; +import path from "path"; +import favicon from "serve-favicon"; + +import assets from "./routes/assets.js"; +import custom from "./routes/custom.js"; +import error_handlers from "./routes/error_handlers.js"; +import routes from "./routes/routes.js"; +import config from "./services/config.js"; +import { startScheduledCleanup } from "./services/erase.js"; +import log from "./services/log.js"; +import openID from "./services/open_id.js"; import { RESOURCE_DIR } from "./services/resource_dir.js"; +import sql_init from "./services/sql_init.js"; +import utils, { getResourceDir, isDev } from "./services/utils.js"; export default async function buildApp() { const app = express(); diff --git a/apps/server/src/becca/becca_loader.ts b/apps/server/src/becca/becca_loader.ts index f7faf13097..3d5e7c3207 100644 --- a/apps/server/src/becca/becca_loader.ts +++ b/apps/server/src/becca/becca_loader.ts @@ -1,20 +1,19 @@ -"use strict"; - -import sql from "../services/sql.js"; -import eventService from "../services/events.js"; -import becca from "./becca.js"; -import log from "../services/log.js"; -import BNote from "./entities/bnote.js"; -import BBranch from "./entities/bbranch.js"; -import BAttribute from "./entities/battribute.js"; -import BOption from "./entities/boption.js"; -import BEtapiToken from "./entities/betapi_token.js"; -import cls from "../services/cls.js"; -import entityConstructor from "../becca/entity_constructor.js"; import type { AttributeRow, BranchRow, EtapiTokenRow, NoteRow, OptionRow } from "@triliumnext/commons"; -import type AbstractBeccaEntity from "./entities/abstract_becca_entity.js"; -import ws from "../services/ws.js"; +import { events as eventService } from "@triliumnext/core"; + +import entityConstructor from "../becca/entity_constructor.js"; +import cls from "../services/cls.js"; +import log from "../services/log.js"; +import sql from "../services/sql.js"; import { dbReady } from "../services/sql_init.js"; +import ws from "../services/ws.js"; +import becca from "./becca.js"; +import type AbstractBeccaEntity from "./entities/abstract_becca_entity.js"; +import BAttribute from "./entities/battribute.js"; +import BBranch from "./entities/bbranch.js"; +import BEtapiToken from "./entities/betapi_token.js"; +import BNote from "./entities/bnote.js"; +import BOption from "./entities/boption.js"; export const beccaLoaded = new Promise(async (res, rej) => { // We have to import async since options init requires keyboard actions which require translations. diff --git a/apps/server/src/becca/entities/abstract_becca_entity.ts b/apps/server/src/becca/entities/abstract_becca_entity.ts index 3b30eccd82..0792c6bd48 100644 --- a/apps/server/src/becca/entities/abstract_becca_entity.ts +++ b/apps/server/src/becca/entities/abstract_becca_entity.ts @@ -1,8 +1,9 @@ +import { events as eventService } from "@triliumnext/core"; + import blobService from "../../services/blob.js"; import cls from "../../services/cls.js"; import dateUtils from "../../services/date_utils.js"; import entityChangesService from "../../services/entity_changes.js"; -import eventService from "../../services/events.js"; import log from "../../services/log.js"; import protectedSessionService from "../../services/protected_session.js"; import sql from "../../services/sql.js"; diff --git a/apps/server/src/becca/entities/bnote.ts b/apps/server/src/becca/entities/bnote.ts index 7f271e88cb..2ed302ccc6 100644 --- a/apps/server/src/becca/entities/bnote.ts +++ b/apps/server/src/becca/entities/bnote.ts @@ -1,10 +1,10 @@ import type { AttachmentRow, AttributeType, CloneResponse, NoteRow, NoteType, RevisionRow } from "@triliumnext/commons"; import { dayjs } from "@triliumnext/commons"; +import { events as eventService } from "@triliumnext/core"; import cloningService from "../../services/cloning.js"; import dateUtils from "../../services/date_utils.js"; import eraseService from "../../services/erase.js"; -import eventService from "../../services/events.js"; import handlers from "../../services/handlers.js"; import log from "../../services/log.js"; import noteService from "../../services/notes.js"; diff --git a/apps/server/src/routes/api/branches.ts b/apps/server/src/routes/api/branches.ts index 73ce03a7a8..bf28fff988 100644 --- a/apps/server/src/routes/api/branches.ts +++ b/apps/server/src/routes/api/branches.ts @@ -1,18 +1,17 @@ -"use strict"; - -import sql from "../../services/sql.js"; -import utils from "../../services/utils.js"; -import entityChangesService from "../../services/entity_changes.js"; -import treeService from "../../services/tree.js"; -import eraseService from "../../services/erase.js"; -import becca from "../../becca/becca.js"; -import TaskContext from "../../services/task_context.js"; -import branchService from "../../services/branches.js"; -import log from "../../services/log.js"; -import ValidationError from "../../errors/validation_error.js"; -import eventService from "../../services/events.js"; +import { events as eventService } from "@triliumnext/core"; import type { Request } from "express"; +import becca from "../../becca/becca.js"; +import ValidationError from "../../errors/validation_error.js"; +import branchService from "../../services/branches.js"; +import entityChangesService from "../../services/entity_changes.js"; +import eraseService from "../../services/erase.js"; +import log from "../../services/log.js"; +import sql from "../../services/sql.js"; +import TaskContext from "../../services/task_context.js"; +import treeService from "../../services/tree.js"; +import utils from "../../services/utils.js"; + /** * Code in this file deals with moving and cloning branches. The relationship between note and parent note is unique * for not deleted branches. There may be multiple deleted note-parent note relationships. @@ -256,7 +255,7 @@ function deleteBranch(req: Request) { } return { - noteDeleted: noteDeleted + noteDeleted }; } @@ -272,7 +271,7 @@ function setPrefix(req: Request) { function setPrefixBatch(req: Request) { const { branchIds, prefix } = req.body; - + if (!Array.isArray(branchIds)) { throw new ValidationError("branchIds must be an array"); } diff --git a/apps/server/src/routes/api/login.ts b/apps/server/src/routes/api/login.ts index 22c0e6ab0f..b7dc4eace9 100644 --- a/apps/server/src/routes/api/login.ts +++ b/apps/server/src/routes/api/login.ts @@ -1,20 +1,19 @@ -"use strict"; - -import options from "../../services/options.js"; -import utils from "../../services/utils.js"; -import dateUtils from "../../services/date_utils.js"; -import instanceId from "../../services/instance_id.js"; -import passwordEncryptionService from "../../services/encryption/password_encryption.js"; -import protectedSessionService from "../../services/protected_session.js"; -import appInfo from "../../services/app_info.js"; -import eventService from "../../services/events.js"; -import sqlInit from "../../services/sql_init.js"; -import sql from "../../services/sql.js"; -import ws from "../../services/ws.js"; -import etapiTokenService from "../../services/etapi_tokens.js"; +import { events as eventService } from "@triliumnext/core"; import type { Request } from "express"; -import totp from "../../services/totp"; + +import appInfo from "../../services/app_info.js"; +import dateUtils from "../../services/date_utils.js"; +import passwordEncryptionService from "../../services/encryption/password_encryption.js"; import recoveryCodeService from "../../services/encryption/recovery_codes"; +import etapiTokenService from "../../services/etapi_tokens.js"; +import instanceId from "../../services/instance_id.js"; +import options from "../../services/options.js"; +import protectedSessionService from "../../services/protected_session.js"; +import sql from "../../services/sql.js"; +import sqlInit from "../../services/sql_init.js"; +import totp from "../../services/totp"; +import utils from "../../services/utils.js"; +import ws from "../../services/ws.js"; /** * @swagger @@ -115,7 +114,7 @@ function loginSync(req: Request) { req.session.loggedIn = true; return { - instanceId: instanceId, + instanceId, maxEntityChangeId: sql.getValue("SELECT COALESCE(MAX(id), 0) FROM entity_changes WHERE isSynced = 1") }; } diff --git a/apps/server/src/services/entity_changes.ts b/apps/server/src/services/entity_changes.ts index c0a97c7d6b..d6e86ef0e1 100644 --- a/apps/server/src/services/entity_changes.ts +++ b/apps/server/src/services/entity_changes.ts @@ -1,14 +1,15 @@ -import sql from "./sql.js"; -import dateUtils from "./date_utils.js"; -import log from "./log.js"; -import cls from "./cls.js"; -import { randomString } from "./utils.js"; -import instanceId from "./instance_id.js"; +import type { EntityChange } from "@triliumnext/commons"; +import { events as eventService } from "@triliumnext/core"; + import becca from "../becca/becca.js"; import blobService from "../services/blob.js"; -import type { EntityChange } from "@triliumnext/commons"; import type { Blob } from "./blob-interface.js"; -import eventService from "./events.js"; +import cls from "./cls.js"; +import dateUtils from "./date_utils.js"; +import instanceId from "./instance_id.js"; +import log from "./log.js"; +import sql from "./sql.js"; +import { randomString } from "./utils.js"; let maxEntityChangeId = 0; diff --git a/apps/server/src/services/handlers.ts b/apps/server/src/services/handlers.ts index f32bf6ddd0..39387c7992 100644 --- a/apps/server/src/services/handlers.ts +++ b/apps/server/src/services/handlers.ts @@ -1,14 +1,15 @@ -import eventService from "./events.js"; +import { events as eventService } from "@triliumnext/core"; + +import becca from "../becca/becca.js"; +import type AbstractBeccaEntity from "../becca/entities/abstract_becca_entity.js"; +import BAttribute from "../becca/entities/battribute.js"; +import type BNote from "../becca/entities/bnote.js"; +import hiddenSubtreeService from "./hidden_subtree.js"; +import noteService from "./notes.js"; +import oneTimeTimer from "./one_time_timer.js"; +import type { DefinitionObject } from "./promoted_attribute_definition_interface.js"; import scriptService from "./script.js"; import treeService from "./tree.js"; -import noteService from "./notes.js"; -import becca from "../becca/becca.js"; -import BAttribute from "../becca/entities/battribute.js"; -import hiddenSubtreeService from "./hidden_subtree.js"; -import oneTimeTimer from "./one_time_timer.js"; -import type BNote from "../becca/entities/bnote.js"; -import type AbstractBeccaEntity from "../becca/entities/abstract_becca_entity.js"; -import type { DefinitionObject } from "./promoted_attribute_definition_interface.js"; type Handler = (definition: DefinitionObject, note: BNote, targetNote: BNote) => void; diff --git a/apps/server/src/services/llm/ai_service_manager.ts b/apps/server/src/services/llm/ai_service_manager.ts index bd47b43274..5a16146476 100644 --- a/apps/server/src/services/llm/ai_service_manager.ts +++ b/apps/server/src/services/llm/ai_service_manager.ts @@ -1,32 +1,28 @@ -import options from '../options.js'; -import eventService from '../events.js'; -import type { AIService, ChatCompletionOptions, ChatResponse, Message } from './ai_interface.js'; -import { AnthropicService } from './providers/anthropic_service.js'; -import { ContextExtractor } from './context/index.js'; -import agentTools from './context_extractors/index.js'; -import contextService from './context/services/context_service.js'; import log from '../log.js'; -import { OllamaService } from './providers/ollama_service.js'; -import { OpenAIService } from './providers/openai_service.js'; - -// Import interfaces -import type { - ServiceProviders, - IAIServiceManager, - ProviderMetadata -} from './interfaces/ai_service_interfaces.js'; -import type { NoteSearchResult } from './interfaces/context_interfaces.js'; - +import options from '../options.js'; +import type { AIService, ChatCompletionOptions, ChatResponse, Message } from './ai_interface.js'; // Import new configuration system import { - getSelectedProvider, - parseModelIdentifier, - isAIEnabled, - getDefaultModelForProvider, clearConfigurationCache, + getDefaultModelForProvider, + getSelectedProvider, + isAIEnabled, + parseModelIdentifier, validateConfiguration } from './config/configuration_helpers.js'; +import { ContextExtractor } from './context/index.js'; +import contextService from './context/services/context_service.js'; +import agentTools from './context_extractors/index.js'; +// Import interfaces +import type { + IAIServiceManager, + ProviderMetadata, + ServiceProviders} from './interfaces/ai_service_interfaces.js'; import type { ProviderType } from './interfaces/configuration_interfaces.js'; +import type { NoteSearchResult } from './interfaces/context_interfaces.js'; +import { AnthropicService } from './providers/anthropic_service.js'; +import { OllamaService } from './providers/ollama_service.js'; +import { OpenAIService } from './providers/openai_service.js'; /** * Interface representing relevant note context @@ -173,7 +169,7 @@ export class AIServiceManager implements IAIServiceManager { /** * Get list of available providers */ - getAvailableProviders(): ServiceProviders[] { + getAvailableProviders(): ServiceProviders[] { this.ensureInitialized(); const allProviders: ServiceProviders[] = ['openai', 'anthropic', 'ollama']; diff --git a/apps/server/src/services/notes.ts b/apps/server/src/services/notes.ts index a4e1ed3cf9..7b368e3b9c 100644 --- a/apps/server/src/services/notes.ts +++ b/apps/server/src/services/notes.ts @@ -1,6 +1,7 @@ import type { AttachmentRow, AttributeRow, BranchRow, NoteRow } from "@triliumnext/commons"; import { dayjs } from "@triliumnext/commons"; import { date_utils } from "@triliumnext/core"; +import { events as eventService } from "@triliumnext/core"; import fs from "fs"; import html2plaintext from "html2plaintext"; import { t } from "i18next"; @@ -18,7 +19,6 @@ import log from "../services/log.js"; import protectedSessionService from "../services/protected_session.js"; import { newEntityId, quoteRegex, toMap,unescapeHtml } from "../services/utils.js"; import entityChangesService from "./entity_changes.js"; -import eventService from "./events.js"; import htmlSanitizer from "./html_sanitizer.js"; import imageService from "./image.js"; import noteTypesService from "./note_types.js"; diff --git a/apps/server/src/services/sql_init.ts b/apps/server/src/services/sql_init.ts index 93452669fc..1b69117602 100644 --- a/apps/server/src/services/sql_init.ts +++ b/apps/server/src/services/sql_init.ts @@ -1,24 +1,25 @@ -import log from "./log.js"; +import { deferred, type OptionRow } from "@triliumnext/commons"; +import { events as eventService } from "@triliumnext/core"; import fs from "fs"; -import resourceDir from "./resource_dir.js"; -import sql from "./sql.js"; -import { isElectron } from "./utils.js"; -import optionService from "./options.js"; -import port from "./port.js"; +import { t } from "i18next"; + +import BBranch from "../becca/entities/bbranch.js"; +import BNote from "../becca/entities/bnote.js"; import BOption from "../becca/entities/boption.js"; -import TaskContext from "./task_context.js"; -import migrationService from "./migration.js"; +import backup from "./backup.js"; import cls from "./cls.js"; import config from "./config.js"; -import { deferred, type OptionRow } from "@triliumnext/commons"; -import BNote from "../becca/entities/bnote.js"; -import BBranch from "../becca/entities/bbranch.js"; -import zipImportService from "./import/zip.js"; import password from "./encryption/password.js"; -import backup from "./backup.js"; -import eventService from "./events.js"; -import { t } from "i18next"; import hidden_subtree from "./hidden_subtree.js"; +import zipImportService from "./import/zip.js"; +import log from "./log.js"; +import migrationService from "./migration.js"; +import optionService from "./options.js"; +import port from "./port.js"; +import resourceDir from "./resource_dir.js"; +import sql from "./sql.js"; +import TaskContext from "./task_context.js"; +import { isElectron } from "./utils.js"; export const dbReady = deferred(); @@ -65,7 +66,7 @@ async function initDbConnection() { isSetup TEXT DEFAULT "false", UNIQUE (tmpID), PRIMARY KEY (tmpID) - );`) + );`); dbReady.resolve(); } diff --git a/apps/server/src/services/sync_update.ts b/apps/server/src/services/sync_update.ts index 9d4ff5c4c0..9e88e5796a 100644 --- a/apps/server/src/services/sync_update.ts +++ b/apps/server/src/services/sync_update.ts @@ -1,10 +1,11 @@ -import sql from "./sql.js"; -import log from "./log.js"; -import entityChangesService from "./entity_changes.js"; -import eventService from "./events.js"; -import entityConstructor from "../becca/entity_constructor.js"; -import ws from "./ws.js"; import type { EntityChange, EntityChangeRecord, EntityRow } from "@triliumnext/commons"; +import { events as eventService } from "@triliumnext/core"; + +import entityConstructor from "../becca/entity_constructor.js"; +import entityChangesService from "./entity_changes.js"; +import log from "./log.js"; +import sql from "./sql.js"; +import ws from "./ws.js"; interface UpdateContext { alreadyErased: number; diff --git a/apps/server/src/share/shaca/shaca_loader.ts b/apps/server/src/share/shaca/shaca_loader.ts index c0834cb8b1..4e374a7842 100644 --- a/apps/server/src/share/shaca/shaca_loader.ts +++ b/apps/server/src/share/shaca/shaca_loader.ts @@ -1,15 +1,14 @@ -"use strict"; +import { events as eventService } from "@triliumnext/core"; -import sql from "../sql.js"; -import shaca from "./shaca.js"; import log from "../../services/log.js"; -import SNote from "./entities/snote.js"; -import SBranch from "./entities/sbranch.js"; -import SAttribute from "./entities/sattribute.js"; -import SAttachment from "./entities/sattachment.js"; import shareRoot from "../share_root.js"; -import eventService from "../../services/events.js"; +import sql from "../sql.js"; import type { SAttachmentRow, SAttributeRow, SBranchRow, SNoteRow } from "./entities/rows.js"; +import SAttachment from "./entities/sattachment.js"; +import SAttribute from "./entities/sattribute.js"; +import SBranch from "./entities/sbranch.js"; +import SNote from "./entities/snote.js"; +import shaca from "./shaca.js"; function load() { const start = Date.now(); diff --git a/packages/trilium-core/src/index.ts b/packages/trilium-core/src/index.ts index 042bde8b21..f38fc96929 100644 --- a/packages/trilium-core/src/index.ts +++ b/packages/trilium-core/src/index.ts @@ -10,6 +10,7 @@ export * as protected_session from "./services/encryption/protected_session"; export { default as data_encryption } from "./services/encryption/data_encryption" export * as binary_utils from "./services/utils/binary"; export { default as date_utils } from "./services/utils/date"; +export { default as events } from "./services/events"; export { getContext, type ExecutionContext } from "./services/context"; export type { CryptoProvider } from "./services/encryption/crypto"; diff --git a/apps/server/src/services/events.ts b/packages/trilium-core/src/services/events.ts similarity index 100% rename from apps/server/src/services/events.ts rename to packages/trilium-core/src/services/events.ts From 320d8e3b45971ebf5101cfff48758f81c5d81745 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Tue, 6 Jan 2026 11:23:52 +0200 Subject: [PATCH 13/72] chore(core): partially integrate becca --- .../src/becca/becca-interface.ts | 14 ++++++------ .../trilium-core}/src/becca/becca.ts | 0 .../trilium-core}/src/becca/becca_loader.ts | 15 +++++++------ .../trilium-core}/src/becca/becca_service.ts | 10 ++++----- .../becca/entities/abstract_becca_entity.ts | 22 ++++++++++++------- .../src/becca/entities/battachment.ts | 10 ++++----- .../src/becca/entities/battribute.ts | 2 +- .../trilium-core}/src/becca/entities/bblob.ts | 0 .../src/becca/entities/bbranch.ts | 18 +++++++-------- .../src/becca/entities/betapi_token.ts | 2 +- .../trilium-core}/src/becca/entities/bnote.ts | 22 +++++++++---------- .../src/becca/entities/boption.ts | 2 +- .../src/becca/entities/brecent_note.ts | 2 +- .../src/becca/entities/brevision.spec.ts | 0 .../src/becca/entities/brevision.ts | 10 ++++----- .../src/becca/entity_constructor.ts | 0 .../src/becca/similarity.spec.ts | 0 .../trilium-core}/src/becca/similarity.ts | 6 ++--- packages/trilium-core/src/services/events.ts | 4 ++-- 19 files changed, 73 insertions(+), 66 deletions(-) rename {apps/server => packages/trilium-core}/src/becca/becca-interface.ts (92%) rename {apps/server => packages/trilium-core}/src/becca/becca.ts (100%) rename {apps/server => packages/trilium-core}/src/becca/becca_loader.ts (95%) rename {apps/server => packages/trilium-core}/src/becca/becca_service.ts (91%) rename {apps/server => packages/trilium-core}/src/becca/entities/abstract_becca_entity.ts (95%) rename {apps/server => packages/trilium-core}/src/becca/entities/battachment.ts (96%) rename {apps/server => packages/trilium-core}/src/becca/entities/battribute.ts (99%) rename {apps/server => packages/trilium-core}/src/becca/entities/bblob.ts (100%) rename {apps/server => packages/trilium-core}/src/becca/entities/bbranch.ts (96%) rename {apps/server => packages/trilium-core}/src/becca/entities/betapi_token.ts (97%) rename {apps/server => packages/trilium-core}/src/becca/entities/bnote.ts (98%) rename {apps/server => packages/trilium-core}/src/becca/entities/boption.ts (95%) rename {apps/server => packages/trilium-core}/src/becca/entities/brecent_note.ts (94%) rename {apps/server => packages/trilium-core}/src/becca/entities/brevision.spec.ts (100%) rename {apps/server => packages/trilium-core}/src/becca/entities/brevision.ts (96%) rename {apps/server => packages/trilium-core}/src/becca/entity_constructor.ts (100%) rename {apps/server => packages/trilium-core}/src/becca/similarity.spec.ts (100%) rename {apps/server => packages/trilium-core}/src/becca/similarity.ts (98%) diff --git a/apps/server/src/becca/becca-interface.ts b/packages/trilium-core/src/becca/becca-interface.ts similarity index 92% rename from apps/server/src/becca/becca-interface.ts rename to packages/trilium-core/src/becca/becca-interface.ts index 1a8203f436..a70a07e1e7 100644 --- a/apps/server/src/becca/becca-interface.ts +++ b/packages/trilium-core/src/becca/becca-interface.ts @@ -1,4 +1,3 @@ -import sql from "../services/sql.js"; import NoteSet from "../services/search/note_set.js"; import NotFoundError from "../errors/not_found_error.js"; import type BOption from "./entities/boption.js"; @@ -12,6 +11,7 @@ import type { AttachmentRow, BlobRow, RevisionRow } from "@triliumnext/commons"; import BBlob from "./entities/bblob.js"; import BRecentNote from "./entities/brecent_note.js"; import type AbstractBeccaEntity from "./entities/abstract_becca_entity.js"; +import { getSql } from "src/services/sql/index.js"; /** * Becca is a backend cache of all notes, branches, and attributes. @@ -151,7 +151,7 @@ export default class Becca { } getRevision(revisionId: string): BRevision | null { - const row = sql.getRow("SELECT * FROM revisions WHERE revisionId = ?", [revisionId]); + const row = getSql().getRow("SELECT * FROM revisions WHERE revisionId = ?", [revisionId]); return row ? new BRevision(row) : null; } @@ -170,7 +170,7 @@ export default class Becca { JOIN blobs USING (blobId) WHERE attachmentId = ? AND isDeleted = 0`; - return sql.getRows(query, [attachmentId]).map((row) => new BAttachment(row))[0]; + return getSql().getRows(query, [attachmentId]).map((row) => new BAttachment(row))[0]; } getAttachmentOrThrow(attachmentId: string): BAttachment { @@ -182,7 +182,7 @@ export default class Becca { } getAttachments(attachmentIds: string[]): BAttachment[] { - return sql.getManyRows("SELECT * FROM attachments WHERE attachmentId IN (???) AND isDeleted = 0", attachmentIds).map((row) => new BAttachment(row)); + return getSql().getManyRows("SELECT * FROM attachments WHERE attachmentId IN (???) AND isDeleted = 0", attachmentIds).map((row) => new BAttachment(row)); } getBlob(entity: { blobId?: string }): BBlob | null { @@ -190,7 +190,7 @@ export default class Becca { return null; } - const row = sql.getRow("SELECT *, LENGTH(content) AS contentLength FROM blobs WHERE blobId = ?", [entity.blobId]); + const row = getSql().getRow("SELECT *, LENGTH(content) AS contentLength FROM blobs WHERE blobId = ?", [entity.blobId]); return row ? new BBlob(row) : null; } @@ -227,12 +227,12 @@ export default class Becca { } getRecentNotesFromQuery(query: string, params: string[] = []): BRecentNote[] { - const rows = sql.getRows(query, params); + const rows = getSql().getRows(query, params); return rows.map((row) => new BRecentNote(row)); } getRevisionsFromQuery(query: string, params: string[] = []): BRevision[] { - const rows = sql.getRows(query, params); + const rows = getSql().getRows(query, params); return rows.map((row) => new BRevision(row)); } diff --git a/apps/server/src/becca/becca.ts b/packages/trilium-core/src/becca/becca.ts similarity index 100% rename from apps/server/src/becca/becca.ts rename to packages/trilium-core/src/becca/becca.ts diff --git a/apps/server/src/becca/becca_loader.ts b/packages/trilium-core/src/becca/becca_loader.ts similarity index 95% rename from apps/server/src/becca/becca_loader.ts rename to packages/trilium-core/src/becca/becca_loader.ts index 3d5e7c3207..e2cd1804c1 100644 --- a/apps/server/src/becca/becca_loader.ts +++ b/packages/trilium-core/src/becca/becca_loader.ts @@ -1,10 +1,8 @@ import type { AttributeRow, BranchRow, EtapiTokenRow, NoteRow, OptionRow } from "@triliumnext/commons"; -import { events as eventService } from "@triliumnext/core"; +import eventService from "../services/events"; import entityConstructor from "../becca/entity_constructor.js"; -import cls from "../services/cls.js"; -import log from "../services/log.js"; -import sql from "../services/sql.js"; +import { getLog } from "../services/log.js"; import { dbReady } from "../services/sql_init.js"; import ws from "../services/ws.js"; import becca from "./becca.js"; @@ -14,13 +12,15 @@ import BBranch from "./entities/bbranch.js"; import BEtapiToken from "./entities/betapi_token.js"; import BNote from "./entities/bnote.js"; import BOption from "./entities/boption.js"; +import { getSql } from "src/services/sql/index.js"; +import { getContext } from "src/services/context.js"; export const beccaLoaded = new Promise(async (res, rej) => { // We have to import async since options init requires keyboard actions which require translations. const options_init = (await import("../services/options_init.js")).default; dbReady.then(() => { - cls.init(() => { + getContext().init(() => { load(); options_init.initStartupOptions(); @@ -35,6 +35,7 @@ function load() { becca.reset(); // we know this is slow and the total becca load time is logged + const sql = getSql(); sql.disableSlowQueryLogging(() => { // using a raw query and passing arrays to avoid allocating new objects, // this is worth it for the becca load since it happens every run and blocks the app until finished @@ -71,7 +72,7 @@ function load() { becca.loaded = true; - log.info(`Becca (note cache) load took ${Date.now() - start}ms`); + getLog().info(`Becca (note cache) load took ${Date.now() - start}ms`); } function reload(reason: string) { @@ -283,7 +284,7 @@ eventService.subscribeBeccaLoader(eventService.ENTER_PROTECTED_SESSION, () => { try { becca.decryptProtectedNotes(); } catch (e: any) { - log.error(`Could not decrypt protected notes: ${e.message} ${e.stack}`); + getLog().error(`Could not decrypt protected notes: ${e.message} ${e.stack}`); } }); diff --git a/apps/server/src/becca/becca_service.ts b/packages/trilium-core/src/becca/becca_service.ts similarity index 91% rename from apps/server/src/becca/becca_service.ts rename to packages/trilium-core/src/becca/becca_service.ts index 92967da342..a5a4001a91 100644 --- a/apps/server/src/becca/becca_service.ts +++ b/packages/trilium-core/src/becca/becca_service.ts @@ -1,8 +1,8 @@ "use strict"; import becca from "./becca.js"; -import cls from "../services/cls.js"; -import log from "../services/log.js"; +import { getLog } from "../services/log.js"; +import { getContext } from "src/services/context.js"; function isNotePathArchived(notePath: string[]) { const noteId = notePath[notePath.length - 1]; @@ -29,7 +29,7 @@ function getNoteTitle(childNoteId: string, parentNoteId?: string) { const parentNote = parentNoteId ? becca.notes[parentNoteId] : null; if (!childNote) { - log.info(`Cannot find note '${childNoteId}'`); + getLog().info(`Cannot find note '${childNoteId}'`); return "[error fetching title]"; } @@ -50,7 +50,7 @@ function getNoteTitleAndIcon(childNoteId: string, parentNoteId?: string) { const parentNote = parentNoteId ? becca.notes[parentNoteId] : null; if (!childNote) { - log.info(`Cannot find note '${childNoteId}'`); + getLog().info(`Cannot find note '${childNoteId}'`); return { title: "[error fetching title]" } @@ -82,7 +82,7 @@ function getNoteTitleArrayForPath(notePathArray: string[]) { let hoistedNotePassed = false; // this is a notePath from outside of hoisted subtree, so the full title path needs to be returned - const hoistedNoteId = cls.getHoistedNoteId(); + const hoistedNoteId = getContext().getHoistedNoteId(); const outsideOfHoistedSubtree = !notePathArray.includes(hoistedNoteId); for (const noteId of notePathArray) { diff --git a/apps/server/src/becca/entities/abstract_becca_entity.ts b/packages/trilium-core/src/becca/entities/abstract_becca_entity.ts similarity index 95% rename from apps/server/src/becca/entities/abstract_becca_entity.ts rename to packages/trilium-core/src/becca/entities/abstract_becca_entity.ts index 0792c6bd48..b224349ca2 100644 --- a/apps/server/src/becca/entities/abstract_becca_entity.ts +++ b/packages/trilium-core/src/becca/entities/abstract_becca_entity.ts @@ -1,15 +1,15 @@ -import { events as eventService } from "@triliumnext/core"; +import eventService from "../../services/events"; import blobService from "../../services/blob.js"; -import cls from "../../services/cls.js"; -import dateUtils from "../../services/date_utils.js"; +import * as cls from "../../services/context"; +import dateUtils from "../../services/utils/date"; import entityChangesService from "../../services/entity_changes.js"; -import log from "../../services/log.js"; +import { getLog } from "../../services/log.js"; import protectedSessionService from "../../services/protected_session.js"; -import sql from "../../services/sql.js"; import utils from "../../services/utils.js"; import becca from "../becca.js"; import type { ConstructorData,default as Becca } from "../becca-interface.js"; +import { getSql } from "src/services/sql"; interface ContentOpts { forceSave?: boolean; @@ -110,6 +110,7 @@ abstract class AbstractBeccaEntity> { const pojo = this.getPojoToSave(); + const sql = getSql(); sql.transactional(() => { sql.upsert(entityName, primaryKeyName, pojo); @@ -166,7 +167,7 @@ abstract class AbstractBeccaEntity> { } } - sql.transactional(() => { + getSql().transactional(() => { const newBlobId = this.saveBlob(content, unencryptedContentForHashCalculation, opts); const oldBlobId = this.blobId; @@ -182,6 +183,7 @@ abstract class AbstractBeccaEntity> { } private deleteBlobIfNotUsed(oldBlobId: string) { + const sql = getSql(); if (sql.getValue("SELECT 1 FROM notes WHERE blobId = ? LIMIT 1", [oldBlobId])) { return; } @@ -218,6 +220,7 @@ abstract class AbstractBeccaEntity> { * notes/attachments), but the trade-off comes out clearly positive. */ const newBlobId = utils.hashedBlobId(unencryptedContentForHashCalculation); + const sql = getSql(); const blobNeedsInsert = !sql.getValue("SELECT 1 FROM blobs WHERE blobId = ?", [newBlobId]); if (!blobNeedsInsert) { @@ -258,6 +261,7 @@ abstract class AbstractBeccaEntity> { } protected _getContent(): string | Buffer { + const sql = getSql(); const row = sql.getRow<{ content: string | Buffer }>(/*sql*/`SELECT content FROM blobs WHERE blobId = ?`, [this.blobId]); if (!row) { @@ -280,6 +284,7 @@ abstract class AbstractBeccaEntity> { this.utcDateModified = dateUtils.utcNowDateTime(); + const sql = getSql(); sql.execute( /*sql*/`UPDATE ${entityName} SET isDeleted = 1, deleteId = ?, utcDateModified = ? WHERE ${constructorData.primaryKeyName} = ?`, @@ -292,7 +297,7 @@ abstract class AbstractBeccaEntity> { sql.execute(/*sql*/`UPDATE ${entityName} SET dateModified = ? WHERE ${constructorData.primaryKeyName} = ?`, [this.dateModified, entityId]); } - log.info(`Marking ${entityName} ${entityId} as deleted`); + getLog().info(`Marking ${entityName} ${entityId} as deleted`); this.putEntityChange(true); @@ -306,13 +311,14 @@ abstract class AbstractBeccaEntity> { this.utcDateModified = dateUtils.utcNowDateTime(); + const sql = getSql(); sql.execute( /*sql*/`UPDATE ${entityName} SET isDeleted = 1, utcDateModified = ? WHERE ${constructorData.primaryKeyName} = ?`, [this.utcDateModified, entityId] ); - log.info(`Marking ${entityName} ${entityId} as deleted`); + getLog().info(`Marking ${entityName} ${entityId} as deleted`); this.putEntityChange(true); diff --git a/apps/server/src/becca/entities/battachment.ts b/packages/trilium-core/src/becca/entities/battachment.ts similarity index 96% rename from apps/server/src/becca/entities/battachment.ts rename to packages/trilium-core/src/becca/entities/battachment.ts index 924dd972ab..cfd0a638b9 100644 --- a/apps/server/src/becca/entities/battachment.ts +++ b/packages/trilium-core/src/becca/entities/battachment.ts @@ -2,15 +2,15 @@ import type { AttachmentRow } from "@triliumnext/commons"; -import dateUtils from "../../services/date_utils.js"; -import log from "../../services/log.js"; +import dateUtils from "../../services/utils/date"; +import { getLog } from "../../services/log.js"; import noteService from "../../services/notes.js"; import protectedSessionService from "../../services/protected_session.js"; -import sql from "../../services/sql.js"; import utils from "../../services/utils.js"; import AbstractBeccaEntity from "./abstract_becca_entity.js"; import type BBranch from "./bbranch.js"; import type BNote from "./bnote.js"; +import { getSql } from "src/services/sql/index.js"; const attachmentRoleToNoteTypeMapping = { image: "image", @@ -131,7 +131,7 @@ class BAttachment extends AbstractBeccaEntity { this.title = protectedSessionService.decryptString(this.title) || ""; this.isDecrypted = true; } catch (e: any) { - log.error(`Could not decrypt attachment ${this.attachmentId}: ${e.message} ${e.stack}`); + getLog().error(`Could not decrypt attachment ${this.attachmentId}: ${e.message} ${e.stack}`); } } } @@ -210,7 +210,7 @@ class BAttachment extends AbstractBeccaEntity { if (this.position === undefined || this.position === null) { this.position = 10 + - sql.getValue( + getSql().getValue( /*sql*/`SELECT COALESCE(MAX(position), 0) FROM attachments WHERE ownerId = ?`, diff --git a/apps/server/src/becca/entities/battribute.ts b/packages/trilium-core/src/becca/entities/battribute.ts similarity index 99% rename from apps/server/src/becca/entities/battribute.ts rename to packages/trilium-core/src/becca/entities/battribute.ts index 6ff1246fcf..5e4028fe65 100644 --- a/apps/server/src/becca/entities/battribute.ts +++ b/packages/trilium-core/src/becca/entities/battribute.ts @@ -2,7 +2,7 @@ import BNote from "./bnote.js"; import AbstractBeccaEntity from "./abstract_becca_entity.js"; -import dateUtils from "../../services/date_utils.js"; +import dateUtils from "../../services/utils/date"; import promotedAttributeDefinitionParser from "../../services/promoted_attribute_definition_parser.js"; import sanitizeAttributeName from "../../services/sanitize_attribute_name.js"; import type { AttributeRow, AttributeType } from "@triliumnext/commons"; diff --git a/apps/server/src/becca/entities/bblob.ts b/packages/trilium-core/src/becca/entities/bblob.ts similarity index 100% rename from apps/server/src/becca/entities/bblob.ts rename to packages/trilium-core/src/becca/entities/bblob.ts diff --git a/apps/server/src/becca/entities/bbranch.ts b/packages/trilium-core/src/becca/entities/bbranch.ts similarity index 96% rename from apps/server/src/becca/entities/bbranch.ts rename to packages/trilium-core/src/becca/entities/bbranch.ts index d1eb91a9ef..4c7be548b9 100644 --- a/apps/server/src/becca/entities/bbranch.ts +++ b/packages/trilium-core/src/becca/entities/bbranch.ts @@ -2,14 +2,14 @@ import type { BranchRow } from "@triliumnext/commons"; -import cls from "../../services/cls.js"; -import dateUtils from "../../services/date_utils.js"; +import dateUtils from "../../services/utils/date"; import handlers from "../../services/handlers.js"; -import log from "../../services/log.js"; +import { getLog } from "../../services/log.js"; import TaskContext from "../../services/task_context.js"; import utils from "../../services/utils.js"; import AbstractBeccaEntity from "./abstract_becca_entity.js"; import BNote from "./bnote.js"; +import { getContext } from "src/services/context"; /** * Branch represents a relationship between a child note and its parent note. Trilium allows a note to have multiple @@ -160,7 +160,7 @@ class BBranch extends AbstractBeccaEntity { } } - if ((this.noteId === "root" || this.noteId === cls.getHoistedNoteId()) && !this.isWeak) { + if ((this.noteId === "root" || this.noteId === getContext().getHoistedNoteId()) && !this.isWeak) { throw new Error("Can't delete root or hoisted branch/note"); } @@ -181,7 +181,7 @@ class BBranch extends AbstractBeccaEntity { // first delete children and then parent - this will show up better in recent changes - log.info(`Deleting note '${note.noteId}'`); + getLog().info(`Deleting note '${note.noteId}'`); this.becca.notes[note.noteId].isBeingDeleted = true; @@ -200,9 +200,9 @@ class BBranch extends AbstractBeccaEntity { note.markAsDeleted(deleteId); return true; - } + } return false; - + } override beforeSaving() { @@ -269,7 +269,7 @@ class BBranch extends AbstractBeccaEntity { existingBranch.notePosition = notePosition; } return existingBranch; - } + } return new BBranch({ noteId: this.noteId, parentNoteId, @@ -277,7 +277,7 @@ class BBranch extends AbstractBeccaEntity { prefix: this.prefix, isExpanded: this.isExpanded }); - + } getParentNote() { diff --git a/apps/server/src/becca/entities/betapi_token.ts b/packages/trilium-core/src/becca/entities/betapi_token.ts similarity index 97% rename from apps/server/src/becca/entities/betapi_token.ts rename to packages/trilium-core/src/becca/entities/betapi_token.ts index c355d2c8f0..d76ede6bdc 100644 --- a/apps/server/src/becca/entities/betapi_token.ts +++ b/packages/trilium-core/src/becca/entities/betapi_token.ts @@ -2,7 +2,7 @@ import type { EtapiTokenRow } from "@triliumnext/commons"; -import dateUtils from "../../services/date_utils.js"; +import dateUtils from "../../services/utils/date"; import AbstractBeccaEntity from "./abstract_becca_entity.js"; /** diff --git a/apps/server/src/becca/entities/bnote.ts b/packages/trilium-core/src/becca/entities/bnote.ts similarity index 98% rename from apps/server/src/becca/entities/bnote.ts rename to packages/trilium-core/src/becca/entities/bnote.ts index 2ed302ccc6..ea3c60803c 100644 --- a/apps/server/src/becca/entities/bnote.ts +++ b/packages/trilium-core/src/becca/entities/bnote.ts @@ -3,15 +3,14 @@ import { dayjs } from "@triliumnext/commons"; import { events as eventService } from "@triliumnext/core"; import cloningService from "../../services/cloning.js"; -import dateUtils from "../../services/date_utils.js"; +import dateUtils from "../../services/utils/date"; import eraseService from "../../services/erase.js"; import handlers from "../../services/handlers.js"; -import log from "../../services/log.js"; +import log, { getLog } from "../../services/log.js"; import noteService from "../../services/notes.js"; import optionService from "../../services/options.js"; import protectedSessionService from "../../services/protected_session.js"; import searchService from "../../services/search/services/search.js"; -import sql from "../../services/sql.js"; import TaskContext from "../../services/task_context.js"; import utils from "../../services/utils.js"; import type { NotePojo } from "../becca-interface.js"; @@ -20,6 +19,7 @@ import BAttachment from "./battachment.js"; import BAttribute from "./battribute.js"; import type BBranch from "./bbranch.js"; import BRevision from "./brevision.js"; +import { getSql } from "src/services/sql/index.js"; const LABEL = "label"; const RELATION = "relation"; @@ -891,7 +891,7 @@ class BNote extends AbstractBeccaEntity { const becca = this.becca; return result.searchResultNoteIds.map((resultNoteId) => becca.notes[resultNoteId]).filter((note) => !!note); } catch (e: any) { - log.error(`Could not resolve search note ${this.noteId}: ${e.message}`); + getLog().error(`Could not resolve search note ${this.noteId}: ${e.message}`); return []; } } @@ -909,7 +909,7 @@ class BNote extends AbstractBeccaEntity { addSubtreeNotesInner(resultNote, searchNote); } } catch (e: any) { - log.error(`Could not resolve search note ${searchNote?.noteId}: ${e.message}`); + getLog().error(`Could not resolve search note ${searchNote?.noteId}: ${e.message}`); } } @@ -1093,7 +1093,7 @@ class BNote extends AbstractBeccaEntity { } getRevisions(): BRevision[] { - return sql.getRows("SELECT * FROM revisions WHERE noteId = ? ORDER BY revisions.utcDateCreated ASC", [this.noteId]).map((row) => new BRevision(row)); + return getSql().getRows("SELECT * FROM revisions WHERE noteId = ? ORDER BY revisions.utcDateCreated ASC", [this.noteId]).map((row) => new BRevision(row)); } getAttachments() { @@ -1104,7 +1104,7 @@ class BNote extends AbstractBeccaEntity { WHERE ownerId = ? AND isDeleted = 0 ORDER BY position`; - return sql.getRows(query, [this.noteId]).map((row) => new BAttachment(row)); + return getSql().getRows(query, [this.noteId]).map((row) => new BAttachment(row)); } getAttachmentById(attachmentId: string) { @@ -1114,11 +1114,11 @@ class BNote extends AbstractBeccaEntity { JOIN blobs USING (blobId) WHERE ownerId = ? AND attachmentId = ? AND isDeleted = 0`; - return sql.getRows(query, [this.noteId, attachmentId]).map((row) => new BAttachment(row))[0]; + return getSql().getRows(query, [this.noteId, attachmentId]).map((row) => new BAttachment(row))[0]; } getAttachmentsByRole(role: string): BAttachment[] { - return sql + return getSql() .getRows( ` SELECT attachments.* @@ -1527,7 +1527,7 @@ class BNote extends AbstractBeccaEntity { this.isDecrypted = true; } catch (e: any) { - log.error(`Could not decrypt note ${this.noteId}: ${e.message} ${e.stack}`); + getLog().error(`Could not decrypt note ${this.noteId}: ${e.message} ${e.stack}`); } } } @@ -1549,7 +1549,7 @@ class BNote extends AbstractBeccaEntity { } saveRevision(): BRevision { - return sql.transactional(() => { + return getSql().transactional(() => { let noteContent = this.getContent(); const revision = new BRevision( diff --git a/apps/server/src/becca/entities/boption.ts b/packages/trilium-core/src/becca/entities/boption.ts similarity index 95% rename from apps/server/src/becca/entities/boption.ts rename to packages/trilium-core/src/becca/entities/boption.ts index 7c841931b9..f0cd473e9b 100644 --- a/apps/server/src/becca/entities/boption.ts +++ b/packages/trilium-core/src/becca/entities/boption.ts @@ -1,6 +1,6 @@ "use strict"; -import dateUtils from "../../services/date_utils.js"; +import dateUtils from "../../services/utils/date"; import AbstractBeccaEntity from "./abstract_becca_entity.js"; import type { OptionRow } from "@triliumnext/commons"; diff --git a/apps/server/src/becca/entities/brecent_note.ts b/packages/trilium-core/src/becca/entities/brecent_note.ts similarity index 94% rename from apps/server/src/becca/entities/brecent_note.ts rename to packages/trilium-core/src/becca/entities/brecent_note.ts index bfaa465447..d950df369e 100644 --- a/apps/server/src/becca/entities/brecent_note.ts +++ b/packages/trilium-core/src/becca/entities/brecent_note.ts @@ -2,7 +2,7 @@ import type { RecentNoteRow } from "@triliumnext/commons"; -import dateUtils from "../../services/date_utils.js"; +import dateUtils from "../../services/utils/date"; import AbstractBeccaEntity from "./abstract_becca_entity.js"; /** diff --git a/apps/server/src/becca/entities/brevision.spec.ts b/packages/trilium-core/src/becca/entities/brevision.spec.ts similarity index 100% rename from apps/server/src/becca/entities/brevision.spec.ts rename to packages/trilium-core/src/becca/entities/brevision.spec.ts diff --git a/apps/server/src/becca/entities/brevision.ts b/packages/trilium-core/src/becca/entities/brevision.ts similarity index 96% rename from apps/server/src/becca/entities/brevision.ts rename to packages/trilium-core/src/becca/entities/brevision.ts index 88f647db29..2df8a4c9e6 100644 --- a/apps/server/src/becca/entities/brevision.ts +++ b/packages/trilium-core/src/becca/entities/brevision.ts @@ -2,13 +2,13 @@ import protectedSessionService from "../../services/protected_session.js"; import utils from "../../services/utils.js"; -import dateUtils from "../../services/date_utils.js"; +import dateUtils from "../../services/utils/date"; import becca from "../becca.js"; import AbstractBeccaEntity from "./abstract_becca_entity.js"; -import sql from "../../services/sql.js"; import BAttachment from "./battachment.js"; import type { AttachmentRow, NoteType, RevisionPojo, RevisionRow } from "@triliumnext/commons"; import eraseService from "../../services/erase.js"; +import { getSql } from "src/services/sql/index.js"; interface ContentOpts { /** will also save this BRevision entity */ @@ -125,7 +125,7 @@ class BRevision extends AbstractBeccaEntity { } getAttachments(): BAttachment[] { - return sql + return getSql() .getRows( ` SELECT attachments.* @@ -147,11 +147,11 @@ class BRevision extends AbstractBeccaEntity { WHERE ownerId = ? AND attachmentId = ? AND isDeleted = 0` : /*sql*/`SELECT * FROM attachments WHERE ownerId = ? AND attachmentId = ? AND isDeleted = 0`; - return sql.getRows(query, [this.revisionId, attachmentId]).map((row) => new BAttachment(row))[0]; + return getSql().getRows(query, [this.revisionId, attachmentId]).map((row) => new BAttachment(row))[0]; } getAttachmentsByRole(role: string): BAttachment[] { - return sql + return getSql() .getRows( ` SELECT attachments.* diff --git a/apps/server/src/becca/entity_constructor.ts b/packages/trilium-core/src/becca/entity_constructor.ts similarity index 100% rename from apps/server/src/becca/entity_constructor.ts rename to packages/trilium-core/src/becca/entity_constructor.ts diff --git a/apps/server/src/becca/similarity.spec.ts b/packages/trilium-core/src/becca/similarity.spec.ts similarity index 100% rename from apps/server/src/becca/similarity.spec.ts rename to packages/trilium-core/src/becca/similarity.spec.ts diff --git a/apps/server/src/becca/similarity.ts b/packages/trilium-core/src/becca/similarity.ts similarity index 98% rename from apps/server/src/becca/similarity.ts rename to packages/trilium-core/src/becca/similarity.ts index 10a0e706dc..2696c9c51b 100644 --- a/apps/server/src/becca/similarity.ts +++ b/packages/trilium-core/src/becca/similarity.ts @@ -1,7 +1,7 @@ import becca from "./becca.js"; -import log from "../services/log.js"; +import { getLog } from "../services/log.js"; import beccaService from "./becca_service.js"; -import dateUtils from "../services/date_utils.js"; +import dateUtils from "../services/utils/date"; import { parse } from "node-html-parser"; import type BNote from "./entities/bnote.js"; import { SimilarNote } from "@triliumnext/commons"; @@ -359,7 +359,7 @@ async function findSimilarNotes(noteId: string): Promise Date: Tue, 6 Jan 2026 11:31:13 +0200 Subject: [PATCH 14/72] chore(core): integrate errors --- apps/server/src/errors/forbidden_error.ts | 12 ----- apps/server/src/errors/http_error.ts | 13 ------ apps/server/src/errors/not_found_error.ts | 12 ----- apps/server/src/errors/open_id_error.ts | 9 ---- apps/server/src/errors/validation_error.ts | 12 ----- apps/server/src/routes/api/export.ts | 18 ++++---- apps/server/src/routes/api/tree.ts | 9 ++-- apps/server/src/routes/error_handlers.ts | 5 +- apps/server/src/routes/route_api.ts | 3 +- .../trilium-core/src/becca/becca-interface.ts | 2 +- packages/trilium-core/src/errors.ts | 46 +++++++++++++++++++ packages/trilium-core/src/index.ts | 1 + .../trilium-core}/src/services/blob.ts | 2 +- 13 files changed, 64 insertions(+), 80 deletions(-) delete mode 100644 apps/server/src/errors/forbidden_error.ts delete mode 100644 apps/server/src/errors/http_error.ts delete mode 100644 apps/server/src/errors/not_found_error.ts delete mode 100644 apps/server/src/errors/open_id_error.ts delete mode 100644 apps/server/src/errors/validation_error.ts create mode 100644 packages/trilium-core/src/errors.ts rename {apps/server => packages/trilium-core}/src/services/blob.ts (97%) diff --git a/apps/server/src/errors/forbidden_error.ts b/apps/server/src/errors/forbidden_error.ts deleted file mode 100644 index 3e62665b07..0000000000 --- a/apps/server/src/errors/forbidden_error.ts +++ /dev/null @@ -1,12 +0,0 @@ -import HttpError from "./http_error.js"; - -class ForbiddenError extends HttpError { - - constructor(message: string) { - super(message, 403); - this.name = "ForbiddenError"; - } - -} - -export default ForbiddenError; \ No newline at end of file diff --git a/apps/server/src/errors/http_error.ts b/apps/server/src/errors/http_error.ts deleted file mode 100644 index 2ab806d8bd..0000000000 --- a/apps/server/src/errors/http_error.ts +++ /dev/null @@ -1,13 +0,0 @@ -class HttpError extends Error { - - statusCode: number; - - constructor(message: string, statusCode: number) { - super(message); - this.name = "HttpError"; - this.statusCode = statusCode; - } - -} - -export default HttpError; \ No newline at end of file diff --git a/apps/server/src/errors/not_found_error.ts b/apps/server/src/errors/not_found_error.ts deleted file mode 100644 index 44f718a2c8..0000000000 --- a/apps/server/src/errors/not_found_error.ts +++ /dev/null @@ -1,12 +0,0 @@ -import HttpError from "./http_error.js"; - -class NotFoundError extends HttpError { - - constructor(message: string) { - super(message, 404); - this.name = "NotFoundError"; - } - -} - -export default NotFoundError; diff --git a/apps/server/src/errors/open_id_error.ts b/apps/server/src/errors/open_id_error.ts deleted file mode 100644 index 0206a17f34..0000000000 --- a/apps/server/src/errors/open_id_error.ts +++ /dev/null @@ -1,9 +0,0 @@ -class OpenIdError { - message: string; - - constructor(message: string) { - this.message = message; - } -} - -export default OpenIdError; \ No newline at end of file diff --git a/apps/server/src/errors/validation_error.ts b/apps/server/src/errors/validation_error.ts deleted file mode 100644 index 25cdd509ef..0000000000 --- a/apps/server/src/errors/validation_error.ts +++ /dev/null @@ -1,12 +0,0 @@ -import HttpError from "./http_error.js"; - -class ValidationError extends HttpError { - - constructor(message: string) { - super(message, 400) - this.name = "ValidationError"; - } - -} - -export default ValidationError; diff --git a/apps/server/src/routes/api/export.ts b/apps/server/src/routes/api/export.ts index 944eee841c..fc328b444b 100644 --- a/apps/server/src/routes/api/export.ts +++ b/apps/server/src/routes/api/export.ts @@ -1,14 +1,12 @@ -"use strict"; - -import zipExportService from "../../services/export/zip.js"; -import singleExportService from "../../services/export/single.js"; -import opmlExportService from "../../services/export/opml.js"; -import becca from "../../becca/becca.js"; -import TaskContext from "../../services/task_context.js"; -import log from "../../services/log.js"; -import NotFoundError from "../../errors/not_found_error.js"; +import { NotFoundError, ValidationError } from "@triliumnext/core"; import type { Request, Response } from "express"; -import ValidationError from "../../errors/validation_error.js"; + +import becca from "../../becca/becca.js"; +import opmlExportService from "../../services/export/opml.js"; +import singleExportService from "../../services/export/single.js"; +import zipExportService from "../../services/export/zip.js"; +import log from "../../services/log.js"; +import TaskContext from "../../services/task_context.js"; import { safeExtractMessageAndStackFromError } from "../../services/utils.js"; function exportBranch(req: Request, res: Response) { diff --git a/apps/server/src/routes/api/tree.ts b/apps/server/src/routes/api/tree.ts index b9621d5d02..6c1c3c6844 100644 --- a/apps/server/src/routes/api/tree.ts +++ b/apps/server/src/routes/api/tree.ts @@ -1,11 +1,10 @@ -"use strict"; +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 log from "../../services/log.js"; -import NotFoundError from "../../errors/not_found_error.js"; -import type { Request } from "express"; import type BNote from "../../becca/entities/bnote.js"; -import type { AttributeRow, BranchRow, NoteRow } from "@triliumnext/commons"; +import log from "../../services/log.js"; function getNotesAndBranchesAndAttributes(_noteIds: string[] | Set) { const noteIds = new Set(_noteIds); diff --git a/apps/server/src/routes/error_handlers.ts b/apps/server/src/routes/error_handlers.ts index 05b05f6a4a..146d28fbee 100644 --- a/apps/server/src/routes/error_handlers.ts +++ b/apps/server/src/routes/error_handlers.ts @@ -1,8 +1,7 @@ +import { ForbiddenError, HttpError, NotFoundError } from "@triliumnext/core"; import type { Application, NextFunction, Request, Response } from "express"; + import log from "../services/log.js"; -import NotFoundError from "../errors/not_found_error.js"; -import ForbiddenError from "../errors/forbidden_error.js"; -import HttpError from "../errors/http_error.js"; function register(app: Application) { diff --git a/apps/server/src/routes/route_api.ts b/apps/server/src/routes/route_api.ts index 6f33260740..d2629e5267 100644 --- a/apps/server/src/routes/route_api.ts +++ b/apps/server/src/routes/route_api.ts @@ -1,10 +1,9 @@ +import { NotFoundError, ValidationError } from "@triliumnext/core"; import express, { type RequestHandler } from "express"; import multer from "multer"; import AbstractBeccaEntity from "../becca/entities/abstract_becca_entity.js"; import { namespace } from "../cls_provider.js"; -import NotFoundError from "../errors/not_found_error.js"; -import ValidationError from "../errors/validation_error.js"; import auth from "../services/auth.js"; import cls from "../services/cls.js"; import entityChangesService from "../services/entity_changes.js"; diff --git a/packages/trilium-core/src/becca/becca-interface.ts b/packages/trilium-core/src/becca/becca-interface.ts index a70a07e1e7..8c8099cd44 100644 --- a/packages/trilium-core/src/becca/becca-interface.ts +++ b/packages/trilium-core/src/becca/becca-interface.ts @@ -1,5 +1,5 @@ import NoteSet from "../services/search/note_set.js"; -import NotFoundError from "../errors/not_found_error.js"; +import { NotFoundError } from "../errors.js"; import type BOption from "./entities/boption.js"; import type BNote from "./entities/bnote.js"; import type BEtapiToken from "./entities/betapi_token.js"; diff --git a/packages/trilium-core/src/errors.ts b/packages/trilium-core/src/errors.ts new file mode 100644 index 0000000000..f2127f280c --- /dev/null +++ b/packages/trilium-core/src/errors.ts @@ -0,0 +1,46 @@ +export class HttpError extends Error { + + statusCode: number; + + constructor(message: string, statusCode: number) { + super(message); + this.name = "HttpError"; + this.statusCode = statusCode; + } + +} + +export class NotFoundError extends HttpError { + + constructor(message: string) { + super(message, 404); + this.name = "NotFoundError"; + } + +} + +export class ForbiddenError extends HttpError { + + constructor(message: string) { + super(message, 403); + this.name = "ForbiddenError"; + } + +} + +export class OpenIdError { + message: string; + + constructor(message: string) { + this.message = message; + } +} + +export class ValidationError extends HttpError { + + constructor(message: string) { + super(message, 400) + this.name = "ValidationError"; + } + +} diff --git a/packages/trilium-core/src/index.ts b/packages/trilium-core/src/index.ts index f38fc96929..6e0c44f983 100644 --- a/packages/trilium-core/src/index.ts +++ b/packages/trilium-core/src/index.ts @@ -12,6 +12,7 @@ export * as binary_utils from "./services/utils/binary"; export { default as date_utils } from "./services/utils/date"; export { default as events } from "./services/events"; export { getContext, type ExecutionContext } from "./services/context"; +export * from "./errors"; export type { CryptoProvider } from "./services/encryption/crypto"; export function initializeCore({ dbConfig, executionContext, crypto }: { diff --git a/apps/server/src/services/blob.ts b/packages/trilium-core/src/services/blob.ts similarity index 97% rename from apps/server/src/services/blob.ts rename to packages/trilium-core/src/services/blob.ts index 2f123cb03f..912ed64164 100644 --- a/apps/server/src/services/blob.ts +++ b/packages/trilium-core/src/services/blob.ts @@ -1,7 +1,7 @@ import { binary_utils } from "@triliumnext/core"; import becca from "../becca/becca.js"; -import NotFoundError from "../errors/not_found_error.js"; +import { NotFoundError } from "../errors"; import type { Blob } from "./blob-interface.js"; import protectedSessionService from "./protected_session.js"; import { hash } from "./utils.js"; From 4506b717d548b4904e9f2259e5f824c3f184fdb1 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Tue, 6 Jan 2026 11:38:25 +0200 Subject: [PATCH 15/72] chore(server): fix imports to becca --- apps/server/src/becca/becca.ts | 2 ++ packages/trilium-core/src/index.ts | 2 ++ 2 files changed, 4 insertions(+) create mode 100644 apps/server/src/becca/becca.ts diff --git a/apps/server/src/becca/becca.ts b/apps/server/src/becca/becca.ts new file mode 100644 index 0000000000..a2c6aaa800 --- /dev/null +++ b/apps/server/src/becca/becca.ts @@ -0,0 +1,2 @@ +import { becca } from "@triliumnext/core"; +export default becca; diff --git a/packages/trilium-core/src/index.ts b/packages/trilium-core/src/index.ts index 6e0c44f983..2922f6c81b 100644 --- a/packages/trilium-core/src/index.ts +++ b/packages/trilium-core/src/index.ts @@ -15,6 +15,8 @@ export { getContext, type ExecutionContext } from "./services/context"; export * from "./errors"; export type { CryptoProvider } from "./services/encryption/crypto"; +export { default as becca } from "./becca/becca"; + export function initializeCore({ dbConfig, executionContext, crypto }: { dbConfig: SqlServiceParams, executionContext: ExecutionContext, From 3459d2906e811fcf0825119e133735ec04283780 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Tue, 6 Jan 2026 11:41:06 +0200 Subject: [PATCH 16/72] chore(server): fix imports to validation error --- apps/server/src/routes/api/attachments.ts | 9 +-- apps/server/src/routes/api/attributes.ts | 21 +++--- apps/server/src/routes/api/autocomplete.ts | 2 +- apps/server/src/routes/api/branches.ts | 3 +- apps/server/src/routes/api/clipper.ts | 14 ++-- apps/server/src/routes/api/database.ts | 21 +++--- apps/server/src/routes/api/files.ts | 4 +- apps/server/src/routes/api/import.ts | 23 ++++--- apps/server/src/routes/api/notes.ts | 27 ++++---- apps/server/src/routes/api/options.ts | 2 +- apps/server/src/routes/api/password.ts | 11 ++-- apps/server/src/routes/api/search.ts | 18 +++--- apps/server/src/routes/api/sql.ts | 9 ++- apps/server/src/routes/api/sync.ts | 29 ++++----- apps/server/src/routes/login.ts | 31 ++++----- apps/server/src/services/export/zip.ts | 75 +++++++++++----------- apps/server/src/services/notes.ts | 4 +- 17 files changed, 145 insertions(+), 158 deletions(-) diff --git a/apps/server/src/routes/api/attachments.ts b/apps/server/src/routes/api/attachments.ts index b2c877fcb3..a466847a13 100644 --- a/apps/server/src/routes/api/attachments.ts +++ b/apps/server/src/routes/api/attachments.ts @@ -1,9 +1,10 @@ +import { ConvertAttachmentToNoteResponse } from "@triliumnext/commons"; +import { ValidationError } from "@triliumnext/core"; +import type { Request } from "express"; + import becca from "../../becca/becca.js"; import blobService from "../../services/blob.js"; -import ValidationError from "../../errors/validation_error.js"; import imageService from "../../services/image.js"; -import type { Request } from "express"; -import { ConvertAttachmentToNoteResponse } from "@triliumnext/commons"; function getAttachmentBlob(req: Request) { const preview = req.query.preview === "true"; @@ -34,7 +35,7 @@ function getAllAttachments(req: Request) { function saveAttachment(req: Request) { const { noteId } = req.params; const { attachmentId, role, mime, title, content } = req.body; - const matchByQuery = req.query.matchBy + const matchByQuery = req.query.matchBy; const isValidMatchBy = (typeof matchByQuery === "string") && (matchByQuery === "attachmentId" || matchByQuery === "title"); const matchBy = isValidMatchBy ? matchByQuery : undefined; diff --git a/apps/server/src/routes/api/attributes.ts b/apps/server/src/routes/api/attributes.ts index 55c3e3e298..3b0ac83269 100644 --- a/apps/server/src/routes/api/attributes.ts +++ b/apps/server/src/routes/api/attributes.ts @@ -1,13 +1,12 @@ -"use strict"; - -import sql from "../../services/sql.js"; -import log from "../../services/log.js"; -import attributeService from "../../services/attributes.js"; -import BAttribute from "../../becca/entities/battribute.js"; -import becca from "../../becca/becca.js"; -import ValidationError from "../../errors/validation_error.js"; -import type { Request } from "express"; import { UpdateAttributeResponse } from "@triliumnext/commons"; +import { ValidationError } from "@triliumnext/core"; +import type { Request } from "express"; + +import becca from "../../becca/becca.js"; +import BAttribute from "../../becca/entities/battribute.js"; +import attributeService from "../../services/attributes.js"; +import log from "../../services/log.js"; +import sql from "../../services/sql.js"; function getEffectiveNoteAttributes(req: Request) { const note = becca.getNote(req.params.noteId); @@ -47,7 +46,7 @@ function updateNoteAttribute(req: Request) { } attribute = new BAttribute({ - noteId: noteId, + noteId, name: body.name, type: body.type, isInheritable: body.isInheritable @@ -208,7 +207,7 @@ function createRelation(req: Request) { if (!attribute) { attribute = new BAttribute({ noteId: sourceNoteId, - name: name, + name, type: "relation", value: targetNoteId }).save(); diff --git a/apps/server/src/routes/api/autocomplete.ts b/apps/server/src/routes/api/autocomplete.ts index 9915f58cb1..6d3deb33a6 100644 --- a/apps/server/src/routes/api/autocomplete.ts +++ b/apps/server/src/routes/api/autocomplete.ts @@ -1,8 +1,8 @@ +import { ValidationError } from "@triliumnext/core"; import type { Request } from "express"; import becca from "../../becca/becca.js"; import beccaService from "../../becca/becca_service.js"; -import ValidationError from "../../errors/validation_error.js"; import cls from "../../services/cls.js"; import log from "../../services/log.js"; import searchService from "../../services/search/services/search.js"; diff --git a/apps/server/src/routes/api/branches.ts b/apps/server/src/routes/api/branches.ts index bf28fff988..8a21f8420c 100644 --- a/apps/server/src/routes/api/branches.ts +++ b/apps/server/src/routes/api/branches.ts @@ -1,8 +1,7 @@ -import { events as eventService } from "@triliumnext/core"; +import { events as eventService, ValidationError } from "@triliumnext/core"; import type { Request } from "express"; import becca from "../../becca/becca.js"; -import ValidationError from "../../errors/validation_error.js"; import branchService from "../../services/branches.js"; import entityChangesService from "../../services/entity_changes.js"; import eraseService from "../../services/erase.js"; diff --git a/apps/server/src/routes/api/clipper.ts b/apps/server/src/routes/api/clipper.ts index 133c35a88e..c73896f15d 100644 --- a/apps/server/src/routes/api/clipper.ts +++ b/apps/server/src/routes/api/clipper.ts @@ -1,9 +1,9 @@ +import { ValidationError } from "@triliumnext/core"; import type { Request } from "express"; import { parse } from "node-html-parser"; import path from "path"; import type BNote from "../../becca/entities/bnote.js"; -import ValidationError from "../../errors/validation_error.js"; import appInfo from "../../services/app_info.js"; import attributeFormatter from "../../services/attribute_formatter.js"; import attributeService from "../../services/attributes.js"; @@ -38,7 +38,7 @@ async function addClipping(req: Request) { if (!clippingNote) { clippingNote = noteService.createNewNote({ parentNoteId: clipperInbox.noteId, - title: title, + title, content: "", type: "text" }).note; @@ -198,11 +198,11 @@ function openNote(req: Request) { return { result: "ok" }; - } else { - return { - result: "open-in-browser" - }; - } + } + return { + result: "open-in-browser" + }; + } function handshake() { diff --git a/apps/server/src/routes/api/database.ts b/apps/server/src/routes/api/database.ts index c29f6e9aaa..8ec4b10f46 100644 --- a/apps/server/src/routes/api/database.ts +++ b/apps/server/src/routes/api/database.ts @@ -1,15 +1,14 @@ -"use strict"; - -import sql from "../../services/sql.js"; -import log from "../../services/log.js"; -import backupService from "../../services/backup.js"; -import anonymizationService from "../../services/anonymization.js"; -import consistencyChecksService from "../../services/consistency_checks.js"; -import type { Request } from "express"; -import ValidationError from "../../errors/validation_error.js"; -import sql_init from "../../services/sql_init.js"; -import becca_loader from "../../becca/becca_loader.js"; import { BackupDatabaseNowResponse, DatabaseCheckIntegrityResponse } from "@triliumnext/commons"; +import { ValidationError } from "@triliumnext/core"; +import type { Request } from "express"; + +import becca_loader from "../../becca/becca_loader.js"; +import anonymizationService from "../../services/anonymization.js"; +import backupService from "../../services/backup.js"; +import consistencyChecksService from "../../services/consistency_checks.js"; +import log from "../../services/log.js"; +import sql from "../../services/sql.js"; +import sql_init from "../../services/sql_init.js"; function getExistingBackups() { return backupService.getExistingBackups(); diff --git a/apps/server/src/routes/api/files.ts b/apps/server/src/routes/api/files.ts index 4a6e17382b..2095f3b712 100644 --- a/apps/server/src/routes/api/files.ts +++ b/apps/server/src/routes/api/files.ts @@ -1,5 +1,4 @@ - - +import { ValidationError } from "@triliumnext/core"; import chokidar from "chokidar"; import type { Request, Response } from "express"; import fs from "fs"; @@ -9,7 +8,6 @@ import tmp from "tmp"; import becca from "../../becca/becca.js"; import type BAttachment from "../../becca/entities/battachment.js"; import type BNote from "../../becca/entities/bnote.js"; -import ValidationError from "../../errors/validation_error.js"; import dataDirs from "../../services/data_dir.js"; import log from "../../services/log.js"; import noteService from "../../services/notes.js"; diff --git a/apps/server/src/routes/api/import.ts b/apps/server/src/routes/api/import.ts index 273dc1e1da..45e3f5f0be 100644 --- a/apps/server/src/routes/api/import.ts +++ b/apps/server/src/routes/api/import.ts @@ -1,18 +1,17 @@ -"use strict"; - -import enexImportService from "../../services/import/enex.js"; -import opmlImportService from "../../services/import/opml.js"; -import zipImportService from "../../services/import/zip.js"; -import singleImportService from "../../services/import/single.js"; -import cls from "../../services/cls.js"; +import { ValidationError } from "@triliumnext/core"; +import type { Request } from "express"; import path from "path"; + import becca from "../../becca/becca.js"; import beccaLoader from "../../becca/becca_loader.js"; +import type BNote from "../../becca/entities/bnote.js"; +import cls from "../../services/cls.js"; +import enexImportService from "../../services/import/enex.js"; +import opmlImportService from "../../services/import/opml.js"; +import singleImportService from "../../services/import/single.js"; +import zipImportService from "../../services/import/zip.js"; import log from "../../services/log.js"; import TaskContext from "../../services/task_context.js"; -import ValidationError from "../../errors/validation_error.js"; -import type { Request } from "express"; -import type BNote from "../../becca/entities/bnote.js"; import { safeExtractMessageAndStackFromError } from "../../services/utils.js"; async function importNotesToBranch(req: Request) { @@ -88,7 +87,7 @@ async function importNotesToBranch(req: Request) { setTimeout( () => taskContext.taskSucceeded({ - parentNoteId: parentNoteId, + parentNoteId, importedNoteId: note?.noteId }), 1000 @@ -138,7 +137,7 @@ function importAttachmentsToNote(req: Request) { setTimeout( () => taskContext.taskSucceeded({ - parentNoteId: parentNoteId + parentNoteId }), 1000 ); diff --git a/apps/server/src/routes/api/notes.ts b/apps/server/src/routes/api/notes.ts index 3c6db40549..c3f1746a62 100644 --- a/apps/server/src/routes/api/notes.ts +++ b/apps/server/src/routes/api/notes.ts @@ -1,18 +1,17 @@ -"use strict"; - -import noteService from "../../services/notes.js"; -import eraseService from "../../services/erase.js"; -import treeService from "../../services/tree.js"; -import sql from "../../services/sql.js"; -import utils from "../../services/utils.js"; -import log from "../../services/log.js"; -import TaskContext from "../../services/task_context.js"; -import becca from "../../becca/becca.js"; -import ValidationError from "../../errors/validation_error.js"; -import blobService from "../../services/blob.js"; -import type { Request } from "express"; -import type BBranch from "../../becca/entities/bbranch.js"; import type { AttributeRow, CreateChildrenResponse, DeleteNotesPreview, MetadataResponse } from "@triliumnext/commons"; +import { ValidationError } from "@triliumnext/core"; +import type { Request } from "express"; + +import becca from "../../becca/becca.js"; +import type BBranch from "../../becca/entities/bbranch.js"; +import blobService from "../../services/blob.js"; +import eraseService from "../../services/erase.js"; +import log from "../../services/log.js"; +import noteService from "../../services/notes.js"; +import sql from "../../services/sql.js"; +import TaskContext from "../../services/task_context.js"; +import treeService from "../../services/tree.js"; +import utils from "../../services/utils.js"; /** * @swagger diff --git a/apps/server/src/routes/api/options.ts b/apps/server/src/routes/api/options.ts index e7377bdfdb..123c048043 100644 --- a/apps/server/src/routes/api/options.ts +++ b/apps/server/src/routes/api/options.ts @@ -1,9 +1,9 @@ import type { OptionNames } from "@triliumnext/commons"; +import { ValidationError } from "@triliumnext/core"; import type { Request } from "express"; -import ValidationError from "../../errors/validation_error.js"; import config from "../../services/config.js"; import { changeLanguage, getLocales } from "../../services/i18n.js"; import log from "../../services/log.js"; diff --git a/apps/server/src/routes/api/password.ts b/apps/server/src/routes/api/password.ts index 8a9c93940f..959357893a 100644 --- a/apps/server/src/routes/api/password.ts +++ b/apps/server/src/routes/api/password.ts @@ -1,16 +1,15 @@ -"use strict"; +import { ChangePasswordResponse } from "@triliumnext/commons"; +import { ValidationError } from "@triliumnext/core"; +import type { Request } from "express"; import passwordService from "../../services/encryption/password.js"; -import ValidationError from "../../errors/validation_error.js"; -import type { Request } from "express"; -import { ChangePasswordResponse } from "@triliumnext/commons"; function changePassword(req: Request): ChangePasswordResponse { if (passwordService.isPasswordSet()) { return passwordService.changePassword(req.body.current_password, req.body.new_password); - } else { - return passwordService.setPassword(req.body.new_password); } + return passwordService.setPassword(req.body.new_password); + } function resetPassword(req: Request) { diff --git a/apps/server/src/routes/api/search.ts b/apps/server/src/routes/api/search.ts index cbd5845299..fa2f8a2595 100644 --- a/apps/server/src/routes/api/search.ts +++ b/apps/server/src/routes/api/search.ts @@ -1,17 +1,15 @@ -"use strict"; - +import { ValidationError } from "@triliumnext/core"; import type { Request } from "express"; import becca from "../../becca/becca.js"; -import SearchContext from "../../services/search/search_context.js"; -import searchService, { EMPTY_RESULT, type SearchNoteResult } from "../../services/search/services/search.js"; +import beccaService from "../../becca/becca_service.js"; +import attributeFormatter from "../../services/attribute_formatter.js"; import bulkActionService from "../../services/bulk_actions.js"; import cls from "../../services/cls.js"; -import attributeFormatter from "../../services/attribute_formatter.js"; -import ValidationError from "../../errors/validation_error.js"; -import type SearchResult from "../../services/search/search_result.js"; import hoistedNoteService from "../../services/hoisted_note.js"; -import beccaService from "../../becca/becca_service.js"; +import SearchContext from "../../services/search/search_context.js"; +import type SearchResult from "../../services/search/search_result.js"; +import searchService, { EMPTY_RESULT, type SearchNoteResult } from "../../services/search/services/search.js"; function searchFromNote(req: Request): SearchNoteResult { const note = becca.getNoteOrThrow(req.params.noteId); @@ -82,7 +80,7 @@ function quickSearch(req: Request) { highlightedContentSnippet: result.highlightedContentSnippet, attributeSnippet: result.attributeSnippet, highlightedAttributeSnippet: result.highlightedAttributeSnippet, - icon: icon + icon }; }); @@ -90,7 +88,7 @@ function quickSearch(req: Request) { return { searchResultNoteIds: resultNoteIds, - searchResults: searchResults, + searchResults, error: searchContext.getError() }; } diff --git a/apps/server/src/routes/api/sql.ts b/apps/server/src/routes/api/sql.ts index 33cd61b5e7..71c404a9d0 100644 --- a/apps/server/src/routes/api/sql.ts +++ b/apps/server/src/routes/api/sql.ts @@ -1,9 +1,8 @@ -"use strict"; - -import sql from "../../services/sql.js"; -import becca from "../../becca/becca.js"; +import { ValidationError } from "@triliumnext/core"; import type { Request } from "express"; -import ValidationError from "../../errors/validation_error.js"; + +import becca from "../../becca/becca.js"; +import sql from "../../services/sql.js"; import { safeExtractMessageAndStackFromError } from "../../services/utils.js"; interface Table { diff --git a/apps/server/src/routes/api/sync.ts b/apps/server/src/routes/api/sync.ts index 5e1c53041c..da55e4ba4b 100644 --- a/apps/server/src/routes/api/sync.ts +++ b/apps/server/src/routes/api/sync.ts @@ -1,21 +1,20 @@ -"use strict"; +import { type EntityChange,SyncTestResponse } from "@triliumnext/commons"; +import { ValidationError } from "@triliumnext/core"; +import type { Request } from "express"; +import { t } from "i18next"; -import syncService from "../../services/sync.js"; -import syncUpdateService from "../../services/sync_update.js"; +import consistencyChecksService from "../../services/consistency_checks.js"; +import contentHashService from "../../services/content_hash.js"; import entityChangesService from "../../services/entity_changes.js"; +import log from "../../services/log.js"; +import optionService from "../../services/options.js"; import sql from "../../services/sql.js"; import sqlInit from "../../services/sql_init.js"; -import optionService from "../../services/options.js"; -import contentHashService from "../../services/content_hash.js"; -import log from "../../services/log.js"; +import syncService from "../../services/sync.js"; import syncOptions from "../../services/sync_options.js"; +import syncUpdateService from "../../services/sync_update.js"; import utils, { safeExtractMessageAndStackFromError } from "../../services/utils.js"; import ws from "../../services/ws.js"; -import type { Request } from "express"; -import ValidationError from "../../errors/validation_error.js"; -import consistencyChecksService from "../../services/consistency_checks.js"; -import { t } from "i18next"; -import { SyncTestResponse, type EntityChange } from "@triliumnext/commons"; async function testSync(): Promise { try { @@ -287,10 +286,10 @@ function update(req: Request) { if (pageIndex !== pageCount - 1) { return; - } else { - body = JSON.parse(partialRequests[requestId].payload); - delete partialRequests[requestId]; - } + } + body = JSON.parse(partialRequests[requestId].payload); + delete partialRequests[requestId]; + } const { entities, instanceId } = body; diff --git a/apps/server/src/routes/login.ts b/apps/server/src/routes/login.ts index 2a505a9938..bcb81ad85e 100644 --- a/apps/server/src/routes/login.ts +++ b/apps/server/src/routes/login.ts @@ -1,18 +1,19 @@ +import { ValidationError } from "@triliumnext/core"; import crypto from "crypto"; -import utils from "../services/utils.js"; -import optionService from "../services/options.js"; -import myScryptService from "../services/encryption/my_scrypt.js"; -import log from "../services/log.js"; -import passwordService from "../services/encryption/password.js"; -import assetPath, { assetUrlFragment } from "../services/asset_path.js"; -import appPath from "../services/app_path.js"; -import ValidationError from "../errors/validation_error.js"; import type { Request, Response } from 'express'; -import totp from '../services/totp.js'; -import recoveryCodeService from '../services/encryption/recovery_codes.js'; -import openID from '../services/open_id.js'; + +import appPath from "../services/app_path.js"; +import assetPath, { assetUrlFragment } from "../services/asset_path.js"; +import myScryptService from "../services/encryption/my_scrypt.js"; import openIDEncryption from '../services/encryption/open_id_encryption.js'; +import passwordService from "../services/encryption/password.js"; +import recoveryCodeService from '../services/encryption/recovery_codes.js'; import { getCurrentLocale } from "../services/i18n.js"; +import log from "../services/log.js"; +import openID from '../services/open_id.js'; +import optionService from "../services/options.js"; +import totp from '../services/totp.js'; +import utils from "../services/utils.js"; function loginPage(req: Request, res: Response) { // Login page is triggered twice. Once here, and another time (see sendLoginError) if the password is failed. @@ -23,9 +24,9 @@ function loginPage(req: Request, res: Response) { ssoEnabled: openID.isOpenIDEnabled(), ssoIssuerName: openID.getSSOIssuerName(), ssoIssuerIcon: openID.getSSOIssuerIcon(), - assetPath: assetPath, + assetPath, assetPathFragment: assetUrlFragment, - appPath: appPath, + appPath, currentLocale: getCurrentLocale() }); } @@ -181,9 +182,9 @@ function sendLoginError(req: Request, res: Response, errorType: 'password' | 'to wrongTotp: errorType === 'totp', totpEnabled: totp.isTotpEnabled(), ssoEnabled: openID.isOpenIDEnabled(), - assetPath: assetPath, + assetPath, assetPathFragment: assetUrlFragment, - appPath: appPath, + appPath, currentLocale: getCurrentLocale() }); } diff --git a/apps/server/src/services/export/zip.ts b/apps/server/src/services/export/zip.ts index 4a9f140411..dbab81687e 100644 --- a/apps/server/src/services/export/zip.ts +++ b/apps/server/src/services/export/zip.ts @@ -1,29 +1,28 @@ -"use strict"; - -import dateUtils from "../date_utils.js"; -import path from "path"; -import packageInfo from "../../../package.json" with { type: "json" }; -import { getContentDisposition } from "../utils.js"; -import protectedSessionService from "../protected_session.js"; -import sanitize from "sanitize-filename"; -import fs from "fs"; -import becca from "../../becca/becca.js"; +import { NoteType } from "@triliumnext/commons"; +import { ValidationError } from "@triliumnext/core"; import archiver from "archiver"; +import type { Response } from "express"; +import fs from "fs"; +import path from "path"; +import sanitize from "sanitize-filename"; + +import packageInfo from "../../../package.json" with { type: "json" }; +import becca from "../../becca/becca.js"; +import BBranch from "../../becca/entities/bbranch.js"; +import type BNote from "../../becca/entities/bnote.js"; +import dateUtils from "../date_utils.js"; import log from "../log.js"; -import TaskContext from "../task_context.js"; -import ValidationError from "../../errors/validation_error.js"; -import type NoteMeta from "../meta/note_meta.js"; import type AttachmentMeta from "../meta/attachment_meta.js"; import type AttributeMeta from "../meta/attribute_meta.js"; -import BBranch from "../../becca/entities/bbranch.js"; -import type { Response } from "express"; +import type NoteMeta from "../meta/note_meta.js"; import type { NoteMetaFile } from "../meta/note_meta.js"; -import HtmlExportProvider from "./zip/html.js"; +import protectedSessionService from "../protected_session.js"; +import TaskContext from "../task_context.js"; +import { getContentDisposition } from "../utils.js"; import { AdvancedExportOptions, type ExportFormat, ZipExportProviderData } from "./zip/abstract_provider.js"; +import HtmlExportProvider from "./zip/html.js"; import MarkdownExportProvider from "./zip/markdown.js"; import ShareThemeExportProvider from "./zip/share_theme.js"; -import type BNote from "../../becca/entities/bnote.js"; -import { NoteType } from "@triliumnext/commons"; async function exportToZip(taskContext: TaskContext<"export">, branch: BBranch, format: ExportFormat, res: Response | fs.WriteStream, setHeaders = true, zipExportOptions?: AdvancedExportOptions) { if (!["html", "markdown", "share"].includes(format)) { @@ -73,11 +72,11 @@ async function exportToZip(taskContext: TaskContext<"export">, branch: BBranch, } while (newName in existingFileNames); return `${index}_${fileName}`; - } else { - existingFileNames[lcFileName] = 1; - - return fileName; } + existingFileNames[lcFileName] = 1; + + return fileName; + } function getDataFileName(type: NoteType | null, mime: string, baseFileName: string, existingFileNames: Record): string { @@ -89,15 +88,15 @@ async function exportToZip(taskContext: TaskContext<"export">, branch: BBranch, // Crop fileName to avoid its length exceeding 30 and prevent cutting into the extension. if (fileName.length > 30) { // We use regex to match the extension to preserve multiple dots in extensions (e.g. .tar.gz). - let match = fileName.match(/(\.[a-zA-Z0-9_.!#-]+)$/); - let ext = match ? match[0] : ""; + const match = fileName.match(/(\.[a-zA-Z0-9_.!#-]+)$/); + const ext = match ? match[0] : ""; // Crop the extension if extension length exceeds 30 const croppedExt = ext.slice(-30); // Crop the file name section and append the cropped extension fileName = fileName.slice(0, 30 - croppedExt.length) + croppedExt; } - let existingExtension = path.extname(fileName).toLowerCase(); + const existingExtension = path.extname(fileName).toLowerCase(); const newExtension = provider.mapExtension(type, mime, existingExtension, format); // if the note is already named with the extension (e.g. "image.jpg"), then it's silly to append the exact same extension again @@ -140,7 +139,7 @@ async function exportToZip(taskContext: TaskContext<"export">, branch: BBranch, const meta: NoteMeta = { isClone: true, noteId: note.noteId, - notePath: notePath, + notePath, title: note.getTitleOrProtected(), prefix: branch.prefix, dataFileName: fileName, @@ -198,16 +197,16 @@ async function exportToZip(taskContext: TaskContext<"export">, branch: BBranch, meta.attachments = attachments .toSorted((a, b) => ((a.attachmentId ?? "").localeCompare(b.attachmentId ?? "", "en") ?? 1)) .map((attachment) => { - const attMeta: AttachmentMeta = { - attachmentId: attachment.attachmentId, - title: attachment.title, - role: attachment.role, - mime: attachment.mime, - position: attachment.position, - dataFileName: getDataFileName(null, attachment.mime, baseFileName + "_" + attachment.title, existingFileNames) - }; - return attMeta; - }); + const attMeta: AttachmentMeta = { + attachmentId: attachment.attachmentId, + title: attachment.title, + role: attachment.role, + mime: attachment.mime, + position: attachment.position, + dataFileName: getDataFileName(null, attachment.mime, `${baseFileName }_${ attachment.title}`, existingFileNames) + }; + return attMeta; + }); if (childBranches.length > 0) { meta.dirFileName = getUniqueFilename(existingFileNames, baseFileName); @@ -421,9 +420,9 @@ async function exportToZip(taskContext: TaskContext<"export">, branch: BBranch, } else if (attr.value === "root" || attr.value?.startsWith("_")) { // relations to "named" noteIds can be preserved return true; - } else { - return false; } + return false; + }); } diff --git a/apps/server/src/services/notes.ts b/apps/server/src/services/notes.ts index 7b368e3b9c..8c4a66f968 100644 --- a/apps/server/src/services/notes.ts +++ b/apps/server/src/services/notes.ts @@ -1,7 +1,6 @@ import type { AttachmentRow, AttributeRow, BranchRow, NoteRow } from "@triliumnext/commons"; import { dayjs } from "@triliumnext/commons"; -import { date_utils } from "@triliumnext/core"; -import { events as eventService } from "@triliumnext/core"; +import { date_utils, events as eventService, ValidationError } from "@triliumnext/core"; import fs from "fs"; import html2plaintext from "html2plaintext"; import { t } from "i18next"; @@ -13,7 +12,6 @@ import BAttachment from "../becca/entities/battachment.js"; import BAttribute from "../becca/entities/battribute.js"; import BBranch from "../becca/entities/bbranch.js"; import BNote from "../becca/entities/bnote.js"; -import ValidationError from "../errors/validation_error.js"; import cls from "../services/cls.js"; import log from "../services/log.js"; import protectedSessionService from "../services/protected_session.js"; From f88ac5dfaeff24d1fa61f439d45934008a141d59 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Tue, 6 Jan 2026 11:46:15 +0200 Subject: [PATCH 17/72] chore(server): fix imports to becca entities --- apps/server/src/becca/entities/battachment.ts | 2 + apps/server/src/becca/entities/battribute.ts | 2 + apps/server/src/becca/entities/bbranch.ts | 2 + .../server/src/becca/entities/betapi_token.ts | 2 + apps/server/src/becca/entities/bnote.ts | 2 + apps/server/src/becca/entities/boption.ts | 2 + .../server/src/becca/entities/brecent_note.ts | 2 + apps/server/src/becca/entities/brevision.ts | 2 + apps/server/src/services/import/zip.ts | 87 ++++++++++--------- packages/trilium-core/src/index.ts | 9 ++ 10 files changed, 69 insertions(+), 43 deletions(-) create mode 100644 apps/server/src/becca/entities/battachment.ts create mode 100644 apps/server/src/becca/entities/battribute.ts create mode 100644 apps/server/src/becca/entities/bbranch.ts create mode 100644 apps/server/src/becca/entities/betapi_token.ts create mode 100644 apps/server/src/becca/entities/bnote.ts create mode 100644 apps/server/src/becca/entities/boption.ts create mode 100644 apps/server/src/becca/entities/brecent_note.ts create mode 100644 apps/server/src/becca/entities/brevision.ts diff --git a/apps/server/src/becca/entities/battachment.ts b/apps/server/src/becca/entities/battachment.ts new file mode 100644 index 0000000000..45a2e0cacf --- /dev/null +++ b/apps/server/src/becca/entities/battachment.ts @@ -0,0 +1,2 @@ +import { BAttachment } from "@triliumnext/core"; +export default BAttachment; diff --git a/apps/server/src/becca/entities/battribute.ts b/apps/server/src/becca/entities/battribute.ts new file mode 100644 index 0000000000..f7c6f70c3c --- /dev/null +++ b/apps/server/src/becca/entities/battribute.ts @@ -0,0 +1,2 @@ +import { BAttribute } from "@triliumnext/core"; +export default BAttribute; diff --git a/apps/server/src/becca/entities/bbranch.ts b/apps/server/src/becca/entities/bbranch.ts new file mode 100644 index 0000000000..0c687237b1 --- /dev/null +++ b/apps/server/src/becca/entities/bbranch.ts @@ -0,0 +1,2 @@ +import { BBranch } from "@triliumnext/core"; +export default BBranch; diff --git a/apps/server/src/becca/entities/betapi_token.ts b/apps/server/src/becca/entities/betapi_token.ts new file mode 100644 index 0000000000..114aa74003 --- /dev/null +++ b/apps/server/src/becca/entities/betapi_token.ts @@ -0,0 +1,2 @@ +import { BEtapiToken } from "@triliumnext/core"; +export default BEtapiToken; diff --git a/apps/server/src/becca/entities/bnote.ts b/apps/server/src/becca/entities/bnote.ts new file mode 100644 index 0000000000..523e4e0042 --- /dev/null +++ b/apps/server/src/becca/entities/bnote.ts @@ -0,0 +1,2 @@ +import { BNote } from "@triliumnext/core"; +export default BNote; diff --git a/apps/server/src/becca/entities/boption.ts b/apps/server/src/becca/entities/boption.ts new file mode 100644 index 0000000000..e17b896ff1 --- /dev/null +++ b/apps/server/src/becca/entities/boption.ts @@ -0,0 +1,2 @@ +import { BOption } from "@triliumnext/core"; +export default BOption; diff --git a/apps/server/src/becca/entities/brecent_note.ts b/apps/server/src/becca/entities/brecent_note.ts new file mode 100644 index 0000000000..e8cee478da --- /dev/null +++ b/apps/server/src/becca/entities/brecent_note.ts @@ -0,0 +1,2 @@ +import { BRecentNote } from "@triliumnext/core"; +export default BRecentNote; diff --git a/apps/server/src/becca/entities/brevision.ts b/apps/server/src/becca/entities/brevision.ts new file mode 100644 index 0000000000..c11de52a48 --- /dev/null +++ b/apps/server/src/becca/entities/brevision.ts @@ -0,0 +1,2 @@ +import { BRevision } from "@triliumnext/core"; +export default BRevision; diff --git a/apps/server/src/services/import/zip.ts b/apps/server/src/services/import/zip.ts index c1ac90b913..51bb38cd07 100644 --- a/apps/server/src/services/import/zip.ts +++ b/apps/server/src/services/import/zip.ts @@ -1,26 +1,27 @@ -"use strict"; -import BAttribute from "../../becca/entities/battribute.js"; -import { removeTextFileExtension, newEntityId, getNoteTitle, processStringOrBuffer, unescapeHtml } from "../../services/utils.js"; -import log from "../../services/log.js"; -import noteService from "../../services/notes.js"; -import attributeService from "../../services/attributes.js"; -import BBranch from "../../becca/entities/bbranch.js"; + +import { ALLOWED_NOTE_TYPES, type NoteType } from "@triliumnext/commons"; import path from "path"; -import protectedSessionService from "../protected_session.js"; -import mimeService from "./mime.js"; -import treeService from "../tree.js"; +import type { Stream } from "stream"; import yauzl from "yauzl"; -import htmlSanitizer from "../html_sanitizer.js"; + import becca from "../../becca/becca.js"; import BAttachment from "../../becca/entities/battachment.js"; -import markdownService from "./markdown.js"; -import type TaskContext from "../task_context.js"; +import BAttribute from "../../becca/entities/battribute.js"; +import BBranch from "../../becca/entities/bbranch.js"; import type BNote from "../../becca/entities/bnote.js"; -import type NoteMeta from "../meta/note_meta.js"; +import attributeService from "../../services/attributes.js"; +import log from "../../services/log.js"; +import noteService from "../../services/notes.js"; +import { getNoteTitle, newEntityId, processStringOrBuffer, removeTextFileExtension, unescapeHtml } from "../../services/utils.js"; +import htmlSanitizer from "../html_sanitizer.js"; import type AttributeMeta from "../meta/attribute_meta.js"; -import type { Stream } from "stream"; -import { ALLOWED_NOTE_TYPES, type NoteType } from "@triliumnext/commons"; +import type NoteMeta from "../meta/note_meta.js"; +import protectedSessionService from "../protected_session.js"; +import type TaskContext from "../task_context.js"; +import treeService from "../tree.js"; +import markdownService from "./markdown.js"; +import mimeService from "./mime.js"; interface MetaFile { files: NoteMeta[]; @@ -108,7 +109,7 @@ async function importZip(taskContext: TaskContext<"importNotes">, fileBuffer: Bu dataFileName: "" }; - let parent: NoteMeta | undefined = undefined; + let parent: NoteMeta | undefined; for (let segment of pathSegments) { if (!cursor?.children?.length) { @@ -241,10 +242,10 @@ async function importZip(taskContext: TaskContext<"importNotes">, fileBuffer: Bu } const { note } = noteService.createNewNote({ - parentNoteId: parentNoteId, + parentNoteId, title: noteTitle || "", content: "", - noteId: noteId, + noteId, type: resolveNoteType(noteMeta?.type), mime: noteMeta ? noteMeta.mime : "text/html", prefix: noteMeta?.prefix || "", @@ -294,12 +295,12 @@ async function importZip(taskContext: TaskContext<"importNotes">, fileBuffer: Bu attachmentId: getNewAttachmentId(attachmentMeta.attachmentId), noteId: getNewNoteId(noteMeta.noteId) }; - } else { - // don't check for noteMeta since it's not mandatory for notes - return { - noteId: getNoteId(noteMeta, absUrl) - }; - } + } + // don't check for noteMeta since it's not mandatory for notes + return { + noteId: getNoteId(noteMeta, absUrl) + }; + } function processTextNoteContent(content: string, noteTitle: string, filePath: string, noteMeta?: NoteMeta) { @@ -312,9 +313,9 @@ async function importZip(taskContext: TaskContext<"importNotes">, fileBuffer: Bu content = content.replace(/

([^<]*)<\/h1>/gi, (match, text) => { if (noteTitle.trim() === text.trim()) { return ""; // remove whole H1 tag - } else { - return `

${text}

`; - } + } + return `

${text}

`; + }); if (taskContext.data?.safeImport) { @@ -347,9 +348,9 @@ async function importZip(taskContext: TaskContext<"importNotes">, fileBuffer: Bu return `src="api/attachments/${target.attachmentId}/image/${path.basename(url)}"`; } else if (target.noteId) { return `src="api/images/${target.noteId}/${path.basename(url)}"`; - } else { - return match; - } + } + return match; + }); content = content.replace(/href="([^"]*)"/g, (match, url) => { @@ -373,9 +374,9 @@ async function importZip(taskContext: TaskContext<"importNotes">, fileBuffer: Bu return `href="#root/${target.noteId}?viewMode=attachments&attachmentId=${target.attachmentId}"`; } else if (target.noteId) { return `href="#root/${target.noteId}"`; - } else { - return match; - } + } + return match; + }); if (noteMeta) { @@ -525,9 +526,9 @@ async function importZip(taskContext: TaskContext<"importNotes">, fileBuffer: Bu } ({ note } = noteService.createNewNote({ - parentNoteId: parentNoteId, + parentNoteId, title: noteTitle || "", - content: content, + content, noteId, type, mime, @@ -536,7 +537,7 @@ async function importZip(taskContext: TaskContext<"importNotes">, fileBuffer: Bu // root notePosition should be ignored since it relates to the original document // now import root should be placed after existing notes into new parent notePosition: noteMeta && firstNote ? noteMeta.notePosition : undefined, - isProtected: isProtected + isProtected })); createdNoteIds.add(note.noteId); @@ -648,7 +649,7 @@ function streamToBuffer(stream: Stream): Promise { export function readContent(zipfile: yauzl.ZipFile, entry: yauzl.Entry): Promise { return new Promise((res, rej) => { - zipfile.openReadStream(entry, function (err, readStream) { + zipfile.openReadStream(entry, (err, readStream) => { if (err) rej(err); if (!readStream) throw new Error("Unable to read content."); @@ -659,7 +660,7 @@ export function readContent(zipfile: yauzl.ZipFile, entry: yauzl.Entry): Promise export function readZipFile(buffer: Buffer, processEntryCallback: (zipfile: yauzl.ZipFile, entry: yauzl.Entry) => Promise) { return new Promise((res, rej) => { - yauzl.fromBuffer(buffer, { lazyEntries: true, validateEntrySizes: false }, function (err, zipfile) { + yauzl.fromBuffer(buffer, { lazyEntries: true, validateEntrySizes: false }, (err, zipfile) => { if (err) rej(err); if (!zipfile) throw new Error("Unable to read zip file."); @@ -691,9 +692,9 @@ function resolveNoteType(type: string | undefined): NoteType { if (type && (ALLOWED_NOTE_TYPES as readonly string[]).includes(type)) { return type as NoteType; - } else { - return "text"; - } + } + return "text"; + } export function removeTriliumTags(content: string) { @@ -702,7 +703,7 @@ export function removeTriliumTags(content: string) { "([^<]*)<\/title>" ]; for (const tag of tagsToRemove) { - let re = new RegExp(tag, "gi"); + const re = new RegExp(tag, "gi"); content = content.replace(re, ""); } diff --git a/packages/trilium-core/src/index.ts b/packages/trilium-core/src/index.ts index 2922f6c81b..c73eb6c80c 100644 --- a/packages/trilium-core/src/index.ts +++ b/packages/trilium-core/src/index.ts @@ -16,6 +16,15 @@ export * from "./errors"; export type { CryptoProvider } from "./services/encryption/crypto"; export { default as becca } from "./becca/becca"; +export { default as BAttachment } from "./becca/entities/battachment"; +export { default as BAttribute } from "./becca/entities/battribute"; +export { default as BBlob } from "./becca/entities/bblob"; +export { default as BBranch } from "./becca/entities/bbranch"; +export { default as BEtapiToken } from "./becca/entities/betapi_token"; +export { default as BNote } from "./becca/entities/bnote"; +export { default as BOption } from "./becca/entities/boption"; +export { default as BRecentNote } from "./becca/entities/brecent_note"; +export { default as BRevision } from "./becca/entities/brevision"; export function initializeCore({ dbConfig, executionContext, crypto }: { dbConfig: SqlServiceParams, From f9e22a9ba97766d5ab6c0f4070c8cea5eb375b69 Mon Sep 17 00:00:00 2001 From: Elian Doran <contact@eliandoran.me> Date: Tue, 6 Jan 2026 11:49:22 +0200 Subject: [PATCH 18/72] chore(server): fix references to becca loader --- apps/server/src/main.ts | 2 +- .../0220__migrate_images_to_attachments.ts | 3 +- .../0233__migrate_geo_map_to_collection.ts | 3 +- apps/server/src/routes/api/database.ts | 3 +- apps/server/src/routes/api/import.ts | 5 +- .../server/src/services/consistency_checks.ts | 51 ++++---- apps/server/src/services/sql_init.ts | 2 +- apps/server/src/services/sync.ts | 119 +++++++++--------- packages/trilium-core/src/index.ts | 1 + 9 files changed, 96 insertions(+), 93 deletions(-) diff --git a/apps/server/src/main.ts b/apps/server/src/main.ts index 5d9bb8033c..7dd11fa64b 100644 --- a/apps/server/src/main.ts +++ b/apps/server/src/main.ts @@ -26,7 +26,7 @@ async function startApplication() { }, async onTransactionRollback() { const cls = (await import("./services/cls.js")).default; - const becca_loader = (await import("./becca/becca_loader.js")).default; + const becca_loader = (await import("@triliumnext/core")).becca_loader; const entity_changes = (await import("./services/entity_changes.js")).default; const log = (await import("./services/log")).default; diff --git a/apps/server/src/migrations/0220__migrate_images_to_attachments.ts b/apps/server/src/migrations/0220__migrate_images_to_attachments.ts index 9e06644c3b..f20e21cb8a 100644 --- a/apps/server/src/migrations/0220__migrate_images_to_attachments.ts +++ b/apps/server/src/migrations/0220__migrate_images_to_attachments.ts @@ -1,5 +1,6 @@ +import { becca_loader } from "@triliumnext/core"; + import becca from "../becca/becca.js"; -import becca_loader from "../becca/becca_loader.js"; import cls from "../services/cls.js"; import log from "../services/log.js"; import sql from "../services/sql.js"; diff --git a/apps/server/src/migrations/0233__migrate_geo_map_to_collection.ts b/apps/server/src/migrations/0233__migrate_geo_map_to_collection.ts index 7bcf55ebe3..ccd32b1b0d 100644 --- a/apps/server/src/migrations/0233__migrate_geo_map_to_collection.ts +++ b/apps/server/src/migrations/0233__migrate_geo_map_to_collection.ts @@ -1,5 +1,6 @@ +import { becca_loader } from "@triliumnext/core"; + import becca from "../becca/becca"; -import becca_loader from "../becca/becca_loader"; import cls from "../services/cls.js"; import hidden_subtree from "../services/hidden_subtree"; diff --git a/apps/server/src/routes/api/database.ts b/apps/server/src/routes/api/database.ts index 8ec4b10f46..2fe001a0b5 100644 --- a/apps/server/src/routes/api/database.ts +++ b/apps/server/src/routes/api/database.ts @@ -1,8 +1,7 @@ import { BackupDatabaseNowResponse, DatabaseCheckIntegrityResponse } from "@triliumnext/commons"; -import { ValidationError } from "@triliumnext/core"; +import { becca_loader, ValidationError } from "@triliumnext/core"; import type { Request } from "express"; -import becca_loader from "../../becca/becca_loader.js"; import anonymizationService from "../../services/anonymization.js"; import backupService from "../../services/backup.js"; import consistencyChecksService from "../../services/consistency_checks.js"; diff --git a/apps/server/src/routes/api/import.ts b/apps/server/src/routes/api/import.ts index 45e3f5f0be..a37124bbbb 100644 --- a/apps/server/src/routes/api/import.ts +++ b/apps/server/src/routes/api/import.ts @@ -1,9 +1,8 @@ -import { ValidationError } from "@triliumnext/core"; +import { becca_loader,ValidationError } from "@triliumnext/core"; import type { Request } from "express"; import path from "path"; import becca from "../../becca/becca.js"; -import beccaLoader from "../../becca/becca_loader.js"; import type BNote from "../../becca/entities/bnote.js"; import cls from "../../services/cls.js"; import enexImportService from "../../services/import/enex.js"; @@ -95,7 +94,7 @@ async function importNotesToBranch(req: Request) { } // import has deactivated note events so becca is not updated, instead we force it to reload - beccaLoader.load(); + becca_loader.load(); return note.getPojo(); } diff --git a/apps/server/src/services/consistency_checks.ts b/apps/server/src/services/consistency_checks.ts index 7b4ba72adf..887b8990d6 100644 --- a/apps/server/src/services/consistency_checks.ts +++ b/apps/server/src/services/consistency_checks.ts @@ -1,22 +1,23 @@ -"use strict"; -import sql from "./sql.js"; -import sqlInit from "./sql_init.js"; -import log from "./log.js"; -import ws from "./ws.js"; -import syncMutexService from "./sync_mutex.js"; -import cls from "./cls.js"; -import entityChangesService from "./entity_changes.js"; -import optionsService from "./options.js"; -import BBranch from "../becca/entities/bbranch.js"; -import becca from "../becca/becca.js"; -import { hash as getHash, hashedBlobId, randomString } from "../services/utils.js"; -import eraseService from "../services/erase.js"; -import sanitizeAttributeName from "./sanitize_attribute_name.js"; -import noteTypesService from "../services/note_types.js"; + import type { BranchRow } from "@triliumnext/commons"; import type { EntityChange } from "@triliumnext/commons"; -import becca_loader from "../becca/becca_loader.js"; +import { becca_loader } from "@triliumnext/core"; + +import becca from "../becca/becca.js"; +import BBranch from "../becca/entities/bbranch.js"; +import eraseService from "../services/erase.js"; +import noteTypesService from "../services/note_types.js"; +import { hash as getHash, hashedBlobId, randomString } from "../services/utils.js"; +import cls from "./cls.js"; +import entityChangesService from "./entity_changes.js"; +import log from "./log.js"; +import optionsService from "./options.js"; +import sanitizeAttributeName from "./sanitize_attribute_name.js"; +import sql from "./sql.js"; +import sqlInit from "./sql_init.js"; +import syncMutexService from "./sync_mutex.js"; +import ws from "./ws.js"; const noteTypes = noteTypesService.getNoteTypeNames(); class ConsistencyChecks { @@ -84,11 +85,11 @@ class ConsistencyChecks { } return true; - } else { - logError(`Tree cycle detected at parent-child relationship: '${parentNoteId}' - '${noteId}', whole path: '${path}'`); + } + logError(`Tree cycle detected at parent-child relationship: '${parentNoteId}' - '${noteId}', whole path: '${path}'`); - this.unrecoveredConsistencyErrors = true; - } + this.unrecoveredConsistencyErrors = true; + } else { const newPath = path.slice(); newPath.push(noteId); @@ -186,7 +187,7 @@ class ConsistencyChecks { if (note.getParentBranches().length === 0) { const newBranch = new BBranch({ parentNoteId: "root", - noteId: noteId, + noteId, prefix: "recovered" }).save(); @@ -349,7 +350,7 @@ class ConsistencyChecks { if (this.autoFix) { const branch = new BBranch({ parentNoteId: "root", - noteId: noteId, + noteId, prefix: "recovered" }).save(); @@ -485,7 +486,7 @@ class ConsistencyChecks { if (!blobAlreadyExists) { // manually creating row since this can also affect deleted notes sql.upsert("blobs", "blobId", { - noteId: noteId, + noteId, content: blankContent, utcDateModified: fakeDate, dateModified: fakeDate @@ -496,7 +497,7 @@ class ConsistencyChecks { entityChangesService.putEntityChange({ entityName: "blobs", entityId: blobId, - hash: hash, + hash, isErased: false, utcDateChanged: fakeDate, isSynced: true @@ -911,7 +912,7 @@ class ConsistencyChecks { ws.sendMessageToAllClients({ type: "consistency-checks-failed" }); } else { - log.info(`All consistency checks passed ` + (this.fixedIssues ? "after some fixes" : "with no errors detected") + ` (took ${elapsedTimeMs}ms)`); + log.info(`All consistency checks passed ${ this.fixedIssues ? "after some fixes" : "with no errors detected" } (took ${elapsedTimeMs}ms)`); } } } diff --git a/apps/server/src/services/sql_init.ts b/apps/server/src/services/sql_init.ts index 1b69117602..c50b81acf3 100644 --- a/apps/server/src/services/sql_init.ts +++ b/apps/server/src/services/sql_init.ts @@ -89,7 +89,7 @@ async function createInitialDatabase(skipDemoDb?: boolean) { // We have to import async since options init requires keyboard actions which require translations. const optionsInitService = (await import("./options_init.js")).default; - const becca_loader = (await import("../becca/becca_loader.js")).default; + const becca_loader = (await import("@triliumnext/core")).becca_loader; sql.transactional(() => { log.info("Creating database schema ..."); diff --git a/apps/server/src/services/sync.ts b/apps/server/src/services/sync.ts index ef3bd6cba9..1ded101a34 100644 --- a/apps/server/src/services/sync.ts +++ b/apps/server/src/services/sync.ts @@ -1,27 +1,28 @@ -"use strict"; -import log from "./log.js"; -import sql from "./sql.js"; -import optionService from "./options.js"; -import { hmac, randomString, timeLimit } from "./utils.js"; -import instanceId from "./instance_id.js"; -import dateUtils from "./date_utils.js"; -import syncUpdateService from "./sync_update.js"; -import contentHashService from "./content_hash.js"; -import appInfo from "./app_info.js"; -import syncOptions from "./sync_options.js"; -import syncMutexService from "./sync_mutex.js"; -import cls from "./cls.js"; -import request from "./request.js"; -import ws from "./ws.js"; -import entityChangesService from "./entity_changes.js"; -import entityConstructor from "../becca/entity_constructor.js"; -import becca from "../becca/becca.js"; + import type { EntityChange, EntityChangeRecord, EntityRow } from "@triliumnext/commons"; +import { becca_loader } from "@triliumnext/core"; + +import becca from "../becca/becca.js"; +import entityConstructor from "../becca/entity_constructor.js"; +import appInfo from "./app_info.js"; +import cls from "./cls.js"; +import consistency_checks from "./consistency_checks.js"; +import contentHashService from "./content_hash.js"; +import dateUtils from "./date_utils.js"; +import entityChangesService from "./entity_changes.js"; +import instanceId from "./instance_id.js"; +import log from "./log.js"; +import optionService from "./options.js"; +import request from "./request.js"; import type { CookieJar, ExecOpts } from "./request_interface.js"; import setupService from "./setup.js"; -import consistency_checks from "./consistency_checks.js"; -import becca_loader from "../becca/becca_loader.js"; +import sql from "./sql.js"; +import syncMutexService from "./sync_mutex.js"; +import syncOptions from "./sync_options.js"; +import syncUpdateService from "./sync_update.js"; +import { hmac, randomString, timeLimit } from "./utils.js"; +import ws from "./ws.js"; let proxyToggle = true; @@ -94,16 +95,16 @@ async function sync() { success: false, message: "No connection to sync server." }; - } else { - log.info(`Sync failed: '${e.message}', stack: ${e.stack}`); + } + log.info(`Sync failed: '${e.message}', stack: ${e.stack}`); - ws.syncFailed(); + ws.syncFailed(); - return { - success: false, - message: e.message - }; - } + return { + success: false, + message: e.message + }; + } } @@ -123,9 +124,9 @@ async function doLogin(): Promise<SyncContext> { const syncContext: SyncContext = { cookieJar: {} }; const resp = await syncRequest<SyncResponse>(syncContext, "POST", "/api/login/sync", { - timestamp: timestamp, + timestamp, syncVersion: appInfo.syncVersion, - hash: hash + hash }); if (!resp) { @@ -219,9 +220,9 @@ async function pushChanges(syncContext: SyncContext) { lastSyncedPush = entityChange.id; return false; - } else { - return true; - } + } + return true; + }); if (filteredEntityChanges.length === 0 && lastSyncedPush) { @@ -319,7 +320,7 @@ async function syncRequest<T extends {}>(syncContext: SyncContext, method: strin method, url: syncOptions.getSyncServerHost() + requestPath, cookieJar: syncContext.cookieJar, - timeout: timeout, + timeout, paging: { pageIndex, pageCount, @@ -340,33 +341,33 @@ function getEntityChangeRow(entityChange: EntityChange) { if (entityName === "note_reordering") { return sql.getMap("SELECT branchId, notePosition FROM branches WHERE parentNoteId = ? AND isDeleted = 0", [entityId]); - } else { - const primaryKey = entityConstructor.getEntityFromEntityName(entityName).primaryKeyName; + } + const primaryKey = entityConstructor.getEntityFromEntityName(entityName).primaryKeyName; - if (!primaryKey) { - throw new Error(`Unknown entity for entity change ${JSON.stringify(entityChange)}`); - } - - const entityRow = sql.getRow<EntityRow>(/*sql*/`SELECT * FROM ${entityName} WHERE ${primaryKey} = ?`, [entityId]); - - if (!entityRow) { - log.error(`Cannot find entity for entity change ${JSON.stringify(entityChange)}`); - return null; - } - - if (entityName === "blobs" && entityRow.content !== null) { - if (typeof entityRow.content === "string") { - entityRow.content = Buffer.from(entityRow.content, "utf-8"); - } - - if (entityRow.content) { - entityRow.content = entityRow.content.toString("base64"); - } - } - - - return entityRow; + if (!primaryKey) { + throw new Error(`Unknown entity for entity change ${JSON.stringify(entityChange)}`); } + + const entityRow = sql.getRow<EntityRow>(/*sql*/`SELECT * FROM ${entityName} WHERE ${primaryKey} = ?`, [entityId]); + + if (!entityRow) { + log.error(`Cannot find entity for entity change ${JSON.stringify(entityChange)}`); + return null; + } + + if (entityName === "blobs" && entityRow.content !== null) { + if (typeof entityRow.content === "string") { + entityRow.content = Buffer.from(entityRow.content, "utf-8"); + } + + if (entityRow.content) { + entityRow.content = entityRow.content.toString("base64"); + } + } + + + return entityRow; + } function getEntityChangeRecords(entityChanges: EntityChange[]) { diff --git a/packages/trilium-core/src/index.ts b/packages/trilium-core/src/index.ts index c73eb6c80c..c5b9fa3173 100644 --- a/packages/trilium-core/src/index.ts +++ b/packages/trilium-core/src/index.ts @@ -16,6 +16,7 @@ export * from "./errors"; export type { CryptoProvider } from "./services/encryption/crypto"; export { default as becca } from "./becca/becca"; +export { default as becca_loader } from "./becca/becca_loader"; export { default as BAttachment } from "./becca/entities/battachment"; export { default as BAttribute } from "./becca/entities/battribute"; export { default as BBlob } from "./becca/entities/bblob"; From 9391159413e03a72fcb7327c1a1e7602b096ad5d Mon Sep 17 00:00:00 2001 From: Elian Doran <contact@eliandoran.me> Date: Tue, 6 Jan 2026 11:52:25 +0200 Subject: [PATCH 19/72] chore(server): fix references to becca service --- apps/server/src/routes/api/autocomplete.ts | 7 +- apps/server/src/routes/api/revisions.ts | 33 ++++--- apps/server/src/routes/api/search.ts | 5 +- .../search/expressions/note_flat_text.ts | 26 +++-- .../src/services/search/search_result.ts | 38 ++++--- .../src/services/search/services/search.ts | 98 +++++++++---------- apps/server/src/services/tray.ts | 20 ++-- packages/trilium-core/src/index.ts | 1 + 8 files changed, 111 insertions(+), 117 deletions(-) diff --git a/apps/server/src/routes/api/autocomplete.ts b/apps/server/src/routes/api/autocomplete.ts index 6d3deb33a6..8b75dd7a6a 100644 --- a/apps/server/src/routes/api/autocomplete.ts +++ b/apps/server/src/routes/api/autocomplete.ts @@ -1,8 +1,7 @@ -import { ValidationError } from "@triliumnext/core"; +import { becca_service, ValidationError } from "@triliumnext/core"; import type { Request } from "express"; import becca from "../../becca/becca.js"; -import beccaService from "../../becca/becca_service.js"; import cls from "../../services/cls.js"; import log from "../../services/log.js"; import searchService from "../../services/search/services/search.js"; @@ -67,8 +66,8 @@ function getRecentNotes(activeNoteId: string) { return recentNotes.map((rn) => { const notePathArray = rn.notePath.split("/"); - const { title, icon } = beccaService.getNoteTitleAndIcon(notePathArray[notePathArray.length - 1]); - const notePathTitle = beccaService.getNoteTitleForPath(notePathArray); + const { title, icon } = becca_service.getNoteTitleAndIcon(notePathArray[notePathArray.length - 1]); + const notePathTitle = becca_service.getNoteTitleForPath(notePathArray); return { notePath: rn.notePath, diff --git a/apps/server/src/routes/api/revisions.ts b/apps/server/src/routes/api/revisions.ts index 9700e7f782..6505a32c7f 100644 --- a/apps/server/src/routes/api/revisions.ts +++ b/apps/server/src/routes/api/revisions.ts @@ -1,18 +1,19 @@ -"use strict"; -import beccaService from "../../becca/becca_service.js"; -import utils from "../../services/utils.js"; -import sql from "../../services/sql.js"; -import cls from "../../services/cls.js"; -import path from "path"; -import becca from "../../becca/becca.js"; -import blobService from "../../services/blob.js"; -import eraseService from "../../services/erase.js"; -import type { Request, Response } from "express"; -import type BRevision from "../../becca/entities/brevision.js"; -import type BNote from "../../becca/entities/bnote.js"; -import type { NotePojo } from "../../becca/becca-interface.js"; + import { EditedNotesResponse, RevisionItem, RevisionPojo, RevisionRow } from "@triliumnext/commons"; +import { becca_service } from "@triliumnext/core"; +import type { Request, Response } from "express"; +import path from "path"; + +import becca from "../../becca/becca.js"; +import type { NotePojo } from "../../becca/becca-interface.js"; +import type BNote from "../../becca/entities/bnote.js"; +import type BRevision from "../../becca/entities/brevision.js"; +import blobService from "../../services/blob.js"; +import cls from "../../services/cls.js"; +import eraseService from "../../services/erase.js"; +import sql from "../../services/sql.js"; +import utils from "../../services/utils.js"; interface NotePath { noteId: string; @@ -166,7 +167,7 @@ function getEditedNotesOnDate(req: Request) { ) ORDER BY isDeleted LIMIT 50`, - { date: `${req.params.date}%` } + { date: `${req.params.date}%` } ); let notes = becca.getNotes(noteIds, true); @@ -191,7 +192,7 @@ function getNotePathData(note: BNote): NotePath | undefined { const retPath = note.getBestNotePath(); if (retPath) { - const noteTitle = beccaService.getNoteTitleForPath(retPath); + const noteTitle = becca_service.getNoteTitleForPath(retPath); let branchId; @@ -204,7 +205,7 @@ function getNotePathData(note: BNote): NotePath | undefined { return { noteId: note.noteId, - branchId: branchId, + branchId, title: noteTitle, notePath: retPath, path: retPath.join("/") diff --git a/apps/server/src/routes/api/search.ts b/apps/server/src/routes/api/search.ts index fa2f8a2595..d406524ed5 100644 --- a/apps/server/src/routes/api/search.ts +++ b/apps/server/src/routes/api/search.ts @@ -1,8 +1,7 @@ -import { ValidationError } from "@triliumnext/core"; +import { becca_service,ValidationError } from "@triliumnext/core"; import type { Request } from "express"; import becca from "../../becca/becca.js"; -import beccaService from "../../becca/becca_service.js"; import attributeFormatter from "../../services/attribute_formatter.js"; import bulkActionService from "../../services/bulk_actions.js"; import cls from "../../services/cls.js"; @@ -70,7 +69,7 @@ function quickSearch(req: Request) { // Map to API format const searchResults = trimmed.map((result) => { - const { title, icon } = beccaService.getNoteTitleAndIcon(result.noteId); + const { title, icon } = becca_service.getNoteTitleAndIcon(result.noteId); return { notePath: result.notePath, noteTitle: title, diff --git a/apps/server/src/services/search/expressions/note_flat_text.ts b/apps/server/src/services/search/expressions/note_flat_text.ts index b9ad19c36c..18bb0944d8 100644 --- a/apps/server/src/services/search/expressions/note_flat_text.ts +++ b/apps/server/src/services/search/expressions/note_flat_text.ts @@ -1,14 +1,12 @@ -"use strict"; +import { becca_service } from "@triliumnext/core"; -import type BNote from "../../../becca/entities/bnote.js"; -import type SearchContext from "../search_context.js"; - -import Expression from "./expression.js"; -import NoteSet from "../note_set.js"; import becca from "../../../becca/becca.js"; +import type BNote from "../../../becca/entities/bnote.js"; import { normalize } from "../../utils.js"; -import { normalizeSearchText, fuzzyMatchWord, fuzzyMatchWordWithResult } from "../utils/text_utils.js"; -import beccaService from "../../../becca/becca_service.js"; +import NoteSet from "../note_set.js"; +import type SearchContext from "../search_context.js"; +import { fuzzyMatchWord, fuzzyMatchWordWithResult,normalizeSearchText } from "../utils/text_utils.js"; +import Expression from "./expression.js"; class NoteFlatTextExp extends Expression { tokens: string[]; @@ -60,7 +58,7 @@ class NoteFlatTextExp extends Expression { // Add defensive checks for undefined properties const typeMatches = note.type && note.type.includes(token); const mimeMatches = note.mime && note.mime.includes(token); - + if (typeMatches || mimeMatches) { foundAttrTokens.push(token); } @@ -78,7 +76,7 @@ class NoteFlatTextExp extends Expression { } for (const parentNote of note.parents) { - const title = normalizeSearchText(beccaService.getNoteTitle(note.noteId, parentNote.noteId)); + const title = normalizeSearchText(becca_service.getNoteTitle(note.noteId, parentNote.noteId)); const foundTokens: string[] = foundAttrTokens.slice(); for (const token of remainingTokens) { @@ -112,7 +110,7 @@ class NoteFlatTextExp extends Expression { // Add defensive checks for undefined properties const typeMatches = note.type && note.type.includes(token); const mimeMatches = note.mime && note.mime.includes(token); - + if (typeMatches || mimeMatches) { foundAttrTokens.push(token); } @@ -125,7 +123,7 @@ class NoteFlatTextExp extends Expression { } for (const parentNote of note.parents) { - const title = normalizeSearchText(beccaService.getNoteTitle(note.noteId, parentNote.noteId)); + const title = normalizeSearchText(becca_service.getNoteTitle(note.noteId, parentNote.noteId)); const foundTokens = foundAttrTokens.slice(); for (const token of this.tokens) { @@ -190,7 +188,7 @@ class NoteFlatTextExp extends Expression { if (text.includes(token)) { return true; } - + // Fuzzy fallback only if enabled and for tokens >= 4 characters if (searchContext?.enableFuzzyMatching && token.length >= 4) { const matchedWord = fuzzyMatchWordWithResult(token, text); @@ -202,7 +200,7 @@ class NoteFlatTextExp extends Expression { return true; } } - + return false; } } diff --git a/apps/server/src/services/search/search_result.ts b/apps/server/src/services/search/search_result.ts index bf8a33524b..984a6d97a8 100644 --- a/apps/server/src/services/search/search_result.ts +++ b/apps/server/src/services/search/search_result.ts @@ -1,12 +1,10 @@ -"use strict"; +import { becca_service } from "@triliumnext/core"; -import beccaService from "../../becca/becca_service.js"; import becca from "../../becca/becca.js"; -import { - normalizeSearchText, - calculateOptimizedEditDistance, - FUZZY_SEARCH_CONFIG -} from "./utils/text_utils.js"; +import { + calculateOptimizedEditDistance, + FUZZY_SEARCH_CONFIG, + normalizeSearchText} from "./utils/text_utils.js"; // Scoring constants for better maintainability const SCORE_WEIGHTS = { @@ -41,7 +39,7 @@ class SearchResult { constructor(notePathArray: string[]) { this.notePathArray = notePathArray; - this.notePathTitle = beccaService.getNoteTitleForPath(notePathArray); + this.notePathTitle = becca_service.getNoteTitleForPath(notePathArray); this.score = 0; this.fuzzyScore = 0; } @@ -98,7 +96,7 @@ class SearchResult { for (const chunk of chunks) { for (const token of tokens) { const normalizedToken = normalizeSearchText(token.toLowerCase()); - + if (chunk === normalizedToken) { tokenScore += SCORE_WEIGHTS.TOKEN_EXACT_MATCH * token.length * factor; } else if (chunk.startsWith(normalizedToken)) { @@ -108,10 +106,10 @@ class SearchResult { } else { // Try fuzzy matching for individual tokens with caps applied const editDistance = calculateOptimizedEditDistance(chunk, normalizedToken, FUZZY_SEARCH_CONFIG.MAX_EDIT_DISTANCE); - if (editDistance <= FUZZY_SEARCH_CONFIG.MAX_EDIT_DISTANCE && + if (editDistance <= FUZZY_SEARCH_CONFIG.MAX_EDIT_DISTANCE && normalizedToken.length >= FUZZY_SEARCH_CONFIG.MIN_FUZZY_TOKEN_LENGTH && this.fuzzyScore < SCORE_WEIGHTS.MAX_TOTAL_FUZZY_SCORE) { - + const fuzzyWeight = SCORE_WEIGHTS.TOKEN_FUZZY_MATCH * (1 - editDistance / FUZZY_SEARCH_CONFIG.MAX_EDIT_DISTANCE); // Apply caps: limit token length multiplier and per-token contribution const cappedTokenLength = Math.min(token.length, SCORE_WEIGHTS.MAX_FUZZY_TOKEN_LENGTH_MULTIPLIER); @@ -119,7 +117,7 @@ class SearchResult { fuzzyWeight * cappedTokenLength * factor, SCORE_WEIGHTS.MAX_FUZZY_SCORE_PER_TOKEN ); - + tokenScore += fuzzyTokenScore; this.fuzzyScore += fuzzyTokenScore; } @@ -134,8 +132,8 @@ class SearchResult { * Checks if the query matches as a complete word in the text */ private isWordMatch(text: string, query: string): boolean { - return text.includes(` ${query} `) || - text.startsWith(`${query} `) || + return text.includes(` ${query} `) || + text.startsWith(`${query} `) || text.endsWith(` ${query}`); } @@ -147,21 +145,21 @@ class SearchResult { if (this.fuzzyScore >= SCORE_WEIGHTS.MAX_TOTAL_FUZZY_SCORE) { return 0; } - + const editDistance = calculateOptimizedEditDistance(title, query, FUZZY_SEARCH_CONFIG.MAX_EDIT_DISTANCE); const maxLen = Math.max(title.length, query.length); - + // Only apply fuzzy matching if the query is reasonably long and edit distance is small - if (query.length >= FUZZY_SEARCH_CONFIG.MIN_FUZZY_TOKEN_LENGTH && - editDistance <= FUZZY_SEARCH_CONFIG.MAX_EDIT_DISTANCE && + if (query.length >= FUZZY_SEARCH_CONFIG.MIN_FUZZY_TOKEN_LENGTH && + editDistance <= FUZZY_SEARCH_CONFIG.MAX_EDIT_DISTANCE && editDistance / maxLen <= 0.3) { const similarity = 1 - (editDistance / maxLen); const baseFuzzyScore = SCORE_WEIGHTS.TITLE_WORD_MATCH * similarity * 0.7; // Reduced weight for fuzzy matches - + // Apply cap to ensure fuzzy title matches don't exceed reasonable bounds return Math.min(baseFuzzyScore, SCORE_WEIGHTS.MAX_TOTAL_FUZZY_SCORE * 0.3); } - + return 0; } diff --git a/apps/server/src/services/search/services/search.ts b/apps/server/src/services/search/services/search.ts index 5ca4bda4a1..16eeb70ccd 100644 --- a/apps/server/src/services/search/services/search.ts +++ b/apps/server/src/services/search/services/search.ts @@ -1,24 +1,22 @@ -"use strict"; - +import { becca_service } from "@triliumnext/core"; import normalizeString from "normalize-strings"; -import lex from "./lex.js"; -import handleParens from "./handle_parens.js"; -import parse from "./parse.js"; -import SearchResult from "../search_result.js"; -import SearchContext from "../search_context.js"; -import becca from "../../../becca/becca.js"; -import beccaService from "../../../becca/becca_service.js"; -import { normalize, escapeHtml, escapeRegExp } from "../../utils.js"; -import log from "../../log.js"; -import hoistedNoteService from "../../hoisted_note.js"; -import type BNote from "../../../becca/entities/bnote.js"; -import type BAttribute from "../../../becca/entities/battribute.js"; -import type { SearchParams, TokenStructure } from "./types.js"; -import type Expression from "../expressions/expression.js"; -import sql from "../../sql.js"; -import scriptService from "../../script.js"; import striptags from "striptags"; + +import becca from "../../../becca/becca.js"; +import type BNote from "../../../becca/entities/bnote.js"; +import hoistedNoteService from "../../hoisted_note.js"; +import log from "../../log.js"; import protectedSessionService from "../../protected_session.js"; +import scriptService from "../../script.js"; +import sql from "../../sql.js"; +import { escapeHtml, escapeRegExp } from "../../utils.js"; +import type Expression from "../expressions/expression.js"; +import SearchContext from "../search_context.js"; +import SearchResult from "../search_result.js"; +import handleParens from "./handle_parens.js"; +import lex from "./lex.js"; +import parse from "./parse.js"; +import type { SearchParams, TokenStructure } from "./types.js"; export interface SearchNoteResult { searchResultNoteIds: string[]; @@ -67,7 +65,7 @@ function searchFromNote(note: BNote): SearchNoteResult { return { searchResultNoteIds: searchResultNoteIds.filter((resultNoteId) => !["root", note.noteId].includes(resultNoteId)), highlightedTokens, - error: error + error }; } @@ -252,21 +250,21 @@ function findResultsWithExpression(expression: Expression, searchContext: Search // Phase 1: Try exact matches first (without fuzzy matching) const exactResults = performSearch(expression, searchContext, false); - + // Check if we have sufficient high-quality results const minResultThreshold = 5; const minScoreForQuality = 10; // Minimum score to consider a result "high quality" - + const highQualityResults = exactResults.filter(result => result.score >= minScoreForQuality); - + // If we have enough high-quality exact matches, return them if (highQualityResults.length >= minResultThreshold) { return exactResults; } - + // Phase 2: Add fuzzy matching as fallback when exact matches are insufficient const fuzzyResults = performSearch(expression, searchContext, true); - + // Merge results, ensuring exact matches always rank higher than fuzzy matches return mergeExactAndFuzzyResults(exactResults, fuzzyResults); } @@ -326,10 +324,10 @@ function performSearch(expression: Expression, searchContext: SearchContext, ena function mergeExactAndFuzzyResults(exactResults: SearchResult[], fuzzyResults: SearchResult[]): SearchResult[] { // Create a map of exact result note IDs for deduplication const exactNoteIds = new Set(exactResults.map(result => result.noteId)); - + // Add fuzzy results that aren't already in exact results const additionalFuzzyResults = fuzzyResults.filter(result => !exactNoteIds.has(result.noteId)); - + // Sort exact results by score (best exact matches first) exactResults.sort((a, b) => { if (a.score > b.score) { @@ -345,7 +343,7 @@ function mergeExactAndFuzzyResults(exactResults: SearchResult[], fuzzyResults: S return a.notePathArray.length < b.notePathArray.length ? -1 : 1; }); - + // Sort fuzzy results by score (best fuzzy matches first) additionalFuzzyResults.sort((a, b) => { if (a.score > b.score) { @@ -361,7 +359,7 @@ function mergeExactAndFuzzyResults(exactResults: SearchResult[], fuzzyResults: S return a.notePathArray.length < b.notePathArray.length ? -1 : 1; }); - + // CRITICAL: Always put exact matches before fuzzy matches, regardless of scores return [...exactResults, ...additionalFuzzyResults]; } @@ -417,10 +415,10 @@ function findResultsWithQuery(query: string, searchContext: SearchContext): Sear } // If the query starts with '#', it's a pure expression query. - // Don't use progressive search for these as they may have complex + // Don't use progressive search for these as they may have complex // ordering or other logic that shouldn't be interfered with. const isPureExpressionQuery = query.trim().startsWith('#'); - + if (isPureExpressionQuery) { // For pure expression queries, use standard search without progressive phases return performSearch(expression, searchContext, searchContext.enableFuzzyMatching); @@ -448,7 +446,7 @@ function extractContentSnippet(noteId: string, searchTokens: string[], maxLength try { let content = note.getContent(); - + if (!content || typeof content !== "string") { return ""; } @@ -489,7 +487,7 @@ function extractContentSnippet(noteId: string, searchTokens: string[], maxLength for (const token of searchTokens) { const normalizedToken = normalizeString(token.toLowerCase()); const matchIndex = normalizedContent.indexOf(normalizedToken); - + if (matchIndex !== -1) { // Center the snippet around the match snippetStart = Math.max(0, matchIndex - maxLength / 2); @@ -528,7 +526,7 @@ function extractContentSnippet(noteId: string, searchTokens: string[], maxLength snippet = lines.slice(0, 4).join('\n'); } // Add ellipsis if we truncated lines - snippet = snippet + "..."; + snippet = `${snippet }...`; } else if (lines.length > 1) { // For multi-line snippets that are 4 or fewer lines, keep them as-is // No need to truncate @@ -540,15 +538,15 @@ function extractContentSnippet(noteId: string, searchTokens: string[], maxLength if (firstSpace > 0 && firstSpace < 20) { snippet = snippet.substring(firstSpace + 1); } - snippet = "..." + snippet; + snippet = `...${ snippet}`; } - + if (snippetStart + maxLength < content.length) { const lastSpace = snippet.search(/\s[^\s]*$/); if (lastSpace > snippet.length - 20 && lastSpace > 0) { snippet = snippet.substring(0, lastSpace); } - snippet = snippet + "..."; + snippet = `${snippet }...`; } } @@ -572,20 +570,20 @@ function extractAttributeSnippet(noteId: string, searchTokens: string[], maxLeng return ""; } - let matchingAttributes: Array<{name: string, value: string, type: string}> = []; - + const matchingAttributes: Array<{name: string, value: string, type: string}> = []; + // Look for attributes that match the search tokens for (const attr of attributes) { const attrName = attr.name?.toLowerCase() || ""; const attrValue = attr.value?.toLowerCase() || ""; const attrType = attr.type || ""; - + // Check if any search token matches the attribute name or value const hasMatch = searchTokens.some(token => { const normalizedToken = normalizeString(token.toLowerCase()); return attrName.includes(normalizedToken) || attrValue.includes(normalizedToken); }); - + if (hasMatch) { matchingAttributes.push({ name: attr.name || "", @@ -611,20 +609,20 @@ function extractAttributeSnippet(noteId: string, searchTokens: string[], maxLeng const targetTitle = targetNote ? targetNote.title : attr.value; line = `~${attr.name}="${targetTitle}"`; } - + if (line) { lines.push(line); } } let snippet = lines.join('\n'); - + // Apply length limit while preserving line structure if (snippet.length > maxLength) { // Try to truncate at word boundaries but keep lines intact const truncated = snippet.substring(0, maxLength); const lastNewline = truncated.lastIndexOf('\n'); - + if (lastNewline > maxLength / 2) { // If we can keep most content by truncating to last complete line snippet = truncated.substring(0, lastNewline); @@ -632,7 +630,7 @@ function extractAttributeSnippet(noteId: string, searchTokens: string[], maxLeng // Otherwise just truncate and add ellipsis const lastSpace = truncated.lastIndexOf(' '); snippet = truncated.substring(0, lastSpace > maxLength / 2 ? lastSpace : maxLength - 3); - snippet = snippet + "..."; + snippet = `${snippet }...`; } } @@ -645,7 +643,7 @@ function extractAttributeSnippet(noteId: string, searchTokens: string[], maxLeng function searchNotesForAutocomplete(query: string, fastSearch: boolean = true) { const searchContext = new SearchContext({ - fastSearch: fastSearch, + fastSearch, includeArchivedNotes: false, includeHiddenNotes: true, fuzzyAttributeSearch: true, @@ -666,7 +664,7 @@ function searchNotesForAutocomplete(query: string, fastSearch: boolean = true) { highlightSearchResults(trimmed, searchContext.highlightedTokens, searchContext.ignoreInternalAttributes); return trimmed.map((result) => { - const { title, icon } = beccaService.getNoteTitleAndIcon(result.noteId); + const { title, icon } = becca_service.getNoteTitleAndIcon(result.noteId); return { notePath: result.notePath, noteTitle: title, @@ -698,7 +696,7 @@ function highlightSearchResults(searchResults: SearchResult[], highlightedTokens for (const result of searchResults) { result.highlightedNotePathTitle = result.notePathTitle.replace(/[<{}]/g, ""); - + // Initialize highlighted content snippet if (result.contentSnippet) { // Escape HTML but preserve newlines for later conversion to <br> @@ -706,7 +704,7 @@ function highlightSearchResults(searchResults: SearchResult[], highlightedTokens // Remove any stray < { } that might interfere with our highlighting markers result.highlightedContentSnippet = result.highlightedContentSnippet.replace(/[<{}]/g, ""); } - + // Initialize highlighted attribute snippet if (result.attributeSnippet) { // Escape HTML but preserve newlines for later conversion to <br> @@ -767,14 +765,14 @@ function highlightSearchResults(searchResults: SearchResult[], highlightedTokens if (result.highlightedNotePathTitle) { result.highlightedNotePathTitle = result.highlightedNotePathTitle.replace(/{/g, "<b>").replace(/}/g, "</b>"); } - + if (result.highlightedContentSnippet) { // Replace highlighting markers with HTML tags result.highlightedContentSnippet = result.highlightedContentSnippet.replace(/{/g, "<b>").replace(/}/g, "</b>"); // Convert newlines to <br> tags for HTML display result.highlightedContentSnippet = result.highlightedContentSnippet.replace(/\n/g, "<br>"); } - + if (result.highlightedAttributeSnippet) { // Replace highlighting markers with HTML tags result.highlightedAttributeSnippet = result.highlightedAttributeSnippet.replace(/{/g, "<b>").replace(/}/g, "</b>"); diff --git a/apps/server/src/services/tray.ts b/apps/server/src/services/tray.ts index 504e81b21e..95d158fa3a 100644 --- a/apps/server/src/services/tray.ts +++ b/apps/server/src/services/tray.ts @@ -1,15 +1,15 @@ -import electron from "electron"; +import type { KeyboardActionNames } from "@triliumnext/commons"; +import { becca_service } from "@triliumnext/core"; import type { BrowserWindow, Tray } from "electron"; +import electron from "electron"; import { default as i18next, t } from "i18next"; import path from "path"; import becca from "../becca/becca.js"; -import becca_service from "../becca/becca_service.js"; import type BNote from "../becca/entities/bnote.js"; import type BRecentNote from "../becca/entities/brecent_note.js"; import cls from "./cls.js"; import date_notes from "./date_notes.js"; -import type { KeyboardActionNames } from "@triliumnext/commons"; import optionService from "./options.js"; import { getResourceDir, isDev, isMac } from "./utils.js"; import windowService from "./window.js"; @@ -31,9 +31,9 @@ function getTrayIconPath() { if (process.env.NODE_ENV === "development") { return path.join(__dirname, "../../../desktop/src/assets/images/tray", `${name}.png`); - } else { - return path.resolve(path.join(getResourceDir(), "assets", "images", "tray", `${name}.png`)); - } + } + return path.resolve(path.join(getResourceDir(), "assets", "images", "tray", `${name}.png`)); + } function getIconPath(name: string) { @@ -41,9 +41,9 @@ function getIconPath(name: string) { if (process.env.NODE_ENV === "development") { return path.join(__dirname, "../../../desktop/src/assets/images/tray", `${name}Template${suffix}.png`); - } else { - return path.resolve(path.join(getResourceDir(), "assets", "images", "tray", `${name}Template${suffix}.png`)); - } + } + return path.resolve(path.join(getResourceDir(), "assets", "images", "tray", `${name}Template${suffix}.png`)); + } function registerVisibilityListener(window: BrowserWindow) { @@ -74,7 +74,7 @@ function getWindowTitle(window: BrowserWindow | null) { // Limit title maximum length to 17 if (titleWithoutAppName.length > 20) { - return titleWithoutAppName.substring(0, 17) + '...'; + return `${titleWithoutAppName.substring(0, 17) }...`; } return titleWithoutAppName; diff --git a/packages/trilium-core/src/index.ts b/packages/trilium-core/src/index.ts index c5b9fa3173..67dde2bfa5 100644 --- a/packages/trilium-core/src/index.ts +++ b/packages/trilium-core/src/index.ts @@ -17,6 +17,7 @@ export type { CryptoProvider } from "./services/encryption/crypto"; 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"; export { default as BAttachment } from "./becca/entities/battachment"; export { default as BAttribute } from "./becca/entities/battribute"; export { default as BBlob } from "./becca/entities/bblob"; From 544c52931c75c8a22bda11cbfc8b75c5db6dffd7 Mon Sep 17 00:00:00 2001 From: Elian Doran <contact@eliandoran.me> Date: Tue, 6 Jan 2026 11:54:29 +0200 Subject: [PATCH 20/72] chore(server): fix references to abstract becca entity --- apps/server/src/routes/route_api.ts | 3 +- .../server/src/services/backend_script_api.ts | 79 ++++++++++--------- .../services/backend_script_api_interface.ts | 3 +- apps/server/src/services/handlers.ts | 3 +- apps/server/src/services/ws.ts | 22 +++--- packages/trilium-core/src/index.ts | 1 + 6 files changed, 56 insertions(+), 55 deletions(-) diff --git a/apps/server/src/routes/route_api.ts b/apps/server/src/routes/route_api.ts index d2629e5267..dc11dfb417 100644 --- a/apps/server/src/routes/route_api.ts +++ b/apps/server/src/routes/route_api.ts @@ -1,8 +1,7 @@ -import { NotFoundError, ValidationError } from "@triliumnext/core"; +import { AbstractBeccaEntity,NotFoundError, ValidationError } from "@triliumnext/core"; import express, { type RequestHandler } from "express"; import multer from "multer"; -import AbstractBeccaEntity from "../becca/entities/abstract_becca_entity.js"; import { namespace } from "../cls_provider.js"; import auth from "../services/auth.js"; import cls from "../services/cls.js"; diff --git a/apps/server/src/services/backend_script_api.ts b/apps/server/src/services/backend_script_api.ts index b322926efc..d8e0a63c00 100644 --- a/apps/server/src/services/backend_script_api.ts +++ b/apps/server/src/services/backend_script_api.ts @@ -1,41 +1,42 @@ -import log from "./log.js"; -import noteService from "./notes.js"; -import sql from "./sql.js"; -import { randomString, escapeHtml, unescapeHtml } from "./utils.js"; -import attributeService from "./attributes.js"; -import dateNoteService from "./date_notes.js"; -import treeService from "./tree.js"; -import config from "./config.js"; -import axios from "axios"; +import type { AttributeRow } from "@triliumnext/commons"; import { dayjs } from "@triliumnext/commons"; -import xml2js from "xml2js"; +import { formatLogMessage } from "@triliumnext/commons"; +import type { AbstractBeccaEntity } from "@triliumnext/core"; +import axios from "axios"; import * as cheerio from "cheerio"; -import cloningService from "./cloning.js"; -import appInfo from "./app_info.js"; -import searchService from "./search/services/search.js"; -import SearchContext from "./search/search_context.js"; +import xml2js from "xml2js"; + import becca from "../becca/becca.js"; -import ws from "./ws.js"; +import type Becca from "../becca/becca-interface.js"; +import type BAttachment from "../becca/entities/battachment.js"; +import type BAttribute from "../becca/entities/battribute.js"; +import type BBranch from "../becca/entities/bbranch.js"; +import type BEtapiToken from "../becca/entities/betapi_token.js"; +import type BNote from "../becca/entities/bnote.js"; +import type BOption from "../becca/entities/boption.js"; +import type BRevision from "../becca/entities/brevision.js"; +import appInfo from "./app_info.js"; +import attributeService from "./attributes.js"; +import type { ApiParams } from "./backend_script_api_interface.js"; +import backupService from "./backup.js"; +import branchService from "./branches.js"; +import cloningService from "./cloning.js"; +import config from "./config.js"; +import dateNoteService from "./date_notes.js"; +import exportService from "./export/zip.js"; +import log from "./log.js"; +import type { NoteParams } from "./note-interface.js"; +import noteService from "./notes.js"; +import optionsService from "./options.js"; +import SearchContext from "./search/search_context.js"; +import searchService from "./search/services/search.js"; import SpacedUpdate from "./spaced_update.js"; import specialNotesService from "./special_notes.js"; -import branchService from "./branches.js"; -import exportService from "./export/zip.js"; +import sql from "./sql.js"; import syncMutex from "./sync_mutex.js"; -import backupService from "./backup.js"; -import optionsService from "./options.js"; -import { formatLogMessage } from "@triliumnext/commons"; -import type BNote from "../becca/entities/bnote.js"; -import type AbstractBeccaEntity from "../becca/entities/abstract_becca_entity.js"; -import type BBranch from "../becca/entities/bbranch.js"; -import type BAttribute from "../becca/entities/battribute.js"; -import type BAttachment from "../becca/entities/battachment.js"; -import type BRevision from "../becca/entities/brevision.js"; -import type BEtapiToken from "../becca/entities/betapi_token.js"; -import type BOption from "../becca/entities/boption.js"; -import type { AttributeRow } from "@triliumnext/commons"; -import type Becca from "../becca/becca-interface.js"; -import type { NoteParams } from "./note-interface.js"; -import type { ApiParams } from "./backend_script_api_interface.js"; +import treeService from "./tree.js"; +import { escapeHtml, randomString, unescapeHtml } from "./utils.js"; +import ws from "./ws.js"; /** * A whole number @@ -506,7 +507,7 @@ function BackendScriptApi(this: Api, currentNote: BNote, apiParams: ApiParams) { throw new Error(`Unable to find parent note with ID ${parentNote}.`); } - let extraOptions: NoteParams = { + const extraOptions: NoteParams = { ..._extraOptions, content: "", type: "text", @@ -620,13 +621,13 @@ function BackendScriptApi(this: Api, currentNote: BNote, apiParams: ApiParams) { } const parentNoteId = opts.isVisible ? "_lbVisibleLaunchers" : "_lbAvailableLaunchers"; - const noteId = "al_" + opts.id; + const noteId = `al_${ opts.id}`; const launcherNote = becca.getNote(noteId) || specialNotesService.createLauncher({ - noteId: noteId, - parentNoteId: parentNoteId, + noteId, + parentNoteId, launcherType: opts.type }).note; @@ -680,7 +681,7 @@ function BackendScriptApi(this: Api, currentNote: BNote, apiParams: ApiParams) { ws.sendMessageToAllClients({ type: "execute-script", - script: script, + script, params: prepareParams(params), startNoteId: this.startNote?.noteId, currentNoteId: this.currentNote.noteId, @@ -696,9 +697,9 @@ function BackendScriptApi(this: Api, currentNote: BNote, apiParams: ApiParams) { return params.map((p) => { if (typeof p === "function") { return `!@#Function: ${p.toString()}`; - } else { - return p; } + return p; + }); } }; diff --git a/apps/server/src/services/backend_script_api_interface.ts b/apps/server/src/services/backend_script_api_interface.ts index 4dce6e6a43..c7729ba669 100644 --- a/apps/server/src/services/backend_script_api_interface.ts +++ b/apps/server/src/services/backend_script_api_interface.ts @@ -1,5 +1,6 @@ +import type { AbstractBeccaEntity } from "@triliumnext/core"; import type { Request, Response } from "express"; -import type AbstractBeccaEntity from "../becca/entities/abstract_becca_entity.js"; + import type BNote from "../becca/entities/bnote.js"; export interface ApiParams { diff --git a/apps/server/src/services/handlers.ts b/apps/server/src/services/handlers.ts index 39387c7992..b76417554e 100644 --- a/apps/server/src/services/handlers.ts +++ b/apps/server/src/services/handlers.ts @@ -1,7 +1,6 @@ -import { events as eventService } from "@triliumnext/core"; +import { type AbstractBeccaEntity, events as eventService } from "@triliumnext/core"; import becca from "../becca/becca.js"; -import type AbstractBeccaEntity from "../becca/entities/abstract_becca_entity.js"; import BAttribute from "../becca/entities/battribute.js"; import type BNote from "../becca/entities/bnote.js"; import hiddenSubtreeService from "./hidden_subtree.js"; diff --git a/apps/server/src/services/ws.ts b/apps/server/src/services/ws.ts index 9dfcbc0198..32e7f9c557 100644 --- a/apps/server/src/services/ws.ts +++ b/apps/server/src/services/ws.ts @@ -1,16 +1,16 @@ -import { WebSocketServer as WebSocketServer, WebSocket } from "ws"; -import { isElectron, randomString } from "./utils.js"; -import log from "./log.js"; -import sql from "./sql.js"; +import { type EntityChange,WebSocketMessage } from "@triliumnext/commons"; +import { AbstractBeccaEntity } from "@triliumnext/core"; +import type { IncomingMessage, Server as HttpServer } from "http"; +import { WebSocket,WebSocketServer } from "ws"; + +import becca from "../becca/becca.js"; import cls from "./cls.js"; import config from "./config.js"; -import syncMutexService from "./sync_mutex.js"; +import log from "./log.js"; import protectedSessionService from "./protected_session.js"; -import becca from "../becca/becca.js"; -import AbstractBeccaEntity from "../becca/entities/abstract_becca_entity.js"; - -import type { IncomingMessage, Server as HttpServer } from "http"; -import { WebSocketMessage, type EntityChange } from "@triliumnext/commons"; +import sql from "./sql.js"; +import syncMutexService from "./sync_mutex.js"; +import { isElectron, randomString } from "./utils.js"; let webSocketServer!: WebSocketServer; let lastSyncedPush: number; @@ -80,7 +80,7 @@ function sendMessageToAllClients(message: WebSocketMessage) { } let clientCount = 0; - webSocketServer.clients.forEach(function each(client) { + webSocketServer.clients.forEach((client) => { if (client.readyState === WebSocket.OPEN) { client.send(jsonStr); clientCount++; diff --git a/packages/trilium-core/src/index.ts b/packages/trilium-core/src/index.ts index 67dde2bfa5..1aacf6beeb 100644 --- a/packages/trilium-core/src/index.ts +++ b/packages/trilium-core/src/index.ts @@ -27,6 +27,7 @@ export { default as BNote } from "./becca/entities/bnote"; export { default as BOption } from "./becca/entities/boption"; export { default as BRecentNote } from "./becca/entities/brecent_note"; export { default as BRevision } from "./becca/entities/brevision"; +export { default as AbstractBeccaEntity } from "./becca/entities/abstract_becca_entity"; export function initializeCore({ dbConfig, executionContext, crypto }: { dbConfig: SqlServiceParams, From a15b84b4e539d5d593d993c77c9c53755c199adf Mon Sep 17 00:00:00 2001 From: Elian Doran <contact@eliandoran.me> Date: Tue, 6 Jan 2026 11:56:52 +0200 Subject: [PATCH 21/72] chore(core): fix type error in getFlatText --- packages/trilium-core/src/becca/entities/bnote.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/trilium-core/src/becca/entities/bnote.ts b/packages/trilium-core/src/becca/entities/bnote.ts index ea3c60803c..3d9a9fe2cc 100644 --- a/packages/trilium-core/src/becca/entities/bnote.ts +++ b/packages/trilium-core/src/becca/entities/bnote.ts @@ -775,7 +775,7 @@ class BNote extends AbstractBeccaEntity<BNote> { * * @returns - returns flattened textual representation of note, prefixes and attributes */ - getFlatText() { + getFlatText(): string { if (!this.__flatTextCache) { this.__flatTextCache = `${this.noteId} ${this.type} ${this.mime} `; @@ -801,7 +801,7 @@ class BNote extends AbstractBeccaEntity<BNote> { this.__flatTextCache = utils.normalize(this.__flatTextCache); } - return this.__flatTextCache; + return this.__flatTextCache as string; } invalidateThisCache() { From 40b07c3e8a49fa9b54efb7b83a3e02db01bf2c03 Mon Sep 17 00:00:00 2001 From: Elian Doran <contact@eliandoran.me> Date: Tue, 6 Jan 2026 11:59:45 +0200 Subject: [PATCH 22/72] chore(core): fix references to becca-interface --- apps/server/src/routes/api/revisions.ts | 5 ++--- apps/server/src/services/backend_script_api.ts | 7 ++----- packages/trilium-core/src/index.ts | 2 ++ 3 files changed, 6 insertions(+), 8 deletions(-) diff --git a/apps/server/src/routes/api/revisions.ts b/apps/server/src/routes/api/revisions.ts index 6505a32c7f..1eb6916450 100644 --- a/apps/server/src/routes/api/revisions.ts +++ b/apps/server/src/routes/api/revisions.ts @@ -1,12 +1,11 @@ -import { EditedNotesResponse, RevisionItem, RevisionPojo, RevisionRow } from "@triliumnext/commons"; -import { becca_service } from "@triliumnext/core"; +import { EditedNotesResponse, RevisionItem, RevisionPojo } from "@triliumnext/commons"; +import { becca_service, NotePojo } from "@triliumnext/core"; import type { Request, Response } from "express"; import path from "path"; import becca from "../../becca/becca.js"; -import type { NotePojo } from "../../becca/becca-interface.js"; import type BNote from "../../becca/entities/bnote.js"; import type BRevision from "../../becca/entities/brevision.js"; import blobService from "../../services/blob.js"; diff --git a/apps/server/src/services/backend_script_api.ts b/apps/server/src/services/backend_script_api.ts index d8e0a63c00..b7df6ccea8 100644 --- a/apps/server/src/services/backend_script_api.ts +++ b/apps/server/src/services/backend_script_api.ts @@ -1,13 +1,10 @@ -import type { AttributeRow } from "@triliumnext/commons"; -import { dayjs } from "@triliumnext/commons"; -import { formatLogMessage } from "@triliumnext/commons"; -import type { AbstractBeccaEntity } from "@triliumnext/core"; +import { type AttributeRow, dayjs, formatLogMessage } from "@triliumnext/commons"; +import { type AbstractBeccaEntity, Becca } from "@triliumnext/core"; import axios from "axios"; import * as cheerio from "cheerio"; import xml2js from "xml2js"; import becca from "../becca/becca.js"; -import type Becca from "../becca/becca-interface.js"; import type BAttachment from "../becca/entities/battachment.js"; import type BAttribute from "../becca/entities/battribute.js"; import type BBranch from "../becca/entities/bbranch.js"; diff --git a/packages/trilium-core/src/index.ts b/packages/trilium-core/src/index.ts index 1aacf6beeb..dcc228c4aa 100644 --- a/packages/trilium-core/src/index.ts +++ b/packages/trilium-core/src/index.ts @@ -28,6 +28,8 @@ export { default as BOption } from "./becca/entities/boption"; export { default as BRecentNote } from "./becca/entities/brecent_note"; export { default as BRevision } from "./becca/entities/brevision"; export { default as AbstractBeccaEntity } from "./becca/entities/abstract_becca_entity"; +export { default as Becca } from "./becca/becca-interface"; +export type { NotePojo } from "./becca/becca-interface"; export function initializeCore({ dbConfig, executionContext, crypto }: { dbConfig: SqlServiceParams, From e19e9b3830bc1d9ab937e8b462184484ece10cfc Mon Sep 17 00:00:00 2001 From: Elian Doran <contact@eliandoran.me> Date: Tue, 6 Jan 2026 12:01:18 +0200 Subject: [PATCH 23/72] chore(core): fix references to blob-service --- apps/server/src/routes/api/attachments.ts | 3 +-- apps/server/src/routes/api/notes.ts | 3 +-- apps/server/src/routes/api/revisions.ts | 3 +-- apps/server/src/services/entity_changes.ts | 3 +-- packages/trilium-core/src/index.ts | 1 + 5 files changed, 5 insertions(+), 8 deletions(-) diff --git a/apps/server/src/routes/api/attachments.ts b/apps/server/src/routes/api/attachments.ts index a466847a13..9e409e6f9f 100644 --- a/apps/server/src/routes/api/attachments.ts +++ b/apps/server/src/routes/api/attachments.ts @@ -1,9 +1,8 @@ import { ConvertAttachmentToNoteResponse } from "@triliumnext/commons"; -import { ValidationError } from "@triliumnext/core"; +import { blob as blobService, ValidationError } from "@triliumnext/core"; import type { Request } from "express"; import becca from "../../becca/becca.js"; -import blobService from "../../services/blob.js"; import imageService from "../../services/image.js"; function getAttachmentBlob(req: Request) { diff --git a/apps/server/src/routes/api/notes.ts b/apps/server/src/routes/api/notes.ts index c3f1746a62..9ca191c8e2 100644 --- a/apps/server/src/routes/api/notes.ts +++ b/apps/server/src/routes/api/notes.ts @@ -1,10 +1,9 @@ import type { AttributeRow, CreateChildrenResponse, DeleteNotesPreview, MetadataResponse } from "@triliumnext/commons"; -import { ValidationError } from "@triliumnext/core"; +import { blob as blobService, ValidationError } from "@triliumnext/core"; import type { Request } from "express"; import becca from "../../becca/becca.js"; import type BBranch from "../../becca/entities/bbranch.js"; -import blobService from "../../services/blob.js"; import eraseService from "../../services/erase.js"; import log from "../../services/log.js"; import noteService from "../../services/notes.js"; diff --git a/apps/server/src/routes/api/revisions.ts b/apps/server/src/routes/api/revisions.ts index 1eb6916450..ee4041ecef 100644 --- a/apps/server/src/routes/api/revisions.ts +++ b/apps/server/src/routes/api/revisions.ts @@ -1,14 +1,13 @@ import { EditedNotesResponse, RevisionItem, RevisionPojo } from "@triliumnext/commons"; -import { becca_service, NotePojo } from "@triliumnext/core"; +import { becca_service, blob as blobService, NotePojo } from "@triliumnext/core"; import type { Request, Response } from "express"; import path from "path"; import becca from "../../becca/becca.js"; import type BNote from "../../becca/entities/bnote.js"; import type BRevision from "../../becca/entities/brevision.js"; -import blobService from "../../services/blob.js"; import cls from "../../services/cls.js"; import eraseService from "../../services/erase.js"; import sql from "../../services/sql.js"; diff --git a/apps/server/src/services/entity_changes.ts b/apps/server/src/services/entity_changes.ts index d6e86ef0e1..7d0b53e574 100644 --- a/apps/server/src/services/entity_changes.ts +++ b/apps/server/src/services/entity_changes.ts @@ -1,8 +1,7 @@ import type { EntityChange } from "@triliumnext/commons"; -import { events as eventService } from "@triliumnext/core"; +import { blob as blobService, events as eventService } from "@triliumnext/core"; import becca from "../becca/becca.js"; -import blobService from "../services/blob.js"; import type { Blob } from "./blob-interface.js"; import cls from "./cls.js"; import dateUtils from "./date_utils.js"; diff --git a/packages/trilium-core/src/index.ts b/packages/trilium-core/src/index.ts index dcc228c4aa..4a4ed3111d 100644 --- a/packages/trilium-core/src/index.ts +++ b/packages/trilium-core/src/index.ts @@ -11,6 +11,7 @@ export { default as data_encryption } from "./services/encryption/data_encryptio export * as binary_utils from "./services/utils/binary"; export { default as date_utils } from "./services/utils/date"; export { default as events } from "./services/events"; +export { default as blob } from "./services/blob"; export { getContext, type ExecutionContext } from "./services/context"; export * from "./errors"; export type { CryptoProvider } from "./services/encryption/crypto"; From b7ad76827ab79a40ee94e617871ccc735228c893 Mon Sep 17 00:00:00 2001 From: Elian Doran <contact@eliandoran.me> Date: Tue, 6 Jan 2026 12:05:52 +0200 Subject: [PATCH 24/72] chore(server): various references to core --- apps/server/src/anonymize.ts | 2 +- apps/server/src/routes/api/similar_notes.ts | 8 +++----- .../services/encryption/open_id_encryption.ts | 3 +-- apps/server/src/services/sync.ts | 17 ++++++++--------- apps/server/src/services/sync_update.ts | 5 ++--- apps/server/src/share/shaca/entities/snote.ts | 2 +- packages/trilium-core/src/index.ts | 4 +++- 7 files changed, 19 insertions(+), 22 deletions(-) diff --git a/apps/server/src/anonymize.ts b/apps/server/src/anonymize.ts index e78fe00179..aea817831e 100644 --- a/apps/server/src/anonymize.ts +++ b/apps/server/src/anonymize.ts @@ -1,6 +1,6 @@ import anonymizationService from "./services/anonymization.js"; import sqlInit from "./services/sql_init.js"; -await import("./becca/entity_constructor.js"); +await import("@triliumnext/core"); sqlInit.dbReady.then(async () => { try { diff --git a/apps/server/src/routes/api/similar_notes.ts b/apps/server/src/routes/api/similar_notes.ts index 6b9cbb9261..afa845b102 100644 --- a/apps/server/src/routes/api/similar_notes.ts +++ b/apps/server/src/routes/api/similar_notes.ts @@ -1,17 +1,15 @@ -"use strict"; - +import { SimilarNoteResponse } from "@triliumnext/commons"; +import { similarity } from "@triliumnext/core"; import type { Request } from "express"; -import similarityService from "../../becca/similarity.js"; import becca from "../../becca/becca.js"; -import { SimilarNoteResponse } from "@triliumnext/commons"; async function getSimilarNotes(req: Request) { const noteId = req.params.noteId; const _note = becca.getNoteOrThrow(noteId); - return (await similarityService.findSimilarNotes(noteId) satisfies SimilarNoteResponse); + return (await similarity.findSimilarNotes(noteId) satisfies SimilarNoteResponse); } export default { diff --git a/apps/server/src/services/encryption/open_id_encryption.ts b/apps/server/src/services/encryption/open_id_encryption.ts index a191b782b3..5126a0cf5c 100644 --- a/apps/server/src/services/encryption/open_id_encryption.ts +++ b/apps/server/src/services/encryption/open_id_encryption.ts @@ -1,6 +1,5 @@ -import { data_encryption } from "@triliumnext/core"; +import { data_encryption, OpenIdError } from "@triliumnext/core"; -import OpenIdError from "../../errors/open_id_error.js"; import sql from "../sql.js"; import sqlInit from "../sql_init.js"; import utils, { constantTimeCompare } from "../utils.js"; diff --git a/apps/server/src/services/sync.ts b/apps/server/src/services/sync.ts index 1ded101a34..665e140e3e 100644 --- a/apps/server/src/services/sync.ts +++ b/apps/server/src/services/sync.ts @@ -1,10 +1,9 @@ import type { EntityChange, EntityChangeRecord, EntityRow } from "@triliumnext/commons"; -import { becca_loader } from "@triliumnext/core"; +import { becca_loader, entity_constructor } from "@triliumnext/core"; import becca from "../becca/becca.js"; -import entityConstructor from "../becca/entity_constructor.js"; import appInfo from "./app_info.js"; import cls from "./cls.js"; import consistency_checks from "./consistency_checks.js"; @@ -95,7 +94,7 @@ async function sync() { success: false, message: "No connection to sync server." }; - } + } log.info(`Sync failed: '${e.message}', stack: ${e.stack}`); ws.syncFailed(); @@ -104,7 +103,7 @@ async function sync() { success: false, message: e.message }; - + } } @@ -220,9 +219,9 @@ async function pushChanges(syncContext: SyncContext) { lastSyncedPush = entityChange.id; return false; - } + } return true; - + }); if (filteredEntityChanges.length === 0 && lastSyncedPush) { @@ -341,8 +340,8 @@ function getEntityChangeRow(entityChange: EntityChange) { if (entityName === "note_reordering") { return sql.getMap("SELECT branchId, notePosition FROM branches WHERE parentNoteId = ? AND isDeleted = 0", [entityId]); - } - const primaryKey = entityConstructor.getEntityFromEntityName(entityName).primaryKeyName; + } + const primaryKey = entity_constructor.getEntityFromEntityName(entityName).primaryKeyName; if (!primaryKey) { throw new Error(`Unknown entity for entity change ${JSON.stringify(entityChange)}`); @@ -367,7 +366,7 @@ function getEntityChangeRow(entityChange: EntityChange) { return entityRow; - + } function getEntityChangeRecords(entityChanges: EntityChange[]) { diff --git a/apps/server/src/services/sync_update.ts b/apps/server/src/services/sync_update.ts index 9e88e5796a..0e28f8b014 100644 --- a/apps/server/src/services/sync_update.ts +++ b/apps/server/src/services/sync_update.ts @@ -1,7 +1,6 @@ import type { EntityChange, EntityChangeRecord, EntityRow } from "@triliumnext/commons"; -import { events as eventService } from "@triliumnext/core"; +import { entity_constructor, events as eventService } from "@triliumnext/core"; -import entityConstructor from "../becca/entity_constructor.js"; import entityChangesService from "./entity_changes.js"; import log from "./log.js"; import sql from "./sql.js"; @@ -155,7 +154,7 @@ function eraseEntity(entityChange: EntityChange) { return; } - const primaryKeyName = entityConstructor.getEntityFromEntityName(entityName).primaryKeyName; + const primaryKeyName = entity_constructor.getEntityFromEntityName(entityName).primaryKeyName; sql.execute(/*sql*/`DELETE FROM ${entityName} WHERE ${primaryKeyName} = ?`, [entityId]); } diff --git a/apps/server/src/share/shaca/entities/snote.ts b/apps/server/src/share/shaca/entities/snote.ts index da72cd419f..ecc1d31917 100644 --- a/apps/server/src/share/shaca/entities/snote.ts +++ b/apps/server/src/share/shaca/entities/snote.ts @@ -1,6 +1,6 @@ +import { NOTE_TYPE_ICONS } from "@triliumnext/core"; import escape from "escape-html"; -import { NOTE_TYPE_ICONS } from "../../../becca/entities/bnote.js"; import type { Blob } from "../../../services/blob-interface.js"; import utils from "../../../services/utils.js"; import sql from "../../sql.js"; diff --git a/packages/trilium-core/src/index.ts b/packages/trilium-core/src/index.ts index 4a4ed3111d..e8f5e95f4b 100644 --- a/packages/trilium-core/src/index.ts +++ b/packages/trilium-core/src/index.ts @@ -19,12 +19,14 @@ export type { CryptoProvider } from "./services/encryption/crypto"; 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"; +export { default as entity_constructor } from "./becca/entity_constructor"; +export { default as similarity } from "./becca/similarity"; export { default as BAttachment } from "./becca/entities/battachment"; export { default as BAttribute } from "./becca/entities/battribute"; export { default as BBlob } from "./becca/entities/bblob"; export { default as BBranch } from "./becca/entities/bbranch"; export { default as BEtapiToken } from "./becca/entities/betapi_token"; -export { default as BNote } from "./becca/entities/bnote"; +export { default as BNote, NOTE_TYPE_ICONS } from "./becca/entities/bnote"; export { default as BOption } from "./becca/entities/boption"; export { default as BRecentNote } from "./becca/entities/brecent_note"; export { default as BRevision } from "./becca/entities/brevision"; From 8149460547b0481cf54e6e337263df7e226e75f4 Mon Sep 17 00:00:00 2001 From: Elian Doran <contact@eliandoran.me> Date: Tue, 6 Jan 2026 12:07:16 +0200 Subject: [PATCH 25/72] chore(commons): fix issues with Buffer --- packages/commons/src/lib/rows.ts | 2 +- packages/commons/tsconfig.lib.json | 5 +---- packages/commons/tsconfig.spec.json | 1 - 3 files changed, 2 insertions(+), 6 deletions(-) diff --git a/packages/commons/src/lib/rows.ts b/packages/commons/src/lib/rows.ts index 809e36481d..d1e8a0e409 100644 --- a/packages/commons/src/lib/rows.ts +++ b/packages/commons/src/lib/rows.ts @@ -16,7 +16,7 @@ export interface AttachmentRow { isDeleted?: boolean; deleteId?: string; contentLength?: number; - content?: Buffer | string; + content?: Uint8Array | string; } export interface RevisionRow { diff --git a/packages/commons/tsconfig.lib.json b/packages/commons/tsconfig.lib.json index 31ab54c998..754cce2a31 100644 --- a/packages/commons/tsconfig.lib.json +++ b/packages/commons/tsconfig.lib.json @@ -6,10 +6,7 @@ "outDir": "dist", "tsBuildInfoFile": "dist/tsconfig.lib.tsbuildinfo", "emitDeclarationOnly": true, - "forceConsistentCasingInFileNames": true, - "types": [ - "node" - ] + "forceConsistentCasingInFileNames": true }, "include": [ "src/**/*.ts" diff --git a/packages/commons/tsconfig.spec.json b/packages/commons/tsconfig.spec.json index 699ed84389..de726af213 100644 --- a/packages/commons/tsconfig.spec.json +++ b/packages/commons/tsconfig.spec.json @@ -3,7 +3,6 @@ "compilerOptions": { "outDir": "./out-tsc/vitest", "types": [ - "node", "vitest" ], "forceConsistentCasingInFileNames": true From d717a891632a74635d6ecec93d2391d77be87890 Mon Sep 17 00:00:00 2001 From: Elian Doran <contact@eliandoran.me> Date: Tue, 6 Jan 2026 12:16:38 +0200 Subject: [PATCH 26/72] chore(core): fix references to Buffer --- packages/commons/src/lib/rows.ts | 4 ++-- packages/commons/src/lib/server_api.ts | 2 +- packages/commons/src/lib/ws_api.ts | 2 +- .../becca/entities/abstract_becca_entity.ts | 23 +++++++++++-------- .../src/becca/entities/battachment.ts | 6 ++--- .../trilium-core/src/becca/entities/bblob.ts | 2 +- .../trilium-core/src/becca/entities/bnote.ts | 2 +- .../src/becca/entities/brevision.ts | 6 ++--- packages/trilium-core/src/services/blob.ts | 6 ++--- .../services/encryption/data_encryption.ts | 4 ++-- .../trilium-core/src/services/sql/types.ts | 2 +- .../trilium-core/src/services/utils/binary.ts | 21 +++++++++++++++++ 12 files changed, 53 insertions(+), 27 deletions(-) diff --git a/packages/commons/src/lib/rows.ts b/packages/commons/src/lib/rows.ts index d1e8a0e409..03599a8b61 100644 --- a/packages/commons/src/lib/rows.ts +++ b/packages/commons/src/lib/rows.ts @@ -68,7 +68,7 @@ export interface EtapiTokenRow { export interface BlobRow { blobId: string; - content: string | Buffer; + content: string | Uint8Array; contentLength: number; dateModified: string; utcDateModified: string; @@ -137,6 +137,6 @@ export interface NoteRow { dateModified?: string; utcDateCreated?: string; utcDateModified?: string; - content?: string | Buffer; + content?: string | Uint8Array; } diff --git a/packages/commons/src/lib/server_api.ts b/packages/commons/src/lib/server_api.ts index a15192fd28..5a78d36769 100644 --- a/packages/commons/src/lib/server_api.ts +++ b/packages/commons/src/lib/server_api.ts @@ -50,7 +50,7 @@ export interface RevisionPojo { utcDateLastEdited?: string; utcDateCreated?: string; utcDateModified?: string; - content?: string | Buffer<ArrayBufferLike>; + content?: string | Uint8Array; contentLength?: number; } diff --git a/packages/commons/src/lib/ws_api.ts b/packages/commons/src/lib/ws_api.ts index 67beb0b42e..5b2b164a3f 100644 --- a/packages/commons/src/lib/ws_api.ts +++ b/packages/commons/src/lib/ws_api.ts @@ -18,7 +18,7 @@ export interface EntityChange { export interface EntityRow { isDeleted?: boolean; - content?: Buffer | string; + content?: Uint8Array | string; } export interface EntityChangeRecord { diff --git a/packages/trilium-core/src/becca/entities/abstract_becca_entity.ts b/packages/trilium-core/src/becca/entities/abstract_becca_entity.ts index b224349ca2..d14626781e 100644 --- a/packages/trilium-core/src/becca/entities/abstract_becca_entity.ts +++ b/packages/trilium-core/src/becca/entities/abstract_becca_entity.ts @@ -10,6 +10,7 @@ import utils from "../../services/utils.js"; import becca from "../becca.js"; import type { ConstructorData,default as Becca } from "../becca-interface.js"; import { getSql } from "src/services/sql"; +import { concat2, encodeUtf8, unwrapStringOrBuffer, wrapStringOrBuffer } from "src/services/utils/binary"; interface ContentOpts { forceSave?: boolean; @@ -137,7 +138,7 @@ abstract class AbstractBeccaEntity<T extends AbstractBeccaEntity<T>> { return this; } - protected _setContent(content: string | Buffer, opts: ContentOpts = {}) { + protected _setContent(content: string | Uint8Array, opts: ContentOpts = {}) { // client code asks to save entity even if blobId didn't change (something else was changed) opts.forceSave = !!opts.forceSave; opts.forceFrontendReload = !!opts.forceFrontendReload; @@ -148,9 +149,9 @@ abstract class AbstractBeccaEntity<T extends AbstractBeccaEntity<T>> { } if (this.hasStringContent()) { - content = content.toString(); + content = unwrapStringOrBuffer(content); } else { - content = Buffer.isBuffer(content) ? content : Buffer.from(content); + content = wrapStringOrBuffer(content); } const unencryptedContentForHashCalculation = this.getUnencryptedContentForHashCalculation(content); @@ -202,17 +203,21 @@ abstract class AbstractBeccaEntity<T extends AbstractBeccaEntity<T>> { sql.execute("DELETE FROM entity_changes WHERE entityName = 'blobs' AND entityId = ?", [oldBlobId]); } - private getUnencryptedContentForHashCalculation(unencryptedContent: Buffer | string) { + private getUnencryptedContentForHashCalculation(unencryptedContent: Uint8Array | string) { if (this.isProtected) { // a "random" prefix makes sure that the calculated hash/blobId is different for a decrypted/encrypted content const encryptedPrefixSuffix = "t$[nvQg7q)&_ENCRYPTED_?M:Bf&j3jr_"; - return Buffer.isBuffer(unencryptedContent) ? Buffer.concat([Buffer.from(encryptedPrefixSuffix), unencryptedContent]) : `${encryptedPrefixSuffix}${unencryptedContent}`; + if (typeof unencryptedContent === "string") { + return `${encryptedPrefixSuffix}${unencryptedContent}`; + } else { + return concat2(encodeUtf8(encryptedPrefixSuffix), unencryptedContent) + } } return unencryptedContent; } - private saveBlob(content: string | Buffer, unencryptedContentForHashCalculation: string | Buffer, opts: ContentOpts = {}) { + private saveBlob(content: string | Uint8Array, unencryptedContentForHashCalculation: string | Uint8Array, opts: ContentOpts = {}) { /* * We're using the unencrypted blob for the hash calculation, because otherwise the random IV would * cause every content blob to be unique which would balloon the database size (esp. with revisioning). @@ -260,16 +265,16 @@ abstract class AbstractBeccaEntity<T extends AbstractBeccaEntity<T>> { return newBlobId; } - protected _getContent(): string | Buffer { + protected _getContent(): string | Uint8Array { const sql = getSql(); - const row = sql.getRow<{ content: string | Buffer }>(/*sql*/`SELECT content FROM blobs WHERE blobId = ?`, [this.blobId]); + const row = sql.getRow<{ content: string | Uint8Array }>(/*sql*/`SELECT content FROM blobs WHERE blobId = ?`, [this.blobId]); if (!row) { const constructorData = this.constructor as unknown as ConstructorData<T>; throw new Error(`Cannot find content for ${constructorData.primaryKeyName} '${(this as any)[constructorData.primaryKeyName]}', blobId '${this.blobId}'`); } - return blobService.processContent(row.content, this.isProtected || false, this.hasStringContent()) as string | Buffer; + return blobService.processContent(row.content, this.isProtected || false, this.hasStringContent()) as string | Uint8Array; } /** diff --git a/packages/trilium-core/src/becca/entities/battachment.ts b/packages/trilium-core/src/becca/entities/battachment.ts index cfd0a638b9..b8b86ace1a 100644 --- a/packages/trilium-core/src/becca/entities/battachment.ts +++ b/packages/trilium-core/src/becca/entities/battachment.ts @@ -136,11 +136,11 @@ class BAttachment extends AbstractBeccaEntity<BAttachment> { } } - getContent(): Buffer { - return this._getContent() as Buffer; + getContent(): Uint8Array { + return this._getContent() as Uint8Array; } - setContent(content: string | Buffer, opts?: ContentOpts) { + setContent(content: string | Uint8Array, opts?: ContentOpts) { this._setContent(content, opts); } diff --git a/packages/trilium-core/src/becca/entities/bblob.ts b/packages/trilium-core/src/becca/entities/bblob.ts index 2cff185d5c..a3ec261382 100644 --- a/packages/trilium-core/src/becca/entities/bblob.ts +++ b/packages/trilium-core/src/becca/entities/bblob.ts @@ -13,7 +13,7 @@ class BBlob extends AbstractBeccaEntity<BBlob> { return ["blobId", "content"]; } - content!: string | Buffer; + content!: string | Uint8Array; contentLength!: number; constructor(row: BlobRow) { diff --git a/packages/trilium-core/src/becca/entities/bnote.ts b/packages/trilium-core/src/becca/entities/bnote.ts index 3d9a9fe2cc..4f7b70a3c0 100644 --- a/packages/trilium-core/src/becca/entities/bnote.ts +++ b/packages/trilium-core/src/becca/entities/bnote.ts @@ -251,7 +251,7 @@ class BNote extends AbstractBeccaEntity<BNote> { } } - setContent(content: Buffer | string, opts: ContentOpts = {}) { + setContent(content: Uint8Array | string, opts: ContentOpts = {}) { this._setContent(content, opts); eventService.emit(eventService.NOTE_CONTENT_CHANGE, { entity: this }); diff --git a/packages/trilium-core/src/becca/entities/brevision.ts b/packages/trilium-core/src/becca/entities/brevision.ts index 2df8a4c9e6..c09e9ff211 100644 --- a/packages/trilium-core/src/becca/entities/brevision.ts +++ b/packages/trilium-core/src/becca/entities/brevision.ts @@ -42,7 +42,7 @@ class BRevision extends AbstractBeccaEntity<BRevision> { dateLastEdited?: string; utcDateLastEdited?: string; contentLength?: number; - content?: string | Buffer; + content?: string | Uint8Array; constructor(row: RevisionRow, titleDecrypted = false) { super(); @@ -95,7 +95,7 @@ class BRevision extends AbstractBeccaEntity<BRevision> { * * This is the same approach as is used for Note's content. */ - getContent(): string | Buffer { + getContent(): string | Uint8Array { return this._getContent(); } @@ -120,7 +120,7 @@ class BRevision extends AbstractBeccaEntity<BRevision> { } } - setContent(content: string | Buffer, opts: ContentOpts = {}) { + setContent(content: string | Uint8Array, opts: ContentOpts = {}) { this._setContent(content, opts); } diff --git a/packages/trilium-core/src/services/blob.ts b/packages/trilium-core/src/services/blob.ts index 912ed64164..06e76d5bd9 100644 --- a/packages/trilium-core/src/services/blob.ts +++ b/packages/trilium-core/src/services/blob.ts @@ -23,13 +23,13 @@ function getBlobPojo(entityName: string, entityId: string, opts?: { preview: boo if (!entity.hasStringContent()) { pojo.content = null; } else { - pojo.content = processContent(pojo.content, !!entity.isProtected, true) as string | Buffer; + pojo.content = processContent(pojo.content, !!entity.isProtected, true) as string | Uint8Array; } return pojo; } -function processContent(content: Buffer | Uint8Array | string | null, isProtected: boolean, isStringContent: boolean) { +function processContent(content: Uint8Array | string | null, isProtected: boolean, isStringContent: boolean) { if (isProtected) { if (protectedSessionService.isProtectedSessionAvailable()) { content = content === null ? null : protectedSessionService.decrypt(content as Uint8Array); @@ -47,7 +47,7 @@ function processContent(content: Buffer | Uint8Array | string | null, isProtecte // IIRC a zero-sized buffer can be returned as null from the database if (content === null) { // this will force de/encryption - content = Buffer.alloc(0); + content = new Uint8Array(0); } return content; diff --git a/packages/trilium-core/src/services/encryption/data_encryption.ts b/packages/trilium-core/src/services/encryption/data_encryption.ts index e6bb9d1e84..ffae2c1e9d 100644 --- a/packages/trilium-core/src/services/encryption/data_encryption.ts +++ b/packages/trilium-core/src/services/encryption/data_encryption.ts @@ -1,5 +1,5 @@ import { getLog } from "../log.js"; -import { concat2, decodeBase64, decodeUtf8, encodeBase64 } from "../utils/binary.js"; +import { concat2, decodeBase64, decodeUtf8, encodeBase64, encodeUtf8 } from "../utils/binary.js"; import { getCrypto } from "./crypto.js"; function arraysIdentical(a: any[] | Uint8Array, b: any[] | Uint8Array) { @@ -55,7 +55,7 @@ function decrypt(key: Uint8Array, cipherText: string | Uint8Array): Uint8Array | } if (!key) { - return Uint8Array.from("[protected]"); + return encodeUtf8("[protected]"); } try { diff --git a/packages/trilium-core/src/services/sql/types.ts b/packages/trilium-core/src/services/sql/types.ts index eff81ae23b..e4bd5cb1c7 100644 --- a/packages/trilium-core/src/services/sql/types.ts +++ b/packages/trilium-core/src/services/sql/types.ts @@ -21,7 +21,7 @@ export interface RunResult { export interface DatabaseProvider { loadFromFile(path: string, isReadOnly: boolean): void; loadFromMemory(): void; - loadFromBuffer(buffer: NonSharedBuffer): void; + loadFromBuffer(buffer: Uint8Array): void; backup(destinationFile: string): void; prepare(query: string): Statement; transaction<T>(func: (statement: Statement) => T): Transaction; diff --git a/packages/trilium-core/src/services/utils/binary.ts b/packages/trilium-core/src/services/utils/binary.ts index 5861ac9067..94272039f6 100644 --- a/packages/trilium-core/src/services/utils/binary.ts +++ b/packages/trilium-core/src/services/utils/binary.ts @@ -1,4 +1,5 @@ const utf8Decoder = new TextDecoder("utf-8"); +const utf8Encoder = new TextEncoder(); export function concat2(a: Uint8Array, b: Uint8Array): Uint8Array { const out = new Uint8Array(a.length + b.length); @@ -33,3 +34,23 @@ export function decodeBase64(base64: string): Uint8Array { export function decodeUtf8(bytes: Uint8Array) { return utf8Decoder.decode(bytes); } + +export function encodeUtf8(string: string) { + return utf8Encoder.encode(string); +} + +export function unwrapStringOrBuffer(stringOrBuffer: string | Uint8Array) { + if (typeof stringOrBuffer === "string") { + return stringOrBuffer; + } else { + return decodeUtf8(stringOrBuffer); + } +} + +export function wrapStringOrBuffer(stringOrBuffer: string | Uint8Array) { + if (typeof stringOrBuffer === "string") { + return encodeUtf8(stringOrBuffer); + } else { + return stringOrBuffer; + } +} From c8d3b091fdbba6ba107bbaa2c656d472ff619e17 Mon Sep 17 00:00:00 2001 From: Elian Doran <contact@eliandoran.me> Date: Tue, 6 Jan 2026 12:17:20 +0200 Subject: [PATCH 27/72] chore(commons): fix Node reference --- packages/commons/src/lib/test-utils.ts | 4 ---- packages/commons/tsconfig.spec.json | 3 ++- 2 files changed, 2 insertions(+), 5 deletions(-) diff --git a/packages/commons/src/lib/test-utils.ts b/packages/commons/src/lib/test-utils.ts index 86ebfb0d60..cf1797361d 100644 --- a/packages/commons/src/lib/test-utils.ts +++ b/packages/commons/src/lib/test-utils.ts @@ -55,10 +55,6 @@ export function trimIndentation(strings: TemplateStringsArray, ...values: any[]) return output.join("\n"); } -export function flushPromises() { - return new Promise(setImmediate); -} - export function sleepFor(duration: number) { return new Promise(resolve => setTimeout(resolve, duration)); } diff --git a/packages/commons/tsconfig.spec.json b/packages/commons/tsconfig.spec.json index de726af213..e56c0502f4 100644 --- a/packages/commons/tsconfig.spec.json +++ b/packages/commons/tsconfig.spec.json @@ -3,7 +3,8 @@ "compilerOptions": { "outDir": "./out-tsc/vitest", "types": [ - "vitest" + "vitest", + "node" ], "forceConsistentCasingInFileNames": true }, From 05b9e2ec2ac4d155baca41cd9c7f3cea19b1f0b3 Mon Sep 17 00:00:00 2001 From: Elian Doran <contact@eliandoran.me> Date: Tue, 6 Jan 2026 12:20:01 +0200 Subject: [PATCH 28/72] chore(core): fix references to core --- packages/trilium-core/src/becca/entities/bnote.ts | 2 +- packages/trilium-core/src/services/blob.ts | 5 ++--- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/packages/trilium-core/src/becca/entities/bnote.ts b/packages/trilium-core/src/becca/entities/bnote.ts index 4f7b70a3c0..20d7dd970b 100644 --- a/packages/trilium-core/src/becca/entities/bnote.ts +++ b/packages/trilium-core/src/becca/entities/bnote.ts @@ -1,6 +1,6 @@ import type { AttachmentRow, AttributeType, CloneResponse, NoteRow, NoteType, RevisionRow } from "@triliumnext/commons"; import { dayjs } from "@triliumnext/commons"; -import { events as eventService } from "@triliumnext/core"; +import eventService from "../../services/events"; import cloningService from "../../services/cloning.js"; import dateUtils from "../../services/utils/date"; diff --git a/packages/trilium-core/src/services/blob.ts b/packages/trilium-core/src/services/blob.ts index 06e76d5bd9..bdf04e01c8 100644 --- a/packages/trilium-core/src/services/blob.ts +++ b/packages/trilium-core/src/services/blob.ts @@ -1,10 +1,9 @@ -import { binary_utils } from "@triliumnext/core"; - import becca from "../becca/becca.js"; import { NotFoundError } from "../errors"; import type { Blob } from "./blob-interface.js"; import protectedSessionService from "./protected_session.js"; import { hash } from "./utils.js"; +import { decodeUtf8 } from "./utils/binary.js"; function getBlobPojo(entityName: string, entityId: string, opts?: { preview: boolean }) { // TODO: Unused opts. @@ -41,7 +40,7 @@ function processContent(content: Uint8Array | string | null, isProtected: boolea if (isStringContent) { if (content === null) return ""; if (typeof content === "string") return content; - return binary_utils.decodeUtf8(content as Uint8Array); + return decodeUtf8(content as Uint8Array); } // see https://github.com/zadam/trilium/issues/3523 // IIRC a zero-sized buffer can be returned as null from the database From 01f3c32d9228489df7418288e54cf9823d2d57c3 Mon Sep 17 00:00:00 2001 From: Elian Doran <contact@eliandoran.me> Date: Tue, 6 Jan 2026 12:24:09 +0200 Subject: [PATCH 29/72] refactor(server): remove Blob interface in favor of BlobRow --- apps/server/src/services/blob-interface.ts | 5 ---- apps/server/src/services/entity_changes.ts | 5 ++-- .../src/share/shaca/entities/sattachment.ts | 23 ++++++++++--------- apps/server/src/share/shaca/entities/snote.ts | 4 ++-- packages/trilium-core/src/services/blob.ts | 4 ++-- 5 files changed, 18 insertions(+), 23 deletions(-) delete mode 100644 apps/server/src/services/blob-interface.ts diff --git a/apps/server/src/services/blob-interface.ts b/apps/server/src/services/blob-interface.ts deleted file mode 100644 index a0e6052785..0000000000 --- a/apps/server/src/services/blob-interface.ts +++ /dev/null @@ -1,5 +0,0 @@ -export interface Blob { - blobId: string; - content: string | Buffer; - utcDateModified: string; -} diff --git a/apps/server/src/services/entity_changes.ts b/apps/server/src/services/entity_changes.ts index 7d0b53e574..65c941e1c5 100644 --- a/apps/server/src/services/entity_changes.ts +++ b/apps/server/src/services/entity_changes.ts @@ -1,8 +1,7 @@ -import type { EntityChange } from "@triliumnext/commons"; +import type { BlobRow, EntityChange } from "@triliumnext/commons"; import { blob as blobService, events as eventService } from "@triliumnext/core"; import becca from "../becca/becca.js"; -import type { Blob } from "./blob-interface.js"; import cls from "./cls.js"; import dateUtils from "./date_utils.js"; import instanceId from "./instance_id.js"; @@ -146,7 +145,7 @@ function fillEntityChanges(entityName: string, entityPrimaryKey: string, conditi }; if (entityName === "blobs") { - const blob = sql.getRow<Blob>("SELECT blobId, content, utcDateModified FROM blobs WHERE blobId = ?", [entityId]); + const blob = sql.getRow<Pick<BlobRow, "blobId" | "content" | "utcDateModified">>("SELECT blobId, content, utcDateModified FROM blobs WHERE blobId = ?", [entityId]); ec.hash = blobService.calculateContentHash(blob); ec.utcDateChanged = blob.utcDateModified; ec.isSynced = true; // blobs are always synced diff --git a/apps/server/src/share/shaca/entities/sattachment.ts b/apps/server/src/share/shaca/entities/sattachment.ts index 11d3af0969..1f4f1ae90d 100644 --- a/apps/server/src/share/shaca/entities/sattachment.ts +++ b/apps/server/src/share/shaca/entities/sattachment.ts @@ -1,11 +1,12 @@ -"use strict"; -import sql from "../../sql.js"; + +import { BlobRow } from "@triliumnext/commons"; + import utils from "../../../services/utils.js"; +import sql from "../../sql.js"; import AbstractShacaEntity from "./abstract_shaca_entity.js"; -import type SNote from "./snote.js"; -import type { Blob } from "../../../services/blob-interface.js"; import type { SAttachmentRow } from "./rows.js"; +import type SNote from "./snote.js"; class SAttachment extends AbstractShacaEntity { private attachmentId: string; @@ -37,23 +38,23 @@ class SAttachment extends AbstractShacaEntity { } getContent(silentNotFoundError = false) { - const row = sql.getRow<Pick<Blob, "content">>(/*sql*/`SELECT content FROM blobs WHERE blobId = ?`, [this.blobId]); + const row = sql.getRow<Pick<BlobRow, "content">>(/*sql*/`SELECT content FROM blobs WHERE blobId = ?`, [this.blobId]); if (!row) { if (silentNotFoundError) { return undefined; - } else { - throw new Error(`Cannot find blob for attachment '${this.attachmentId}', blob '${this.blobId}'`); - } + } + throw new Error(`Cannot find blob for attachment '${this.attachmentId}', blob '${this.blobId}'`); + } const content = row.content; if (this.hasStringContent()) { return content === null ? "" : content.toString("utf-8"); - } else { - return content; - } + } + return content; + } /** @returns true if the attachment has string content (not binary) */ diff --git a/apps/server/src/share/shaca/entities/snote.ts b/apps/server/src/share/shaca/entities/snote.ts index ecc1d31917..6f0b9c8c6d 100644 --- a/apps/server/src/share/shaca/entities/snote.ts +++ b/apps/server/src/share/shaca/entities/snote.ts @@ -1,7 +1,7 @@ +import { BlobRow } from "@triliumnext/commons"; import { NOTE_TYPE_ICONS } from "@triliumnext/core"; import escape from "escape-html"; -import type { Blob } from "../../../services/blob-interface.js"; import utils from "../../../services/utils.js"; import sql from "../../sql.js"; import AbstractShacaEntity from "./abstract_shaca_entity.js"; @@ -95,7 +95,7 @@ class SNote extends AbstractShacaEntity { } getContent(silentNotFoundError = false) { - const row = sql.getRow<Pick<Blob, "content">>(/*sql*/`SELECT content FROM blobs WHERE blobId = ?`, [this.blobId]); + const row = sql.getRow<Pick<BlobRow, "content">>(/*sql*/`SELECT content FROM blobs WHERE blobId = ?`, [this.blobId]); if (!row) { if (silentNotFoundError) { diff --git a/packages/trilium-core/src/services/blob.ts b/packages/trilium-core/src/services/blob.ts index bdf04e01c8..9de989abcd 100644 --- a/packages/trilium-core/src/services/blob.ts +++ b/packages/trilium-core/src/services/blob.ts @@ -1,6 +1,6 @@ +import { BlobRow } from "@triliumnext/commons"; import becca from "../becca/becca.js"; import { NotFoundError } from "../errors"; -import type { Blob } from "./blob-interface.js"; import protectedSessionService from "./protected_session.js"; import { hash } from "./utils.js"; import { decodeUtf8 } from "./utils/binary.js"; @@ -52,7 +52,7 @@ function processContent(content: Uint8Array | string | null, isProtected: boolea return content; } -function calculateContentHash({ blobId, content }: Blob) { +function calculateContentHash({ blobId, content }: Pick<BlobRow, "blobId" | "content">) { return hash(`${blobId}|${content.toString()}`); } From b9a59fe0c4d77b77d8ffb59be2f66d456309cfc9 Mon Sep 17 00:00:00 2001 From: Elian Doran <contact@eliandoran.me> Date: Tue, 6 Jan 2026 12:27:00 +0200 Subject: [PATCH 30/72] chore(core): fixs some imports to protected_session --- packages/trilium-core/src/index.ts | 2 +- .../src/services/{encryption => }/protected_session.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) rename packages/trilium-core/src/services/{encryption => }/protected_session.ts (95%) diff --git a/packages/trilium-core/src/index.ts b/packages/trilium-core/src/index.ts index e8f5e95f4b..cbdcba64d9 100644 --- a/packages/trilium-core/src/index.ts +++ b/packages/trilium-core/src/index.ts @@ -6,7 +6,7 @@ import { SqlService, SqlServiceParams } from "./services/sql/sql"; export type * from "./services/sql/types"; export * from "./services/sql/index"; -export * as protected_session from "./services/encryption/protected_session"; +export * as protected_session from "./services/protected_session"; export { default as data_encryption } from "./services/encryption/data_encryption" export * as binary_utils from "./services/utils/binary"; export { default as date_utils } from "./services/utils/date"; diff --git a/packages/trilium-core/src/services/encryption/protected_session.ts b/packages/trilium-core/src/services/protected_session.ts similarity index 95% rename from packages/trilium-core/src/services/encryption/protected_session.ts rename to packages/trilium-core/src/services/protected_session.ts index ae4d69029b..107bf298ec 100644 --- a/packages/trilium-core/src/services/encryption/protected_session.ts +++ b/packages/trilium-core/src/services/protected_session.ts @@ -1,6 +1,6 @@ "use strict"; -import dataEncryptionService from "./data_encryption.js"; +import dataEncryptionService from "./encryption/data_encryption"; let dataKey: Uint8Array | null = null; From 321fcf34f25ac501dd749acfb1b5ae0a30f73d9a Mon Sep 17 00:00:00 2001 From: Elian Doran <contact@eliandoran.me> Date: Tue, 6 Jan 2026 12:29:13 +0200 Subject: [PATCH 31/72] chore(core): fix references to getHoistedNoteId --- apps/server/src/services/cls.ts | 4 ++-- packages/trilium-core/src/becca/becca_service.ts | 4 ++-- packages/trilium-core/src/becca/entities/bbranch.ts | 4 ++-- packages/trilium-core/src/services/context.ts | 4 ++++ 4 files changed, 10 insertions(+), 6 deletions(-) diff --git a/apps/server/src/services/cls.ts b/apps/server/src/services/cls.ts index 4d75ca67d9..8ecb3132ff 100644 --- a/apps/server/src/services/cls.ts +++ b/apps/server/src/services/cls.ts @@ -1,5 +1,5 @@ import type { EntityChange } from "@triliumnext/commons"; -import { getContext } from "@triliumnext/core/src/services/context"; +import { getContext, getHoistedNoteId as getHoistedNoteIdInternal } from "@triliumnext/core/src/services/context"; type Callback = (...args: any[]) => any; @@ -18,7 +18,7 @@ function wrap(callback: Callback) { } function getHoistedNoteId() { - return getContext().get("hoistedNoteId") || "root"; + return getHoistedNoteIdInternal(); } function getComponentId() { diff --git a/packages/trilium-core/src/becca/becca_service.ts b/packages/trilium-core/src/becca/becca_service.ts index a5a4001a91..dcd67c7095 100644 --- a/packages/trilium-core/src/becca/becca_service.ts +++ b/packages/trilium-core/src/becca/becca_service.ts @@ -2,7 +2,7 @@ import becca from "./becca.js"; import { getLog } from "../services/log.js"; -import { getContext } from "src/services/context.js"; +import { getHoistedNoteId } from "src/services/context.js"; function isNotePathArchived(notePath: string[]) { const noteId = notePath[notePath.length - 1]; @@ -82,7 +82,7 @@ function getNoteTitleArrayForPath(notePathArray: string[]) { let hoistedNotePassed = false; // this is a notePath from outside of hoisted subtree, so the full title path needs to be returned - const hoistedNoteId = getContext().getHoistedNoteId(); + const hoistedNoteId = getHoistedNoteId(); const outsideOfHoistedSubtree = !notePathArray.includes(hoistedNoteId); for (const noteId of notePathArray) { diff --git a/packages/trilium-core/src/becca/entities/bbranch.ts b/packages/trilium-core/src/becca/entities/bbranch.ts index 4c7be548b9..78e8bb199d 100644 --- a/packages/trilium-core/src/becca/entities/bbranch.ts +++ b/packages/trilium-core/src/becca/entities/bbranch.ts @@ -9,7 +9,7 @@ import TaskContext from "../../services/task_context.js"; import utils from "../../services/utils.js"; import AbstractBeccaEntity from "./abstract_becca_entity.js"; import BNote from "./bnote.js"; -import { getContext } from "src/services/context"; +import { getHoistedNoteId } from "src/services/context"; /** * Branch represents a relationship between a child note and its parent note. Trilium allows a note to have multiple @@ -160,7 +160,7 @@ class BBranch extends AbstractBeccaEntity<BBranch> { } } - if ((this.noteId === "root" || this.noteId === getContext().getHoistedNoteId()) && !this.isWeak) { + if ((this.noteId === "root" || this.noteId === getHoistedNoteId()) && !this.isWeak) { throw new Error("Can't delete root or hoisted branch/note"); } diff --git a/packages/trilium-core/src/services/context.ts b/packages/trilium-core/src/services/context.ts index 99cf356653..7f783853c5 100644 --- a/packages/trilium-core/src/services/context.ts +++ b/packages/trilium-core/src/services/context.ts @@ -16,3 +16,7 @@ export function getContext(): ExecutionContext { if (!ctx) throw new Error("Context not initialized"); return ctx; } + +export function getHoistedNoteId() { + return getContext().get("hoistedNoteId") || "root"; +} From bbfef0315f434499b0d631ef361ba6c2ca234947 Mon Sep 17 00:00:00 2001 From: Elian Doran <contact@eliandoran.me> Date: Tue, 6 Jan 2026 12:34:10 +0200 Subject: [PATCH 32/72] chore(core): fix incompatibility with Uint8Array --- apps/server/src/routes/api/revisions.ts | 4 ++-- apps/server/src/services/sync.ts | 4 ++-- .../src/share/shaca/entities/sattachment.ts | 11 ++++++----- apps/server/src/share/shaca/entities/snote.ts | 4 ++-- .../trilium-core/src/services/utils/binary.ts | 15 ++++++++++----- 5 files changed, 22 insertions(+), 16 deletions(-) diff --git a/apps/server/src/routes/api/revisions.ts b/apps/server/src/routes/api/revisions.ts index ee4041ecef..b0df419223 100644 --- a/apps/server/src/routes/api/revisions.ts +++ b/apps/server/src/routes/api/revisions.ts @@ -1,7 +1,7 @@ import { EditedNotesResponse, RevisionItem, RevisionPojo } from "@triliumnext/commons"; -import { becca_service, blob as blobService, NotePojo } from "@triliumnext/core"; +import { becca_service, binary_utils, blob as blobService, NotePojo } from "@triliumnext/core"; import type { Request, Response } from "express"; import path from "path"; @@ -55,7 +55,7 @@ function getRevision(req: Request) { revision.content = revision.getContent(); if (revision.content && revision.type === "image") { - revision.content = revision.content.toString("base64"); + revision.content = binary_utils.encodeBase64(revision.content); } } diff --git a/apps/server/src/services/sync.ts b/apps/server/src/services/sync.ts index 665e140e3e..6b52fc60df 100644 --- a/apps/server/src/services/sync.ts +++ b/apps/server/src/services/sync.ts @@ -1,7 +1,7 @@ import type { EntityChange, EntityChangeRecord, EntityRow } from "@triliumnext/commons"; -import { becca_loader, entity_constructor } from "@triliumnext/core"; +import { becca_loader, binary_utils, entity_constructor } from "@triliumnext/core"; import becca from "../becca/becca.js"; import appInfo from "./app_info.js"; @@ -360,7 +360,7 @@ function getEntityChangeRow(entityChange: EntityChange) { } if (entityRow.content) { - entityRow.content = entityRow.content.toString("base64"); + entityRow.content = binary_utils.encodeBase64(entityRow.content); } } diff --git a/apps/server/src/share/shaca/entities/sattachment.ts b/apps/server/src/share/shaca/entities/sattachment.ts index 1f4f1ae90d..090eaf734c 100644 --- a/apps/server/src/share/shaca/entities/sattachment.ts +++ b/apps/server/src/share/shaca/entities/sattachment.ts @@ -1,6 +1,7 @@ import { BlobRow } from "@triliumnext/commons"; +import { binary_utils } from "@triliumnext/core"; import utils from "../../../services/utils.js"; import sql from "../../sql.js"; @@ -43,18 +44,18 @@ class SAttachment extends AbstractShacaEntity { if (!row) { if (silentNotFoundError) { return undefined; - } + } throw new Error(`Cannot find blob for attachment '${this.attachmentId}', blob '${this.blobId}'`); - + } const content = row.content; if (this.hasStringContent()) { - return content === null ? "" : content.toString("utf-8"); - } + return content === null ? "" : binary_utils.decodeUtf8(content); + } return content; - + } /** @returns true if the attachment has string content (not binary) */ diff --git a/apps/server/src/share/shaca/entities/snote.ts b/apps/server/src/share/shaca/entities/snote.ts index 6f0b9c8c6d..ab51a434fa 100644 --- a/apps/server/src/share/shaca/entities/snote.ts +++ b/apps/server/src/share/shaca/entities/snote.ts @@ -1,5 +1,5 @@ import { BlobRow } from "@triliumnext/commons"; -import { NOTE_TYPE_ICONS } from "@triliumnext/core"; +import { binary_utils, NOTE_TYPE_ICONS } from "@triliumnext/core"; import escape from "escape-html"; import utils from "../../../services/utils.js"; @@ -107,7 +107,7 @@ class SNote extends AbstractShacaEntity { const content = row.content; if (this.hasStringContent()) { - return content === null ? "" : content.toString("utf-8"); + return content === null ? "" : binary_utils.decodeUtf8(content); } return content; } diff --git a/packages/trilium-core/src/services/utils/binary.ts b/packages/trilium-core/src/services/utils/binary.ts index 94272039f6..54b734ca76 100644 --- a/packages/trilium-core/src/services/utils/binary.ts +++ b/packages/trilium-core/src/services/utils/binary.ts @@ -8,7 +8,8 @@ export function concat2(a: Uint8Array, b: Uint8Array): Uint8Array { return out; } -export function encodeBase64(bytes: Uint8Array): string { +export function encodeBase64(stringOrBuffer: string | Uint8Array): string { + const bytes = wrapStringOrBuffer(stringOrBuffer); let binary = ""; const len = bytes.length; @@ -31,12 +32,16 @@ export function decodeBase64(base64: string): Uint8Array { return bytes; } -export function decodeUtf8(bytes: Uint8Array) { - return utf8Decoder.decode(bytes); +export function decodeUtf8(stringOrBuffer: string | Uint8Array) { + if (typeof stringOrBuffer === "string") { + return stringOrBuffer; + } else { + return utf8Decoder.decode(stringOrBuffer); + } } -export function encodeUtf8(string: string) { - return utf8Encoder.encode(string); +export function encodeUtf8(string: string | Uint8Array) { + return utf8Encoder.encode(wrapStringOrBuffer(string)); } export function unwrapStringOrBuffer(stringOrBuffer: string | Uint8Array) { From 20c90d1296cf426ad6760c61d33f258991f36c7f Mon Sep 17 00:00:00 2001 From: Elian Doran <contact@eliandoran.me> Date: Tue, 6 Jan 2026 12:40:43 +0200 Subject: [PATCH 33/72] chore(server): fix incompatibility with Uint8Array --- apps/server/src/routes/api/files.ts | 4 +- apps/server/src/routes/api/image.ts | 11 +-- apps/server/src/services/export/single.ts | 19 ++--- apps/server/src/services/export/zip.ts | 10 +-- .../services/export/zip/abstract_provider.ts | 2 +- apps/server/src/services/export/zip/html.ts | 2 +- .../src/services/export/zip/markdown.ts | 6 +- .../src/services/export/zip/share_theme.ts | 8 +- .../src/services/llm/tools/read_note_tool.ts | 6 +- apps/server/src/services/notes.ts | 6 +- apps/server/src/services/script.ts | 5 +- .../expressions/note_content_fulltext.ts | 79 +++++++++---------- apps/server/src/share/content_renderer.ts | 2 +- 13 files changed, 81 insertions(+), 79 deletions(-) diff --git a/apps/server/src/routes/api/files.ts b/apps/server/src/routes/api/files.ts index 2095f3b712..fbfa63a0d0 100644 --- a/apps/server/src/routes/api/files.ts +++ b/apps/server/src/routes/api/files.ts @@ -121,7 +121,7 @@ function attachmentContentProvider(req: Request) { return streamContent(attachment.getContent(), attachment.getFileName(), attachment.mime); } -async function streamContent(content: string | Buffer, fileName: string, mimeType: string) { +async function streamContent(content: string | Uint8Array, fileName: string, mimeType: string) { if (typeof content === "string") { content = Buffer.from(content, "utf8"); } @@ -168,7 +168,7 @@ function saveAttachmentToTmpDir(req: Request) { const createdTemporaryFiles = new Set<string>(); -function saveToTmpDir(fileName: string, content: string | Buffer, entityType: string, entityId: string) { +function saveToTmpDir(fileName: string, content: string | Uint8Array, entityType: string, entityId: string) { const tmpObj = tmp.fileSync({ postfix: fileName, tmpdir: dataDirs.TMP_DIR diff --git a/apps/server/src/routes/api/image.ts b/apps/server/src/routes/api/image.ts index aa24e7aae9..ec7228d7b9 100644 --- a/apps/server/src/routes/api/image.ts +++ b/apps/server/src/routes/api/image.ts @@ -1,11 +1,12 @@ -"use strict"; -import imageService from "../../services/image.js"; -import becca from "../../becca/becca.js"; -import fs from "fs"; + import type { Request, Response } from "express"; +import fs from "fs"; + +import becca from "../../becca/becca.js"; import type BNote from "../../becca/entities/bnote.js"; import type BRevision from "../../becca/entities/brevision.js"; +import imageService from "../../services/image.js"; import { RESOURCE_DIR } from "../../services/resource_dir.js"; function returnImageFromNote(req: Request, res: Response) { @@ -42,7 +43,7 @@ function returnImageInt(image: BNote | BRevision | null, res: Response) { } export function renderSvgAttachment(image: BNote | BRevision, res: Response, attachmentName: string) { - let svg: string | Buffer = `<svg xmlns="http://www.w3.org/2000/svg"></svg>`; + let svg: string | Uint8Array = `<svg xmlns="http://www.w3.org/2000/svg"></svg>`; const attachment = image.getAttachmentByTitle(attachmentName); if (attachment) { diff --git a/apps/server/src/services/export/single.ts b/apps/server/src/services/export/single.ts index 16d36807e8..e115e0e54b 100644 --- a/apps/server/src/services/export/single.ts +++ b/apps/server/src/services/export/single.ts @@ -1,14 +1,15 @@ -"use strict"; -import mimeTypes from "mime-types"; -import html from "html"; -import { getContentDisposition, escapeHtml } from "../utils.js"; -import mdService from "./markdown.js"; -import becca from "../../becca/becca.js"; -import type TaskContext from "../task_context.js"; -import type BBranch from "../../becca/entities/bbranch.js"; + import type { Response } from "express"; +import html from "html"; +import mimeTypes from "mime-types"; + +import becca from "../../becca/becca.js"; +import type BBranch from "../../becca/entities/bbranch.js"; import type BNote from "../../becca/entities/bnote.js"; +import type TaskContext from "../task_context.js"; +import { escapeHtml,getContentDisposition } from "../utils.js"; +import mdService from "./markdown.js"; import type { ExportFormat } from "./zip/abstract_provider.js"; function exportSingleNote(taskContext: TaskContext<"export">, branch: BBranch, format: ExportFormat, res: Response) { @@ -34,7 +35,7 @@ function exportSingleNote(taskContext: TaskContext<"export">, branch: BBranch, f taskContext.taskSucceeded(null); } -export function mapByNoteType(note: BNote, content: string | Buffer<ArrayBufferLike>, format: ExportFormat) { +export function mapByNoteType(note: BNote, content: string | Uint8Array, format: ExportFormat) { let payload, extension, mime; if (typeof content !== "string") { diff --git a/apps/server/src/services/export/zip.ts b/apps/server/src/services/export/zip.ts index dbab81687e..a0c32ef6bd 100644 --- a/apps/server/src/services/export/zip.ts +++ b/apps/server/src/services/export/zip.ts @@ -318,7 +318,7 @@ async function exportToZip(taskContext: TaskContext<"export">, branch: BBranch, } } - function prepareContent(title: string, content: string | Buffer, noteMeta: NoteMeta, note?: BNote): string | Buffer { + function prepareContent(title: string, content: string | Uint8Array, noteMeta: NoteMeta, note?: BNote): string | Uint8Array { const isText = ["html", "markdown"].includes(noteMeta?.format || ""); if (isText) { content = content.toString(); @@ -339,11 +339,11 @@ async function exportToZip(taskContext: TaskContext<"export">, branch: BBranch, if (noteMeta.isClone) { const targetUrl = getNoteTargetUrl(noteMeta.noteId, noteMeta); - let content: string | Buffer = `<p>This is a clone of a note. Go to its <a href="${targetUrl}">primary location</a>.</p>`; + let content: string | Uint8Array = `<p>This is a clone of a note. Go to its <a href="${targetUrl}">primary location</a>.</p>`; content = prepareContent(noteMeta.title, content, noteMeta, undefined); - archive.append(content, { name: filePathPrefix + noteMeta.dataFileName }); + archive.append(content as Buffer, { name: filePathPrefix + noteMeta.dataFileName }); return; } @@ -359,7 +359,7 @@ async function exportToZip(taskContext: TaskContext<"export">, branch: BBranch, if (noteMeta.dataFileName) { const content = prepareContent(noteMeta.title, note.getContent(), noteMeta, note); - archive.append(content, { + archive.append(content as string | Buffer, { name: filePathPrefix + noteMeta.dataFileName, date: dateUtils.parseDateTime(note.utcDateModified) }); @@ -375,7 +375,7 @@ async function exportToZip(taskContext: TaskContext<"export">, branch: BBranch, const attachment = note.getAttachmentById(attachmentMeta.attachmentId); const content = attachment.getContent(); - archive.append(content, { + archive.append(content as Buffer, { name: filePathPrefix + attachmentMeta.dataFileName, date: dateUtils.parseDateTime(note.utcDateModified) }); diff --git a/apps/server/src/services/export/zip/abstract_provider.ts b/apps/server/src/services/export/zip/abstract_provider.ts index 5eda4b0767..d3c5ea2220 100644 --- a/apps/server/src/services/export/zip/abstract_provider.ts +++ b/apps/server/src/services/export/zip/abstract_provider.ts @@ -52,7 +52,7 @@ export abstract class ZipExportProvider { } abstract prepareMeta(metaFile: NoteMetaFile): void; - abstract prepareContent(title: string, content: string | Buffer, noteMeta: NoteMeta, note: BNote | undefined, branch: BBranch): string | Buffer; + abstract prepareContent(title: string, content: string | Uint8Array, noteMeta: NoteMeta, note: BNote | undefined, branch: BBranch): string | Uint8Array; abstract afterDone(rootMeta: NoteMeta): void; /** diff --git a/apps/server/src/services/export/zip/html.ts b/apps/server/src/services/export/zip/html.ts index 14fb44acc3..0ba9ed2136 100644 --- a/apps/server/src/services/export/zip/html.ts +++ b/apps/server/src/services/export/zip/html.ts @@ -34,7 +34,7 @@ export default class HtmlExportProvider extends ZipExportProvider { metaFile.files.push(this.cssMeta); } - prepareContent(title: string, content: string | Buffer, noteMeta: NoteMeta): string | Buffer { + prepareContent(title: string, content: string | Uint8Array, noteMeta: NoteMeta): string | Uint8Array { if (noteMeta.format === "html" && typeof content === "string") { if (!content.substr(0, 100).toLowerCase().includes("<html") && !this.zipExportOptions?.skipHtmlTemplate) { if (!noteMeta?.notePath?.length) { diff --git a/apps/server/src/services/export/zip/markdown.ts b/apps/server/src/services/export/zip/markdown.ts index c876a5c161..382bef8747 100644 --- a/apps/server/src/services/export/zip/markdown.ts +++ b/apps/server/src/services/export/zip/markdown.ts @@ -1,12 +1,12 @@ -import NoteMeta from "../../meta/note_meta" -import { ZipExportProvider } from "./abstract_provider.js" +import NoteMeta from "../../meta/note_meta"; import mdService from "../markdown.js"; +import { ZipExportProvider } from "./abstract_provider.js"; export default class MarkdownExportProvider extends ZipExportProvider { prepareMeta() { } - prepareContent(title: string, content: string | Buffer, noteMeta: NoteMeta): string | Buffer { + prepareContent(title: string, content: string | Uint8Array, noteMeta: NoteMeta): string | Uint8Array { if (noteMeta.format === "markdown" && typeof content === "string") { content = this.rewriteFn(content, noteMeta); content = mdService.toMarkdown(content); diff --git a/apps/server/src/services/export/zip/share_theme.ts b/apps/server/src/services/export/zip/share_theme.ts index 316984d50c..534ccf785b 100644 --- a/apps/server/src/services/export/zip/share_theme.ts +++ b/apps/server/src/services/export/zip/share_theme.ts @@ -61,7 +61,7 @@ export default class ShareThemeExportProvider extends ZipExportProvider { metaFile.files.push(this.indexMeta); } - prepareContent(title: string, content: string | Buffer, noteMeta: NoteMeta, note: BNote | undefined, branch: BBranch): string | Buffer { + prepareContent(title: string, content: string | Uint8Array, noteMeta: NoteMeta, note: BNote | undefined, branch: BBranch): string | Uint8Array { if (!noteMeta?.notePath?.length) { throw new Error("Missing note path."); } @@ -150,7 +150,7 @@ export default class ShareThemeExportProvider extends ZipExportProvider { const note = this.branch.getNote(); const fullHtml = this.prepareContent(rootMeta.title ?? "", note.getContent(), rootMeta, note, this.branch); - this.archive.append(fullHtml, { name: this.indexMeta.dataFileName }); + this.archive.append(fullHtml as Buffer, { name: this.indexMeta.dataFileName }); } #saveAssets(rootMeta: NoteMeta, assetsMeta: NoteMeta[]) { @@ -166,7 +166,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]; - let fontData: Buffer | undefined; + let fontData: Uint8Array | undefined; if (iconPack.builtin) { fontData = readFileSync(join(getClientDir(), "fonts", `${iconPack.fontAttachmentId}.${extension}`)); } else { @@ -178,7 +178,7 @@ export default class ShareThemeExportProvider extends ZipExportProvider { continue; }; const fontFileName = `assets/icon-pack-${iconPack.prefix.toLowerCase()}.${extension}`; - this.archive.append(fontData, { name: fontFileName }); + this.archive.append(fontData as Buffer, { name: fontFileName }); } } diff --git a/apps/server/src/services/llm/tools/read_note_tool.ts b/apps/server/src/services/llm/tools/read_note_tool.ts index ddcad559f1..0230e59dad 100644 --- a/apps/server/src/services/llm/tools/read_note_tool.ts +++ b/apps/server/src/services/llm/tools/read_note_tool.ts @@ -4,16 +4,16 @@ * This tool allows the LLM to read the content of a specific note. */ -import type { Tool, ToolHandler } from './tool_interfaces.js'; -import log from '../../log.js'; import becca from '../../../becca/becca.js'; +import log from '../../log.js'; +import type { Tool, ToolHandler } from './tool_interfaces.js'; // Define type for note response interface NoteResponse { noteId: string; title: string; type: string; - content: string | Buffer; + content: string | Uint8Array; attributes?: Array<{ name: string; value: string; diff --git a/apps/server/src/services/notes.ts b/apps/server/src/services/notes.ts index 8c4a66f968..de144196c7 100644 --- a/apps/server/src/services/notes.ts +++ b/apps/server/src/services/notes.ts @@ -670,7 +670,7 @@ function saveAttachments(note: BNote, content: string) { return content; } -function saveLinks(note: BNote, content: string | Buffer) { +function saveLinks(note: BNote, content: string | Uint8Array) { if ((note.type !== "text" && note.type !== "relationMap") || (note.isProtected && !protectedSessionService.isProtectedSessionAvailable())) { return { forceFrontendReload: false, @@ -889,7 +889,7 @@ function getUndeletedParentBranchIds(noteId: string, deleteId: string) { ); } -function scanForLinks(note: BNote, content: string | Buffer) { +function scanForLinks(note: BNote, content: string | Uint8Array) { if (!note || !["text", "relationMap"].includes(note.type)) { return; } @@ -910,7 +910,7 @@ function scanForLinks(note: BNote, content: string | Buffer) { /** * Things which have to be executed after updating content, but asynchronously (separate transaction) */ -async function asyncPostProcessContent(note: BNote, content: string | Buffer) { +async function asyncPostProcessContent(note: BNote, content: string | Uint8Array) { if (cls.isMigrationRunning()) { // this is rarely needed for migrations, but can cause trouble by e.g. triggering downloads return; diff --git a/apps/server/src/services/script.ts b/apps/server/src/services/script.ts index 97dd99898a..4b335f11d2 100644 --- a/apps/server/src/services/script.ts +++ b/apps/server/src/services/script.ts @@ -1,3 +1,4 @@ +import { binary_utils } from "@triliumnext/core"; import { transform } from "sucrase"; import becca from "../becca/becca.js"; @@ -217,8 +218,8 @@ return module.exports; return bundle; } -export function buildJsx(contentRaw: string | Buffer) { - const content = Buffer.isBuffer(contentRaw) ? contentRaw.toString("utf-8") : contentRaw; +export function buildJsx(contentRaw: string | Uint8Array) { + const content = binary_utils.unwrapStringOrBuffer(contentRaw); const output = transform(content, { transforms: ["jsx", "imports"], jsxPragma: "api.preact.h", diff --git a/apps/server/src/services/search/expressions/note_content_fulltext.ts b/apps/server/src/services/search/expressions/note_content_fulltext.ts index 89ba1bc984..4bd3408f9e 100644 --- a/apps/server/src/services/search/expressions/note_content_fulltext.ts +++ b/apps/server/src/services/search/expressions/note_content_fulltext.ts @@ -1,24 +1,23 @@ -"use strict"; + import type { NoteRow } from "@triliumnext/commons"; -import type SearchContext from "../search_context.js"; - -import Expression from "./expression.js"; -import NoteSet from "../note_set.js"; -import log from "../../log.js"; -import becca from "../../../becca/becca.js"; -import protectedSessionService from "../../protected_session.js"; import striptags from "striptags"; -import { normalize } from "../../utils.js"; + +import becca from "../../../becca/becca.js"; +import log from "../../log.js"; +import protectedSessionService from "../../protected_session.js"; import sql from "../../sql.js"; -import { - normalizeSearchText, - calculateOptimizedEditDistance, - validateFuzzySearchTokens, - validateAndPreprocessContent, +import { normalize } from "../../utils.js"; +import NoteSet from "../note_set.js"; +import type SearchContext from "../search_context.js"; +import { + calculateOptimizedEditDistance, + FUZZY_SEARCH_CONFIG, fuzzyMatchWord, - FUZZY_SEARCH_CONFIG -} from "../utils/text_utils.js"; + normalizeSearchText, + validateAndPreprocessContent, + validateFuzzySearchTokens} from "../utils/text_utils.js"; +import Expression from "./expression.js"; const ALLOWED_OPERATORS = new Set(["=", "!=", "*=*", "*=", "=*", "%=", "~=", "~*"]); @@ -295,7 +294,7 @@ class NoteContentFulltextExp extends Expression { return content; } - preprocessContent(content: string | Buffer, type: string, mime: string) { + preprocessContent(content: string | Uint8Array, type: string, mime: string) { content = normalize(content.toString()); if (type === "text" && mime === "text/html") { @@ -338,16 +337,16 @@ class NoteContentFulltextExp extends Expression { private tokenMatchesContent(token: string, content: string, noteId: string): boolean { const normalizedToken = normalizeSearchText(token); const normalizedContent = normalizeSearchText(content); - + if (normalizedContent.includes(normalizedToken)) { return true; } - + // Check flat text for default fulltext search if (!this.flatText || !becca.notes[noteId].getFlatText().includes(token)) { return false; } - + return true; } @@ -358,15 +357,15 @@ class NoteContentFulltextExp extends Expression { try { const normalizedContent = normalizeSearchText(content); const flatText = this.flatText ? normalizeSearchText(becca.notes[noteId].getFlatText()) : ""; - + // For phrase matching, check if tokens appear within reasonable proximity if (this.tokens.length > 1) { return this.matchesPhrase(normalizedContent, flatText); } - + // Single token fuzzy matching const token = normalizeSearchText(this.tokens[0]); - return this.fuzzyMatchToken(token, normalizedContent) || + return this.fuzzyMatchToken(token, normalizedContent) || (this.flatText && this.fuzzyMatchToken(token, flatText)); } catch (error) { log.error(`Error in fuzzy matching for note ${noteId}: ${error}`); @@ -379,45 +378,45 @@ class NoteContentFulltextExp extends Expression { */ private matchesPhrase(content: string, flatText: string): boolean { const searchText = this.flatText ? `${content} ${flatText}` : content; - + // Apply content size limits for phrase matching const limitedText = validateAndPreprocessContent(searchText); if (!limitedText) { return false; } - + const words = limitedText.toLowerCase().split(/\s+/); - + // Only skip phrase matching for truly extreme word counts that could crash the system if (words.length > FUZZY_SEARCH_CONFIG.ABSOLUTE_MAX_WORD_COUNT) { console.error(`Phrase matching skipped due to extreme word count that could cause system instability: ${words.length} words`); return false; } - + // Warn about large word counts but still attempt matching if (words.length > FUZZY_SEARCH_CONFIG.PERFORMANCE_WARNING_WORDS) { console.info(`Large word count for phrase matching: ${words.length} words - may take longer but will attempt full matching`); } - + // Find positions of each token const tokenPositions: number[][] = this.tokens.map(token => { const normalizedToken = normalizeSearchText(token); const positions: number[] = []; - + words.forEach((word, index) => { if (this.fuzzyMatchSingle(normalizedToken, word)) { positions.push(index); } }); - + return positions; }); - + // Check if we found all tokens if (tokenPositions.some(positions => positions.length === 0)) { return false; } - + // Check for phrase proximity using configurable distance return this.hasProximityMatch(tokenPositions, FUZZY_SEARCH_CONFIG.MAX_PHRASE_PROXIMITY); } @@ -431,18 +430,18 @@ class NoteContentFulltextExp extends Expression { const [pos1, pos2] = tokenPositions; return pos1.some(p1 => pos2.some(p2 => Math.abs(p1 - p2) <= maxDistance)); } - + // For more tokens, check if we can find a sequence where all tokens are within range const findSequence = (remaining: number[][], currentPos: number): boolean => { if (remaining.length === 0) return true; - + const [nextPositions, ...rest] = remaining; - return nextPositions.some(pos => - Math.abs(pos - currentPos) <= maxDistance && + return nextPositions.some(pos => + Math.abs(pos - currentPos) <= maxDistance && findSequence(rest, pos) ); }; - + const [firstPositions, ...rest] = tokenPositions; return firstPositions.some(startPos => findSequence(rest, startPos)); } @@ -455,12 +454,12 @@ class NoteContentFulltextExp extends Expression { // For short tokens, require exact match to avoid too many false positives return content.includes(token); } - + const words = content.split(/\s+/); - + // Only limit word processing for truly extreme cases to prevent system instability const limitedWords = words.slice(0, FUZZY_SEARCH_CONFIG.ABSOLUTE_MAX_WORD_COUNT); - + return limitedWords.some(word => this.fuzzyMatchSingle(token, word)); } diff --git a/apps/server/src/share/content_renderer.ts b/apps/server/src/share/content_renderer.ts index e37ac8ab45..2de68222e2 100644 --- a/apps/server/src/share/content_renderer.ts +++ b/apps/server/src/share/content_renderer.ts @@ -30,7 +30,7 @@ const templateCache: Map<string, string> = new Map(); */ export interface Result { header: string; - content: string | Buffer | undefined; + content: string | Uint8Array | undefined; /** Set to `true` if the provided content should be rendered as empty. */ isEmpty?: boolean; } From f5a77477aa5c5978b17072537d32d74d50bfe7ae Mon Sep 17 00:00:00 2001 From: Elian Doran <contact@eliandoran.me> Date: Tue, 6 Jan 2026 12:42:35 +0200 Subject: [PATCH 34/72] chore(core): fix missing CLS method --- apps/server/src/services/cls.ts | 4 ++-- packages/trilium-core/src/services/context.ts | 4 ++++ 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/apps/server/src/services/cls.ts b/apps/server/src/services/cls.ts index 8ecb3132ff..ecbef741ed 100644 --- a/apps/server/src/services/cls.ts +++ b/apps/server/src/services/cls.ts @@ -1,5 +1,5 @@ import type { EntityChange } from "@triliumnext/commons"; -import { getContext, getHoistedNoteId as getHoistedNoteIdInternal } from "@triliumnext/core/src/services/context"; +import { getContext, getHoistedNoteId as getHoistedNoteIdInternal, isEntityEventsDisabled as isEntityEventsDisabledInternal } from "@triliumnext/core/src/services/context"; type Callback = (...args: any[]) => any; @@ -34,7 +34,7 @@ function enableEntityEvents() { } function isEntityEventsDisabled() { - return !!getContext().get("disableEntityEvents"); + return isEntityEventsDisabledInternal(); } function setMigrationRunning(running: boolean) { diff --git a/packages/trilium-core/src/services/context.ts b/packages/trilium-core/src/services/context.ts index 7f783853c5..870596ae33 100644 --- a/packages/trilium-core/src/services/context.ts +++ b/packages/trilium-core/src/services/context.ts @@ -20,3 +20,7 @@ export function getContext(): ExecutionContext { export function getHoistedNoteId() { return getContext().get("hoistedNoteId") || "root"; } + +export function isEntityEventsDisabled() { + return !!getContext().get("disableEntityEvents"); +} From 321558a01f4ac170b06acee6142985d36538a1b8 Mon Sep 17 00:00:00 2001 From: Elian Doran <contact@eliandoran.me> Date: Tue, 6 Jan 2026 12:43:45 +0200 Subject: [PATCH 35/72] chore(core): fix minor type issue --- packages/trilium-core/src/becca/entities/bnote.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/trilium-core/src/becca/entities/bnote.ts b/packages/trilium-core/src/becca/entities/bnote.ts index 20d7dd970b..e59be195c5 100644 --- a/packages/trilium-core/src/becca/entities/bnote.ts +++ b/packages/trilium-core/src/becca/entities/bnote.ts @@ -1515,7 +1515,7 @@ class BNote extends AbstractBeccaEntity<BNote> { taskContext.noteDeletionHandlerTriggered = true; for (const branch of this.getParentBranches()) { - branch.deleteBranch(deleteId, taskContext); + branch.deleteBranch(deleteId ?? undefined, taskContext); } } From bd45c3225142b76935007b0f9e3b13de431bae9b Mon Sep 17 00:00:00 2001 From: Elian Doran <contact@eliandoran.me> Date: Tue, 6 Jan 2026 13:06:14 +0200 Subject: [PATCH 36/72] chore(core): integrate utils partially --- apps/server/src/crypto_provider.ts | 7 ++ .../server/src/services/consistency_checks.ts | 12 +-- apps/server/src/services/content_hash.ts | 9 +- apps/server/src/services/utils.ts | 95 ++++++++----------- .../becca/entities/abstract_becca_entity.ts | 10 +- .../src/becca/entities/battachment.ts | 6 +- .../src/becca/entities/bbranch.ts | 4 +- .../trilium-core/src/becca/entities/bnote.ts | 12 +-- .../src/becca/entities/brevision.ts | 4 +- packages/trilium-core/src/index.ts | 1 + packages/trilium-core/src/services/blob.ts | 2 +- .../src/services/encryption/crypto.ts | 3 +- .../trilium-core/src/services/utils/index.ts | 60 ++++++++++++ 13 files changed, 135 insertions(+), 90 deletions(-) create mode 100644 packages/trilium-core/src/services/utils/index.ts diff --git a/apps/server/src/crypto_provider.ts b/apps/server/src/crypto_provider.ts index c51b7bc9dd..6ddeecf9f3 100644 --- a/apps/server/src/crypto_provider.ts +++ b/apps/server/src/crypto_provider.ts @@ -1,5 +1,8 @@ import { CryptoProvider } from "@triliumnext/core"; import crypto from "crypto"; +import { generator } from "rand-token"; + +const randtoken = generator({ source: "crypto" }); export default class NodejsCryptoProvider implements CryptoProvider { @@ -19,4 +22,8 @@ export default class NodejsCryptoProvider implements CryptoProvider { return crypto.randomBytes(size); } + randomString(length: number): string { + return randtoken.generate(length); + } + } diff --git a/apps/server/src/services/consistency_checks.ts b/apps/server/src/services/consistency_checks.ts index 887b8990d6..23727c30fb 100644 --- a/apps/server/src/services/consistency_checks.ts +++ b/apps/server/src/services/consistency_checks.ts @@ -1,14 +1,12 @@ - - import type { BranchRow } from "@triliumnext/commons"; import type { EntityChange } from "@triliumnext/commons"; -import { becca_loader } from "@triliumnext/core"; +import { becca_loader, utils } from "@triliumnext/core"; import becca from "../becca/becca.js"; import BBranch from "../becca/entities/bbranch.js"; import eraseService from "../services/erase.js"; import noteTypesService from "../services/note_types.js"; -import { hash as getHash, hashedBlobId, randomString } from "../services/utils.js"; +import { hashedBlobId, randomString } from "../services/utils.js"; import cls from "./cls.js"; import entityChangesService from "./entity_changes.js"; import log from "./log.js"; @@ -85,11 +83,11 @@ class ConsistencyChecks { } return true; - } + } logError(`Tree cycle detected at parent-child relationship: '${parentNoteId}' - '${noteId}', whole path: '${path}'`); this.unrecoveredConsistencyErrors = true; - + } else { const newPath = path.slice(); newPath.push(noteId); @@ -492,7 +490,7 @@ class ConsistencyChecks { dateModified: fakeDate }); - const hash = getHash(randomString(10)); + const hash = utils.hash(randomString(10)); entityChangesService.putEntityChange({ entityName: "blobs", diff --git a/apps/server/src/services/content_hash.ts b/apps/server/src/services/content_hash.ts index 6c8365e2b6..41bd27d29e 100644 --- a/apps/server/src/services/content_hash.ts +++ b/apps/server/src/services/content_hash.ts @@ -1,9 +1,8 @@ -"use strict"; +import { utils } from "@triliumnext/core"; -import sql from "./sql.js"; -import { hash } from "./utils.js"; -import log from "./log.js"; import eraseService from "./erase.js"; +import log from "./log.js"; +import sql from "./sql.js"; type SectorHash = Record<string, string>; @@ -48,7 +47,7 @@ function getEntityHashes() { for (const entityHashMap of Object.values(hashMap)) { for (const key in entityHashMap) { - entityHashMap[key] = hash(entityHashMap[key]); + entityHashMap[key] = utils.hash(entityHashMap[key]); } } diff --git a/apps/server/src/services/utils.ts b/apps/server/src/services/utils.ts index 370f9297ff..e0e40094d8 100644 --- a/apps/server/src/services/utils.ts +++ b/apps/server/src/services/utils.ts @@ -1,23 +1,23 @@ -"use strict"; + +import { utils as coreUtils } from "@triliumnext/core"; import chardet from "chardet"; -import stripBom from "strip-bom"; import crypto from "crypto"; -import { generator } from "rand-token"; -import unescape from "unescape"; import escape from "escape-html"; -import sanitize from "sanitize-filename"; -import mimeTypes from "mime-types"; -import path from "path"; -import type NoteMeta from "./meta/note_meta.js"; -import log from "./log.js"; import { t } from "i18next"; +import mimeTypes from "mime-types"; import { release as osRelease } from "os"; +import path from "path"; +import { generator } from "rand-token"; +import sanitize from "sanitize-filename"; +import stripBom from "strip-bom"; +import unescape from "unescape"; + +import log from "./log.js"; +import type NoteMeta from "./meta/note_meta.js"; const osVersion = osRelease().split('.').map(Number); -const randtoken = generator({ source: "crypto" }); - export const isMac = process.platform === "darwin"; export const isWindows = process.platform === "win32"; @@ -28,12 +28,14 @@ export const isElectron = !!process.versions["electron"]; export const isDev = !!(process.env.TRILIUM_ENV && process.env.TRILIUM_ENV === "dev"); +/** @deprecated */ export function newEntityId() { - return randomString(12); + return coreUtils.newEntityId(); } +/** @deprecated */ export function randomString(length: number): string { - return randtoken.generate(length); + return coreUtils.randomString(length); } export function randomSecureToken(bytes = 32) { @@ -44,19 +46,9 @@ export function md5(content: crypto.BinaryLike) { return crypto.createHash("md5").update(content).digest("hex"); } +/** @deprecated */ export function hashedBlobId(content: string | Buffer) { - if (content === null || content === undefined) { - content = ""; - } - - // sha512 is faster than sha256 - const base64Hash = crypto.createHash("sha512").update(content).digest("base64"); - - // we don't want such + and / in the IDs - const kindaBase62Hash = base64Hash.replaceAll("+", "X").replaceAll("/", "Y"); - - // 20 characters of base62 gives us ~120 bit of entropy which is plenty enough - return kindaBase62Hash.substr(0, 20); + return coreUtils.hashedBlobId(content); } export function toBase64(plainText: string | Buffer) { @@ -104,12 +96,6 @@ export function constantTimeCompare(a: string | null | undefined, b: string | nu return crypto.timingSafeEqual(bufA, bufB); } -export function hash(text: string) { - text = text.normalize(); - - return crypto.createHash("sha1").update(text).digest("base64"); -} - export function isEmptyOrWhitespace(str: string | null | undefined) { if (!str) return true; return str.match(/^ *$/) !== null; @@ -160,22 +146,18 @@ export function getContentDisposition(filename: string) { return `file; filename="${uriEncodedFilename}"; filename*=UTF-8''${uriEncodedFilename}`; } -// render and book are string note in the sense that they are expected to contain empty string -const STRING_NOTE_TYPES = new Set(["text", "code", "relationMap", "search", "render", "book", "mermaid", "canvas", "webView"]); -const STRING_MIME_TYPES = new Set(["application/javascript", "application/x-javascript", "application/json", "application/x-sql", "image/svg+xml"]); - export function isStringNote(type: string | undefined, mime: string) { - return (type && STRING_NOTE_TYPES.has(type)) || mime.startsWith("text/") || STRING_MIME_TYPES.has(mime); + return coreUtils.isStringNote(type, mime); } +/** @deprecated */ export function quoteRegex(url: string) { - return url.replace(/[.*+\-?^${}()|[\]\\]/g, "\\$&"); + return coreUtils.quoteRegex(url); } +/** @deprecated */ export function replaceAll(string: string, replaceWhat: string, replaceWith: string) { - const quotedReplaceWhat = quoteRegex(replaceWhat); - - return string.replace(new RegExp(quotedReplaceWhat, "g"), replaceWith); + return coreUtils.replaceAll(string, replaceWhat, replaceWith); } export function formatDownloadTitle(fileName: string, type: string | null, mime: string) { @@ -259,16 +241,14 @@ export function timeLimit<T>(promise: Promise<T>, limitMs: number, errorMessage? }); } +/** @deprecated */ export function removeDiacritic(str: string) { - if (!str) { - return ""; - } - str = str.toString(); - return str.normalize("NFD").replace(/\p{Diacritic}/gu, ""); + return coreUtils.removeDiacritic(str); } +/** @deprecated */ export function normalize(str: string) { - return removeDiacritic(str).toLowerCase(); + return coreUtils.normalize(str); } export function toMap<T extends Record<string, any>>(list: T[], key: keyof T) { @@ -467,28 +447,28 @@ export function normalizeCustomHandlerPattern(pattern: string | null | undefined // If already ends with slash, create both versions if (basePattern.endsWith('/')) { - const withoutSlash = basePattern.slice(0, -1) + '$'; + const withoutSlash = `${basePattern.slice(0, -1) }$`; const withSlash = pattern; return [withoutSlash, withSlash]; - } else { - // Add optional trailing slash - const withSlash = basePattern + '/?$'; - return [withSlash]; } + // Add optional trailing slash + const withSlash = `${basePattern }/?$`; + return [withSlash]; + } // For patterns without $, add both versions if (pattern.endsWith('/')) { const withoutSlash = pattern.slice(0, -1); return [withoutSlash, pattern]; - } else { - const withSlash = pattern + '/'; - return [pattern, withSlash]; } + const withSlash = `${pattern }/`; + return [pattern, withSlash]; + } export function formatUtcTime(time: string) { - return time.replace("T", " ").substring(0, 19) + return time.replace("T", " ").substring(0, 19); } // TODO: Deduplicate with client utils @@ -501,9 +481,9 @@ export function formatSize(size: number | null | undefined) { if (size < 1024) { return `${size} KiB`; - } else { - return `${Math.round(size / 102.4) / 10} MiB`; } + return `${Math.round(size / 102.4) / 10} MiB`; + } function slugify(text: string) { @@ -526,7 +506,6 @@ export default { getContentDisposition, getNoteTitle, getResourceDir, - hash, hashedBlobId, hmac, isDev, diff --git a/packages/trilium-core/src/becca/entities/abstract_becca_entity.ts b/packages/trilium-core/src/becca/entities/abstract_becca_entity.ts index d14626781e..74f9442484 100644 --- a/packages/trilium-core/src/becca/entities/abstract_becca_entity.ts +++ b/packages/trilium-core/src/becca/entities/abstract_becca_entity.ts @@ -6,11 +6,11 @@ import dateUtils from "../../services/utils/date"; import entityChangesService from "../../services/entity_changes.js"; import { getLog } from "../../services/log.js"; import protectedSessionService from "../../services/protected_session.js"; -import utils from "../../services/utils.js"; import becca from "../becca.js"; import type { ConstructorData,default as Becca } from "../becca-interface.js"; import { getSql } from "src/services/sql"; import { concat2, encodeUtf8, unwrapStringOrBuffer, wrapStringOrBuffer } from "src/services/utils/binary"; +import { hash, hashedBlobId, newEntityId, randomString } from "src/services/utils"; interface ContentOpts { forceSave?: boolean; @@ -36,7 +36,7 @@ abstract class AbstractBeccaEntity<T extends AbstractBeccaEntity<T>> { protected beforeSaving(opts?: {}) { const constructorData = this.constructor as unknown as ConstructorData<T>; if (!(this as any)[constructorData.primaryKeyName]) { - (this as any)[constructorData.primaryKeyName] = utils.newEntityId(); + (this as any)[constructorData.primaryKeyName] = newEntityId(); } } @@ -72,7 +72,7 @@ abstract class AbstractBeccaEntity<T extends AbstractBeccaEntity<T>> { contentToHash += "|deleted"; } - return utils.hash(contentToHash).substr(0, 10); + return hash(contentToHash).substr(0, 10); } protected getPojoToSave() { @@ -224,7 +224,7 @@ abstract class AbstractBeccaEntity<T extends AbstractBeccaEntity<T>> { * This has minor security implications (it's easy to infer that given content is shared between different * notes/attachments), but the trade-off comes out clearly positive. */ - const newBlobId = utils.hashedBlobId(unencryptedContentForHashCalculation); + const newBlobId = hashedBlobId(unencryptedContentForHashCalculation); const sql = getSql(); const blobNeedsInsert = !sql.getValue("SELECT 1 FROM blobs WHERE blobId = ?", [newBlobId]); @@ -254,7 +254,7 @@ abstract class AbstractBeccaEntity<T extends AbstractBeccaEntity<T>> { isSynced: true, // overriding componentId will cause the frontend to think the change is coming from a different component // and thus reload - componentId: opts.forceFrontendReload ? utils.randomString(10) : null + componentId: opts.forceFrontendReload ? randomString(10) : null }); eventService.emit(eventService.ENTITY_CHANGED, { diff --git a/packages/trilium-core/src/becca/entities/battachment.ts b/packages/trilium-core/src/becca/entities/battachment.ts index b8b86ace1a..bd6a1ff45f 100644 --- a/packages/trilium-core/src/becca/entities/battachment.ts +++ b/packages/trilium-core/src/becca/entities/battachment.ts @@ -6,11 +6,11 @@ import dateUtils from "../../services/utils/date"; import { getLog } from "../../services/log.js"; import noteService from "../../services/notes.js"; import protectedSessionService from "../../services/protected_session.js"; -import utils from "../../services/utils.js"; import AbstractBeccaEntity from "./abstract_becca_entity.js"; import type BBranch from "./bbranch.js"; import type BNote from "./bnote.js"; import { getSql } from "src/services/sql/index.js"; +import { isStringNote, replaceAll } from "src/services/utils"; const attachmentRoleToNoteTypeMapping = { image: "image", @@ -105,7 +105,7 @@ class BAttachment extends AbstractBeccaEntity<BAttachment> { /** @returns true if the note has string content (not binary) */ override hasStringContent(): boolean { - return utils.isStringNote(this.type, this.mime); // here was !== undefined && utils.isStringNote(this.type, this.mime); I dont know why we need !=undefined. But it filters out canvas libary items + return isStringNote(this.type, this.mime); // here was !== undefined && utils.isStringNote(this.type, this.mime); I dont know why we need !=undefined. But it filters out canvas libary items } isContentAvailable() { @@ -186,7 +186,7 @@ class BAttachment extends AbstractBeccaEntity<BAttachment> { const oldAttachmentUrl = `api/attachments/${this.attachmentId}/image/`; const newNoteUrl = `api/images/${note.noteId}/`; - const fixedContent = utils.replaceAll(origContent, oldAttachmentUrl, newNoteUrl); + const fixedContent = replaceAll(origContent, oldAttachmentUrl, newNoteUrl); if (fixedContent !== origContent) { parentNote.setContent(fixedContent); diff --git a/packages/trilium-core/src/becca/entities/bbranch.ts b/packages/trilium-core/src/becca/entities/bbranch.ts index 78e8bb199d..b7603b2702 100644 --- a/packages/trilium-core/src/becca/entities/bbranch.ts +++ b/packages/trilium-core/src/becca/entities/bbranch.ts @@ -6,10 +6,10 @@ import dateUtils from "../../services/utils/date"; import handlers from "../../services/handlers.js"; import { getLog } from "../../services/log.js"; import TaskContext from "../../services/task_context.js"; -import utils from "../../services/utils.js"; import AbstractBeccaEntity from "./abstract_becca_entity.js"; import BNote from "./bnote.js"; import { getHoistedNoteId } from "src/services/context"; +import { randomString } from "src/services/utils"; /** * Branch represents a relationship between a child note and its parent note. Trilium allows a note to have multiple @@ -140,7 +140,7 @@ class BBranch extends AbstractBeccaEntity<BBranch> { */ deleteBranch(deleteId?: string, taskContext?: TaskContext<"deleteNotes">): boolean { if (!deleteId) { - deleteId = utils.randomString(10); + deleteId = randomString(10); } if (!taskContext) { diff --git a/packages/trilium-core/src/becca/entities/bnote.ts b/packages/trilium-core/src/becca/entities/bnote.ts index e59be195c5..14402d7798 100644 --- a/packages/trilium-core/src/becca/entities/bnote.ts +++ b/packages/trilium-core/src/becca/entities/bnote.ts @@ -6,13 +6,12 @@ import cloningService from "../../services/cloning.js"; import dateUtils from "../../services/utils/date"; import eraseService from "../../services/erase.js"; import handlers from "../../services/handlers.js"; -import log, { getLog } from "../../services/log.js"; +import { getLog } from "../../services/log.js"; import noteService from "../../services/notes.js"; import optionService from "../../services/options.js"; import protectedSessionService from "../../services/protected_session.js"; import searchService from "../../services/search/services/search.js"; import TaskContext from "../../services/task_context.js"; -import utils from "../../services/utils.js"; import type { NotePojo } from "../becca-interface.js"; import AbstractBeccaEntity from "./abstract_becca_entity.js"; import BAttachment from "./battachment.js"; @@ -20,6 +19,7 @@ import BAttribute from "./battribute.js"; import type BBranch from "./bbranch.js"; import BRevision from "./brevision.js"; import { getSql } from "src/services/sql/index.js"; +import { isStringNote, normalize, randomString, replaceAll } from "src/services/utils"; const LABEL = "label"; const RELATION = "relation"; @@ -316,7 +316,7 @@ class BNote extends AbstractBeccaEntity<BNote> { /** @returns true if the note has string content (not binary) */ override hasStringContent() { - return utils.isStringNote(this.type, this.mime); + return isStringNote(this.type, this.mime); } /** @returns JS script environment - either "frontend" or "backend" */ @@ -798,7 +798,7 @@ class BNote extends AbstractBeccaEntity<BNote> { this.__flatTextCache += " "; } - this.__flatTextCache = utils.normalize(this.__flatTextCache); + this.__flatTextCache = normalize(this.__flatTextCache); } return this.__flatTextCache as string; @@ -1481,7 +1481,7 @@ class BNote extends AbstractBeccaEntity<BNote> { throw new Error("Unable to convert image note into attachment because parent note does not have a string content."); } - const fixedContent = utils.replaceAll(parentContent, oldNoteUrl, newAttachmentUrl); + const fixedContent = replaceAll(parentContent, oldNoteUrl, newAttachmentUrl); parentNote.setContent(fixedContent); @@ -1503,7 +1503,7 @@ class BNote extends AbstractBeccaEntity<BNote> { } if (!deleteId) { - deleteId = utils.randomString(10); + deleteId = randomString(10); } if (!taskContext) { diff --git a/packages/trilium-core/src/becca/entities/brevision.ts b/packages/trilium-core/src/becca/entities/brevision.ts index c09e9ff211..9a5f79740a 100644 --- a/packages/trilium-core/src/becca/entities/brevision.ts +++ b/packages/trilium-core/src/becca/entities/brevision.ts @@ -1,7 +1,6 @@ "use strict"; import protectedSessionService from "../../services/protected_session.js"; -import utils from "../../services/utils.js"; import dateUtils from "../../services/utils/date"; import becca from "../becca.js"; import AbstractBeccaEntity from "./abstract_becca_entity.js"; @@ -9,6 +8,7 @@ import BAttachment from "./battachment.js"; import type { AttachmentRow, NoteType, RevisionPojo, RevisionRow } from "@triliumnext/commons"; import eraseService from "../../services/erase.js"; import { getSql } from "src/services/sql/index.js"; +import { isStringNote } from "src/services/utils/index.js"; interface ContentOpts { /** will also save this BRevision entity */ @@ -76,7 +76,7 @@ class BRevision extends AbstractBeccaEntity<BRevision> { /** @returns true if the note has string content (not binary) */ override hasStringContent(): boolean { - return utils.isStringNote(this.type, this.mime); + return isStringNote(this.type, this.mime); } isContentAvailable() { diff --git a/packages/trilium-core/src/index.ts b/packages/trilium-core/src/index.ts index cbdcba64d9..5abb13025e 100644 --- a/packages/trilium-core/src/index.ts +++ b/packages/trilium-core/src/index.ts @@ -9,6 +9,7 @@ export * from "./services/sql/index"; export * as protected_session from "./services/protected_session"; export { default as data_encryption } from "./services/encryption/data_encryption" export * as binary_utils from "./services/utils/binary"; +export * as utils from "./services/utils/index"; export { default as date_utils } from "./services/utils/date"; export { default as events } from "./services/events"; export { default as blob } from "./services/blob"; diff --git a/packages/trilium-core/src/services/blob.ts b/packages/trilium-core/src/services/blob.ts index 9de989abcd..6792c75270 100644 --- a/packages/trilium-core/src/services/blob.ts +++ b/packages/trilium-core/src/services/blob.ts @@ -2,8 +2,8 @@ import { BlobRow } from "@triliumnext/commons"; import becca from "../becca/becca.js"; import { NotFoundError } from "../errors"; import protectedSessionService from "./protected_session.js"; -import { hash } from "./utils.js"; import { decodeUtf8 } from "./utils/binary.js"; +import { hash } from "./utils/index.js"; function getBlobPojo(entityName: string, entityId: string, opts?: { preview: boolean }) { // TODO: Unused opts. diff --git a/packages/trilium-core/src/services/encryption/crypto.ts b/packages/trilium-core/src/services/encryption/crypto.ts index 94ebcb1ad9..87cd8fb6c0 100644 --- a/packages/trilium-core/src/services/encryption/crypto.ts +++ b/packages/trilium-core/src/services/encryption/crypto.ts @@ -5,8 +5,9 @@ interface Cipher { export interface CryptoProvider { - createHash(algorithm: "sha1", content: string | Uint8Array): Uint8Array; + createHash(algorithm: "sha1" | "sha512", content: string | Uint8Array): Uint8Array; randomBytes(size: number): Uint8Array; + randomString(length: number): string; createCipheriv(algorithm: "aes-128-cbc", key: Uint8Array, iv: Uint8Array): Cipher; createDecipheriv(algorithm: "aes-128-cbc", key: Uint8Array, iv: Uint8Array): Cipher; } diff --git a/packages/trilium-core/src/services/utils/index.ts b/packages/trilium-core/src/services/utils/index.ts new file mode 100644 index 0000000000..06f730dcbf --- /dev/null +++ b/packages/trilium-core/src/services/utils/index.ts @@ -0,0 +1,60 @@ +import { getCrypto } from "../encryption/crypto"; +import { encodeBase64 } from "./binary"; + +// render and book are string note in the sense that they are expected to contain empty string +const STRING_NOTE_TYPES = new Set(["text", "code", "relationMap", "search", "render", "book", "mermaid", "canvas", "webView"]); +const STRING_MIME_TYPES = new Set(["application/javascript", "application/x-javascript", "application/json", "application/x-sql", "image/svg+xml"]); + +export function hash(text: string) { + return encodeBase64(getCrypto().createHash("sha1", text.normalize())); +} + +export function isStringNote(type: string | undefined, mime: string) { + return (type && STRING_NOTE_TYPES.has(type)) || mime.startsWith("text/") || STRING_MIME_TYPES.has(mime); +} + +// TODO: Refactor to use getCrypto() directly. +export function randomString(length: number) { + return getCrypto().randomString(length); +} + +export function newEntityId() { + return randomString(12); +} + +export function hashedBlobId(content: string | Uint8Array) { + if (content === null || content === undefined) { + content = ""; + } + + // sha512 is faster than sha256 + const base64Hash = encodeBase64(getCrypto().createHash("sha512", content)); + + // we don't want such + and / in the IDs + const kindaBase62Hash = base64Hash.replaceAll("+", "X").replaceAll("/", "Y"); + + // 20 characters of base62 gives us ~120 bit of entropy which is plenty enough + return kindaBase62Hash.substr(0, 20); +} + +export function quoteRegex(url: string) { + return url.replace(/[.*+\-?^${}()|[\]\\]/g, "\\$&"); +} + +export function replaceAll(string: string, replaceWhat: string, replaceWith: string) { + const quotedReplaceWhat = quoteRegex(replaceWhat); + + return string.replace(new RegExp(quotedReplaceWhat, "g"), replaceWith); +} + +export function removeDiacritic(str: string) { + if (!str) { + return ""; + } + str = str.toString(); + return str.normalize("NFD").replace(/\p{Diacritic}/gu, ""); +} + +export function normalize(str: string) { + return removeDiacritic(str).toLowerCase(); +} From 26d299aa445da930eb25976418e5f3284b56a895 Mon Sep 17 00:00:00 2001 From: Elian Doran <contact@eliandoran.me> Date: Tue, 6 Jan 2026 13:20:42 +0200 Subject: [PATCH 37/72] chore(core): integrate options, options_init & keyboard_actions --- apps/server/src/services/app_info.ts | 18 +- apps/server/src/services/keyboard_actions.ts | 884 +----------------- apps/server/src/services/options.ts | 147 +-- apps/server/src/services/options_init.ts | 282 +----- packages/commons/src/lib/server_api.ts | 4 +- packages/trilium-core/src/index.ts | 5 + .../trilium-core/src/services/app_info.ts | 17 + .../trilium-core}/src/services/build.ts | 0 .../src/services/keyboard_actions.ts | 882 +++++++++++++++++ packages/trilium-core/src/services/options.ts | 146 +++ .../trilium-core/src/services/options_init.ts | 280 ++++++ 11 files changed, 1342 insertions(+), 1323 deletions(-) create mode 100644 packages/trilium-core/src/services/app_info.ts rename {apps/server => packages/trilium-core}/src/services/build.ts (100%) create mode 100644 packages/trilium-core/src/services/keyboard_actions.ts create mode 100644 packages/trilium-core/src/services/options.ts create mode 100644 packages/trilium-core/src/services/options_init.ts diff --git a/apps/server/src/services/app_info.ts b/apps/server/src/services/app_info.ts index 2837e8de79..497bbb102c 100644 --- a/apps/server/src/services/app_info.ts +++ b/apps/server/src/services/app_info.ts @@ -1,21 +1,11 @@ -import path from "path"; -import build from "./build.js"; -import packageJson from "../../package.json" with { type: "json" }; -import dataDir from "./data_dir.js"; import { AppInfo } from "@triliumnext/commons"; +import { app_info as coreAppInfo } from "@triliumnext/core"; +import path from "path"; -const APP_DB_VERSION = 233; -const SYNC_VERSION = 36; -const CLIPPER_PROTOCOL_VERSION = "1.0"; +import dataDir from "./data_dir.js"; export default { - appVersion: packageJson.version, - dbVersion: APP_DB_VERSION, + ...coreAppInfo, nodeVersion: process.version, - syncVersion: SYNC_VERSION, - buildDate: build.buildDate, - buildRevision: build.buildRevision, dataDirectory: path.resolve(dataDir.TRILIUM_DATA_DIR), - clipperProtocolVersion: CLIPPER_PROTOCOL_VERSION, - utcDateTime: new Date().toISOString() } satisfies AppInfo; diff --git a/apps/server/src/services/keyboard_actions.ts b/apps/server/src/services/keyboard_actions.ts index 326672eeb2..d6d70b6bcf 100644 --- a/apps/server/src/services/keyboard_actions.ts +++ b/apps/server/src/services/keyboard_actions.ts @@ -1,882 +1,2 @@ -"use strict"; - -import optionService from "./options.js"; -import log from "./log.js"; -import { isElectron, isMac } from "./utils.js"; -import type { ActionKeyboardShortcut, KeyboardShortcut } from "@triliumnext/commons"; -import { t } from "i18next"; - -function getDefaultKeyboardActions() { - if (!t("keyboard_actions.note-navigation")) { - throw new Error("Keyboard actions loaded before translations."); - } - - const DEFAULT_KEYBOARD_ACTIONS: KeyboardShortcut[] = [ - { - separator: t("keyboard_actions.note-navigation") - }, - { - actionName: "backInNoteHistory", - friendlyName: t("keyboard_action_names.back-in-note-history"), - iconClass: "bx bxs-chevron-left", - // Mac has a different history navigation shortcuts - https://github.com/zadam/trilium/issues/376 - defaultShortcuts: isMac ? ["CommandOrControl+["] : ["Alt+Left"], - description: t("keyboard_actions.back-in-note-history"), - scope: "window" - }, - { - actionName: "forwardInNoteHistory", - friendlyName: t("keyboard_action_names.forward-in-note-history"), - iconClass: "bx bxs-chevron-right", - // Mac has a different history navigation shortcuts - https://github.com/zadam/trilium/issues/376 - defaultShortcuts: isMac ? ["CommandOrControl+]"] : ["Alt+Right"], - description: t("keyboard_actions.forward-in-note-history"), - scope: "window" - }, - { - actionName: "jumpToNote", - friendlyName: t("keyboard_action_names.jump-to-note"), - defaultShortcuts: ["CommandOrControl+J"], - description: t("keyboard_actions.open-jump-to-note-dialog"), - scope: "window", - ignoreFromCommandPalette: true - }, - { - actionName: "openTodayNote", - friendlyName: t("hidden-subtree.open-today-journal-note-title"), - iconClass: "bx bx-calendar", - defaultShortcuts: [], - description: t("hidden-subtree.open-today-journal-note-title"), - scope: "window" - }, - { - actionName: "commandPalette", - friendlyName: t("keyboard_action_names.command-palette"), - defaultShortcuts: ["CommandOrControl+Shift+J"], - description: t("keyboard_actions.open-command-palette"), - scope: "window", - ignoreFromCommandPalette: true - }, - { - actionName: "scrollToActiveNote", - friendlyName: t("keyboard_action_names.scroll-to-active-note"), - defaultShortcuts: ["CommandOrControl+."], - iconClass: "bx bx-current-location", - description: t("keyboard_actions.scroll-to-active-note"), - scope: "window" - }, - { - actionName: "quickSearch", - friendlyName: t("keyboard_action_names.quick-search"), - iconClass: "bx bx-search", - defaultShortcuts: ["CommandOrControl+S"], - description: t("keyboard_actions.quick-search"), - scope: "window" - }, - { - actionName: "searchInSubtree", - friendlyName: t("keyboard_action_names.search-in-subtree"), - defaultShortcuts: ["CommandOrControl+Shift+S"], - iconClass: "bx bx-search-alt", - description: t("keyboard_actions.search-in-subtree"), - scope: "note-tree" - }, - { - actionName: "expandSubtree", - friendlyName: t("keyboard_action_names.expand-subtree"), - defaultShortcuts: [], - iconClass: "bx bx-layer-plus", - description: t("keyboard_actions.expand-subtree"), - scope: "note-tree" - }, - { - actionName: "collapseTree", - friendlyName: t("keyboard_action_names.collapse-tree"), - defaultShortcuts: ["Alt+C"], - iconClass: "bx bx-layer-minus", - description: t("keyboard_actions.collapse-tree"), - scope: "window" - }, - { - actionName: "collapseSubtree", - friendlyName: t("keyboard_action_names.collapse-subtree"), - iconClass: "bx bxs-layer-minus", - defaultShortcuts: ["Alt+-"], - description: t("keyboard_actions.collapse-subtree"), - scope: "note-tree" - }, - { - actionName: "sortChildNotes", - friendlyName: t("keyboard_action_names.sort-child-notes"), - iconClass: "bx bx-sort-down", - defaultShortcuts: ["Alt+S"], - description: t("keyboard_actions.sort-child-notes"), - scope: "note-tree" - }, - - { - separator: t("keyboard_actions.creating-and-moving-notes") - }, - { - actionName: "createNoteAfter", - friendlyName: t("keyboard_action_names.create-note-after"), - iconClass: "bx bx-plus", - defaultShortcuts: ["CommandOrControl+O"], - description: t("keyboard_actions.create-note-after"), - scope: "window" - }, - { - actionName: "createNoteInto", - friendlyName: t("keyboard_action_names.create-note-into"), - iconClass: "bx bx-plus", - defaultShortcuts: ["CommandOrControl+P"], - description: t("keyboard_actions.create-note-into"), - scope: "window" - }, - { - actionName: "createNoteIntoInbox", - friendlyName: t("keyboard_action_names.create-note-into-inbox"), - iconClass: "bx bxs-inbox", - defaultShortcuts: ["global:CommandOrControl+Alt+P"], - description: t("keyboard_actions.create-note-into-inbox"), - scope: "window" - }, - { - actionName: "deleteNotes", - friendlyName: t("keyboard_action_names.delete-notes"), - iconClass: "bx bx-trash", - defaultShortcuts: ["Delete"], - description: t("keyboard_actions.delete-note"), - scope: "note-tree" - }, - { - actionName: "moveNoteUp", - friendlyName: t("keyboard_action_names.move-note-up"), - iconClass: "bx bx-up-arrow-alt", - defaultShortcuts: isMac ? ["Alt+Up"] : ["CommandOrControl+Up"], - description: t("keyboard_actions.move-note-up"), - scope: "note-tree" - }, - { - actionName: "moveNoteDown", - friendlyName: t("keyboard_action_names.move-note-down"), - iconClass: "bx bx-down-arrow-alt", - defaultShortcuts: isMac ? ["Alt+Down"] : ["CommandOrControl+Down"], - description: t("keyboard_actions.move-note-down"), - scope: "note-tree" - }, - { - actionName: "moveNoteUpInHierarchy", - friendlyName: t("keyboard_action_names.move-note-up-in-hierarchy"), - iconClass: "bx bx-arrow-from-bottom", - defaultShortcuts: isMac ? ["Alt+Left"] : ["CommandOrControl+Left"], - description: t("keyboard_actions.move-note-up-in-hierarchy"), - scope: "note-tree" - }, - { - actionName: "moveNoteDownInHierarchy", - friendlyName: t("keyboard_action_names.move-note-down-in-hierarchy"), - iconClass: "bx bx-arrow-from-top", - defaultShortcuts: isMac ? ["Alt+Right"] : ["CommandOrControl+Right"], - description: t("keyboard_actions.move-note-down-in-hierarchy"), - scope: "note-tree" - }, - { - actionName: "editNoteTitle", - friendlyName: t("keyboard_action_names.edit-note-title"), - iconClass: "bx bx-rename", - defaultShortcuts: ["Enter"], - description: t("keyboard_actions.edit-note-title"), - scope: "note-tree" - }, - { - actionName: "editBranchPrefix", - friendlyName: t("keyboard_action_names.edit-branch-prefix"), - iconClass: "bx bx-rename", - defaultShortcuts: ["F2"], - description: t("keyboard_actions.edit-branch-prefix"), - scope: "note-tree" - }, - { - actionName: "cloneNotesTo", - friendlyName: t("keyboard_action_names.clone-notes-to"), - iconClass: "bx bx-duplicate", - defaultShortcuts: ["CommandOrControl+Shift+C"], - description: t("keyboard_actions.clone-notes-to"), - scope: "window" - }, - { - actionName: "moveNotesTo", - friendlyName: t("keyboard_action_names.move-notes-to"), - iconClass: "bx bx-transfer", - defaultShortcuts: ["CommandOrControl+Shift+X"], - description: t("keyboard_actions.move-notes-to"), - scope: "window" - }, - - { - separator: t("keyboard_actions.note-clipboard") - }, - - { - actionName: "copyNotesToClipboard", - friendlyName: t("keyboard_action_names.copy-notes-to-clipboard"), - iconClass: "bx bx-copy", - defaultShortcuts: ["CommandOrControl+C"], - description: t("keyboard_actions.copy-notes-to-clipboard"), - scope: "note-tree" - }, - { - actionName: "pasteNotesFromClipboard", - friendlyName: t("keyboard_action_names.paste-notes-from-clipboard"), - iconClass: "bx bx-paste", - defaultShortcuts: ["CommandOrControl+V"], - description: t("keyboard_actions.paste-notes-from-clipboard"), - scope: "note-tree" - }, - { - actionName: "cutNotesToClipboard", - friendlyName: t("keyboard_action_names.cut-notes-to-clipboard"), - iconClass: "bx bx-cut", - defaultShortcuts: ["CommandOrControl+X"], - description: t("keyboard_actions.cut-notes-to-clipboard"), - scope: "note-tree" - }, - { - actionName: "selectAllNotesInParent", - friendlyName: t("keyboard_action_names.select-all-notes-in-parent"), - iconClass: "bx bx-select-multiple", - defaultShortcuts: ["CommandOrControl+A"], - description: t("keyboard_actions.select-all-notes-in-parent"), - scope: "note-tree" - }, - { - actionName: "addNoteAboveToSelection", - friendlyName: t("keyboard_action_names.add-note-above-to-selection"), - defaultShortcuts: ["Shift+Up"], - description: t("keyboard_actions.add-note-above-to-the-selection"), - scope: "note-tree", - ignoreFromCommandPalette: true - }, - { - actionName: "addNoteBelowToSelection", - friendlyName: t("keyboard_action_names.add-note-below-to-selection"), - defaultShortcuts: ["Shift+Down"], - description: t("keyboard_actions.add-note-below-to-selection"), - scope: "note-tree", - ignoreFromCommandPalette: true - }, - { - actionName: "duplicateSubtree", - friendlyName: t("keyboard_action_names.duplicate-subtree"), - iconClass: "bx bx-outline", - defaultShortcuts: [], - description: t("keyboard_actions.duplicate-subtree"), - scope: "note-tree" - }, - - { - separator: t("keyboard_actions.tabs-and-windows") - }, - { - actionName: "openNewTab", - friendlyName: t("keyboard_action_names.open-new-tab"), - iconClass: "bx bx-plus", - defaultShortcuts: isElectron ? ["CommandOrControl+T"] : [], - description: t("keyboard_actions.open-new-tab"), - scope: "window" - }, - { - actionName: "closeActiveTab", - friendlyName: t("keyboard_action_names.close-active-tab"), - iconClass: "bx bx-minus", - defaultShortcuts: isElectron ? ["CommandOrControl+W"] : [], - description: t("keyboard_actions.close-active-tab"), - scope: "window" - }, - { - actionName: "reopenLastTab", - friendlyName: t("keyboard_action_names.reopen-last-tab"), - iconClass: "bx bx-undo", - defaultShortcuts: isElectron ? ["CommandOrControl+Shift+T"] : [], - isElectronOnly: true, - description: t("keyboard_actions.reopen-last-tab"), - scope: "window" - }, - { - actionName: "activateNextTab", - friendlyName: t("keyboard_action_names.activate-next-tab"), - iconClass: "bx bx-skip-next", - defaultShortcuts: isElectron ? ["CommandOrControl+Tab", "CommandOrControl+PageDown"] : [], - description: t("keyboard_actions.activate-next-tab"), - scope: "window" - }, - { - actionName: "activatePreviousTab", - friendlyName: t("keyboard_action_names.activate-previous-tab"), - iconClass: "bx bx-skip-previous", - defaultShortcuts: isElectron ? ["CommandOrControl+Shift+Tab", "CommandOrControl+PageUp"] : [], - description: t("keyboard_actions.activate-previous-tab"), - scope: "window" - }, - { - actionName: "openNewWindow", - friendlyName: t("keyboard_action_names.open-new-window"), - iconClass: "bx bx-window-open", - defaultShortcuts: [], - description: t("keyboard_actions.open-new-window"), - scope: "window" - }, - { - actionName: "toggleTray", - friendlyName: t("keyboard_action_names.toggle-system-tray-icon"), - iconClass: "bx bx-show", - defaultShortcuts: [], - isElectronOnly: true, - description: t("keyboard_actions.toggle-tray"), - scope: "window" - }, - { - actionName: "toggleZenMode", - friendlyName: t("keyboard_action_names.toggle-zen-mode"), - iconClass: "bx bxs-yin-yang", - defaultShortcuts: ["F9"], - description: t("keyboard_actions.toggle-zen-mode"), - scope: "window" - }, - { - actionName: "firstTab", - friendlyName: t("keyboard_action_names.switch-to-first-tab"), - iconClass: "bx bx-rectangle", - defaultShortcuts: ["CommandOrControl+1"], - description: t("keyboard_actions.first-tab"), - scope: "window" - }, - { - actionName: "secondTab", - friendlyName: t("keyboard_action_names.switch-to-second-tab"), - iconClass: "bx bx-rectangle", - defaultShortcuts: ["CommandOrControl+2"], - description: t("keyboard_actions.second-tab"), - scope: "window" - }, - { - actionName: "thirdTab", - friendlyName: t("keyboard_action_names.switch-to-third-tab"), - iconClass: "bx bx-rectangle", - defaultShortcuts: ["CommandOrControl+3"], - description: t("keyboard_actions.third-tab"), - scope: "window" - }, - { - actionName: "fourthTab", - friendlyName: t("keyboard_action_names.switch-to-fourth-tab"), - iconClass: "bx bx-rectangle", - defaultShortcuts: ["CommandOrControl+4"], - description: t("keyboard_actions.fourth-tab"), - scope: "window" - }, - { - actionName: "fifthTab", - friendlyName: t("keyboard_action_names.switch-to-fifth-tab"), - iconClass: "bx bx-rectangle", - defaultShortcuts: ["CommandOrControl+5"], - description: t("keyboard_actions.fifth-tab"), - scope: "window" - }, - { - actionName: "sixthTab", - friendlyName: t("keyboard_action_names.switch-to-sixth-tab"), - iconClass: "bx bx-rectangle", - defaultShortcuts: ["CommandOrControl+6"], - description: t("keyboard_actions.sixth-tab"), - scope: "window" - }, - { - actionName: "seventhTab", - friendlyName: t("keyboard_action_names.switch-to-seventh-tab"), - iconClass: "bx bx-rectangle", - defaultShortcuts: ["CommandOrControl+7"], - description: t("keyboard_actions.seventh-tab"), - scope: "window" - }, - { - actionName: "eigthTab", - friendlyName: t("keyboard_action_names.switch-to-eighth-tab"), - iconClass: "bx bx-rectangle", - defaultShortcuts: ["CommandOrControl+8"], - description: t("keyboard_actions.eight-tab"), - scope: "window" - }, - { - actionName: "ninthTab", - friendlyName: t("keyboard_action_names.switch-to-ninth-tab"), - iconClass: "bx bx-rectangle", - defaultShortcuts: ["CommandOrControl+9"], - description: t("keyboard_actions.ninth-tab"), - scope: "window" - }, - { - actionName: "lastTab", - friendlyName: t("keyboard_action_names.switch-to-last-tab"), - iconClass: "bx bx-rectangle", - defaultShortcuts: ["CommandOrControl+0"], - description: t("keyboard_actions.last-tab"), - scope: "window" - }, - - { - separator: t("keyboard_actions.dialogs") - }, - { - friendlyName: t("keyboard_action_names.show-note-source"), - actionName: "showNoteSource", - iconClass: "bx bx-code", - defaultShortcuts: [], - description: t("keyboard_actions.show-note-source"), - scope: "window" - }, - { - friendlyName: t("keyboard_action_names.show-options"), - actionName: "showOptions", - iconClass: "bx bx-cog", - defaultShortcuts: [], - description: t("keyboard_actions.show-options"), - scope: "window" - }, - { - friendlyName: t("keyboard_action_names.show-revisions"), - actionName: "showRevisions", - iconClass: "bx bx-history", - defaultShortcuts: [], - description: t("keyboard_actions.show-revisions"), - scope: "window" - }, - { - friendlyName: t("keyboard_action_names.show-recent-changes"), - actionName: "showRecentChanges", - iconClass: "bx bx-history", - defaultShortcuts: [], - description: t("keyboard_actions.show-recent-changes"), - scope: "window" - }, - { - friendlyName: t("keyboard_action_names.show-sql-console"), - actionName: "showSQLConsole", - iconClass: "bx bx-data", - defaultShortcuts: ["Alt+O"], - description: t("keyboard_actions.show-sql-console"), - scope: "window" - }, - { - friendlyName: t("keyboard_action_names.show-backend-log"), - actionName: "showBackendLog", - iconClass: "bx bx-detail", - defaultShortcuts: [], - description: t("keyboard_actions.show-backend-log"), - scope: "window" - }, - { - friendlyName: t("keyboard_action_names.show-help"), - actionName: "showHelp", - iconClass: "bx bx-help-circle", - defaultShortcuts: ["F1"], - description: t("keyboard_actions.show-help"), - scope: "window" - }, - { - friendlyName: t("keyboard_action_names.show-cheatsheet"), - actionName: "showCheatsheet", - iconClass: "bx bxs-keyboard", - defaultShortcuts: ["Shift+F1"], - description: t("keyboard_actions.show-cheatsheet"), - scope: "window" - }, - - { - separator: t("keyboard_actions.text-note-operations") - }, - - { - friendlyName: t("keyboard_action_names.add-link-to-text"), - actionName: "addLinkToText", - iconClass: "bx bx-link", - defaultShortcuts: ["CommandOrControl+L"], - description: t("keyboard_actions.add-link-to-text"), - scope: "text-detail" - }, - { - friendlyName: t("keyboard_action_names.follow-link-under-cursor"), - actionName: "followLinkUnderCursor", - iconClass: "bx bx-link-external", - defaultShortcuts: ["CommandOrControl+Enter"], - description: t("keyboard_actions.follow-link-under-cursor"), - scope: "text-detail" - }, - { - friendlyName: t("keyboard_action_names.insert-date-and-time-to-text"), - actionName: "insertDateTimeToText", - iconClass: "bx bx-calendar-event", - defaultShortcuts: ["Alt+T"], - description: t("keyboard_actions.insert-date-and-time-to-text"), - scope: "text-detail" - }, - { - friendlyName: t("keyboard_action_names.paste-markdown-into-text"), - actionName: "pasteMarkdownIntoText", - iconClass: "bx bxl-markdown", - defaultShortcuts: [], - description: t("keyboard_actions.paste-markdown-into-text"), - scope: "text-detail" - }, - { - friendlyName: t("keyboard_action_names.cut-into-note"), - actionName: "cutIntoNote", - iconClass: "bx bx-cut", - defaultShortcuts: [], - description: t("keyboard_actions.cut-into-note"), - scope: "text-detail" - }, - { - friendlyName: t("keyboard_action_names.add-include-note-to-text"), - actionName: "addIncludeNoteToText", - iconClass: "bx bx-note", - defaultShortcuts: [], - description: t("keyboard_actions.add-include-note-to-text"), - scope: "text-detail" - }, - { - friendlyName: t("keyboard_action_names.edit-read-only-note"), - actionName: "editReadOnlyNote", - iconClass: "bx bx-edit-alt", - defaultShortcuts: [], - description: t("keyboard_actions.edit-readonly-note"), - scope: "window" - }, - - { - separator: t("keyboard_actions.attributes-labels-and-relations") - }, - - { - friendlyName: t("keyboard_action_names.add-new-label"), - actionName: "addNewLabel", - iconClass: "bx bx-hash", - defaultShortcuts: ["Alt+L"], - description: t("keyboard_actions.add-new-label"), - scope: "window" - }, - { - friendlyName: t("keyboard_action_names.add-new-relation"), - actionName: "addNewRelation", - iconClass: "bx bx-transfer", - defaultShortcuts: ["Alt+R"], - description: t("keyboard_actions.create-new-relation"), - scope: "window" - }, - - { - separator: t("keyboard_actions.ribbon-tabs") - }, - - { - friendlyName: t("keyboard_action_names.toggle-ribbon-tab-classic-editor"), - actionName: "toggleRibbonTabClassicEditor", - iconClass: "bx bx-text", - defaultShortcuts: [], - description: t("keyboard_actions.toggle-classic-editor-toolbar"), - scope: "window" - }, - { - actionName: "toggleRibbonTabBasicProperties", - friendlyName: t("keyboard_action_names.toggle-ribbon-tab-basic-properties"), - iconClass: "bx bx-slider", - defaultShortcuts: [], - description: t("keyboard_actions.toggle-basic-properties"), - scope: "window" - }, - { - actionName: "toggleRibbonTabBookProperties", - friendlyName: t("keyboard_action_names.toggle-ribbon-tab-book-properties"), - iconClass: "bx bx-book", - defaultShortcuts: [], - description: t("keyboard_actions.toggle-book-properties"), - scope: "window" - }, - { - actionName: "toggleRibbonTabFileProperties", - friendlyName: t("keyboard_action_names.toggle-ribbon-tab-file-properties"), - iconClass: "bx bx-file", - defaultShortcuts: [], - description: t("keyboard_actions.toggle-file-properties"), - scope: "window" - }, - { - actionName: "toggleRibbonTabImageProperties", - friendlyName: t("keyboard_action_names.toggle-ribbon-tab-image-properties"), - iconClass: "bx bx-image", - defaultShortcuts: [], - description: t("keyboard_actions.toggle-image-properties"), - scope: "window" - }, - { - actionName: "toggleRibbonTabOwnedAttributes", - friendlyName: t("keyboard_action_names.toggle-ribbon-tab-owned-attributes"), - iconClass: "bx bx-list-check", - defaultShortcuts: ["Alt+A"], - description: t("keyboard_actions.toggle-owned-attributes"), - scope: "window" - }, - { - actionName: "toggleRibbonTabInheritedAttributes", - friendlyName: t("keyboard_action_names.toggle-ribbon-tab-inherited-attributes"), - iconClass: "bx bx-list-plus", - defaultShortcuts: [], - description: t("keyboard_actions.toggle-inherited-attributes"), - scope: "window" - }, - // TODO: Remove or change since promoted attributes have been changed. - { - actionName: "toggleRibbonTabPromotedAttributes", - friendlyName: t("keyboard_action_names.toggle-ribbon-tab-promoted-attributes"), - iconClass: "bx bx-star", - defaultShortcuts: [], - description: t("keyboard_actions.toggle-promoted-attributes"), - scope: "window" - }, - { - actionName: "toggleRibbonTabNoteMap", - friendlyName: t("keyboard_action_names.toggle-ribbon-tab-note-map"), - iconClass: "bx bxs-network-chart", - defaultShortcuts: [], - description: t("keyboard_actions.toggle-link-map"), - scope: "window" - }, - { - actionName: "toggleRibbonTabNoteInfo", - friendlyName: t("keyboard_action_names.toggle-ribbon-tab-note-info"), - iconClass: "bx bx-info-circle", - defaultShortcuts: [], - description: t("keyboard_actions.toggle-note-info"), - scope: "window" - }, - { - actionName: "toggleRibbonTabNotePaths", - friendlyName: t("keyboard_action_names.toggle-ribbon-tab-note-paths"), - iconClass: "bx bx-collection", - defaultShortcuts: [], - description: t("keyboard_actions.toggle-note-paths"), - scope: "window" - }, - { - actionName: "toggleRibbonTabSimilarNotes", - friendlyName: t("keyboard_action_names.toggle-ribbon-tab-similar-notes"), - iconClass: "bx bx-bar-chart", - defaultShortcuts: [], - description: t("keyboard_actions.toggle-similar-notes"), - scope: "window" - }, - - { - separator: t("keyboard_actions.other") - }, - - { - actionName: "toggleRightPane", - friendlyName: t("keyboard_action_names.toggle-right-pane"), - iconClass: "bx bx-dock-right", - defaultShortcuts: [], - description: t("keyboard_actions.toggle-right-pane"), - scope: "window" - }, - { - actionName: "printActiveNote", - friendlyName: t("keyboard_action_names.print-active-note"), - iconClass: "bx bx-printer", - defaultShortcuts: [], - description: t("keyboard_actions.print-active-note"), - scope: "window" - }, - { - actionName: "exportAsPdf", - friendlyName: t("keyboard_action_names.export-active-note-as-pdf"), - iconClass: "bx bxs-file-pdf", - defaultShortcuts: [], - description: t("keyboard_actions.export-as-pdf"), - scope: "window" - }, - { - actionName: "openNoteExternally", - friendlyName: t("keyboard_action_names.open-note-externally"), - iconClass: "bx bx-file-find", - defaultShortcuts: [], - description: t("keyboard_actions.open-note-externally"), - scope: "window" - }, - { - actionName: "renderActiveNote", - friendlyName: t("keyboard_action_names.render-active-note"), - iconClass: "bx bx-refresh", - defaultShortcuts: [], - description: t("keyboard_actions.render-active-note"), - scope: "window" - }, - { - actionName: "runActiveNote", - friendlyName: t("keyboard_action_names.run-active-note"), - iconClass: "bx bx-play", - defaultShortcuts: ["CommandOrControl+Enter"], - description: t("keyboard_actions.run-active-note"), - scope: "code-detail" - }, - { - actionName: "toggleNoteHoisting", - friendlyName: t("keyboard_action_names.toggle-note-hoisting"), - iconClass: "bx bx-chevrons-up", - defaultShortcuts: ["Alt+H"], - description: t("keyboard_actions.toggle-note-hoisting"), - scope: "window" - }, - { - actionName: "unhoist", - friendlyName: t("keyboard_action_names.unhoist-note"), - iconClass: "bx bx-door-open", - defaultShortcuts: ["Alt+U"], - description: t("keyboard_actions.unhoist"), - scope: "window" - }, - { - actionName: "reloadFrontendApp", - friendlyName: t("keyboard_action_names.reload-frontend-app"), - iconClass: "bx bx-refresh", - defaultShortcuts: ["F5", "CommandOrControl+R"], - description: t("keyboard_actions.reload-frontend-app"), - scope: "window" - }, - { - actionName: "openDevTools", - friendlyName: t("keyboard_action_names.open-developer-tools"), - iconClass: "bx bx-bug-alt", - defaultShortcuts: isElectron ? ["CommandOrControl+Shift+I"] : [], - isElectronOnly: true, - description: t("keyboard_actions.open-dev-tools"), - scope: "window" - }, - { - actionName: "findInText", - friendlyName: t("keyboard_action_names.find-in-text"), - iconClass: "bx bx-search", - defaultShortcuts: isElectron ? ["CommandOrControl+F"] : [], - description: t("keyboard_actions.find-in-text"), - scope: "window" - }, - { - actionName: "toggleLeftPane", - friendlyName: t("keyboard_action_names.toggle-left-pane"), - iconClass: "bx bx-sidebar", - defaultShortcuts: [], - description: t("keyboard_actions.toggle-left-note-tree-panel"), - scope: "window" - }, - { - actionName: "toggleFullscreen", - friendlyName: t("keyboard_action_names.toggle-full-screen"), - iconClass: "bx bx-fullscreen", - defaultShortcuts: ["F11"], - description: t("keyboard_actions.toggle-full-screen"), - scope: "window" - }, - { - actionName: "zoomOut", - friendlyName: t("keyboard_action_names.zoom-out"), - iconClass: "bx bx-zoom-out", - defaultShortcuts: isElectron ? ["CommandOrControl+-"] : [], - isElectronOnly: true, - description: t("keyboard_actions.zoom-out"), - scope: "window" - }, - { - actionName: "zoomIn", - friendlyName: t("keyboard_action_names.zoom-in"), - iconClass: "bx bx-zoom-in", - description: t("keyboard_actions.zoom-in"), - defaultShortcuts: isElectron ? ["CommandOrControl+="] : [], - isElectronOnly: true, - scope: "window" - }, - { - actionName: "zoomReset", - friendlyName: t("keyboard_action_names.reset-zoom-level"), - iconClass: "bx bx-search-alt", - description: t("keyboard_actions.reset-zoom-level"), - defaultShortcuts: isElectron ? ["CommandOrControl+0"] : [], - isElectronOnly: true, - scope: "window" - }, - { - actionName: "copyWithoutFormatting", - friendlyName: t("keyboard_action_names.copy-without-formatting"), - iconClass: "bx bx-copy-alt", - defaultShortcuts: ["CommandOrControl+Alt+C"], - description: t("keyboard_actions.copy-without-formatting"), - scope: "text-detail" - }, - { - actionName: "forceSaveRevision", - friendlyName: t("keyboard_action_names.force-save-revision"), - iconClass: "bx bx-save", - defaultShortcuts: [], - description: t("keyboard_actions.force-save-revision"), - scope: "window" - } - ]; - - /* - * Apply macOS-specific tweaks. - */ - const platformModifier = isMac ? "Meta" : "Ctrl"; - - for (const action of DEFAULT_KEYBOARD_ACTIONS) { - if ("defaultShortcuts" in action && action.defaultShortcuts) { - action.defaultShortcuts = action.defaultShortcuts.map((shortcut) => shortcut.replace("CommandOrControl", platformModifier)); - } - } - - return DEFAULT_KEYBOARD_ACTIONS; -} - -function getKeyboardActions() { - const actions: KeyboardShortcut[] = JSON.parse(JSON.stringify(getDefaultKeyboardActions())); - - for (const action of actions) { - if ("effectiveShortcuts" in action && action.effectiveShortcuts) { - action.effectiveShortcuts = action.defaultShortcuts ? action.defaultShortcuts.slice() : []; - } - } - - for (const option of optionService.getOptions()) { - if (option.name.startsWith("keyboardShortcuts")) { - let actionName = option.name.substring(17); - actionName = actionName.charAt(0).toLowerCase() + actionName.slice(1); - - const action = actions.find((ea) => "actionName" in ea && ea.actionName === actionName) as ActionKeyboardShortcut; - - if (action) { - try { - action.effectiveShortcuts = JSON.parse(option.value); - } catch (e) { - log.error(`Could not parse shortcuts for action ${actionName}`); - } - } else { - log.info(`Keyboard action ${actionName} found in database, but not in action definition.`); - } - } - } - - return actions; -} - -export default { - getDefaultKeyboardActions, - getKeyboardActions -}; +import { keyboard_actions } from "@triliumnext/core"; +export default keyboard_actions; diff --git a/apps/server/src/services/options.ts b/apps/server/src/services/options.ts index 1cc67df5a5..68c69c5ab4 100644 --- a/apps/server/src/services/options.ts +++ b/apps/server/src/services/options.ts @@ -1,145 +1,2 @@ -/** - * @module - * - * Options are key-value pairs that are used to store information such as user preferences (for example - * the current theme, sync server information), but also information about the state of the application. - * - * Although options internally are represented as strings, their value can be interpreted as a number or - * boolean by calling the appropriate methods from this service (e.g. {@link #getOptionInt}).\ - * - * Generally options are shared across multiple instances of the application via the sync mechanism, - * however it is possible to have options that are local to an instance. For example, the user can select - * a theme on a device and it will not affect other devices. - */ - -import becca from "../becca/becca.js"; -import BOption from "../becca/entities/boption.js"; -import type { OptionRow } from "@triliumnext/commons"; -import type { FilterOptionsByType, OptionDefinitions, OptionMap, OptionNames } from "@triliumnext/commons"; -import sql from "./sql.js"; - -function getOptionOrNull(name: OptionNames): string | null { - let option; - - if (becca.loaded) { - option = becca.getOption(name); - } else { - // e.g. in initial sync becca is not loaded because DB is not initialized - try { - option = sql.getRow<OptionRow>("SELECT * FROM options WHERE name = ?", [name]); - } catch (e: unknown) { - // DB is not initialized. - return null; - } - } - - return option ? option.value : null; -} - -function getOption(name: OptionNames) { - const val = getOptionOrNull(name); - - if (val === null) { - throw new Error(`Option '${name}' doesn't exist`); - } - - return val; -} - -function getOptionInt(name: FilterOptionsByType<number>, defaultValue?: number): number { - const val = getOption(name); - - const intVal = parseInt(val); - - if (isNaN(intVal)) { - if (defaultValue === undefined) { - throw new Error(`Could not parse '${val}' into integer for option '${name}'`); - } else { - return defaultValue; - } - } - - return intVal; -} - -function getOptionBool(name: FilterOptionsByType<boolean>): boolean { - const val = getOption(name); - - if (typeof val !== "string" || !["true", "false"].includes(val)) { - throw new Error(`Could not parse '${val}' into boolean for option '${name}'`); - } - - return val === "true"; -} - -function setOption<T extends OptionNames>(name: T, value: string | OptionDefinitions[T]) { - const option = becca.getOption(name); - - if (option) { - option.value = value as string; - - option.save(); - } else { - createOption(name, value, false); - } - - // Clear current AI provider when AI-related options change - const aiOptions = [ - 'aiSelectedProvider', 'openaiApiKey', 'openaiBaseUrl', 'openaiDefaultModel', - 'anthropicApiKey', 'anthropicBaseUrl', 'anthropicDefaultModel', - 'ollamaBaseUrl', 'ollamaDefaultModel' - ]; - - if (aiOptions.includes(name)) { - // Import dynamically to avoid circular dependencies - setImmediate(async () => { - try { - const aiServiceManager = (await import('./llm/ai_service_manager.js')).default; - aiServiceManager.getInstance().clearCurrentProvider(); - console.log(`Cleared AI provider after ${name} option changed`); - } catch (error) { - console.log(`Could not clear AI provider: ${error}`); - } - }); - } -} - -/** - * Creates a new option in the database, with the given name, value and whether it should be synced. - * - * @param name the name of the option to be created. - * @param value the value of the option, as a string. It can then be interpreted as other types such as a number of boolean. - * @param isSynced `true` if the value should be synced across multiple instances (e.g. locale) or `false` if it should be local-only (e.g. theme). - */ -function createOption<T extends OptionNames>(name: T, value: string | OptionDefinitions[T], isSynced: boolean) { - new BOption({ - name: name, - value: value as string, - isSynced: isSynced - }).save(); -} - -function getOptions() { - return Object.values(becca.options); -} - -function getOptionMap() { - const map: Record<string, string> = {}; - - for (const option of Object.values(becca.options)) { - map[option.name] = option.value; - } - - return map as OptionMap; -} - -export default { - getOption, - getOptionInt, - getOptionBool, - setOption, - createOption, - getOptions, - getOptionMap, - getOptionOrNull -}; +import { options } from "@triliumnext/core"; +export default options; diff --git a/apps/server/src/services/options_init.ts b/apps/server/src/services/options_init.ts index b23e532a86..ce14b77411 100644 --- a/apps/server/src/services/options_init.ts +++ b/apps/server/src/services/options_init.ts @@ -1,280 +1,2 @@ -import { type KeyboardShortcutWithRequiredActionName, type OptionMap, type OptionNames, SANITIZER_DEFAULT_ALLOWED_TAGS } from "@triliumnext/commons"; - -import appInfo from "./app_info.js"; -import dateUtils from "./date_utils.js"; -import keyboardActions from "./keyboard_actions.js"; -import log from "./log.js"; -import optionService from "./options.js"; -import { isWindows, randomSecureToken } from "./utils.js"; - -function initDocumentOptions() { - optionService.createOption("documentId", randomSecureToken(16), false); - optionService.createOption("documentSecret", randomSecureToken(16), false); -} - -/** - * Contains additional options to be initialized for a new database, containing the information entered by the user. - */ -interface NotSyncedOpts { - syncServerHost?: string; - syncProxy?: string; -} - -/** - * Represents a correspondence between an option and its default value, to be initialized when the database is missing that particular option (after a migration from an older version, or when creating a new database). - */ -interface DefaultOption { - name: OptionNames; - /** - * The value to initialize the option with, if the option is not already present in the database. - * - * If a function is passed Gin instead, the function is called if the option does not exist (with access to the current options) and the return value is used instead. Useful to migrate a new option with a value depending on some other option that might be initialized. - */ - value: string | ((options: OptionMap) => string); - isSynced: boolean; -} - -/** - * Initializes the default options for new databases only. - * - * @param initialized `true` if the database has been fully initialized (i.e. a new database was created), or `false` if the database is created for sync. - * @param opts additional options to be initialized, for example the sync configuration. - */ -async function initNotSyncedOptions(initialized: boolean, opts: NotSyncedOpts = {}) { - optionService.createOption( - "openNoteContexts", - JSON.stringify([ - { - notePath: "root", - active: true - } - ]), - false - ); - - optionService.createOption("lastDailyBackupDate", dateUtils.utcNowDateTime(), false); - optionService.createOption("lastWeeklyBackupDate", dateUtils.utcNowDateTime(), false); - optionService.createOption("lastMonthlyBackupDate", dateUtils.utcNowDateTime(), false); - optionService.createOption("dbVersion", appInfo.dbVersion.toString(), false); - - optionService.createOption("initialized", initialized ? "true" : "false", false); - - optionService.createOption("lastSyncedPull", "0", false); - optionService.createOption("lastSyncedPush", "0", false); - - optionService.createOption("theme", "next", false); - optionService.createOption("textNoteEditorType", "ckeditor-classic", true); - - optionService.createOption("syncServerHost", opts.syncServerHost || "", false); - optionService.createOption("syncServerTimeout", "120000", false); - optionService.createOption("syncProxy", opts.syncProxy || "", false); -} - -/** - * Contains all the default options that must be initialized on new and existing databases (at startup). The value can also be determined based on other options, provided they have already been initialized. - */ -const defaultOptions: DefaultOption[] = [ - { name: "revisionSnapshotTimeInterval", value: "600", isSynced: true }, - { name: "revisionSnapshotTimeIntervalTimeScale", value: "60", isSynced: true }, // default to Minutes - { name: "revisionSnapshotNumberLimit", value: "-1", isSynced: true }, - { name: "protectedSessionTimeout", value: "600", isSynced: true }, - { name: "protectedSessionTimeoutTimeScale", value: "60", isSynced: true }, - { name: "zoomFactor", value: isWindows ? "0.9" : "1.0", isSynced: false }, - { name: "overrideThemeFonts", value: "false", isSynced: false }, - { name: "mainFontFamily", value: "theme", isSynced: false }, - { name: "mainFontSize", value: "100", isSynced: false }, - { name: "treeFontFamily", value: "theme", isSynced: false }, - { name: "treeFontSize", value: "100", isSynced: false }, - { name: "detailFontFamily", value: "theme", isSynced: false }, - { name: "detailFontSize", value: "110", isSynced: false }, - { name: "monospaceFontFamily", value: "theme", isSynced: false }, - { name: "monospaceFontSize", value: "110", isSynced: false }, - { name: "spellCheckEnabled", value: "true", isSynced: false }, - { name: "spellCheckLanguageCode", value: "en-US", isSynced: false }, - { name: "imageMaxWidthHeight", value: "2000", isSynced: true }, - { name: "imageJpegQuality", value: "75", isSynced: true }, - { name: "autoFixConsistencyIssues", value: "true", isSynced: false }, - { name: "vimKeymapEnabled", value: "false", isSynced: false }, - { name: "codeLineWrapEnabled", value: "true", isSynced: false }, - { - name: "codeNotesMimeTypes", - value: '["text/x-csrc","text/x-c++src","text/x-csharp","text/css","text/x-elixir","text/x-go","text/x-groovy","text/x-haskell","text/html","message/http","text/x-java","application/javascript;env=frontend","application/javascript;env=backend","application/json","text/x-kotlin","text/x-markdown","text/x-perl","text/x-php","text/x-python","text/x-ruby",null,"text/x-sql","text/x-sqlite;schema=trilium","text/x-swift","text/xml","text/x-yaml","text/x-sh","application/typescript"]', - isSynced: true - }, - { name: "leftPaneWidth", value: "25", isSynced: false }, - { name: "leftPaneVisible", value: "true", isSynced: false }, - { name: "rightPaneWidth", value: "25", isSynced: false }, - { name: "rightPaneVisible", value: "true", isSynced: false }, - { name: "rightPaneCollapsedItems", value: "[]", isSynced: false }, - { name: "nativeTitleBarVisible", value: "false", isSynced: false }, - { name: "eraseEntitiesAfterTimeInSeconds", value: "604800", isSynced: true }, // default is 7 days - { name: "eraseEntitiesAfterTimeScale", value: "86400", isSynced: true }, // default 86400 seconds = Day - { name: "hideArchivedNotes_main", value: "false", isSynced: false }, - { name: "debugModeEnabled", value: "false", isSynced: false }, - { name: "headingStyle", value: "underline", isSynced: true }, - { name: "autoCollapseNoteTree", value: "true", isSynced: true }, - { name: "autoReadonlySizeText", value: "32000", isSynced: false }, - { name: "autoReadonlySizeCode", value: "64000", isSynced: false }, - { name: "dailyBackupEnabled", value: "true", isSynced: false }, - { name: "weeklyBackupEnabled", value: "true", isSynced: false }, - { name: "monthlyBackupEnabled", value: "true", isSynced: false }, - { name: "maxContentWidth", value: "1200", isSynced: false }, - { name: "centerContent", value: "false", isSynced: false }, - { name: "compressImages", value: "true", isSynced: true }, - { name: "downloadImagesAutomatically", value: "true", isSynced: true }, - { name: "minTocHeadings", value: "5", isSynced: true }, - { name: "highlightsList", value: '["underline","color","bgColor"]', isSynced: true }, - { name: "checkForUpdates", value: "true", isSynced: true }, - { name: "disableTray", value: "false", isSynced: false }, - { name: "eraseUnusedAttachmentsAfterSeconds", value: "2592000", isSynced: true }, // default 30 days - { name: "eraseUnusedAttachmentsAfterTimeScale", value: "86400", isSynced: true }, // default 86400 seconds = Day - { name: "logRetentionDays", value: "90", isSynced: false }, // default 90 days - { name: "customSearchEngineName", value: "DuckDuckGo", isSynced: true }, - { name: "customSearchEngineUrl", value: "https://duckduckgo.com/?q={keyword}", isSynced: true }, - { name: "editedNotesOpenInRibbon", value: "true", isSynced: true }, - { name: "mfaEnabled", value: "false", isSynced: false }, - { name: "mfaMethod", value: "totp", isSynced: false }, - { name: "encryptedRecoveryCodes", value: "false", isSynced: false }, - { name: "userSubjectIdentifierSaved", value: "false", isSynced: false }, - - // Appearance - { name: "splitEditorOrientation", value: "horizontal", isSynced: true }, - { - name: "codeNoteTheme", - value: (optionsMap) => { - switch (optionsMap.theme) { - case "light": - case "next-light": - return "default:vs-code-light"; - case "dark": - case "next-dark": - default: - return "default:vs-code-dark"; - } - }, - isSynced: false - }, - { name: "motionEnabled", value: "true", isSynced: false }, - { name: "shadowsEnabled", value: "true", isSynced: false }, - { name: "backdropEffectsEnabled", value: "true", isSynced: false }, - { name: "smoothScrollEnabled", value: "true", isSynced: false }, - { name: "newLayout", value: "true", isSynced: true }, - - // Internationalization - { name: "locale", value: "en", isSynced: true }, - { name: "formattingLocale", value: "", isSynced: true }, // no value means auto-detect - { name: "firstDayOfWeek", value: "1", isSynced: true }, - { name: "firstWeekOfYear", value: "0", isSynced: true }, - { name: "minDaysInFirstWeek", value: "4", isSynced: true }, - { name: "languages", value: "[]", isSynced: true }, - - // Code block configuration - { - name: "codeBlockTheme", - value: (optionsMap) => { - if (optionsMap.theme === "light") { - return "default:stackoverflow-light"; - } - return "default:stackoverflow-dark"; - - }, - isSynced: false - }, - { name: "codeBlockWordWrap", value: "false", isSynced: true }, - - // Text note configuration - { name: "textNoteEditorType", value: "ckeditor-balloon", isSynced: true }, - { name: "textNoteEditorMultilineToolbar", value: "false", isSynced: true }, - { name: "textNoteEmojiCompletionEnabled", value: "true", isSynced: true }, - { name: "textNoteCompletionEnabled", value: "true", isSynced: true }, - { name: "textNoteSlashCommandsEnabled", value: "true", isSynced: true }, - - // HTML import configuration - { name: "layoutOrientation", value: "vertical", isSynced: false }, - { name: "backgroundEffects", value: "true", isSynced: false }, - { - name: "allowedHtmlTags", - value: JSON.stringify(SANITIZER_DEFAULT_ALLOWED_TAGS), - isSynced: true - }, - - // Share settings - { name: "redirectBareDomain", value: "false", isSynced: true }, - { name: "showLoginInShareTheme", value: "false", isSynced: true }, - - // AI Options - { name: "aiEnabled", value: "false", isSynced: true }, - { name: "openaiApiKey", value: "", isSynced: false }, - { name: "openaiDefaultModel", value: "", isSynced: true }, - { name: "openaiBaseUrl", value: "https://api.openai.com/v1", isSynced: true }, - { name: "anthropicApiKey", value: "", isSynced: false }, - { name: "anthropicDefaultModel", value: "", isSynced: true }, - { name: "voyageApiKey", value: "", isSynced: false }, - { name: "anthropicBaseUrl", value: "https://api.anthropic.com/v1", isSynced: true }, - { name: "ollamaEnabled", value: "false", isSynced: true }, - { name: "ollamaDefaultModel", value: "", isSynced: true }, - { name: "ollamaBaseUrl", value: "http://localhost:11434", isSynced: true }, - { name: "aiTemperature", value: "0.7", isSynced: true }, - { name: "aiSystemPrompt", value: "", isSynced: true }, - { name: "aiSelectedProvider", value: "openai", isSynced: true }, - - { - name: "seenCallToActions", - value: JSON.stringify([ - "new_layout", "background_effects", "next_theme" - ]), - isSynced: true - }, - { name: "experimentalFeatures", value: "[]", isSynced: true } -]; - -/** - * Initializes the options, by checking which options from {@link #allDefaultOptions()} are missing and registering them. It will also check some environment variables such as safe mode, to make any necessary adjustments. - * - * This method is called regardless of whether a new database is created, or an existing database is used. - */ -function initStartupOptions() { - const optionsMap = optionService.getOptionMap(); - - const allDefaultOptions = defaultOptions.concat(getKeyboardDefaultOptions()); - - for (const { name, value, isSynced } of allDefaultOptions) { - if (!(name in optionsMap)) { - let resolvedValue; - if (typeof value === "function") { - resolvedValue = value(optionsMap); - } else { - resolvedValue = value; - } - - optionService.createOption(name, resolvedValue, isSynced); - log.info(`Created option "${name}" with default value "${resolvedValue}"`); - } - } - - if (process.env.TRILIUM_START_NOTE_ID || process.env.TRILIUM_SAFE_MODE) { - optionService.setOption( - "openNoteContexts", - JSON.stringify([ - { - notePath: process.env.TRILIUM_START_NOTE_ID || "root", - active: true - } - ]) - ); - } -} - -function getKeyboardDefaultOptions() { - return (keyboardActions.getDefaultKeyboardActions().filter((ka) => "actionName" in ka) as KeyboardShortcutWithRequiredActionName[]).map((ka) => ({ - name: `keyboardShortcuts${ka.actionName.charAt(0).toUpperCase()}${ka.actionName.slice(1)}`, - value: JSON.stringify(ka.defaultShortcuts), - isSynced: false - })) as DefaultOption[]; -} - -export default { - initDocumentOptions, - initNotSyncedOptions, - initStartupOptions -}; +import { options_init } from "@triliumnext/core"; +export default options_init; diff --git a/packages/commons/src/lib/server_api.ts b/packages/commons/src/lib/server_api.ts index 5a78d36769..17d8f14716 100644 --- a/packages/commons/src/lib/server_api.ts +++ b/packages/commons/src/lib/server_api.ts @@ -11,11 +11,11 @@ type Response = { export interface AppInfo { appVersion: string; dbVersion: number; - nodeVersion: string; + nodeVersion?: string; syncVersion: number; buildDate: string; buildRevision: string; - dataDirectory: string; + dataDirectory?: string; clipperProtocolVersion: string; /** for timezone inference */ utcDateTime: string; diff --git a/packages/trilium-core/src/index.ts b/packages/trilium-core/src/index.ts index 5abb13025e..7a0d5ac691 100644 --- a/packages/trilium-core/src/index.ts +++ b/packages/trilium-core/src/index.ts @@ -10,9 +10,14 @@ export * as protected_session from "./services/protected_session"; export { default as data_encryption } from "./services/encryption/data_encryption" export * as binary_utils from "./services/utils/binary"; export * as utils from "./services/utils/index"; +export * from "./services/build"; export { default as date_utils } from "./services/utils/date"; export { default as events } from "./services/events"; export { default as blob } from "./services/blob"; +export { default as options } from "./services/options"; +export { default as options_init } from "./services/options_init"; +export { default as app_info } from "./services/app_info"; +export { default as keyboard_actions } from "./services/keyboard_actions"; export { getContext, type ExecutionContext } from "./services/context"; export * from "./errors"; export type { CryptoProvider } from "./services/encryption/crypto"; diff --git a/packages/trilium-core/src/services/app_info.ts b/packages/trilium-core/src/services/app_info.ts new file mode 100644 index 0000000000..269b15b35c --- /dev/null +++ b/packages/trilium-core/src/services/app_info.ts @@ -0,0 +1,17 @@ +import build from "./build.js"; +import packageJson from "../../package.json" with { type: "json" }; +import { AppInfo } from "@triliumnext/commons"; + +const APP_DB_VERSION = 233; +const SYNC_VERSION = 36; +const CLIPPER_PROTOCOL_VERSION = "1.0"; + +export default { + appVersion: packageJson.version, + dbVersion: APP_DB_VERSION, + syncVersion: SYNC_VERSION, + buildDate: build.buildDate, + buildRevision: build.buildRevision, + clipperProtocolVersion: CLIPPER_PROTOCOL_VERSION, + utcDateTime: new Date().toISOString() +} satisfies AppInfo; diff --git a/apps/server/src/services/build.ts b/packages/trilium-core/src/services/build.ts similarity index 100% rename from apps/server/src/services/build.ts rename to packages/trilium-core/src/services/build.ts diff --git a/packages/trilium-core/src/services/keyboard_actions.ts b/packages/trilium-core/src/services/keyboard_actions.ts new file mode 100644 index 0000000000..326672eeb2 --- /dev/null +++ b/packages/trilium-core/src/services/keyboard_actions.ts @@ -0,0 +1,882 @@ +"use strict"; + +import optionService from "./options.js"; +import log from "./log.js"; +import { isElectron, isMac } from "./utils.js"; +import type { ActionKeyboardShortcut, KeyboardShortcut } from "@triliumnext/commons"; +import { t } from "i18next"; + +function getDefaultKeyboardActions() { + if (!t("keyboard_actions.note-navigation")) { + throw new Error("Keyboard actions loaded before translations."); + } + + const DEFAULT_KEYBOARD_ACTIONS: KeyboardShortcut[] = [ + { + separator: t("keyboard_actions.note-navigation") + }, + { + actionName: "backInNoteHistory", + friendlyName: t("keyboard_action_names.back-in-note-history"), + iconClass: "bx bxs-chevron-left", + // Mac has a different history navigation shortcuts - https://github.com/zadam/trilium/issues/376 + defaultShortcuts: isMac ? ["CommandOrControl+["] : ["Alt+Left"], + description: t("keyboard_actions.back-in-note-history"), + scope: "window" + }, + { + actionName: "forwardInNoteHistory", + friendlyName: t("keyboard_action_names.forward-in-note-history"), + iconClass: "bx bxs-chevron-right", + // Mac has a different history navigation shortcuts - https://github.com/zadam/trilium/issues/376 + defaultShortcuts: isMac ? ["CommandOrControl+]"] : ["Alt+Right"], + description: t("keyboard_actions.forward-in-note-history"), + scope: "window" + }, + { + actionName: "jumpToNote", + friendlyName: t("keyboard_action_names.jump-to-note"), + defaultShortcuts: ["CommandOrControl+J"], + description: t("keyboard_actions.open-jump-to-note-dialog"), + scope: "window", + ignoreFromCommandPalette: true + }, + { + actionName: "openTodayNote", + friendlyName: t("hidden-subtree.open-today-journal-note-title"), + iconClass: "bx bx-calendar", + defaultShortcuts: [], + description: t("hidden-subtree.open-today-journal-note-title"), + scope: "window" + }, + { + actionName: "commandPalette", + friendlyName: t("keyboard_action_names.command-palette"), + defaultShortcuts: ["CommandOrControl+Shift+J"], + description: t("keyboard_actions.open-command-palette"), + scope: "window", + ignoreFromCommandPalette: true + }, + { + actionName: "scrollToActiveNote", + friendlyName: t("keyboard_action_names.scroll-to-active-note"), + defaultShortcuts: ["CommandOrControl+."], + iconClass: "bx bx-current-location", + description: t("keyboard_actions.scroll-to-active-note"), + scope: "window" + }, + { + actionName: "quickSearch", + friendlyName: t("keyboard_action_names.quick-search"), + iconClass: "bx bx-search", + defaultShortcuts: ["CommandOrControl+S"], + description: t("keyboard_actions.quick-search"), + scope: "window" + }, + { + actionName: "searchInSubtree", + friendlyName: t("keyboard_action_names.search-in-subtree"), + defaultShortcuts: ["CommandOrControl+Shift+S"], + iconClass: "bx bx-search-alt", + description: t("keyboard_actions.search-in-subtree"), + scope: "note-tree" + }, + { + actionName: "expandSubtree", + friendlyName: t("keyboard_action_names.expand-subtree"), + defaultShortcuts: [], + iconClass: "bx bx-layer-plus", + description: t("keyboard_actions.expand-subtree"), + scope: "note-tree" + }, + { + actionName: "collapseTree", + friendlyName: t("keyboard_action_names.collapse-tree"), + defaultShortcuts: ["Alt+C"], + iconClass: "bx bx-layer-minus", + description: t("keyboard_actions.collapse-tree"), + scope: "window" + }, + { + actionName: "collapseSubtree", + friendlyName: t("keyboard_action_names.collapse-subtree"), + iconClass: "bx bxs-layer-minus", + defaultShortcuts: ["Alt+-"], + description: t("keyboard_actions.collapse-subtree"), + scope: "note-tree" + }, + { + actionName: "sortChildNotes", + friendlyName: t("keyboard_action_names.sort-child-notes"), + iconClass: "bx bx-sort-down", + defaultShortcuts: ["Alt+S"], + description: t("keyboard_actions.sort-child-notes"), + scope: "note-tree" + }, + + { + separator: t("keyboard_actions.creating-and-moving-notes") + }, + { + actionName: "createNoteAfter", + friendlyName: t("keyboard_action_names.create-note-after"), + iconClass: "bx bx-plus", + defaultShortcuts: ["CommandOrControl+O"], + description: t("keyboard_actions.create-note-after"), + scope: "window" + }, + { + actionName: "createNoteInto", + friendlyName: t("keyboard_action_names.create-note-into"), + iconClass: "bx bx-plus", + defaultShortcuts: ["CommandOrControl+P"], + description: t("keyboard_actions.create-note-into"), + scope: "window" + }, + { + actionName: "createNoteIntoInbox", + friendlyName: t("keyboard_action_names.create-note-into-inbox"), + iconClass: "bx bxs-inbox", + defaultShortcuts: ["global:CommandOrControl+Alt+P"], + description: t("keyboard_actions.create-note-into-inbox"), + scope: "window" + }, + { + actionName: "deleteNotes", + friendlyName: t("keyboard_action_names.delete-notes"), + iconClass: "bx bx-trash", + defaultShortcuts: ["Delete"], + description: t("keyboard_actions.delete-note"), + scope: "note-tree" + }, + { + actionName: "moveNoteUp", + friendlyName: t("keyboard_action_names.move-note-up"), + iconClass: "bx bx-up-arrow-alt", + defaultShortcuts: isMac ? ["Alt+Up"] : ["CommandOrControl+Up"], + description: t("keyboard_actions.move-note-up"), + scope: "note-tree" + }, + { + actionName: "moveNoteDown", + friendlyName: t("keyboard_action_names.move-note-down"), + iconClass: "bx bx-down-arrow-alt", + defaultShortcuts: isMac ? ["Alt+Down"] : ["CommandOrControl+Down"], + description: t("keyboard_actions.move-note-down"), + scope: "note-tree" + }, + { + actionName: "moveNoteUpInHierarchy", + friendlyName: t("keyboard_action_names.move-note-up-in-hierarchy"), + iconClass: "bx bx-arrow-from-bottom", + defaultShortcuts: isMac ? ["Alt+Left"] : ["CommandOrControl+Left"], + description: t("keyboard_actions.move-note-up-in-hierarchy"), + scope: "note-tree" + }, + { + actionName: "moveNoteDownInHierarchy", + friendlyName: t("keyboard_action_names.move-note-down-in-hierarchy"), + iconClass: "bx bx-arrow-from-top", + defaultShortcuts: isMac ? ["Alt+Right"] : ["CommandOrControl+Right"], + description: t("keyboard_actions.move-note-down-in-hierarchy"), + scope: "note-tree" + }, + { + actionName: "editNoteTitle", + friendlyName: t("keyboard_action_names.edit-note-title"), + iconClass: "bx bx-rename", + defaultShortcuts: ["Enter"], + description: t("keyboard_actions.edit-note-title"), + scope: "note-tree" + }, + { + actionName: "editBranchPrefix", + friendlyName: t("keyboard_action_names.edit-branch-prefix"), + iconClass: "bx bx-rename", + defaultShortcuts: ["F2"], + description: t("keyboard_actions.edit-branch-prefix"), + scope: "note-tree" + }, + { + actionName: "cloneNotesTo", + friendlyName: t("keyboard_action_names.clone-notes-to"), + iconClass: "bx bx-duplicate", + defaultShortcuts: ["CommandOrControl+Shift+C"], + description: t("keyboard_actions.clone-notes-to"), + scope: "window" + }, + { + actionName: "moveNotesTo", + friendlyName: t("keyboard_action_names.move-notes-to"), + iconClass: "bx bx-transfer", + defaultShortcuts: ["CommandOrControl+Shift+X"], + description: t("keyboard_actions.move-notes-to"), + scope: "window" + }, + + { + separator: t("keyboard_actions.note-clipboard") + }, + + { + actionName: "copyNotesToClipboard", + friendlyName: t("keyboard_action_names.copy-notes-to-clipboard"), + iconClass: "bx bx-copy", + defaultShortcuts: ["CommandOrControl+C"], + description: t("keyboard_actions.copy-notes-to-clipboard"), + scope: "note-tree" + }, + { + actionName: "pasteNotesFromClipboard", + friendlyName: t("keyboard_action_names.paste-notes-from-clipboard"), + iconClass: "bx bx-paste", + defaultShortcuts: ["CommandOrControl+V"], + description: t("keyboard_actions.paste-notes-from-clipboard"), + scope: "note-tree" + }, + { + actionName: "cutNotesToClipboard", + friendlyName: t("keyboard_action_names.cut-notes-to-clipboard"), + iconClass: "bx bx-cut", + defaultShortcuts: ["CommandOrControl+X"], + description: t("keyboard_actions.cut-notes-to-clipboard"), + scope: "note-tree" + }, + { + actionName: "selectAllNotesInParent", + friendlyName: t("keyboard_action_names.select-all-notes-in-parent"), + iconClass: "bx bx-select-multiple", + defaultShortcuts: ["CommandOrControl+A"], + description: t("keyboard_actions.select-all-notes-in-parent"), + scope: "note-tree" + }, + { + actionName: "addNoteAboveToSelection", + friendlyName: t("keyboard_action_names.add-note-above-to-selection"), + defaultShortcuts: ["Shift+Up"], + description: t("keyboard_actions.add-note-above-to-the-selection"), + scope: "note-tree", + ignoreFromCommandPalette: true + }, + { + actionName: "addNoteBelowToSelection", + friendlyName: t("keyboard_action_names.add-note-below-to-selection"), + defaultShortcuts: ["Shift+Down"], + description: t("keyboard_actions.add-note-below-to-selection"), + scope: "note-tree", + ignoreFromCommandPalette: true + }, + { + actionName: "duplicateSubtree", + friendlyName: t("keyboard_action_names.duplicate-subtree"), + iconClass: "bx bx-outline", + defaultShortcuts: [], + description: t("keyboard_actions.duplicate-subtree"), + scope: "note-tree" + }, + + { + separator: t("keyboard_actions.tabs-and-windows") + }, + { + actionName: "openNewTab", + friendlyName: t("keyboard_action_names.open-new-tab"), + iconClass: "bx bx-plus", + defaultShortcuts: isElectron ? ["CommandOrControl+T"] : [], + description: t("keyboard_actions.open-new-tab"), + scope: "window" + }, + { + actionName: "closeActiveTab", + friendlyName: t("keyboard_action_names.close-active-tab"), + iconClass: "bx bx-minus", + defaultShortcuts: isElectron ? ["CommandOrControl+W"] : [], + description: t("keyboard_actions.close-active-tab"), + scope: "window" + }, + { + actionName: "reopenLastTab", + friendlyName: t("keyboard_action_names.reopen-last-tab"), + iconClass: "bx bx-undo", + defaultShortcuts: isElectron ? ["CommandOrControl+Shift+T"] : [], + isElectronOnly: true, + description: t("keyboard_actions.reopen-last-tab"), + scope: "window" + }, + { + actionName: "activateNextTab", + friendlyName: t("keyboard_action_names.activate-next-tab"), + iconClass: "bx bx-skip-next", + defaultShortcuts: isElectron ? ["CommandOrControl+Tab", "CommandOrControl+PageDown"] : [], + description: t("keyboard_actions.activate-next-tab"), + scope: "window" + }, + { + actionName: "activatePreviousTab", + friendlyName: t("keyboard_action_names.activate-previous-tab"), + iconClass: "bx bx-skip-previous", + defaultShortcuts: isElectron ? ["CommandOrControl+Shift+Tab", "CommandOrControl+PageUp"] : [], + description: t("keyboard_actions.activate-previous-tab"), + scope: "window" + }, + { + actionName: "openNewWindow", + friendlyName: t("keyboard_action_names.open-new-window"), + iconClass: "bx bx-window-open", + defaultShortcuts: [], + description: t("keyboard_actions.open-new-window"), + scope: "window" + }, + { + actionName: "toggleTray", + friendlyName: t("keyboard_action_names.toggle-system-tray-icon"), + iconClass: "bx bx-show", + defaultShortcuts: [], + isElectronOnly: true, + description: t("keyboard_actions.toggle-tray"), + scope: "window" + }, + { + actionName: "toggleZenMode", + friendlyName: t("keyboard_action_names.toggle-zen-mode"), + iconClass: "bx bxs-yin-yang", + defaultShortcuts: ["F9"], + description: t("keyboard_actions.toggle-zen-mode"), + scope: "window" + }, + { + actionName: "firstTab", + friendlyName: t("keyboard_action_names.switch-to-first-tab"), + iconClass: "bx bx-rectangle", + defaultShortcuts: ["CommandOrControl+1"], + description: t("keyboard_actions.first-tab"), + scope: "window" + }, + { + actionName: "secondTab", + friendlyName: t("keyboard_action_names.switch-to-second-tab"), + iconClass: "bx bx-rectangle", + defaultShortcuts: ["CommandOrControl+2"], + description: t("keyboard_actions.second-tab"), + scope: "window" + }, + { + actionName: "thirdTab", + friendlyName: t("keyboard_action_names.switch-to-third-tab"), + iconClass: "bx bx-rectangle", + defaultShortcuts: ["CommandOrControl+3"], + description: t("keyboard_actions.third-tab"), + scope: "window" + }, + { + actionName: "fourthTab", + friendlyName: t("keyboard_action_names.switch-to-fourth-tab"), + iconClass: "bx bx-rectangle", + defaultShortcuts: ["CommandOrControl+4"], + description: t("keyboard_actions.fourth-tab"), + scope: "window" + }, + { + actionName: "fifthTab", + friendlyName: t("keyboard_action_names.switch-to-fifth-tab"), + iconClass: "bx bx-rectangle", + defaultShortcuts: ["CommandOrControl+5"], + description: t("keyboard_actions.fifth-tab"), + scope: "window" + }, + { + actionName: "sixthTab", + friendlyName: t("keyboard_action_names.switch-to-sixth-tab"), + iconClass: "bx bx-rectangle", + defaultShortcuts: ["CommandOrControl+6"], + description: t("keyboard_actions.sixth-tab"), + scope: "window" + }, + { + actionName: "seventhTab", + friendlyName: t("keyboard_action_names.switch-to-seventh-tab"), + iconClass: "bx bx-rectangle", + defaultShortcuts: ["CommandOrControl+7"], + description: t("keyboard_actions.seventh-tab"), + scope: "window" + }, + { + actionName: "eigthTab", + friendlyName: t("keyboard_action_names.switch-to-eighth-tab"), + iconClass: "bx bx-rectangle", + defaultShortcuts: ["CommandOrControl+8"], + description: t("keyboard_actions.eight-tab"), + scope: "window" + }, + { + actionName: "ninthTab", + friendlyName: t("keyboard_action_names.switch-to-ninth-tab"), + iconClass: "bx bx-rectangle", + defaultShortcuts: ["CommandOrControl+9"], + description: t("keyboard_actions.ninth-tab"), + scope: "window" + }, + { + actionName: "lastTab", + friendlyName: t("keyboard_action_names.switch-to-last-tab"), + iconClass: "bx bx-rectangle", + defaultShortcuts: ["CommandOrControl+0"], + description: t("keyboard_actions.last-tab"), + scope: "window" + }, + + { + separator: t("keyboard_actions.dialogs") + }, + { + friendlyName: t("keyboard_action_names.show-note-source"), + actionName: "showNoteSource", + iconClass: "bx bx-code", + defaultShortcuts: [], + description: t("keyboard_actions.show-note-source"), + scope: "window" + }, + { + friendlyName: t("keyboard_action_names.show-options"), + actionName: "showOptions", + iconClass: "bx bx-cog", + defaultShortcuts: [], + description: t("keyboard_actions.show-options"), + scope: "window" + }, + { + friendlyName: t("keyboard_action_names.show-revisions"), + actionName: "showRevisions", + iconClass: "bx bx-history", + defaultShortcuts: [], + description: t("keyboard_actions.show-revisions"), + scope: "window" + }, + { + friendlyName: t("keyboard_action_names.show-recent-changes"), + actionName: "showRecentChanges", + iconClass: "bx bx-history", + defaultShortcuts: [], + description: t("keyboard_actions.show-recent-changes"), + scope: "window" + }, + { + friendlyName: t("keyboard_action_names.show-sql-console"), + actionName: "showSQLConsole", + iconClass: "bx bx-data", + defaultShortcuts: ["Alt+O"], + description: t("keyboard_actions.show-sql-console"), + scope: "window" + }, + { + friendlyName: t("keyboard_action_names.show-backend-log"), + actionName: "showBackendLog", + iconClass: "bx bx-detail", + defaultShortcuts: [], + description: t("keyboard_actions.show-backend-log"), + scope: "window" + }, + { + friendlyName: t("keyboard_action_names.show-help"), + actionName: "showHelp", + iconClass: "bx bx-help-circle", + defaultShortcuts: ["F1"], + description: t("keyboard_actions.show-help"), + scope: "window" + }, + { + friendlyName: t("keyboard_action_names.show-cheatsheet"), + actionName: "showCheatsheet", + iconClass: "bx bxs-keyboard", + defaultShortcuts: ["Shift+F1"], + description: t("keyboard_actions.show-cheatsheet"), + scope: "window" + }, + + { + separator: t("keyboard_actions.text-note-operations") + }, + + { + friendlyName: t("keyboard_action_names.add-link-to-text"), + actionName: "addLinkToText", + iconClass: "bx bx-link", + defaultShortcuts: ["CommandOrControl+L"], + description: t("keyboard_actions.add-link-to-text"), + scope: "text-detail" + }, + { + friendlyName: t("keyboard_action_names.follow-link-under-cursor"), + actionName: "followLinkUnderCursor", + iconClass: "bx bx-link-external", + defaultShortcuts: ["CommandOrControl+Enter"], + description: t("keyboard_actions.follow-link-under-cursor"), + scope: "text-detail" + }, + { + friendlyName: t("keyboard_action_names.insert-date-and-time-to-text"), + actionName: "insertDateTimeToText", + iconClass: "bx bx-calendar-event", + defaultShortcuts: ["Alt+T"], + description: t("keyboard_actions.insert-date-and-time-to-text"), + scope: "text-detail" + }, + { + friendlyName: t("keyboard_action_names.paste-markdown-into-text"), + actionName: "pasteMarkdownIntoText", + iconClass: "bx bxl-markdown", + defaultShortcuts: [], + description: t("keyboard_actions.paste-markdown-into-text"), + scope: "text-detail" + }, + { + friendlyName: t("keyboard_action_names.cut-into-note"), + actionName: "cutIntoNote", + iconClass: "bx bx-cut", + defaultShortcuts: [], + description: t("keyboard_actions.cut-into-note"), + scope: "text-detail" + }, + { + friendlyName: t("keyboard_action_names.add-include-note-to-text"), + actionName: "addIncludeNoteToText", + iconClass: "bx bx-note", + defaultShortcuts: [], + description: t("keyboard_actions.add-include-note-to-text"), + scope: "text-detail" + }, + { + friendlyName: t("keyboard_action_names.edit-read-only-note"), + actionName: "editReadOnlyNote", + iconClass: "bx bx-edit-alt", + defaultShortcuts: [], + description: t("keyboard_actions.edit-readonly-note"), + scope: "window" + }, + + { + separator: t("keyboard_actions.attributes-labels-and-relations") + }, + + { + friendlyName: t("keyboard_action_names.add-new-label"), + actionName: "addNewLabel", + iconClass: "bx bx-hash", + defaultShortcuts: ["Alt+L"], + description: t("keyboard_actions.add-new-label"), + scope: "window" + }, + { + friendlyName: t("keyboard_action_names.add-new-relation"), + actionName: "addNewRelation", + iconClass: "bx bx-transfer", + defaultShortcuts: ["Alt+R"], + description: t("keyboard_actions.create-new-relation"), + scope: "window" + }, + + { + separator: t("keyboard_actions.ribbon-tabs") + }, + + { + friendlyName: t("keyboard_action_names.toggle-ribbon-tab-classic-editor"), + actionName: "toggleRibbonTabClassicEditor", + iconClass: "bx bx-text", + defaultShortcuts: [], + description: t("keyboard_actions.toggle-classic-editor-toolbar"), + scope: "window" + }, + { + actionName: "toggleRibbonTabBasicProperties", + friendlyName: t("keyboard_action_names.toggle-ribbon-tab-basic-properties"), + iconClass: "bx bx-slider", + defaultShortcuts: [], + description: t("keyboard_actions.toggle-basic-properties"), + scope: "window" + }, + { + actionName: "toggleRibbonTabBookProperties", + friendlyName: t("keyboard_action_names.toggle-ribbon-tab-book-properties"), + iconClass: "bx bx-book", + defaultShortcuts: [], + description: t("keyboard_actions.toggle-book-properties"), + scope: "window" + }, + { + actionName: "toggleRibbonTabFileProperties", + friendlyName: t("keyboard_action_names.toggle-ribbon-tab-file-properties"), + iconClass: "bx bx-file", + defaultShortcuts: [], + description: t("keyboard_actions.toggle-file-properties"), + scope: "window" + }, + { + actionName: "toggleRibbonTabImageProperties", + friendlyName: t("keyboard_action_names.toggle-ribbon-tab-image-properties"), + iconClass: "bx bx-image", + defaultShortcuts: [], + description: t("keyboard_actions.toggle-image-properties"), + scope: "window" + }, + { + actionName: "toggleRibbonTabOwnedAttributes", + friendlyName: t("keyboard_action_names.toggle-ribbon-tab-owned-attributes"), + iconClass: "bx bx-list-check", + defaultShortcuts: ["Alt+A"], + description: t("keyboard_actions.toggle-owned-attributes"), + scope: "window" + }, + { + actionName: "toggleRibbonTabInheritedAttributes", + friendlyName: t("keyboard_action_names.toggle-ribbon-tab-inherited-attributes"), + iconClass: "bx bx-list-plus", + defaultShortcuts: [], + description: t("keyboard_actions.toggle-inherited-attributes"), + scope: "window" + }, + // TODO: Remove or change since promoted attributes have been changed. + { + actionName: "toggleRibbonTabPromotedAttributes", + friendlyName: t("keyboard_action_names.toggle-ribbon-tab-promoted-attributes"), + iconClass: "bx bx-star", + defaultShortcuts: [], + description: t("keyboard_actions.toggle-promoted-attributes"), + scope: "window" + }, + { + actionName: "toggleRibbonTabNoteMap", + friendlyName: t("keyboard_action_names.toggle-ribbon-tab-note-map"), + iconClass: "bx bxs-network-chart", + defaultShortcuts: [], + description: t("keyboard_actions.toggle-link-map"), + scope: "window" + }, + { + actionName: "toggleRibbonTabNoteInfo", + friendlyName: t("keyboard_action_names.toggle-ribbon-tab-note-info"), + iconClass: "bx bx-info-circle", + defaultShortcuts: [], + description: t("keyboard_actions.toggle-note-info"), + scope: "window" + }, + { + actionName: "toggleRibbonTabNotePaths", + friendlyName: t("keyboard_action_names.toggle-ribbon-tab-note-paths"), + iconClass: "bx bx-collection", + defaultShortcuts: [], + description: t("keyboard_actions.toggle-note-paths"), + scope: "window" + }, + { + actionName: "toggleRibbonTabSimilarNotes", + friendlyName: t("keyboard_action_names.toggle-ribbon-tab-similar-notes"), + iconClass: "bx bx-bar-chart", + defaultShortcuts: [], + description: t("keyboard_actions.toggle-similar-notes"), + scope: "window" + }, + + { + separator: t("keyboard_actions.other") + }, + + { + actionName: "toggleRightPane", + friendlyName: t("keyboard_action_names.toggle-right-pane"), + iconClass: "bx bx-dock-right", + defaultShortcuts: [], + description: t("keyboard_actions.toggle-right-pane"), + scope: "window" + }, + { + actionName: "printActiveNote", + friendlyName: t("keyboard_action_names.print-active-note"), + iconClass: "bx bx-printer", + defaultShortcuts: [], + description: t("keyboard_actions.print-active-note"), + scope: "window" + }, + { + actionName: "exportAsPdf", + friendlyName: t("keyboard_action_names.export-active-note-as-pdf"), + iconClass: "bx bxs-file-pdf", + defaultShortcuts: [], + description: t("keyboard_actions.export-as-pdf"), + scope: "window" + }, + { + actionName: "openNoteExternally", + friendlyName: t("keyboard_action_names.open-note-externally"), + iconClass: "bx bx-file-find", + defaultShortcuts: [], + description: t("keyboard_actions.open-note-externally"), + scope: "window" + }, + { + actionName: "renderActiveNote", + friendlyName: t("keyboard_action_names.render-active-note"), + iconClass: "bx bx-refresh", + defaultShortcuts: [], + description: t("keyboard_actions.render-active-note"), + scope: "window" + }, + { + actionName: "runActiveNote", + friendlyName: t("keyboard_action_names.run-active-note"), + iconClass: "bx bx-play", + defaultShortcuts: ["CommandOrControl+Enter"], + description: t("keyboard_actions.run-active-note"), + scope: "code-detail" + }, + { + actionName: "toggleNoteHoisting", + friendlyName: t("keyboard_action_names.toggle-note-hoisting"), + iconClass: "bx bx-chevrons-up", + defaultShortcuts: ["Alt+H"], + description: t("keyboard_actions.toggle-note-hoisting"), + scope: "window" + }, + { + actionName: "unhoist", + friendlyName: t("keyboard_action_names.unhoist-note"), + iconClass: "bx bx-door-open", + defaultShortcuts: ["Alt+U"], + description: t("keyboard_actions.unhoist"), + scope: "window" + }, + { + actionName: "reloadFrontendApp", + friendlyName: t("keyboard_action_names.reload-frontend-app"), + iconClass: "bx bx-refresh", + defaultShortcuts: ["F5", "CommandOrControl+R"], + description: t("keyboard_actions.reload-frontend-app"), + scope: "window" + }, + { + actionName: "openDevTools", + friendlyName: t("keyboard_action_names.open-developer-tools"), + iconClass: "bx bx-bug-alt", + defaultShortcuts: isElectron ? ["CommandOrControl+Shift+I"] : [], + isElectronOnly: true, + description: t("keyboard_actions.open-dev-tools"), + scope: "window" + }, + { + actionName: "findInText", + friendlyName: t("keyboard_action_names.find-in-text"), + iconClass: "bx bx-search", + defaultShortcuts: isElectron ? ["CommandOrControl+F"] : [], + description: t("keyboard_actions.find-in-text"), + scope: "window" + }, + { + actionName: "toggleLeftPane", + friendlyName: t("keyboard_action_names.toggle-left-pane"), + iconClass: "bx bx-sidebar", + defaultShortcuts: [], + description: t("keyboard_actions.toggle-left-note-tree-panel"), + scope: "window" + }, + { + actionName: "toggleFullscreen", + friendlyName: t("keyboard_action_names.toggle-full-screen"), + iconClass: "bx bx-fullscreen", + defaultShortcuts: ["F11"], + description: t("keyboard_actions.toggle-full-screen"), + scope: "window" + }, + { + actionName: "zoomOut", + friendlyName: t("keyboard_action_names.zoom-out"), + iconClass: "bx bx-zoom-out", + defaultShortcuts: isElectron ? ["CommandOrControl+-"] : [], + isElectronOnly: true, + description: t("keyboard_actions.zoom-out"), + scope: "window" + }, + { + actionName: "zoomIn", + friendlyName: t("keyboard_action_names.zoom-in"), + iconClass: "bx bx-zoom-in", + description: t("keyboard_actions.zoom-in"), + defaultShortcuts: isElectron ? ["CommandOrControl+="] : [], + isElectronOnly: true, + scope: "window" + }, + { + actionName: "zoomReset", + friendlyName: t("keyboard_action_names.reset-zoom-level"), + iconClass: "bx bx-search-alt", + description: t("keyboard_actions.reset-zoom-level"), + defaultShortcuts: isElectron ? ["CommandOrControl+0"] : [], + isElectronOnly: true, + scope: "window" + }, + { + actionName: "copyWithoutFormatting", + friendlyName: t("keyboard_action_names.copy-without-formatting"), + iconClass: "bx bx-copy-alt", + defaultShortcuts: ["CommandOrControl+Alt+C"], + description: t("keyboard_actions.copy-without-formatting"), + scope: "text-detail" + }, + { + actionName: "forceSaveRevision", + friendlyName: t("keyboard_action_names.force-save-revision"), + iconClass: "bx bx-save", + defaultShortcuts: [], + description: t("keyboard_actions.force-save-revision"), + scope: "window" + } + ]; + + /* + * Apply macOS-specific tweaks. + */ + const platformModifier = isMac ? "Meta" : "Ctrl"; + + for (const action of DEFAULT_KEYBOARD_ACTIONS) { + if ("defaultShortcuts" in action && action.defaultShortcuts) { + action.defaultShortcuts = action.defaultShortcuts.map((shortcut) => shortcut.replace("CommandOrControl", platformModifier)); + } + } + + return DEFAULT_KEYBOARD_ACTIONS; +} + +function getKeyboardActions() { + const actions: KeyboardShortcut[] = JSON.parse(JSON.stringify(getDefaultKeyboardActions())); + + for (const action of actions) { + if ("effectiveShortcuts" in action && action.effectiveShortcuts) { + action.effectiveShortcuts = action.defaultShortcuts ? action.defaultShortcuts.slice() : []; + } + } + + for (const option of optionService.getOptions()) { + if (option.name.startsWith("keyboardShortcuts")) { + let actionName = option.name.substring(17); + actionName = actionName.charAt(0).toLowerCase() + actionName.slice(1); + + const action = actions.find((ea) => "actionName" in ea && ea.actionName === actionName) as ActionKeyboardShortcut; + + if (action) { + try { + action.effectiveShortcuts = JSON.parse(option.value); + } catch (e) { + log.error(`Could not parse shortcuts for action ${actionName}`); + } + } else { + log.info(`Keyboard action ${actionName} found in database, but not in action definition.`); + } + } + } + + return actions; +} + +export default { + getDefaultKeyboardActions, + getKeyboardActions +}; diff --git a/packages/trilium-core/src/services/options.ts b/packages/trilium-core/src/services/options.ts new file mode 100644 index 0000000000..a2ff346ea9 --- /dev/null +++ b/packages/trilium-core/src/services/options.ts @@ -0,0 +1,146 @@ +/** + * @module + * + * Options are key-value pairs that are used to store information such as user preferences (for example + * the current theme, sync server information), but also information about the state of the application. + * + * Although options internally are represented as strings, their value can be interpreted as a number or + * boolean by calling the appropriate methods from this service (e.g. {@link #getOptionInt}).\ + * + * Generally options are shared across multiple instances of the application via the sync mechanism, + * however it is possible to have options that are local to an instance. For example, the user can select + * a theme on a device and it will not affect other devices. + */ + +import becca from "../becca/becca.js"; +import BOption from "../becca/entities/boption.js"; +import type { OptionRow } from "@triliumnext/commons"; +import type { FilterOptionsByType, OptionDefinitions, OptionMap, OptionNames } from "@triliumnext/commons"; +import { getSql } from "./sql/index.js"; + +function getOptionOrNull(name: OptionNames): string | null { + let option; + + if (becca.loaded) { + option = becca.getOption(name); + } else { + // e.g. in initial sync becca is not loaded because DB is not initialized + try { + option = getSql().getRow<OptionRow>("SELECT * FROM options WHERE name = ?", [name]); + } catch (e: unknown) { + // DB is not initialized. + return null; + } + } + + return option ? option.value : null; +} + +function getOption(name: OptionNames) { + const val = getOptionOrNull(name); + + if (val === null) { + throw new Error(`Option '${name}' doesn't exist`); + } + + return val; +} + +function getOptionInt(name: FilterOptionsByType<number>, defaultValue?: number): number { + const val = getOption(name); + + const intVal = parseInt(val); + + if (isNaN(intVal)) { + if (defaultValue === undefined) { + throw new Error(`Could not parse '${val}' into integer for option '${name}'`); + } else { + return defaultValue; + } + } + + return intVal; +} + +function getOptionBool(name: FilterOptionsByType<boolean>): boolean { + const val = getOption(name); + + if (typeof val !== "string" || !["true", "false"].includes(val)) { + throw new Error(`Could not parse '${val}' into boolean for option '${name}'`); + } + + return val === "true"; +} + +function setOption<T extends OptionNames>(name: T, value: string | OptionDefinitions[T]) { + const option = becca.getOption(name); + + if (option) { + option.value = value as string; + + option.save(); + } else { + createOption(name, value, false); + } + + // Clear current AI provider when AI-related options change + const aiOptions = [ + 'aiSelectedProvider', 'openaiApiKey', 'openaiBaseUrl', 'openaiDefaultModel', + 'anthropicApiKey', 'anthropicBaseUrl', 'anthropicDefaultModel', + 'ollamaBaseUrl', 'ollamaDefaultModel' + ]; + + // TODO: Disabled AI integration. + // if (aiOptions.includes(name)) { + // // Import dynamically to avoid circular dependencies + // setImmediate(async () => { + // try { + // const aiServiceManager = (await import('./llm/ai_service_manager.js')).default; + // aiServiceManager.getInstance().clearCurrentProvider(); + // console.log(`Cleared AI provider after ${name} option changed`); + // } catch (error) { + // console.log(`Could not clear AI provider: ${error}`); + // } + // }); + // } +} + +/** + * Creates a new option in the database, with the given name, value and whether it should be synced. + * + * @param name the name of the option to be created. + * @param value the value of the option, as a string. It can then be interpreted as other types such as a number of boolean. + * @param isSynced `true` if the value should be synced across multiple instances (e.g. locale) or `false` if it should be local-only (e.g. theme). + */ +function createOption<T extends OptionNames>(name: T, value: string | OptionDefinitions[T], isSynced: boolean) { + new BOption({ + name: name, + value: value as string, + isSynced: isSynced + }).save(); +} + +function getOptions() { + return Object.values(becca.options); +} + +function getOptionMap() { + const map: Record<string, string> = {}; + + for (const option of Object.values(becca.options)) { + map[option.name] = option.value; + } + + return map as OptionMap; +} + +export default { + getOption, + getOptionInt, + getOptionBool, + setOption, + createOption, + getOptions, + getOptionMap, + getOptionOrNull +}; diff --git a/packages/trilium-core/src/services/options_init.ts b/packages/trilium-core/src/services/options_init.ts new file mode 100644 index 0000000000..832fb92789 --- /dev/null +++ b/packages/trilium-core/src/services/options_init.ts @@ -0,0 +1,280 @@ +import { type KeyboardShortcutWithRequiredActionName, type OptionMap, type OptionNames, SANITIZER_DEFAULT_ALLOWED_TAGS } from "@triliumnext/commons"; + +import appInfo from "./app_info.js"; +import dateUtils from "./utils/date.js"; +import keyboardActions from "./keyboard_actions.js"; +import log from "./log.js"; +import optionService from "./options.js"; +import { isWindows, randomSecureToken } from "./utils.js"; + +function initDocumentOptions() { + optionService.createOption("documentId", randomSecureToken(16), false); + optionService.createOption("documentSecret", randomSecureToken(16), false); +} + +/** + * Contains additional options to be initialized for a new database, containing the information entered by the user. + */ +interface NotSyncedOpts { + syncServerHost?: string; + syncProxy?: string; +} + +/** + * Represents a correspondence between an option and its default value, to be initialized when the database is missing that particular option (after a migration from an older version, or when creating a new database). + */ +interface DefaultOption { + name: OptionNames; + /** + * The value to initialize the option with, if the option is not already present in the database. + * + * If a function is passed Gin instead, the function is called if the option does not exist (with access to the current options) and the return value is used instead. Useful to migrate a new option with a value depending on some other option that might be initialized. + */ + value: string | ((options: OptionMap) => string); + isSynced: boolean; +} + +/** + * Initializes the default options for new databases only. + * + * @param initialized `true` if the database has been fully initialized (i.e. a new database was created), or `false` if the database is created for sync. + * @param opts additional options to be initialized, for example the sync configuration. + */ +async function initNotSyncedOptions(initialized: boolean, opts: NotSyncedOpts = {}) { + optionService.createOption( + "openNoteContexts", + JSON.stringify([ + { + notePath: "root", + active: true + } + ]), + false + ); + + optionService.createOption("lastDailyBackupDate", dateUtils.utcNowDateTime(), false); + optionService.createOption("lastWeeklyBackupDate", dateUtils.utcNowDateTime(), false); + optionService.createOption("lastMonthlyBackupDate", dateUtils.utcNowDateTime(), false); + optionService.createOption("dbVersion", appInfo.dbVersion.toString(), false); + + optionService.createOption("initialized", initialized ? "true" : "false", false); + + optionService.createOption("lastSyncedPull", "0", false); + optionService.createOption("lastSyncedPush", "0", false); + + optionService.createOption("theme", "next", false); + optionService.createOption("textNoteEditorType", "ckeditor-classic", true); + + optionService.createOption("syncServerHost", opts.syncServerHost || "", false); + optionService.createOption("syncServerTimeout", "120000", false); + optionService.createOption("syncProxy", opts.syncProxy || "", false); +} + +/** + * Contains all the default options that must be initialized on new and existing databases (at startup). The value can also be determined based on other options, provided they have already been initialized. + */ +const defaultOptions: DefaultOption[] = [ + { name: "revisionSnapshotTimeInterval", value: "600", isSynced: true }, + { name: "revisionSnapshotTimeIntervalTimeScale", value: "60", isSynced: true }, // default to Minutes + { name: "revisionSnapshotNumberLimit", value: "-1", isSynced: true }, + { name: "protectedSessionTimeout", value: "600", isSynced: true }, + { name: "protectedSessionTimeoutTimeScale", value: "60", isSynced: true }, + { name: "zoomFactor", value: isWindows ? "0.9" : "1.0", isSynced: false }, + { name: "overrideThemeFonts", value: "false", isSynced: false }, + { name: "mainFontFamily", value: "theme", isSynced: false }, + { name: "mainFontSize", value: "100", isSynced: false }, + { name: "treeFontFamily", value: "theme", isSynced: false }, + { name: "treeFontSize", value: "100", isSynced: false }, + { name: "detailFontFamily", value: "theme", isSynced: false }, + { name: "detailFontSize", value: "110", isSynced: false }, + { name: "monospaceFontFamily", value: "theme", isSynced: false }, + { name: "monospaceFontSize", value: "110", isSynced: false }, + { name: "spellCheckEnabled", value: "true", isSynced: false }, + { name: "spellCheckLanguageCode", value: "en-US", isSynced: false }, + { name: "imageMaxWidthHeight", value: "2000", isSynced: true }, + { name: "imageJpegQuality", value: "75", isSynced: true }, + { name: "autoFixConsistencyIssues", value: "true", isSynced: false }, + { name: "vimKeymapEnabled", value: "false", isSynced: false }, + { name: "codeLineWrapEnabled", value: "true", isSynced: false }, + { + name: "codeNotesMimeTypes", + value: '["text/x-csrc","text/x-c++src","text/x-csharp","text/css","text/x-elixir","text/x-go","text/x-groovy","text/x-haskell","text/html","message/http","text/x-java","application/javascript;env=frontend","application/javascript;env=backend","application/json","text/x-kotlin","text/x-markdown","text/x-perl","text/x-php","text/x-python","text/x-ruby",null,"text/x-sql","text/x-sqlite;schema=trilium","text/x-swift","text/xml","text/x-yaml","text/x-sh","application/typescript"]', + isSynced: true + }, + { name: "leftPaneWidth", value: "25", isSynced: false }, + { name: "leftPaneVisible", value: "true", isSynced: false }, + { name: "rightPaneWidth", value: "25", isSynced: false }, + { name: "rightPaneVisible", value: "true", isSynced: false }, + { name: "rightPaneCollapsedItems", value: "[]", isSynced: false }, + { name: "nativeTitleBarVisible", value: "false", isSynced: false }, + { name: "eraseEntitiesAfterTimeInSeconds", value: "604800", isSynced: true }, // default is 7 days + { name: "eraseEntitiesAfterTimeScale", value: "86400", isSynced: true }, // default 86400 seconds = Day + { name: "hideArchivedNotes_main", value: "false", isSynced: false }, + { name: "debugModeEnabled", value: "false", isSynced: false }, + { name: "headingStyle", value: "underline", isSynced: true }, + { name: "autoCollapseNoteTree", value: "true", isSynced: true }, + { name: "autoReadonlySizeText", value: "32000", isSynced: false }, + { name: "autoReadonlySizeCode", value: "64000", isSynced: false }, + { name: "dailyBackupEnabled", value: "true", isSynced: false }, + { name: "weeklyBackupEnabled", value: "true", isSynced: false }, + { name: "monthlyBackupEnabled", value: "true", isSynced: false }, + { name: "maxContentWidth", value: "1200", isSynced: false }, + { name: "centerContent", value: "false", isSynced: false }, + { name: "compressImages", value: "true", isSynced: true }, + { name: "downloadImagesAutomatically", value: "true", isSynced: true }, + { name: "minTocHeadings", value: "5", isSynced: true }, + { name: "highlightsList", value: '["underline","color","bgColor"]', isSynced: true }, + { name: "checkForUpdates", value: "true", isSynced: true }, + { name: "disableTray", value: "false", isSynced: false }, + { name: "eraseUnusedAttachmentsAfterSeconds", value: "2592000", isSynced: true }, // default 30 days + { name: "eraseUnusedAttachmentsAfterTimeScale", value: "86400", isSynced: true }, // default 86400 seconds = Day + { name: "logRetentionDays", value: "90", isSynced: false }, // default 90 days + { name: "customSearchEngineName", value: "DuckDuckGo", isSynced: true }, + { name: "customSearchEngineUrl", value: "https://duckduckgo.com/?q={keyword}", isSynced: true }, + { name: "editedNotesOpenInRibbon", value: "true", isSynced: true }, + { name: "mfaEnabled", value: "false", isSynced: false }, + { name: "mfaMethod", value: "totp", isSynced: false }, + { name: "encryptedRecoveryCodes", value: "false", isSynced: false }, + { name: "userSubjectIdentifierSaved", value: "false", isSynced: false }, + + // Appearance + { name: "splitEditorOrientation", value: "horizontal", isSynced: true }, + { + name: "codeNoteTheme", + value: (optionsMap) => { + switch (optionsMap.theme) { + case "light": + case "next-light": + return "default:vs-code-light"; + case "dark": + case "next-dark": + default: + return "default:vs-code-dark"; + } + }, + isSynced: false + }, + { name: "motionEnabled", value: "true", isSynced: false }, + { name: "shadowsEnabled", value: "true", isSynced: false }, + { name: "backdropEffectsEnabled", value: "true", isSynced: false }, + { name: "smoothScrollEnabled", value: "true", isSynced: false }, + { name: "newLayout", value: "true", isSynced: true }, + + // Internationalization + { name: "locale", value: "en", isSynced: true }, + { name: "formattingLocale", value: "", isSynced: true }, // no value means auto-detect + { name: "firstDayOfWeek", value: "1", isSynced: true }, + { name: "firstWeekOfYear", value: "0", isSynced: true }, + { name: "minDaysInFirstWeek", value: "4", isSynced: true }, + { name: "languages", value: "[]", isSynced: true }, + + // Code block configuration + { + name: "codeBlockTheme", + value: (optionsMap) => { + if (optionsMap.theme === "light") { + return "default:stackoverflow-light"; + } + return "default:stackoverflow-dark"; + + }, + isSynced: false + }, + { name: "codeBlockWordWrap", value: "false", isSynced: true }, + + // Text note configuration + { name: "textNoteEditorType", value: "ckeditor-balloon", isSynced: true }, + { name: "textNoteEditorMultilineToolbar", value: "false", isSynced: true }, + { name: "textNoteEmojiCompletionEnabled", value: "true", isSynced: true }, + { name: "textNoteCompletionEnabled", value: "true", isSynced: true }, + { name: "textNoteSlashCommandsEnabled", value: "true", isSynced: true }, + + // HTML import configuration + { name: "layoutOrientation", value: "vertical", isSynced: false }, + { name: "backgroundEffects", value: "true", isSynced: false }, + { + name: "allowedHtmlTags", + value: JSON.stringify(SANITIZER_DEFAULT_ALLOWED_TAGS), + isSynced: true + }, + + // Share settings + { name: "redirectBareDomain", value: "false", isSynced: true }, + { name: "showLoginInShareTheme", value: "false", isSynced: true }, + + // AI Options + { name: "aiEnabled", value: "false", isSynced: true }, + { name: "openaiApiKey", value: "", isSynced: false }, + { name: "openaiDefaultModel", value: "", isSynced: true }, + { name: "openaiBaseUrl", value: "https://api.openai.com/v1", isSynced: true }, + { name: "anthropicApiKey", value: "", isSynced: false }, + { name: "anthropicDefaultModel", value: "", isSynced: true }, + { name: "voyageApiKey", value: "", isSynced: false }, + { name: "anthropicBaseUrl", value: "https://api.anthropic.com/v1", isSynced: true }, + { name: "ollamaEnabled", value: "false", isSynced: true }, + { name: "ollamaDefaultModel", value: "", isSynced: true }, + { name: "ollamaBaseUrl", value: "http://localhost:11434", isSynced: true }, + { name: "aiTemperature", value: "0.7", isSynced: true }, + { name: "aiSystemPrompt", value: "", isSynced: true }, + { name: "aiSelectedProvider", value: "openai", isSynced: true }, + + { + name: "seenCallToActions", + value: JSON.stringify([ + "new_layout", "background_effects", "next_theme" + ]), + isSynced: true + }, + { name: "experimentalFeatures", value: "[]", isSynced: true } +]; + +/** + * Initializes the options, by checking which options from {@link #allDefaultOptions()} are missing and registering them. It will also check some environment variables such as safe mode, to make any necessary adjustments. + * + * This method is called regardless of whether a new database is created, or an existing database is used. + */ +function initStartupOptions() { + const optionsMap = optionService.getOptionMap(); + + const allDefaultOptions = defaultOptions.concat(getKeyboardDefaultOptions()); + + for (const { name, value, isSynced } of allDefaultOptions) { + if (!(name in optionsMap)) { + let resolvedValue; + if (typeof value === "function") { + resolvedValue = value(optionsMap); + } else { + resolvedValue = value; + } + + optionService.createOption(name, resolvedValue, isSynced); + log.info(`Created option "${name}" with default value "${resolvedValue}"`); + } + } + + if (process.env.TRILIUM_START_NOTE_ID || process.env.TRILIUM_SAFE_MODE) { + optionService.setOption( + "openNoteContexts", + JSON.stringify([ + { + notePath: process.env.TRILIUM_START_NOTE_ID || "root", + active: true + } + ]) + ); + } +} + +function getKeyboardDefaultOptions() { + return (keyboardActions.getDefaultKeyboardActions().filter((ka) => "actionName" in ka) as KeyboardShortcutWithRequiredActionName[]).map((ka) => ({ + name: `keyboardShortcuts${ka.actionName.charAt(0).toUpperCase()}${ka.actionName.slice(1)}`, + value: JSON.stringify(ka.defaultShortcuts), + isSynced: false + })) as DefaultOption[]; +} + +export default { + initDocumentOptions, + initNotSyncedOptions, + initStartupOptions +}; From ebe7276f400ffb4a217b4f485d014dd1ea0c439b Mon Sep 17 00:00:00 2001 From: Elian Doran <contact@eliandoran.me> Date: Tue, 6 Jan 2026 13:21:39 +0200 Subject: [PATCH 38/72] chore(core): fix some use of logs --- packages/trilium-core/src/services/keyboard_actions.ts | 3 ++- packages/trilium-core/src/services/options_init.ts | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/trilium-core/src/services/keyboard_actions.ts b/packages/trilium-core/src/services/keyboard_actions.ts index 326672eeb2..0d20a886c8 100644 --- a/packages/trilium-core/src/services/keyboard_actions.ts +++ b/packages/trilium-core/src/services/keyboard_actions.ts @@ -1,7 +1,7 @@ "use strict"; import optionService from "./options.js"; -import log from "./log.js"; +import { getLog } from "./log.js"; import { isElectron, isMac } from "./utils.js"; import type { ActionKeyboardShortcut, KeyboardShortcut } from "@triliumnext/commons"; import { t } from "i18next"; @@ -854,6 +854,7 @@ function getKeyboardActions() { } } + const log = getLog(); for (const option of optionService.getOptions()) { if (option.name.startsWith("keyboardShortcuts")) { let actionName = option.name.substring(17); diff --git a/packages/trilium-core/src/services/options_init.ts b/packages/trilium-core/src/services/options_init.ts index 832fb92789..a77727ec33 100644 --- a/packages/trilium-core/src/services/options_init.ts +++ b/packages/trilium-core/src/services/options_init.ts @@ -3,7 +3,7 @@ import { type KeyboardShortcutWithRequiredActionName, type OptionMap, type Optio import appInfo from "./app_info.js"; import dateUtils from "./utils/date.js"; import keyboardActions from "./keyboard_actions.js"; -import log from "./log.js"; +import { getLog } from "./log.js"; import optionService from "./options.js"; import { isWindows, randomSecureToken } from "./utils.js"; @@ -238,6 +238,7 @@ function initStartupOptions() { const allDefaultOptions = defaultOptions.concat(getKeyboardDefaultOptions()); + const log = getLog(); for (const { name, value, isSynced } of allDefaultOptions) { if (!(name in optionsMap)) { let resolvedValue; From 61f6f9429531d066dde03142a5da0e52c9b01158 Mon Sep 17 00:00:00 2001 From: Elian Doran <contact@eliandoran.me> Date: Tue, 6 Jan 2026 13:26:19 +0200 Subject: [PATCH 39/72] chore(core): integrate sanitize_attribute_name --- apps/server/src/routes/api/sender.ts | 8 ++--- .../server/src/services/consistency_checks.ts | 3 +- apps/server/src/services/import/enex.ts | 29 ++++++++++--------- .../src/services/sanitize_attribute_name.ts | 6 ---- .../src/becca/entities/battribute.ts | 2 +- .../src/services/utils/index.spec.ts | 2 +- .../trilium-core/src/services/utils/index.ts | 7 +++++ 7 files changed, 29 insertions(+), 28 deletions(-) delete mode 100644 apps/server/src/services/sanitize_attribute_name.ts rename apps/server/src/services/sanitize_attribute_name.spec.ts => packages/trilium-core/src/services/utils/index.spec.ts (93%) diff --git a/apps/server/src/routes/api/sender.ts b/apps/server/src/routes/api/sender.ts index efdf6817a5..a4c8348464 100644 --- a/apps/server/src/routes/api/sender.ts +++ b/apps/server/src/routes/api/sender.ts @@ -1,9 +1,9 @@ +import { utils } from "@triliumnext/core"; import type { Request } from "express"; import imageType from "image-type"; import imageService from "../../services/image.js"; import noteService from "../../services/notes.js"; -import sanitizeAttributeName from "../../services/sanitize_attribute_name.js"; import specialNotesService from "../../services/special_notes.js"; async function uploadImage(req: Request) { @@ -43,14 +43,14 @@ async function uploadImage(req: Request) { const labels = JSON.parse(labelsStr); for (const { name, value } of labels) { - note.setLabel(sanitizeAttributeName(name), value); + note.setLabel(utils.sanitizeAttributeName(name), value); } } note.setLabel("sentFromSender"); return { - noteId: noteId + noteId }; } @@ -72,7 +72,7 @@ async function saveNote(req: Request) { if (req.body.labels) { for (const { name, value } of req.body.labels) { - note.setLabel(sanitizeAttributeName(name), value); + note.setLabel(utils.sanitizeAttributeName(name), value); } } diff --git a/apps/server/src/services/consistency_checks.ts b/apps/server/src/services/consistency_checks.ts index 23727c30fb..a7fe6d3a6e 100644 --- a/apps/server/src/services/consistency_checks.ts +++ b/apps/server/src/services/consistency_checks.ts @@ -11,7 +11,6 @@ import cls from "./cls.js"; import entityChangesService from "./entity_changes.js"; import log from "./log.js"; import optionsService from "./options.js"; -import sanitizeAttributeName from "./sanitize_attribute_name.js"; import sql from "./sql.js"; import sqlInit from "./sql_init.js"; import syncMutexService from "./sync_mutex.js"; @@ -804,7 +803,7 @@ class ConsistencyChecks { const attrNames = sql.getColumn<string>(/*sql*/`SELECT DISTINCT name FROM attributes`); for (const origName of attrNames) { - const fixedName = sanitizeAttributeName(origName); + const fixedName = utils.sanitizeAttributeName(origName); if (fixedName !== origName) { if (this.autoFix) { diff --git a/apps/server/src/services/import/enex.ts b/apps/server/src/services/import/enex.ts index 5f9166fce3..19c6170b5e 100644 --- a/apps/server/src/services/import/enex.ts +++ b/apps/server/src/services/import/enex.ts @@ -1,20 +1,21 @@ +import type { AttributeType } from "@triliumnext/commons"; import { dayjs } from "@triliumnext/commons"; +import { utils } from "@triliumnext/core"; import sax from "sax"; import stream from "stream"; import { Throttle } from "stream-throttle"; -import log from "../log.js"; -import { md5, escapeHtml, fromBase64 } from "../utils.js"; -import date_utils from "../date_utils.js"; -import sql from "../sql.js"; -import noteService from "../notes.js"; -import imageService from "../image.js"; -import protectedSessionService from "../protected_session.js"; -import htmlSanitizer from "../html_sanitizer.js"; -import sanitizeAttributeName from "../sanitize_attribute_name.js"; -import type TaskContext from "../task_context.js"; + import type BNote from "../../becca/entities/bnote.js"; +import date_utils from "../date_utils.js"; +import htmlSanitizer from "../html_sanitizer.js"; +import imageService from "../image.js"; +import log from "../log.js"; +import noteService from "../notes.js"; +import protectedSessionService from "../protected_session.js"; +import sql from "../sql.js"; +import type TaskContext from "../task_context.js"; +import { escapeHtml, fromBase64,md5 } from "../utils.js"; import type { File } from "./common.js"; -import type { AttributeType } from "@triliumnext/commons"; /** * date format is e.g. 20181121T193703Z or 2013-04-14T16:19:00.000Z (Mac evernote, see #3496) @@ -25,7 +26,7 @@ function parseDate(text: string) { text = text.replace(/[-:]/g, ""); // insert - and : to convert it to trilium format - text = text.substr(0, 4) + "-" + text.substr(4, 2) + "-" + text.substr(6, 2) + " " + text.substr(9, 2) + ":" + text.substr(11, 2) + ":" + text.substr(13, 2) + ".000Z"; + text = `${text.substr(0, 4) }-${ text.substr(4, 2) }-${ text.substr(6, 2) } ${ text.substr(9, 2) }:${ text.substr(11, 2) }:${ text.substr(13, 2) }.000Z`; return text; } @@ -155,7 +156,7 @@ function importEnex(taskContext: TaskContext<"importNotes">, file: File, parentN labelName = "pageUrl"; } - labelName = sanitizeAttributeName(labelName || ""); + labelName = utils.sanitizeAttributeName(labelName || ""); if (note.attributes) { note.attributes.push({ @@ -201,7 +202,7 @@ function importEnex(taskContext: TaskContext<"importNotes">, file: File, parentN } else if (currentTag === "tag" && note.attributes) { note.attributes.push({ type: "label", - name: sanitizeAttributeName(text), + name: utils.sanitizeAttributeName(text), value: "" }); } diff --git a/apps/server/src/services/sanitize_attribute_name.ts b/apps/server/src/services/sanitize_attribute_name.ts deleted file mode 100644 index 3e597e94a8..0000000000 --- a/apps/server/src/services/sanitize_attribute_name.ts +++ /dev/null @@ -1,6 +0,0 @@ -export default function sanitizeAttributeName(origName: string) { - const fixedName = origName === "" ? "unnamed" : origName.replace(/[^\p{L}\p{N}_:]/gu, "_"); - // any not allowed character should be replaced with underscore - - return fixedName; -} diff --git a/packages/trilium-core/src/becca/entities/battribute.ts b/packages/trilium-core/src/becca/entities/battribute.ts index 5e4028fe65..2807995802 100644 --- a/packages/trilium-core/src/becca/entities/battribute.ts +++ b/packages/trilium-core/src/becca/entities/battribute.ts @@ -4,8 +4,8 @@ import BNote from "./bnote.js"; import AbstractBeccaEntity from "./abstract_becca_entity.js"; import dateUtils from "../../services/utils/date"; import promotedAttributeDefinitionParser from "../../services/promoted_attribute_definition_parser.js"; -import sanitizeAttributeName from "../../services/sanitize_attribute_name.js"; import type { AttributeRow, AttributeType } from "@triliumnext/commons"; +import { sanitizeAttributeName } from "src/services/utils/index.js"; interface SavingOpts { skipValidation?: boolean; diff --git a/apps/server/src/services/sanitize_attribute_name.spec.ts b/packages/trilium-core/src/services/utils/index.spec.ts similarity index 93% rename from apps/server/src/services/sanitize_attribute_name.spec.ts rename to packages/trilium-core/src/services/utils/index.spec.ts index 3755da5a78..368a81c035 100644 --- a/apps/server/src/services/sanitize_attribute_name.spec.ts +++ b/packages/trilium-core/src/services/utils/index.spec.ts @@ -1,5 +1,5 @@ import { expect, describe, it } from "vitest"; -import sanitizeAttributeName from "./sanitize_attribute_name.js"; +import { sanitizeAttributeName } from "./index"; // fn value, expected value const testCases: [fnValue: string, expectedValue: string][] = [ diff --git a/packages/trilium-core/src/services/utils/index.ts b/packages/trilium-core/src/services/utils/index.ts index 06f730dcbf..fc39276f1a 100644 --- a/packages/trilium-core/src/services/utils/index.ts +++ b/packages/trilium-core/src/services/utils/index.ts @@ -58,3 +58,10 @@ export function removeDiacritic(str: string) { export function normalize(str: string) { return removeDiacritic(str).toLowerCase(); } + +export function sanitizeAttributeName(origName: string) { + const fixedName = origName === "" ? "unnamed" : origName.replace(/[^\p{L}\p{N}_:]/gu, "_"); + // any not allowed character should be replaced with underscore + + return fixedName; +} From 60cb8d950ef0cbc81ce6212754e3eeb67a9446e4 Mon Sep 17 00:00:00 2001 From: Elian Doran <contact@eliandoran.me> Date: Tue, 6 Jan 2026 13:30:21 +0200 Subject: [PATCH 40/72] chore(core): integrate promoted_attribute_definition_parser --- .../promoted_attribute_definition_parser.ts | 12 +-------- .../attribute_widgets/UserAttributesList.tsx | 26 ++++++++++--------- .../src/widgets/collections/table/columns.tsx | 17 ++++++------ apps/server/src/services/handlers.ts | 2 +- ...promoted_attribute_definition_interface.ts | 8 ------ packages/commons/src/lib/server_api.ts | 12 +++++++++ .../promoted_attribute_definition_parser.ts | 6 ++--- 7 files changed, 40 insertions(+), 43 deletions(-) delete mode 100644 apps/server/src/services/promoted_attribute_definition_interface.ts rename {apps/server => packages/trilium-core}/src/services/promoted_attribute_definition_parser.ts (84%) diff --git a/apps/client/src/services/promoted_attribute_definition_parser.ts b/apps/client/src/services/promoted_attribute_definition_parser.ts index 0d93aae3c5..292e64438b 100644 --- a/apps/client/src/services/promoted_attribute_definition_parser.ts +++ b/apps/client/src/services/promoted_attribute_definition_parser.ts @@ -1,14 +1,4 @@ -export type LabelType = "text" | "number" | "boolean" | "date" | "datetime" | "time" | "url" | "color"; -type Multiplicity = "single" | "multi"; - -export interface DefinitionObject { - isPromoted?: boolean; - labelType?: LabelType; - multiplicity?: Multiplicity; - numberPrecision?: number; - promotedAlias?: string; - inverseRelation?: string; -} +import { DefinitionObject, LabelType, Multiplicity } from "@triliumnext/commons"; function parse(value: string) { const tokens = value.split(",").map((t) => t.trim()); diff --git a/apps/client/src/widgets/attribute_widgets/UserAttributesList.tsx b/apps/client/src/widgets/attribute_widgets/UserAttributesList.tsx index f01e70b491..93ed5356c9 100644 --- a/apps/client/src/widgets/attribute_widgets/UserAttributesList.tsx +++ b/apps/client/src/widgets/attribute_widgets/UserAttributesList.tsx @@ -1,14 +1,16 @@ -import { useState } from "preact/hooks"; -import FNote from "../../entities/fnote"; import "./UserAttributesList.css"; -import { useTriliumEvent } from "../react/hooks"; -import attributes from "../../services/attributes"; -import { DefinitionObject } from "../../services/promoted_attribute_definition_parser"; -import { formatDateTime } from "../../utils/formatters"; + +import type { DefinitionObject } from "@triliumnext/commons"; import { ComponentChildren, CSSProperties } from "preact"; +import { useState } from "preact/hooks"; + +import FNote from "../../entities/fnote"; +import attributes from "../../services/attributes"; +import { getReadableTextColor } from "../../services/css_class_manager"; +import { formatDateTime } from "../../utils/formatters"; +import { useTriliumEvent } from "../react/hooks"; import Icon from "../react/Icon"; import NoteLink from "../react/NoteLink"; -import { getReadableTextColor } from "../../services/css_class_manager"; interface UserAttributesListProps { note: FNote; @@ -29,7 +31,7 @@ export default function UserAttributesDisplay({ note, ignoredAttributes }: UserA <div className="user-attributes"> {userAttributes?.map(attr => buildUserAttribute(attr))} </div> - ) + ); } @@ -46,13 +48,13 @@ function useNoteAttributesWithDefinitions(note: FNote, attributesToIgnore: stri } function UserAttribute({ attr, children, style }: { attr: AttributeWithDefinitions, children: ComponentChildren, style?: CSSProperties }) { - const className = `${attr.type === "label" ? "label" + " " + attr.def.labelType : "relation"}`; + const className = `${attr.type === "label" ? `label` + ` ${ attr.def.labelType}` : "relation"}`; return ( <span key={attr.friendlyName} className={`user-attribute type-${className}`} style={style}> {children} </span> - ) + ); } function buildUserAttribute(attr: AttributeWithDefinitions): ComponentChildren { @@ -61,7 +63,7 @@ function buildUserAttribute(attr: AttributeWithDefinitions): ComponentChildren { let style: CSSProperties | undefined; if (attr.type === "label") { - let value = attr.value; + const value = attr.value; switch (attr.def.labelType) { case "number": let formattedValue = value; @@ -102,7 +104,7 @@ function buildUserAttribute(attr: AttributeWithDefinitions): ComponentChildren { content = <>{defaultLabel}<NoteLink notePath={attr.value} showNoteIcon /></>; } - return <UserAttribute attr={attr} style={style}>{content}</UserAttribute> + return <UserAttribute attr={attr} style={style}>{content}</UserAttribute>; } function getAttributesWithDefinitions(note: FNote, attributesToIgnore: string[] = []): AttributeWithDefinitions[] { diff --git a/apps/client/src/widgets/collections/table/columns.tsx b/apps/client/src/widgets/collections/table/columns.tsx index 74db6ddb71..3c2e487634 100644 --- a/apps/client/src/widgets/collections/table/columns.tsx +++ b/apps/client/src/widgets/collections/table/columns.tsx @@ -1,11 +1,12 @@ -import type { CellComponent, ColumnDefinition, EmptyCallback, FormatterParams, ValueBooleanCallback, ValueVoidCallback } from "tabulator-tables"; -import { LabelType } from "../../../services/promoted_attribute_definition_parser.js"; +import { LabelType } from "@triliumnext/commons"; import { JSX } from "preact"; -import { renderReactWidget } from "../../react/react_utils.jsx"; -import Icon from "../../react/Icon.jsx"; import { useEffect, useRef, useState } from "preact/hooks"; +import type { CellComponent, ColumnDefinition, EmptyCallback, FormatterParams, ValueBooleanCallback, ValueVoidCallback } from "tabulator-tables"; + import froca from "../../../services/froca.js"; +import Icon from "../../react/Icon.jsx"; import NoteAutocomplete from "../../react/NoteAutocomplete.jsx"; +import { renderReactWidget } from "../../react/react_utils.jsx"; type ColumnType = LabelType | "relation"; @@ -78,7 +79,7 @@ export function buildColumnDefinitions({ info, movableRows, existingColumnData, rowHandle: movableRows, width: calculateIndexColumnWidth(rowNumberHint, movableRows), formatter: wrapFormatter(({ cell, formatterParams }) => <div> - {(formatterParams as RowNumberFormatterParams).movableRows && <><span class="bx bx-dots-vertical-rounded"></span>{" "}</>} + {(formatterParams as RowNumberFormatterParams).movableRows && <><span class="bx bx-dots-vertical-rounded" />{" "}</>} {cell.getRow().getPosition(true)} </div>), formatterParams: { movableRows } satisfies RowNumberFormatterParams @@ -200,14 +201,14 @@ function wrapEditor(Component: (opts: EditorOpts) => JSX.Element): (( editorParams: {}, ) => HTMLElement | false) { return (cell, _, success, cancel, editorParams) => { - const elWithParams = <Component cell={cell} success={success} cancel={cancel} editorParams={editorParams} /> + const elWithParams = <Component cell={cell} success={success} cancel={cancel} editorParams={editorParams} />; return renderReactWidget(null, elWithParams)[0]; }; } function NoteFormatter({ cell }: FormatterOpts) { const noteId = cell.getValue(); - const [ note, setNote ] = useState(noteId ? froca.getNoteFromCache(noteId) : null) + const [ note, setNote ] = useState(noteId ? froca.getNoteFromCache(noteId) : null); useEffect(() => { if (!noteId || note?.noteId === noteId) return; @@ -231,5 +232,5 @@ function RelationEditor({ cell, success }: EditorOpts) { hideAllButtons: true }} noteIdChanged={success} - /> + />; } diff --git a/apps/server/src/services/handlers.ts b/apps/server/src/services/handlers.ts index b76417554e..8a7d6d12f3 100644 --- a/apps/server/src/services/handlers.ts +++ b/apps/server/src/services/handlers.ts @@ -1,3 +1,4 @@ +import { DefinitionObject } from "@triliumnext/commons"; import { type AbstractBeccaEntity, events as eventService } from "@triliumnext/core"; import becca from "../becca/becca.js"; @@ -6,7 +7,6 @@ import type BNote from "../becca/entities/bnote.js"; import hiddenSubtreeService from "./hidden_subtree.js"; import noteService from "./notes.js"; import oneTimeTimer from "./one_time_timer.js"; -import type { DefinitionObject } from "./promoted_attribute_definition_interface.js"; import scriptService from "./script.js"; import treeService from "./tree.js"; diff --git a/apps/server/src/services/promoted_attribute_definition_interface.ts b/apps/server/src/services/promoted_attribute_definition_interface.ts deleted file mode 100644 index 2f68aa7ac1..0000000000 --- a/apps/server/src/services/promoted_attribute_definition_interface.ts +++ /dev/null @@ -1,8 +0,0 @@ -export interface DefinitionObject { - isPromoted?: boolean; - labelType?: string; - multiplicity?: string; - numberPrecision?: number; - promotedAlias?: string; - inverseRelation?: string; -} diff --git a/packages/commons/src/lib/server_api.ts b/packages/commons/src/lib/server_api.ts index 17d8f14716..a26a8f2861 100644 --- a/packages/commons/src/lib/server_api.ts +++ b/packages/commons/src/lib/server_api.ts @@ -298,3 +298,15 @@ export interface IconRegistry { }[] }[]; } + +export type LabelType = "text" | "number" | "boolean" | "date" | "datetime" | "time" | "url" | "color"; +export type Multiplicity = "single" | "multi"; + +export interface DefinitionObject { + isPromoted?: boolean; + labelType?: LabelType; + multiplicity?: Multiplicity; + numberPrecision?: number; + promotedAlias?: string; + inverseRelation?: string; +} diff --git a/apps/server/src/services/promoted_attribute_definition_parser.ts b/packages/trilium-core/src/services/promoted_attribute_definition_parser.ts similarity index 84% rename from apps/server/src/services/promoted_attribute_definition_parser.ts rename to packages/trilium-core/src/services/promoted_attribute_definition_parser.ts index 630c57a729..bc9c66bbc0 100644 --- a/apps/server/src/services/promoted_attribute_definition_parser.ts +++ b/packages/trilium-core/src/services/promoted_attribute_definition_parser.ts @@ -1,4 +1,4 @@ -import type { DefinitionObject } from "./promoted_attribute_definition_interface.js"; +import { DefinitionObject, LabelType, Multiplicity } from "@triliumnext/commons"; function parse(value: string): DefinitionObject { const tokens = value.split(",").map((t) => t.trim()); @@ -8,9 +8,9 @@ function parse(value: string): DefinitionObject { if (token === "promoted") { defObj.isPromoted = true; } else if (["text", "number", "boolean", "date", "datetime", "time", "url"].includes(token)) { - defObj.labelType = token; + defObj.labelType = token as LabelType; } else if (["single", "multi"].includes(token)) { - defObj.multiplicity = token; + defObj.multiplicity = token as Multiplicity; } else if (token.startsWith("precision")) { const chunks = token.split("="); From 64b212b93e30985f747e33f24bd6d3aa5cf7c45d Mon Sep 17 00:00:00 2001 From: Elian Doran <contact@eliandoran.me> Date: Tue, 6 Jan 2026 13:42:29 +0200 Subject: [PATCH 41/72] chore(core): integrate entity_changes --- apps/server/src/services/cls.ts | 41 ++-- apps/server/src/services/entity_changes.ts | 209 +---------------- apps/server/src/services/instance_id.ts | 7 +- packages/trilium-core/src/index.ts | 3 + packages/trilium-core/src/services/context.ts | 19 ++ .../src/services/entity_changes.ts | 211 ++++++++++++++++++ .../trilium-core/src/services/instance_id.ts | 5 + 7 files changed, 258 insertions(+), 237 deletions(-) create mode 100644 packages/trilium-core/src/services/entity_changes.ts create mode 100644 packages/trilium-core/src/services/instance_id.ts diff --git a/apps/server/src/services/cls.ts b/apps/server/src/services/cls.ts index ecbef741ed..ebf6935597 100644 --- a/apps/server/src/services/cls.ts +++ b/apps/server/src/services/cls.ts @@ -1,10 +1,10 @@ import type { EntityChange } from "@triliumnext/commons"; -import { getContext, getHoistedNoteId as getHoistedNoteIdInternal, isEntityEventsDisabled as isEntityEventsDisabledInternal } from "@triliumnext/core/src/services/context"; +import { cls } from "@triliumnext/core"; type Callback = (...args: any[]) => any; function init<T>(callback: () => T) { - return getContext().init(callback); + return cls.getContext().init(callback); } function wrap(callback: Callback) { @@ -18,68 +18,59 @@ function wrap(callback: Callback) { } function getHoistedNoteId() { - return getHoistedNoteIdInternal(); + return cls.getHoistedNoteId(); } function getComponentId() { - return getContext().get("componentId"); + return cls.getComponentId(); } function disableEntityEvents() { - getContext().set("disableEntityEvents", true); + cls.getContext().set("disableEntityEvents", true); } function enableEntityEvents() { - getContext().set("disableEntityEvents", false); + cls.getContext().set("disableEntityEvents", false); } function isEntityEventsDisabled() { - return isEntityEventsDisabledInternal(); + return cls.isEntityEventsDisabled(); } function setMigrationRunning(running: boolean) { - getContext().set("migrationRunning", !!running); + cls.getContext().set("migrationRunning", !!running); } function isMigrationRunning() { - return !!getContext().get("migrationRunning"); + return !!cls.getContext().get("migrationRunning"); } function getAndClearEntityChangeIds() { - const entityChangeIds = getContext().get("entityChangeIds") || []; + const entityChangeIds = cls.getContext().get("entityChangeIds") || []; - getContext().set("entityChangeIds", []); + cls.getContext().set("entityChangeIds", []); return entityChangeIds; } function putEntityChange(entityChange: EntityChange) { - if (getContext().get("ignoreEntityChangeIds")) { - return; - } - - const entityChangeIds = getContext().get("entityChangeIds") || []; - - // store only ID since the record can be modified (e.g., in erase) - entityChangeIds.push(entityChange.id); - - getContext().set("entityChangeIds", entityChangeIds); + cls.putEntityChange(entityChange); } function ignoreEntityChangeIds() { - getContext().set("ignoreEntityChangeIds", true); + cls.getContext().set("ignoreEntityChangeIds", true); } function get(key: string) { - return getContext().get(key); + return cls.getContext().get(key); } function set(key: string, value: unknown) { - getContext().set(key, value); + cls.getContext().set(key, value); } function reset() { - getContext().reset(); + cls.getContext().reset(); } export default { diff --git a/apps/server/src/services/entity_changes.ts b/apps/server/src/services/entity_changes.ts index 65c941e1c5..24b210d95c 100644 --- a/apps/server/src/services/entity_changes.ts +++ b/apps/server/src/services/entity_changes.ts @@ -1,207 +1,2 @@ -import type { BlobRow, EntityChange } from "@triliumnext/commons"; -import { blob as blobService, events as eventService } from "@triliumnext/core"; - -import becca from "../becca/becca.js"; -import cls from "./cls.js"; -import dateUtils from "./date_utils.js"; -import instanceId from "./instance_id.js"; -import log from "./log.js"; -import sql from "./sql.js"; -import { randomString } from "./utils.js"; - -let maxEntityChangeId = 0; - -function putEntityChangeWithInstanceId(origEntityChange: EntityChange, instanceId: string) { - const ec = { ...origEntityChange, instanceId }; - - putEntityChange(ec); -} - -function putEntityChangeWithForcedChange(origEntityChange: EntityChange) { - const ec = { ...origEntityChange, changeId: null }; - - putEntityChange(ec); -} - -function putEntityChange(origEntityChange: EntityChange) { - const ec = { ...origEntityChange }; - - delete ec.id; - - if (!ec.changeId) { - ec.changeId = randomString(12); - } - - ec.componentId = ec.componentId || cls.getComponentId() || "NA"; // NA = not available - ec.instanceId = ec.instanceId || instanceId; - ec.isSynced = ec.isSynced ? 1 : 0; - ec.isErased = ec.isErased ? 1 : 0; - ec.id = sql.replace("entity_changes", ec); - - if (ec.id) { - maxEntityChangeId = Math.max(maxEntityChangeId, ec.id); - } - - cls.putEntityChange(ec); -} - -function putNoteReorderingEntityChange(parentNoteId: string, componentId?: string) { - putEntityChange({ - entityName: "note_reordering", - entityId: parentNoteId, - hash: "N/A", - isErased: false, - utcDateChanged: dateUtils.utcNowDateTime(), - isSynced: true, - componentId, - instanceId - }); - - eventService.emit(eventService.ENTITY_CHANGED, { - entityName: "note_reordering", - entity: sql.getMap(/*sql*/`SELECT branchId, notePosition FROM branches WHERE isDeleted = 0 AND parentNoteId = ?`, [parentNoteId]) - }); -} - -function putEntityChangeForOtherInstances(ec: EntityChange) { - putEntityChange({ - ...ec, - changeId: null, - instanceId: null - }); -} - -function addEntityChangesForSector(entityName: string, sector: string) { - const entityChanges = sql.getRows<EntityChange>(/*sql*/`SELECT * FROM entity_changes WHERE entityName = ? AND SUBSTR(entityId, 1, 1) = ?`, [entityName, sector]); - - let entitiesInserted = entityChanges.length; - - sql.transactional(() => { - if (entityName === "blobs") { - entitiesInserted += addEntityChangesForDependingEntity(sector, "notes", "noteId"); - entitiesInserted += addEntityChangesForDependingEntity(sector, "attachments", "attachmentId"); - entitiesInserted += addEntityChangesForDependingEntity(sector, "revisions", "revisionId"); - } - - for (const ec of entityChanges) { - putEntityChangeWithForcedChange(ec); - } - }); - - log.info(`Added sector ${sector} of '${entityName}' (${entitiesInserted} entities) to the sync queue.`); -} - -function addEntityChangesForDependingEntity(sector: string, tableName: string, primaryKeyColumn: string) { - // problem in blobs might be caused by problem in entity referencing the blob - const dependingEntityChanges = sql.getRows<EntityChange>( - ` - SELECT dep_change.* - FROM entity_changes orig_sector - JOIN ${tableName} ON ${tableName}.blobId = orig_sector.entityId - JOIN entity_changes dep_change ON dep_change.entityName = '${tableName}' AND dep_change.entityId = ${tableName}.${primaryKeyColumn} - WHERE orig_sector.entityName = 'blobs' AND SUBSTR(orig_sector.entityId, 1, 1) = ?`, - [sector] - ); - - for (const ec of dependingEntityChanges) { - putEntityChangeWithForcedChange(ec); - } - - return dependingEntityChanges.length; -} - -function cleanupEntityChangesForMissingEntities(entityName: string, entityPrimaryKey: string) { - sql.execute(` - DELETE - FROM entity_changes - WHERE - isErased = 0 - AND entityName = '${entityName}' - AND entityId NOT IN (SELECT ${entityPrimaryKey} FROM ${entityName})`); -} - -function fillEntityChanges(entityName: string, entityPrimaryKey: string, condition = "") { - cleanupEntityChangesForMissingEntities(entityName, entityPrimaryKey); - - sql.transactional(() => { - const entityIds = sql.getColumn<string>(/*sql*/`SELECT ${entityPrimaryKey} FROM ${entityName} ${condition}`); - - let createdCount = 0; - - for (const entityId of entityIds) { - const existingRows = sql.getValue("SELECT COUNT(1) FROM entity_changes WHERE entityName = ? AND entityId = ?", [entityName, entityId]); - - if (existingRows !== 0) { - // we don't want to replace existing entities (which would effectively cause full resync) - continue; - } - - createdCount++; - - const ec: Partial<EntityChange> = { - entityName, - entityId, - isErased: false - }; - - if (entityName === "blobs") { - const blob = sql.getRow<Pick<BlobRow, "blobId" | "content" | "utcDateModified">>("SELECT blobId, content, utcDateModified FROM blobs WHERE blobId = ?", [entityId]); - ec.hash = blobService.calculateContentHash(blob); - ec.utcDateChanged = blob.utcDateModified; - ec.isSynced = true; // blobs are always synced - } else { - const entity = becca.getEntity(entityName, entityId); - - if (entity) { - ec.hash = entity.generateHash(); - ec.utcDateChanged = entity.getUtcDateChanged() || dateUtils.utcNowDateTime(); - ec.isSynced = entityName !== "options" || !!entity.isSynced; - } else { - // entity might be null (not present in becca) when it's deleted - // this will produce different hash value than when entity is being deleted since then - // all normal hashed attributes are being used. Sync should recover from that, though. - ec.hash = "deleted"; - ec.utcDateChanged = dateUtils.utcNowDateTime(); - ec.isSynced = true; // deletable (the ones with isDeleted) entities are synced - } - } - - putEntityChange(ec as EntityChange); - } - - if (createdCount > 0) { - log.info(`Created ${createdCount} missing entity changes for entity '${entityName}'.`); - } - }); -} - -function fillAllEntityChanges() { - sql.transactional(() => { - sql.execute("DELETE FROM entity_changes WHERE isErased = 0"); - - fillEntityChanges("notes", "noteId"); - fillEntityChanges("branches", "branchId"); - fillEntityChanges("revisions", "revisionId"); - fillEntityChanges("attachments", "attachmentId"); - fillEntityChanges("blobs", "blobId"); - fillEntityChanges("attributes", "attributeId"); - fillEntityChanges("etapi_tokens", "etapiTokenId"); - fillEntityChanges("options", "name", "WHERE isSynced = 1"); - }); -} - -function recalculateMaxEntityChangeId() { - maxEntityChangeId = sql.getValue<number>("SELECT COALESCE(MAX(id), 0) FROM entity_changes"); -} - -export default { - putNoteReorderingEntityChange, - putEntityChangeForOtherInstances, - putEntityChangeWithForcedChange, - putEntityChange, - putEntityChangeWithInstanceId, - fillAllEntityChanges, - addEntityChangesForSector, - getMaxEntityChangeId: () => maxEntityChangeId, - recalculateMaxEntityChangeId -}; +import { entity_changes } from "@triliumnext/core"; +export default entity_changes; diff --git a/apps/server/src/services/instance_id.ts b/apps/server/src/services/instance_id.ts index 31e620a8fd..a612180200 100644 --- a/apps/server/src/services/instance_id.ts +++ b/apps/server/src/services/instance_id.ts @@ -1,5 +1,2 @@ -import { randomString } from "./utils.js"; - -const instanceId = randomString(12); - -export default instanceId; +import { instance_id } from "@triliumnext/core"; +export default instance_id; diff --git a/packages/trilium-core/src/index.ts b/packages/trilium-core/src/index.ts index 7a0d5ac691..5c10216df7 100644 --- a/packages/trilium-core/src/index.ts +++ b/packages/trilium-core/src/index.ts @@ -18,8 +18,11 @@ export { default as options } from "./services/options"; export { default as options_init } from "./services/options_init"; 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 { getContext, type ExecutionContext } from "./services/context"; +export * as cls from "./services/context"; export * from "./errors"; +export { default as instance_id } from "./services/instance_id"; export type { CryptoProvider } from "./services/encryption/crypto"; export { default as becca } from "./becca/becca"; diff --git a/packages/trilium-core/src/services/context.ts b/packages/trilium-core/src/services/context.ts index 870596ae33..f1276bc95c 100644 --- a/packages/trilium-core/src/services/context.ts +++ b/packages/trilium-core/src/services/context.ts @@ -1,3 +1,5 @@ +import { EntityChange } from "@triliumnext/commons"; + export interface ExecutionContext { init<T>(fn: () => T): T; get<T = any>(key: string): T | undefined; @@ -24,3 +26,20 @@ export function getHoistedNoteId() { export function isEntityEventsDisabled() { return !!getContext().get("disableEntityEvents"); } + +export function getComponentId() { + return getContext().get("componentId"); +} + +export function putEntityChange(entityChange: EntityChange) { + if (getContext().get("ignoreEntityChangeIds")) { + return; + } + + const entityChangeIds = getContext().get("entityChangeIds") || []; + + // store only ID since the record can be modified (e.g., in erase) + entityChangeIds.push(entityChange.id); + + getContext().set("entityChangeIds", entityChangeIds); +} diff --git a/packages/trilium-core/src/services/entity_changes.ts b/packages/trilium-core/src/services/entity_changes.ts new file mode 100644 index 0000000000..b389806d42 --- /dev/null +++ b/packages/trilium-core/src/services/entity_changes.ts @@ -0,0 +1,211 @@ +import type { BlobRow, EntityChange } from "@triliumnext/commons"; + +import becca from "../becca/becca.js"; +import dateUtils from "./utils/date.js"; +import instanceId from "./instance_id.js"; +import log, { getLog } from "./log.js"; +import { randomString } from "./utils/index.js"; +import { getSql } from "./sql/index.js"; +import { getComponentId } from "./context.js"; +import events from "./events.js"; +import blobService from "./blob.js"; + +let maxEntityChangeId = 0; + +function putEntityChangeWithInstanceId(origEntityChange: EntityChange, instanceId: string) { + const ec = { ...origEntityChange, instanceId }; + + putEntityChange(ec); +} + +function putEntityChangeWithForcedChange(origEntityChange: EntityChange) { + const ec = { ...origEntityChange, changeId: null }; + + putEntityChange(ec); +} + +function putEntityChange(origEntityChange: EntityChange) { + const ec = { ...origEntityChange }; + + delete ec.id; + + if (!ec.changeId) { + ec.changeId = randomString(12); + } + + ec.componentId = ec.componentId || getComponentId() || "NA"; // NA = not available + ec.instanceId = ec.instanceId || instanceId; + ec.isSynced = ec.isSynced ? 1 : 0; + ec.isErased = ec.isErased ? 1 : 0; + ec.id = getSql().replace("entity_changes", ec); + + if (ec.id) { + maxEntityChangeId = Math.max(maxEntityChangeId, ec.id); + } + + cls.putEntityChange(ec); +} + +function putNoteReorderingEntityChange(parentNoteId: string, componentId?: string) { + putEntityChange({ + entityName: "note_reordering", + entityId: parentNoteId, + hash: "N/A", + isErased: false, + utcDateChanged: dateUtils.utcNowDateTime(), + isSynced: true, + componentId, + instanceId + }); + + events.emit(events.ENTITY_CHANGED, { + entityName: "note_reordering", + entity: getSql().getMap(/*sql*/`SELECT branchId, notePosition FROM branches WHERE isDeleted = 0 AND parentNoteId = ?`, [parentNoteId]) + }); +} + +function putEntityChangeForOtherInstances(ec: EntityChange) { + putEntityChange({ + ...ec, + changeId: null, + instanceId: null + }); +} + +function addEntityChangesForSector(entityName: string, sector: string) { + const sql = getSql(); + const entityChanges = sql.getRows<EntityChange>(/*sql*/`SELECT * FROM entity_changes WHERE entityName = ? AND SUBSTR(entityId, 1, 1) = ?`, [entityName, sector]); + + let entitiesInserted = entityChanges.length; + + sql.transactional(() => { + if (entityName === "blobs") { + entitiesInserted += addEntityChangesForDependingEntity(sector, "notes", "noteId"); + entitiesInserted += addEntityChangesForDependingEntity(sector, "attachments", "attachmentId"); + entitiesInserted += addEntityChangesForDependingEntity(sector, "revisions", "revisionId"); + } + + for (const ec of entityChanges) { + putEntityChangeWithForcedChange(ec); + } + }); + + getLog().info(`Added sector ${sector} of '${entityName}' (${entitiesInserted} entities) to the sync queue.`); +} + +function addEntityChangesForDependingEntity(sector: string, tableName: string, primaryKeyColumn: string) { + // problem in blobs might be caused by problem in entity referencing the blob + const dependingEntityChanges = getSql().getRows<EntityChange>( + ` + SELECT dep_change.* + FROM entity_changes orig_sector + JOIN ${tableName} ON ${tableName}.blobId = orig_sector.entityId + JOIN entity_changes dep_change ON dep_change.entityName = '${tableName}' AND dep_change.entityId = ${tableName}.${primaryKeyColumn} + WHERE orig_sector.entityName = 'blobs' AND SUBSTR(orig_sector.entityId, 1, 1) = ?`, + [sector] + ); + + for (const ec of dependingEntityChanges) { + putEntityChangeWithForcedChange(ec); + } + + return dependingEntityChanges.length; +} + +function cleanupEntityChangesForMissingEntities(entityName: string, entityPrimaryKey: string) { + getSql().execute(` + DELETE + FROM entity_changes + WHERE + isErased = 0 + AND entityName = '${entityName}' + AND entityId NOT IN (SELECT ${entityPrimaryKey} FROM ${entityName})`); +} + +function fillEntityChanges(entityName: string, entityPrimaryKey: string, condition = "") { + cleanupEntityChangesForMissingEntities(entityName, entityPrimaryKey); + + const sql = getSql(); + sql.transactional(() => { + const entityIds = sql.getColumn<string>(/*sql*/`SELECT ${entityPrimaryKey} FROM ${entityName} ${condition}`); + + let createdCount = 0; + + for (const entityId of entityIds) { + const existingRows = sql.getValue("SELECT COUNT(1) FROM entity_changes WHERE entityName = ? AND entityId = ?", [entityName, entityId]); + + if (existingRows !== 0) { + // we don't want to replace existing entities (which would effectively cause full resync) + continue; + } + + createdCount++; + + const ec: Partial<EntityChange> = { + entityName, + entityId, + isErased: false + }; + + if (entityName === "blobs") { + const blob = sql.getRow<Pick<BlobRow, "blobId" | "content" | "utcDateModified">>("SELECT blobId, content, utcDateModified FROM blobs WHERE blobId = ?", [entityId]); + ec.hash = blobService.calculateContentHash(blob); + ec.utcDateChanged = blob.utcDateModified; + ec.isSynced = true; // blobs are always synced + } else { + const entity = becca.getEntity(entityName, entityId); + + if (entity) { + ec.hash = entity.generateHash(); + ec.utcDateChanged = entity.getUtcDateChanged() || dateUtils.utcNowDateTime(); + ec.isSynced = entityName !== "options" || !!entity.isSynced; + } else { + // entity might be null (not present in becca) when it's deleted + // this will produce different hash value than when entity is being deleted since then + // all normal hashed attributes are being used. Sync should recover from that, though. + ec.hash = "deleted"; + ec.utcDateChanged = dateUtils.utcNowDateTime(); + ec.isSynced = true; // deletable (the ones with isDeleted) entities are synced + } + } + + putEntityChange(ec as EntityChange); + } + + if (createdCount > 0) { + getLog().info(`Created ${createdCount} missing entity changes for entity '${entityName}'.`); + } + }); +} + +function fillAllEntityChanges() { + const sql = getSql(); + sql.transactional(() => { + sql.execute("DELETE FROM entity_changes WHERE isErased = 0"); + + fillEntityChanges("notes", "noteId"); + fillEntityChanges("branches", "branchId"); + fillEntityChanges("revisions", "revisionId"); + fillEntityChanges("attachments", "attachmentId"); + fillEntityChanges("blobs", "blobId"); + fillEntityChanges("attributes", "attributeId"); + fillEntityChanges("etapi_tokens", "etapiTokenId"); + fillEntityChanges("options", "name", "WHERE isSynced = 1"); + }); +} + +function recalculateMaxEntityChangeId() { + maxEntityChangeId = getSql().getValue<number>("SELECT COALESCE(MAX(id), 0) FROM entity_changes"); +} + +export default { + putNoteReorderingEntityChange, + putEntityChangeForOtherInstances, + putEntityChangeWithForcedChange, + putEntityChange, + putEntityChangeWithInstanceId, + fillAllEntityChanges, + addEntityChangesForSector, + getMaxEntityChangeId: () => maxEntityChangeId, + recalculateMaxEntityChangeId +}; diff --git a/packages/trilium-core/src/services/instance_id.ts b/packages/trilium-core/src/services/instance_id.ts new file mode 100644 index 0000000000..cd8257edff --- /dev/null +++ b/packages/trilium-core/src/services/instance_id.ts @@ -0,0 +1,5 @@ +import { randomString } from "./utils"; + +const instanceId = randomString(12); + +export default instanceId; From ad3be73e1b81dafd4a4ab508a85c3e3a7a243a6f Mon Sep 17 00:00:00 2001 From: Elian Doran <contact@eliandoran.me> Date: Tue, 6 Jan 2026 13:45:53 +0200 Subject: [PATCH 42/72] chore(core): integrate note_set --- apps/server/src/services/search/note_set.ts | 67 +------------------ .../trilium-core/src/becca/becca-interface.ts | 2 +- packages/trilium-core/src/index.ts | 2 + .../src/services/search/note_set.ts | 65 ++++++++++++++++++ 4 files changed, 69 insertions(+), 67 deletions(-) create mode 100644 packages/trilium-core/src/services/search/note_set.ts diff --git a/apps/server/src/services/search/note_set.ts b/apps/server/src/services/search/note_set.ts index bab76afa5e..10ecb41346 100644 --- a/apps/server/src/services/search/note_set.ts +++ b/apps/server/src/services/search/note_set.ts @@ -1,67 +1,2 @@ -"use strict"; - -import type BNote from "../../becca/entities/bnote.js"; - -class NoteSet { - private noteIdSet: Set<string>; - - notes: BNote[]; - sorted: boolean; - - constructor(notes: BNote[] = []) { - this.notes = notes; - this.noteIdSet = new Set(notes.map((note) => note.noteId)); - this.sorted = false; - } - - add(note: BNote) { - if (!this.hasNote(note)) { - this.notes.push(note); - this.noteIdSet.add(note.noteId); - } - } - - addAll(notes: BNote[]) { - for (const note of notes) { - this.add(note); - } - } - - hasNote(note: BNote) { - return this.hasNoteId(note.noteId); - } - - hasNoteId(noteId: string) { - return this.noteIdSet.has(noteId); - } - - mergeIn(anotherNoteSet: NoteSet) { - this.addAll(anotherNoteSet.notes); - } - - minus(anotherNoteSet: NoteSet) { - const newNoteSet = new NoteSet(); - - for (const note of this.notes) { - if (!anotherNoteSet.hasNoteId(note.noteId)) { - newNoteSet.add(note); - } - } - - return newNoteSet; - } - - intersection(anotherNoteSet: NoteSet) { - const newNoteSet = new NoteSet(); - - for (const note of this.notes) { - if (anotherNoteSet.hasNote(note)) { - newNoteSet.add(note); - } - } - - return newNoteSet; - } -} - +import { NoteSet } from "@triliumnext/core"; export default NoteSet; diff --git a/packages/trilium-core/src/becca/becca-interface.ts b/packages/trilium-core/src/becca/becca-interface.ts index 8c8099cd44..dd4092fb50 100644 --- a/packages/trilium-core/src/becca/becca-interface.ts +++ b/packages/trilium-core/src/becca/becca-interface.ts @@ -1,4 +1,3 @@ -import NoteSet from "../services/search/note_set.js"; import { NotFoundError } from "../errors.js"; import type BOption from "./entities/boption.js"; import type BNote from "./entities/bnote.js"; @@ -12,6 +11,7 @@ import BBlob from "./entities/bblob.js"; import BRecentNote from "./entities/brecent_note.js"; import type AbstractBeccaEntity from "./entities/abstract_becca_entity.js"; import { getSql } from "src/services/sql/index.js"; +import NoteSet from "src/services/search/note_set.js"; /** * Becca is a backend cache of all notes, branches, and attributes. diff --git a/packages/trilium-core/src/index.ts b/packages/trilium-core/src/index.ts index 5c10216df7..e6940a14f6 100644 --- a/packages/trilium-core/src/index.ts +++ b/packages/trilium-core/src/index.ts @@ -43,6 +43,8 @@ export { default as AbstractBeccaEntity } from "./becca/entities/abstract_becca_ 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 function initializeCore({ dbConfig, executionContext, crypto }: { dbConfig: SqlServiceParams, executionContext: ExecutionContext, diff --git a/packages/trilium-core/src/services/search/note_set.ts b/packages/trilium-core/src/services/search/note_set.ts new file mode 100644 index 0000000000..c621017786 --- /dev/null +++ b/packages/trilium-core/src/services/search/note_set.ts @@ -0,0 +1,65 @@ +import type BNote from "../../becca/entities/bnote.js"; + +class NoteSet { + private noteIdSet: Set<string>; + + notes: BNote[]; + sorted: boolean; + + constructor(notes: BNote[] = []) { + this.notes = notes; + this.noteIdSet = new Set(notes.map((note) => note.noteId)); + this.sorted = false; + } + + add(note: BNote) { + if (!this.hasNote(note)) { + this.notes.push(note); + this.noteIdSet.add(note.noteId); + } + } + + addAll(notes: BNote[]) { + for (const note of notes) { + this.add(note); + } + } + + hasNote(note: BNote) { + return this.hasNoteId(note.noteId); + } + + hasNoteId(noteId: string) { + return this.noteIdSet.has(noteId); + } + + mergeIn(anotherNoteSet: NoteSet) { + this.addAll(anotherNoteSet.notes); + } + + minus(anotherNoteSet: NoteSet) { + const newNoteSet = new NoteSet(); + + for (const note of this.notes) { + if (!anotherNoteSet.hasNoteId(note.noteId)) { + newNoteSet.add(note); + } + } + + return newNoteSet; + } + + intersection(anotherNoteSet: NoteSet) { + const newNoteSet = new NoteSet(); + + for (const note of this.notes) { + if (anotherNoteSet.hasNote(note)) { + newNoteSet.add(note); + } + } + + return newNoteSet; + } +} + +export default NoteSet; From 6a0f6fab8348075b61f4250a14babbef741beb1c Mon Sep 17 00:00:00 2001 From: Elian Doran <contact@eliandoran.me> Date: Tue, 6 Jan 2026 13:50:22 +0200 Subject: [PATCH 43/72] fix(core): server not starting due to crypto not initialized --- apps/server/src/routes/api/login.ts | 5 ++--- apps/server/src/services/instance_id.ts | 2 -- apps/server/src/services/sync.ts | 11 ++++------- packages/trilium-core/src/index.ts | 2 +- packages/trilium-core/src/services/entity_changes.ts | 8 ++++---- packages/trilium-core/src/services/instance_id.ts | 10 ++++++++-- 6 files changed, 19 insertions(+), 19 deletions(-) delete mode 100644 apps/server/src/services/instance_id.ts diff --git a/apps/server/src/routes/api/login.ts b/apps/server/src/routes/api/login.ts index b7dc4eace9..6106fc52d2 100644 --- a/apps/server/src/routes/api/login.ts +++ b/apps/server/src/routes/api/login.ts @@ -1,4 +1,4 @@ -import { events as eventService } from "@triliumnext/core"; +import { events as eventService, getInstanceId } from "@triliumnext/core"; import type { Request } from "express"; import appInfo from "../../services/app_info.js"; @@ -6,7 +6,6 @@ import dateUtils from "../../services/date_utils.js"; import passwordEncryptionService from "../../services/encryption/password_encryption.js"; import recoveryCodeService from "../../services/encryption/recovery_codes"; import etapiTokenService from "../../services/etapi_tokens.js"; -import instanceId from "../../services/instance_id.js"; import options from "../../services/options.js"; import protectedSessionService from "../../services/protected_session.js"; import sql from "../../services/sql.js"; @@ -114,7 +113,7 @@ function loginSync(req: Request) { req.session.loggedIn = true; return { - instanceId, + instanceId: getInstanceId(), maxEntityChangeId: sql.getValue("SELECT COALESCE(MAX(id), 0) FROM entity_changes WHERE isSynced = 1") }; } diff --git a/apps/server/src/services/instance_id.ts b/apps/server/src/services/instance_id.ts deleted file mode 100644 index a612180200..0000000000 --- a/apps/server/src/services/instance_id.ts +++ /dev/null @@ -1,2 +0,0 @@ -import { instance_id } from "@triliumnext/core"; -export default instance_id; diff --git a/apps/server/src/services/sync.ts b/apps/server/src/services/sync.ts index 6b52fc60df..7720e13f71 100644 --- a/apps/server/src/services/sync.ts +++ b/apps/server/src/services/sync.ts @@ -1,7 +1,5 @@ - - import type { EntityChange, EntityChangeRecord, EntityRow } from "@triliumnext/commons"; -import { becca_loader, binary_utils, entity_constructor } from "@triliumnext/core"; +import { becca_loader, binary_utils, entity_constructor, getInstanceId } from "@triliumnext/core"; import becca from "../becca/becca.js"; import appInfo from "./app_info.js"; @@ -10,7 +8,6 @@ import consistency_checks from "./consistency_checks.js"; import contentHashService from "./content_hash.js"; import dateUtils from "./date_utils.js"; import entityChangesService from "./entity_changes.js"; -import instanceId from "./instance_id.js"; import log from "./log.js"; import optionService from "./options.js"; import request from "./request.js"; @@ -132,7 +129,7 @@ async function doLogin(): Promise<SyncContext> { throw new Error("Got no response."); } - if (resp.instanceId === instanceId) { + if (resp.instanceId === getInstanceId()) { throw new Error( `Sync server has instance ID '${resp.instanceId}' which is also local. This usually happens when the sync client is (mis)configured to sync with itself (URL points back to client) instead of the correct sync server.` ); @@ -157,7 +154,7 @@ async function pullChanges(syncContext: SyncContext) { while (true) { const lastSyncedPull = getLastSyncedPull(); const logMarkerId = randomString(10); // to easily pair sync events between client and server logs - const changesUri = `/api/sync/changed?instanceId=${instanceId}&lastEntityChangeId=${lastSyncedPull}&logMarkerId=${logMarkerId}`; + const changesUri = `/api/sync/changed?instanceId=${getInstanceId()}&lastEntityChangeId=${lastSyncedPull}&logMarkerId=${logMarkerId}`; const startDate = Date.now(); @@ -239,7 +236,7 @@ async function pushChanges(syncContext: SyncContext) { await syncRequest(syncContext, "PUT", `/api/sync/update?logMarkerId=${logMarkerId}`, { entities: entityChangesRecords, - instanceId + instanceId: getInstanceId() }); ws.syncPushInProgress(); diff --git a/packages/trilium-core/src/index.ts b/packages/trilium-core/src/index.ts index e6940a14f6..096820bdb0 100644 --- a/packages/trilium-core/src/index.ts +++ b/packages/trilium-core/src/index.ts @@ -22,7 +22,7 @@ export { default as entity_changes } from "./services/entity_changes"; export { getContext, type ExecutionContext } from "./services/context"; export * as cls from "./services/context"; export * from "./errors"; -export { default as instance_id } from "./services/instance_id"; +export { default as getInstanceId } from "./services/instance_id"; export type { CryptoProvider } from "./services/encryption/crypto"; export { default as becca } from "./becca/becca"; diff --git a/packages/trilium-core/src/services/entity_changes.ts b/packages/trilium-core/src/services/entity_changes.ts index b389806d42..d77bd4a58a 100644 --- a/packages/trilium-core/src/services/entity_changes.ts +++ b/packages/trilium-core/src/services/entity_changes.ts @@ -2,13 +2,13 @@ import type { BlobRow, EntityChange } from "@triliumnext/commons"; import becca from "../becca/becca.js"; import dateUtils from "./utils/date.js"; -import instanceId from "./instance_id.js"; -import log, { getLog } from "./log.js"; +import { getLog } from "./log.js"; import { randomString } from "./utils/index.js"; import { getSql } from "./sql/index.js"; import { getComponentId } from "./context.js"; import events from "./events.js"; import blobService from "./blob.js"; +import getInstanceId from "./instance_id.js"; let maxEntityChangeId = 0; @@ -34,7 +34,7 @@ function putEntityChange(origEntityChange: EntityChange) { } ec.componentId = ec.componentId || getComponentId() || "NA"; // NA = not available - ec.instanceId = ec.instanceId || instanceId; + ec.instanceId = ec.instanceId || getInstanceId(); ec.isSynced = ec.isSynced ? 1 : 0; ec.isErased = ec.isErased ? 1 : 0; ec.id = getSql().replace("entity_changes", ec); @@ -55,7 +55,7 @@ function putNoteReorderingEntityChange(parentNoteId: string, componentId?: strin utcDateChanged: dateUtils.utcNowDateTime(), isSynced: true, componentId, - instanceId + instanceId: getInstanceId() }); events.emit(events.ENTITY_CHANGED, { diff --git a/packages/trilium-core/src/services/instance_id.ts b/packages/trilium-core/src/services/instance_id.ts index cd8257edff..7925d54655 100644 --- a/packages/trilium-core/src/services/instance_id.ts +++ b/packages/trilium-core/src/services/instance_id.ts @@ -1,5 +1,11 @@ import { randomString } from "./utils"; -const instanceId = randomString(12); +let instanceId: string | null = null; -export default instanceId; +export default function getInstanceId() { + if (instanceId === null) { + instanceId = randomString(12); + } + + return instanceId; +} From 8cdfc108ba9b6b235af407e6d7a2103e99fef4ae Mon Sep 17 00:00:00 2001 From: Elian Doran <contact@eliandoran.me> Date: Tue, 6 Jan 2026 13:52:57 +0200 Subject: [PATCH 44/72] fix(core): wrong imports to src --- packages/trilium-core/src/becca/becca-interface.ts | 4 ++-- packages/trilium-core/src/becca/becca_loader.ts | 4 ++-- packages/trilium-core/src/becca/becca_service.ts | 2 +- .../src/becca/entities/abstract_becca_entity.ts | 6 +++--- packages/trilium-core/src/becca/entities/battachment.ts | 4 ++-- packages/trilium-core/src/becca/entities/battribute.ts | 2 +- packages/trilium-core/src/becca/entities/bbranch.ts | 4 ++-- packages/trilium-core/src/becca/entities/bnote.ts | 4 ++-- packages/trilium-core/src/becca/entities/brevision.ts | 4 ++-- 9 files changed, 17 insertions(+), 17 deletions(-) diff --git a/packages/trilium-core/src/becca/becca-interface.ts b/packages/trilium-core/src/becca/becca-interface.ts index dd4092fb50..0c2c3690d7 100644 --- a/packages/trilium-core/src/becca/becca-interface.ts +++ b/packages/trilium-core/src/becca/becca-interface.ts @@ -10,8 +10,8 @@ import type { AttachmentRow, BlobRow, RevisionRow } from "@triliumnext/commons"; import BBlob from "./entities/bblob.js"; import BRecentNote from "./entities/brecent_note.js"; import type AbstractBeccaEntity from "./entities/abstract_becca_entity.js"; -import { getSql } from "src/services/sql/index.js"; -import NoteSet from "src/services/search/note_set.js"; +import { getSql } from "../services/sql/index.js"; +import NoteSet from "../services/search/note_set.js"; /** * Becca is a backend cache of all notes, branches, and attributes. diff --git a/packages/trilium-core/src/becca/becca_loader.ts b/packages/trilium-core/src/becca/becca_loader.ts index e2cd1804c1..5ee7e252b2 100644 --- a/packages/trilium-core/src/becca/becca_loader.ts +++ b/packages/trilium-core/src/becca/becca_loader.ts @@ -12,8 +12,8 @@ import BBranch from "./entities/bbranch.js"; import BEtapiToken from "./entities/betapi_token.js"; import BNote from "./entities/bnote.js"; import BOption from "./entities/boption.js"; -import { getSql } from "src/services/sql/index.js"; -import { getContext } from "src/services/context.js"; +import { getSql } from "../services/sql"; +import { getContext } from "../services/context.js"; export const beccaLoaded = new Promise<void>(async (res, rej) => { // We have to import async since options init requires keyboard actions which require translations. diff --git a/packages/trilium-core/src/becca/becca_service.ts b/packages/trilium-core/src/becca/becca_service.ts index dcd67c7095..c7b5731e21 100644 --- a/packages/trilium-core/src/becca/becca_service.ts +++ b/packages/trilium-core/src/becca/becca_service.ts @@ -2,7 +2,7 @@ import becca from "./becca.js"; import { getLog } from "../services/log.js"; -import { getHoistedNoteId } from "src/services/context.js"; +import { getHoistedNoteId } from "../services/context.js"; function isNotePathArchived(notePath: string[]) { const noteId = notePath[notePath.length - 1]; diff --git a/packages/trilium-core/src/becca/entities/abstract_becca_entity.ts b/packages/trilium-core/src/becca/entities/abstract_becca_entity.ts index 74f9442484..dd84718e8f 100644 --- a/packages/trilium-core/src/becca/entities/abstract_becca_entity.ts +++ b/packages/trilium-core/src/becca/entities/abstract_becca_entity.ts @@ -8,9 +8,9 @@ import { getLog } from "../../services/log.js"; import protectedSessionService from "../../services/protected_session.js"; import becca from "../becca.js"; import type { ConstructorData,default as Becca } from "../becca-interface.js"; -import { getSql } from "src/services/sql"; -import { concat2, encodeUtf8, unwrapStringOrBuffer, wrapStringOrBuffer } from "src/services/utils/binary"; -import { hash, hashedBlobId, newEntityId, randomString } from "src/services/utils"; +import { getSql } from "../../services/sql"; +import { concat2, encodeUtf8, unwrapStringOrBuffer, wrapStringOrBuffer } from "../../services/utils/binary"; +import { hash, hashedBlobId, newEntityId, randomString } from "../../services/utils"; interface ContentOpts { forceSave?: boolean; diff --git a/packages/trilium-core/src/becca/entities/battachment.ts b/packages/trilium-core/src/becca/entities/battachment.ts index bd6a1ff45f..bdc28d15de 100644 --- a/packages/trilium-core/src/becca/entities/battachment.ts +++ b/packages/trilium-core/src/becca/entities/battachment.ts @@ -9,8 +9,8 @@ import protectedSessionService from "../../services/protected_session.js"; import AbstractBeccaEntity from "./abstract_becca_entity.js"; import type BBranch from "./bbranch.js"; import type BNote from "./bnote.js"; -import { getSql } from "src/services/sql/index.js"; -import { isStringNote, replaceAll } from "src/services/utils"; +import { getSql } from "../../services/sql/index.js"; +import { isStringNote, replaceAll } from "../../services/utils"; const attachmentRoleToNoteTypeMapping = { image: "image", diff --git a/packages/trilium-core/src/becca/entities/battribute.ts b/packages/trilium-core/src/becca/entities/battribute.ts index 2807995802..9156017793 100644 --- a/packages/trilium-core/src/becca/entities/battribute.ts +++ b/packages/trilium-core/src/becca/entities/battribute.ts @@ -5,7 +5,7 @@ import AbstractBeccaEntity from "./abstract_becca_entity.js"; import dateUtils from "../../services/utils/date"; import promotedAttributeDefinitionParser from "../../services/promoted_attribute_definition_parser.js"; import type { AttributeRow, AttributeType } from "@triliumnext/commons"; -import { sanitizeAttributeName } from "src/services/utils/index.js"; +import { sanitizeAttributeName } from "../../services/utils/index.js"; interface SavingOpts { skipValidation?: boolean; diff --git a/packages/trilium-core/src/becca/entities/bbranch.ts b/packages/trilium-core/src/becca/entities/bbranch.ts index b7603b2702..1468e04599 100644 --- a/packages/trilium-core/src/becca/entities/bbranch.ts +++ b/packages/trilium-core/src/becca/entities/bbranch.ts @@ -8,8 +8,8 @@ import { getLog } from "../../services/log.js"; import TaskContext from "../../services/task_context.js"; import AbstractBeccaEntity from "./abstract_becca_entity.js"; import BNote from "./bnote.js"; -import { getHoistedNoteId } from "src/services/context"; -import { randomString } from "src/services/utils"; +import { getHoistedNoteId } from "../../services/context"; +import { randomString } from "../../services/utils"; /** * Branch represents a relationship between a child note and its parent note. Trilium allows a note to have multiple diff --git a/packages/trilium-core/src/becca/entities/bnote.ts b/packages/trilium-core/src/becca/entities/bnote.ts index 14402d7798..f573b0e0e1 100644 --- a/packages/trilium-core/src/becca/entities/bnote.ts +++ b/packages/trilium-core/src/becca/entities/bnote.ts @@ -18,8 +18,8 @@ import BAttachment from "./battachment.js"; import BAttribute from "./battribute.js"; import type BBranch from "./bbranch.js"; import BRevision from "./brevision.js"; -import { getSql } from "src/services/sql/index.js"; -import { isStringNote, normalize, randomString, replaceAll } from "src/services/utils"; +import { getSql } from "../../services/sql/index.js"; +import { isStringNote, normalize, randomString, replaceAll } from "../../services/utils"; const LABEL = "label"; const RELATION = "relation"; diff --git a/packages/trilium-core/src/becca/entities/brevision.ts b/packages/trilium-core/src/becca/entities/brevision.ts index 9a5f79740a..a3a9b4ce08 100644 --- a/packages/trilium-core/src/becca/entities/brevision.ts +++ b/packages/trilium-core/src/becca/entities/brevision.ts @@ -7,8 +7,8 @@ import AbstractBeccaEntity from "./abstract_becca_entity.js"; import BAttachment from "./battachment.js"; import type { AttachmentRow, NoteType, RevisionPojo, RevisionRow } from "@triliumnext/commons"; import eraseService from "../../services/erase.js"; -import { getSql } from "src/services/sql/index.js"; -import { isStringNote } from "src/services/utils/index.js"; +import { getSql } from "../../services/sql/index.js"; +import { isStringNote } from "../../services/utils/index.js"; interface ContentOpts { /** will also save this BRevision entity */ From 5508b505c8b826be95f8fab23abd13938c55ead8 Mon Sep 17 00:00:00 2001 From: Elian Doran <contact@eliandoran.me> Date: Tue, 6 Jan 2026 14:40:22 +0200 Subject: [PATCH 45/72] chore(core): port notes service partially --- apps/server/src/services/cls.ts | 12 +- apps/server/src/services/notes.ts | 1087 +--------------- packages/trilium-core/src/index.ts | 1 + packages/trilium-core/src/services/context.ts | 21 +- packages/trilium-core/src/services/notes.ts | 1090 +++++++++++++++++ 5 files changed, 1120 insertions(+), 1091 deletions(-) create mode 100644 packages/trilium-core/src/services/notes.ts diff --git a/apps/server/src/services/cls.ts b/apps/server/src/services/cls.ts index ebf6935597..f5bb25ef1e 100644 --- a/apps/server/src/services/cls.ts +++ b/apps/server/src/services/cls.ts @@ -25,24 +25,28 @@ function getComponentId() { return cls.getComponentId(); } +/** @deprecated */ function disableEntityEvents() { - cls.getContext().set("disableEntityEvents", true); + cls.disableEntityEvents(); } +/** @deprecated */ function enableEntityEvents() { - cls.getContext().set("disableEntityEvents", false); + cls.enableEntityEvents(); } function isEntityEventsDisabled() { return cls.isEntityEventsDisabled(); } +/** @deprecated */ function setMigrationRunning(running: boolean) { - cls.getContext().set("migrationRunning", !!running); + cls.setMigrationRunning(running); } +/** @deprecated */ function isMigrationRunning() { - return !!cls.getContext().get("migrationRunning"); + return cls.isMigrationRunning(); } function getAndClearEntityChangeIds() { diff --git a/apps/server/src/services/notes.ts b/apps/server/src/services/notes.ts index de144196c7..97756a3830 100644 --- a/apps/server/src/services/notes.ts +++ b/apps/server/src/services/notes.ts @@ -1,1085 +1,2 @@ -import type { AttachmentRow, AttributeRow, BranchRow, NoteRow } from "@triliumnext/commons"; -import { dayjs } from "@triliumnext/commons"; -import { date_utils, events as eventService, ValidationError } from "@triliumnext/core"; -import fs from "fs"; -import html2plaintext from "html2plaintext"; -import { t } from "i18next"; -import path from "path"; -import url from "url"; - -import becca from "../becca/becca.js"; -import BAttachment from "../becca/entities/battachment.js"; -import BAttribute from "../becca/entities/battribute.js"; -import BBranch from "../becca/entities/bbranch.js"; -import BNote from "../becca/entities/bnote.js"; -import cls from "../services/cls.js"; -import log from "../services/log.js"; -import protectedSessionService from "../services/protected_session.js"; -import { newEntityId, quoteRegex, toMap,unescapeHtml } from "../services/utils.js"; -import entityChangesService from "./entity_changes.js"; -import htmlSanitizer from "./html_sanitizer.js"; -import imageService from "./image.js"; -import noteTypesService from "./note_types.js"; -import type { NoteParams } from "./note-interface.js"; -import optionService from "./options.js"; -import request from "./request.js"; -import revisionService from "./revisions.js"; -import sql from "./sql.js"; -import type TaskContext from "./task_context.js"; -import ws from "./ws.js"; - -interface FoundLink { - name: "imageLink" | "internalLink" | "includeNoteLink" | "relationMapLink"; - value: string; -} - -interface Attachment { - attachmentId?: string; - title: string; -} - -function getNewNotePosition(parentNote: BNote) { - if (parentNote.isLabelTruthy("newNotesOnTop")) { - const minNotePos = parentNote - .getChildBranches() - .filter((branch) => branch?.noteId !== "_hidden") // has "always last" note position - .reduce((min, note) => Math.min(min, note?.notePosition || 0), 0); - - return minNotePos - 10; - } - const maxNotePos = parentNote - .getChildBranches() - .filter((branch) => branch?.noteId !== "_hidden") // has "always last" note position - .reduce((max, note) => Math.max(max, note?.notePosition || 0), 0); - - return maxNotePos + 10; - -} - -function triggerNoteTitleChanged(note: BNote) { - eventService.emit(eventService.NOTE_TITLE_CHANGED, note); -} - -function deriveMime(type: string, mime?: string) { - if (!type) { - throw new Error(`Note type is a required param`); - } - - if (mime) { - return mime; - } - - return noteTypesService.getDefaultMimeForNoteType(type); -} - -function copyChildAttributes(parentNote: BNote, childNote: BNote) { - for (const attr of parentNote.getAttributes()) { - if (attr.name.startsWith("child:")) { - const name = attr.name.substring(6); - const hasAlreadyTemplate = childNote.hasRelation("template"); - - if (hasAlreadyTemplate && attr.type === "relation" && name === "template") { - // if the note already has a template, it means the template was chosen by the user explicitly - // in the menu. In that case, we should override the default templates defined in the child: attrs - continue; - } - - new BAttribute({ - noteId: childNote.noteId, - type: attr.type, - name, - value: attr.value, - position: attr.position, - isInheritable: attr.isInheritable - }).save(); - } - } -} - -function copyAttachments(origNote: BNote, newNote: BNote) { - for (const attachment of origNote.getAttachments()) { - if (attachment.role === "image") { - // Handled separately, see `checkImageAttachments`. - continue; - } - - const newAttachment = new BAttachment({ - ...attachment, - attachmentId: undefined, - ownerId: newNote.noteId - }); - - newAttachment.save(); - } -} - -function getNewNoteTitle(parentNote: BNote) { - let title = t("notes.new-note"); - - const titleTemplate = parentNote.getLabelValue("titleTemplate"); - - if (titleTemplate !== null) { - try { - const now = dayjs(date_utils.localNowDateTime() || new Date()); - - // "officially" injected values: - // - now - // - parentNote - - title = eval(`\`${titleTemplate}\``); - } catch (e: any) { - log.error(`Title template of note '${parentNote.noteId}' failed with: ${e.message}`); - } - } - - // this isn't in theory a good place to sanitize title, but this will catch a lot of XSS attempts. - // title is supposed to contain text only (not HTML) and be printed text only, but given the number of usages, - // it's difficult to guarantee correct handling in all cases - title = htmlSanitizer.sanitize(title); - - return title; -} - -interface GetValidateParams { - parentNoteId: string; - type: string; - ignoreForbiddenParents?: boolean; -} - -function getAndValidateParent(params: GetValidateParams) { - const parentNote = becca.notes[params.parentNoteId]; - - if (!parentNote) { - throw new ValidationError(`Parent note '${params.parentNoteId}' was not found.`); - } - - if (parentNote.type === "launcher" && parentNote.noteId !== "_lbBookmarks") { - throw new ValidationError(`Creating child notes into launcher notes is not allowed.`); - } - - if (["_lbAvailableLaunchers", "_lbVisibleLaunchers"].includes(params.parentNoteId) && params.type !== "launcher") { - throw new ValidationError(`Only 'launcher' notes can be created in parent '${params.parentNoteId}'`); - } - - if (!params.ignoreForbiddenParents) { - if (["_lbRoot", "_hidden"].includes(parentNote.noteId) - || parentNote.noteId.startsWith("_lbTpl") - || parentNote.noteId.startsWith("_help") - || parentNote.isOptions()) { - throw new ValidationError(`Creating child notes into '${parentNote.noteId}' is not allowed.`); - } - } - - return parentNote; -} - -function createNewNote(params: NoteParams): { - note: BNote; - branch: BBranch; -} { - const parentNote = getAndValidateParent(params); - - if (params.title === null || params.title === undefined) { - params.title = getNewNoteTitle(parentNote); - } - - if (params.content === null || params.content === undefined) { - throw new Error(`Note content must be set`); - } - - let error; - if ((error = date_utils.validateLocalDateTime(params.dateCreated))) { - throw new Error(error); - } - - if ((error = date_utils.validateUtcDateTime(params.utcDateCreated))) { - throw new Error(error); - } - - return sql.transactional(() => { - let note, branch, isEntityEventsDisabled; - - try { - isEntityEventsDisabled = cls.isEntityEventsDisabled(); - - if (!isEntityEventsDisabled) { - // it doesn't make sense to run note creation events on a partially constructed note, so - // defer them until note creation is completed - cls.disableEntityEvents(); - } - - // TODO: think about what can happen if the note already exists with the forced ID - // I guess on DB it's going to be fine, but becca references between entities - // might get messed up (two note instances for the same ID existing in the references) - note = new BNote({ - noteId: params.noteId, // optionally can force specific noteId - title: params.title, - isProtected: !!params.isProtected, - type: params.type, - mime: deriveMime(params.type, params.mime), - dateCreated: params.dateCreated, - utcDateCreated: params.utcDateCreated - }).save(); - - note.setContent(params.content); - - branch = new BBranch({ - noteId: note.noteId, - parentNoteId: params.parentNoteId, - notePosition: params.notePosition !== undefined ? params.notePosition : getNewNotePosition(parentNote), - prefix: params.prefix || "", - isExpanded: !!params.isExpanded - }).save(); - } finally { - if (!isEntityEventsDisabled) { - // re-enable entity events only if they were previously enabled - // (they can be disabled in case of import) - cls.enableEntityEvents(); - } - } - - if (params.templateNoteId) { - const templateNote = becca.getNote(params.templateNoteId); - if (!templateNote) { - throw new Error(`Template note '${params.templateNoteId}' does not exist.`); - } - - note.addRelation("template", params.templateNoteId); - copyAttachments(templateNote, note); - - // no special handling for ~inherit since it doesn't matter if it's assigned with the note creation or later - } - - copyChildAttributes(parentNote, note); - - eventService.emit(eventService.ENTITY_CREATED, { entityName: "notes", entity: note }); - eventService.emit(eventService.ENTITY_CHANGED, { entityName: "notes", entity: note }); - triggerNoteTitleChanged(note); - // blobs entity doesn't use "created" event - eventService.emit(eventService.ENTITY_CHANGED, { entityName: "blobs", entity: note }); - eventService.emit(eventService.ENTITY_CREATED, { entityName: "branches", entity: branch }); - eventService.emit(eventService.ENTITY_CHANGED, { entityName: "branches", entity: branch }); - eventService.emit(eventService.CHILD_NOTE_CREATED, { childNote: note, parentNote }); - - log.info(`Created new note '${note.noteId}', branch '${branch.branchId}' of type '${note.type}', mime '${note.mime}'`); - - return { - note, - branch - }; - }); -} - -function createNewNoteWithTarget(target: "into" | "after" | "before", targetBranchId: string | undefined, params: NoteParams) { - if (!params.type) { - const parentNote = becca.notes[params.parentNoteId]; - - // code note type can be inherited, otherwise "text" is the default - params.type = parentNote.type === "code" ? "code" : "text"; - params.mime = parentNote.type === "code" ? parentNote.mime : "text/html"; - } - - if (target === "into") { - return createNewNote(params); - } else if (target === "after" && targetBranchId) { - const afterBranch = becca.branches[targetBranchId]; - - // not updating utcDateModified to avoid having to sync whole rows - sql.execute("UPDATE branches SET notePosition = notePosition + 10 WHERE parentNoteId = ? AND notePosition > ? AND isDeleted = 0", [params.parentNoteId, afterBranch.notePosition]); - - params.notePosition = afterBranch.notePosition + 10; - - const retObject = createNewNote(params); - - entityChangesService.putNoteReorderingEntityChange(params.parentNoteId); - - return retObject; - } else if (target === "before" && targetBranchId) { - const beforeBranch = becca.branches[targetBranchId]; - - // not updating utcDateModified to avoid having to sync whole rows - sql.execute("UPDATE branches SET notePosition = notePosition - 10 WHERE parentNoteId = ? AND notePosition < ? AND isDeleted = 0", [params.parentNoteId, beforeBranch.notePosition]); - - params.notePosition = beforeBranch.notePosition - 10; - - const retObject = createNewNote(params); - - entityChangesService.putNoteReorderingEntityChange(params.parentNoteId); - - return retObject; - } - throw new Error(`Unknown target '${target}'`); - -} - -function protectNoteRecursively(note: BNote, protect: boolean, includingSubTree: boolean, taskContext: TaskContext<"protectNotes">) { - protectNote(note, protect); - - taskContext.increaseProgressCount(); - - if (includingSubTree) { - for (const child of note.getChildNotes()) { - protectNoteRecursively(child, protect, includingSubTree, taskContext); - } - } -} - -function protectNote(note: BNote, protect: boolean) { - if (!protectedSessionService.isProtectedSessionAvailable()) { - throw new Error(`Cannot (un)protect note '${note.noteId}' with protect flag '${protect}' without active protected session`); - } - - try { - if (protect !== note.isProtected) { - const content = note.getContent(); - - note.isProtected = protect; - note.setContent(content, { forceSave: true }); - } - - revisionService.protectRevisions(note); - - for (const attachment of note.getAttachments()) { - if (protect !== attachment.isProtected) { - try { - const content = attachment.getContent(); - - attachment.isProtected = protect; - attachment.setContent(content, { forceSave: true }); - } catch (e) { - log.error(`Could not un/protect attachment '${attachment.attachmentId}'`); - - throw e; - } - } - } - } catch (e) { - log.error(`Could not un/protect note '${note.noteId}'`); - - throw e; - } -} - -function checkImageAttachments(note: BNote, content: string) { - const foundAttachmentIds = new Set<string>(); - let match; - - const imgRegExp = /src="[^"]*api\/attachments\/([a-zA-Z0-9_]+)\/image/g; - while ((match = imgRegExp.exec(content))) { - foundAttachmentIds.add(match[1]); - } - - const linkRegExp = /href="[^"]+attachmentId=([a-zA-Z0-9_]+)/g; - while ((match = linkRegExp.exec(content))) { - foundAttachmentIds.add(match[1]); - } - - const attachments = note.getAttachments(); - - for (const attachment of attachments) { - const attachmentInContent = attachment.attachmentId && foundAttachmentIds.has(attachment.attachmentId); - - if (attachment.utcDateScheduledForErasureSince && attachmentInContent) { - attachment.utcDateScheduledForErasureSince = null; - attachment.save(); - } else if (!attachment.utcDateScheduledForErasureSince && !attachmentInContent) { - attachment.utcDateScheduledForErasureSince = date_utils.utcNowDateTime(); - attachment.save(); - } - } - - const existingAttachmentIds = new Set<string | undefined>(attachments.map((att) => att.attachmentId)); - const unknownAttachmentIds = Array.from(foundAttachmentIds).filter((foundAttId) => !existingAttachmentIds.has(foundAttId)); - const unknownAttachments = becca.getAttachments(unknownAttachmentIds); - - for (const unknownAttachment of unknownAttachments) { - // the attachment belongs to a different note (was copy-pasted). Attachments can be linked only from the note - // which owns it, so either find an existing attachment having the same content or make a copy. - let localAttachment = note.getAttachments().find((att) => att.role === unknownAttachment.role && att.blobId === unknownAttachment.blobId); - - if (localAttachment) { - if (localAttachment.utcDateScheduledForErasureSince) { - // the attachment is for sure linked now, so reset the scheduled deletion - localAttachment.utcDateScheduledForErasureSince = null; - localAttachment.save(); - } - - log.info( - `Found equivalent attachment '${localAttachment.attachmentId}' of note '${note.noteId}' for the linked foreign attachment '${unknownAttachment.attachmentId}' of note '${unknownAttachment.ownerId}'` - ); - } else { - localAttachment = unknownAttachment.copy(); - localAttachment.ownerId = note.noteId; - localAttachment.setContent(unknownAttachment.getContent(), { forceSave: true }); - - ws.sendMessageToAllClients({ type: "toast", message: `Attachment '${localAttachment.title}' has been copied to note '${note.title}'.` }); - log.info(`Copied attachment '${unknownAttachment.attachmentId}' of note '${unknownAttachment.ownerId}' to new '${localAttachment.attachmentId}' of note '${note.noteId}'`); - } - - // replace image links - content = content.replace(`api/attachments/${unknownAttachment.attachmentId}/image`, `api/attachments/${localAttachment.attachmentId}/image`); - // replace reference links - content = content.replace( - new RegExp(`href="[^"]+attachmentId=${unknownAttachment.attachmentId}[^"]*"`, "g"), - `href="#root/${localAttachment.ownerId}?viewMode=attachments&attachmentId=${localAttachment.attachmentId}"` - ); - } - - return { - forceFrontendReload: unknownAttachments.length > 0, - content - }; -} - -function findImageLinks(content: string, foundLinks: FoundLink[]) { - const re = /src="[^"]*api\/images\/([a-zA-Z0-9_]+)\//g; - let match; - - while ((match = re.exec(content))) { - foundLinks.push({ - name: "imageLink", - value: match[1] - }); - } - - // removing absolute references to server to keep it working between instances, - // we also omit / at the beginning to keep the paths relative - return content.replace(/src="[^"]*\/api\/images\//g, 'src="api/images/'); -} - -function findInternalLinks(content: string, foundLinks: FoundLink[]) { - const re = /href="[^"]*#root[a-zA-Z0-9_\/]*\/([a-zA-Z0-9_]+)\/?"/g; - let match; - - while ((match = re.exec(content))) { - foundLinks.push({ - name: "internalLink", - value: match[1] - }); - } - - // removing absolute references to server to keep it working between instances - return content.replace(/href="[^"]*#root/g, 'href="#root'); -} - -function findIncludeNoteLinks(content: string, foundLinks: FoundLink[]) { - const re = /<section class="include-note[^>]+data-note-id="([a-zA-Z0-9_]+)"[^>]*>/g; - let match; - - while ((match = re.exec(content))) { - foundLinks.push({ - name: "includeNoteLink", - value: match[1] - }); - } - - return content; -} - -function findRelationMapLinks(content: string, foundLinks: FoundLink[]) { - try { - const obj = JSON.parse(content); - - for (const note of obj.notes) { - foundLinks.push({ - name: "relationMapLink", - value: note.noteId - }); - } - } catch (e: any) { - log.error(`Could not scan for relation map links: ${ e.message}`); - } -} - -const imageUrlToAttachmentIdMapping: Record<string, string> = {}; - -async function downloadImage(noteId: string, imageUrl: string) { - const unescapedUrl = unescapeHtml(imageUrl); - - try { - let imageBuffer: Buffer; - - if (imageUrl.toLowerCase().startsWith("file://")) { - imageBuffer = await new Promise((res, rej) => { - const localFilePath = imageUrl.substring("file://".length); - - return fs.readFile(localFilePath, (err, data) => { - if (err) { - rej(err); - } else { - res(data); - } - }); - }); - } else { - imageBuffer = await request.getImage(unescapedUrl); - } - - const parsedUrl = url.parse(unescapedUrl); - const title = path.basename(parsedUrl.pathname || ""); - - const attachment = imageService.saveImageToAttachment(noteId, imageBuffer, title, true, true); - - if (attachment.attachmentId) { - imageUrlToAttachmentIdMapping[imageUrl] = attachment.attachmentId; - } else { - log.error(`Download of '${imageUrl}' due to no attachment ID.`); - } - - log.info(`Download of '${imageUrl}' succeeded and was saved as image attachment '${attachment.attachmentId}' of note '${noteId}'`); - } catch (e: any) { - log.error(`Download of '${imageUrl}' for note '${noteId}' failed with error: ${e.message} ${e.stack}`); - } -} - -/** url => download promise */ -const downloadImagePromises: Record<string, Promise<void>> = {}; - -function replaceUrl(content: string, url: string, attachment: Attachment) { - const quotedUrl = quoteRegex(url); - - return content.replace(new RegExp(`\\s+src=[\"']${quotedUrl}[\"']`, "ig"), ` src="api/attachments/${attachment.attachmentId}/image/${encodeURIComponent(attachment.title)}"`); -} - -function downloadImages(noteId: string, content: string) { - const imageRe = /<img[^>]*?\ssrc=['"]([^'">]+)['"]/gi; - let imageMatch; - - while ((imageMatch = imageRe.exec(content))) { - const url = imageMatch[1]; - const inlineImageMatch = /^data:image\/[a-z]+;base64,/.exec(url); - - if (inlineImageMatch) { - const imageBase64 = url.substring(inlineImageMatch[0].length); - const imageBuffer = Buffer.from(imageBase64, "base64"); - - const attachment = imageService.saveImageToAttachment(noteId, imageBuffer, "inline image", true, true); - - const encodedTitle = encodeURIComponent(attachment.title); - - content = `${content.substring(0, imageMatch.index)}<img src="api/attachments/${attachment.attachmentId}/image/${encodedTitle}"${content.substring(imageMatch.index + imageMatch[0].length)}`; - } else if ( - !url.includes("api/images/") && - !/api\/attachments\/.+\/image\/?.*/.test(url) && - // this is an exception for the web clipper's "imageId" - (url.length !== 20 || url.toLowerCase().startsWith("http")) - ) { - if (!optionService.getOptionBool("downloadImagesAutomatically")) { - continue; - } - - if (url in imageUrlToAttachmentIdMapping) { - const attachment = becca.getAttachment(imageUrlToAttachmentIdMapping[url]); - - if (!attachment) { - delete imageUrlToAttachmentIdMapping[url]; - } else { - content = replaceUrl(content, url, attachment); - continue; - } - } - - if (url in downloadImagePromises) { - // download is already in progress - continue; - } - - // this is done asynchronously, it would be too slow to wait for the download - // given that save can be triggered very often - downloadImagePromises[url] = downloadImage(noteId, url); - } - } - - Promise.all(Object.values(downloadImagePromises)).then(() => { - setTimeout(() => { - // the normal expected flow of the offline image saving is that users will paste the image(s) - // which will get asynchronously downloaded, during that time they keep editing the note - // once the download is finished, the image note representing the downloaded image will be used - // to replace the IMG link. - // However, there's another flow where the user pastes the image and leaves the note before the images - // are downloaded and the IMG references are not updated. For this occasion we have this code - // which upon the download of all the images will update the note if the links have not been fixed before - - sql.transactional(() => { - const imageNotes = becca.getNotes(Object.values(imageUrlToAttachmentIdMapping), true); - - const origNote = becca.getNote(noteId); - - if (!origNote) { - log.error(`Cannot find note '${noteId}' to replace image link.`); - return; - } - - const origContent = origNote.getContent(); - let updatedContent = origContent; - - if (typeof updatedContent !== "string") { - log.error(`Note '${noteId}' has a non-string content, cannot replace image link.`); - return; - } - - for (const url in imageUrlToAttachmentIdMapping) { - const imageNote = imageNotes.find((note) => note.noteId === imageUrlToAttachmentIdMapping[url]); - - if (imageNote) { - updatedContent = replaceUrl(updatedContent, url, imageNote); - } - } - - // update only if the links have not been already fixed. - if (updatedContent !== origContent) { - origNote.setContent(updatedContent); - - asyncPostProcessContent(origNote, updatedContent); - - console.log(`Fixed the image links for note '${noteId}' to the offline saved.`); - } - }); - }, 5000); - }); - - return content; -} - -function saveAttachments(note: BNote, content: string) { - const inlineAttachmentRe = /<a[^>]*?\shref=['"]data:([^;'">]+);base64,([^'">]+)['"][^>]*>(.*?)<\/a>/gim; - let attachmentMatch; - - while ((attachmentMatch = inlineAttachmentRe.exec(content))) { - const mime = attachmentMatch[1].toLowerCase(); - - const base64data = attachmentMatch[2]; - const buffer = Buffer.from(base64data, "base64"); - - const title = html2plaintext(attachmentMatch[3]); - - const attachment = note.saveAttachment({ - role: "file", - mime, - title, - content: buffer - }); - - content = `${content.substring(0, attachmentMatch.index)}<a class="reference-link" href="#root/${note.noteId}?viewMode=attachments&attachmentId=${attachment.attachmentId}">${title}</a>${content.substring(attachmentMatch.index + attachmentMatch[0].length)}`; - } - - // removing absolute references to server to keep it working between instances, - // we also omit / at the beginning to keep the paths relative - content = content.replace(/src="[^"]*\/api\/attachments\//g, 'src="api/attachments/'); - - return content; -} - -function saveLinks(note: BNote, content: string | Uint8Array) { - if ((note.type !== "text" && note.type !== "relationMap") || (note.isProtected && !protectedSessionService.isProtectedSessionAvailable())) { - return { - forceFrontendReload: false, - content - }; - } - - const foundLinks: FoundLink[] = []; - let forceFrontendReload = false; - - if (note.type === "text" && typeof content === "string") { - content = downloadImages(note.noteId, content); - content = saveAttachments(note, content); - - content = findImageLinks(content, foundLinks); - content = findInternalLinks(content, foundLinks); - content = findIncludeNoteLinks(content, foundLinks); - - ({ forceFrontendReload, content } = checkImageAttachments(note, content)); - } else if (note.type === "relationMap" && typeof content === "string") { - findRelationMapLinks(content, foundLinks); - } else { - throw new Error(`Unrecognized type '${note.type}'`); - } - - const existingLinks = note.getRelations().filter((rel) => ["internalLink", "imageLink", "relationMapLink", "includeNoteLink"].includes(rel.name)); - - for (const foundLink of foundLinks) { - const targetNote = becca.notes[foundLink.value]; - if (!targetNote) { - continue; - } - - const existingLink = existingLinks.find((existingLink) => existingLink.value === foundLink.value && existingLink.name === foundLink.name); - - if (!existingLink) { - const newLink = new BAttribute({ - noteId: note.noteId, - type: "relation", - name: foundLink.name, - value: foundLink.value - }).save(); - - existingLinks.push(newLink); - } - // else the link exists, so we don't need to do anything - } - - // marking links as deleted if they are not present on the page anymore - const unusedLinks = existingLinks.filter((existingLink) => !foundLinks.some((foundLink) => existingLink.value === foundLink.value && existingLink.name === foundLink.name)); - - for (const unusedLink of unusedLinks) { - unusedLink.markAsDeleted(); - } - - return { forceFrontendReload, content }; -} - -function saveRevisionIfNeeded(note: BNote) { - // files and images are versioned separately - if (note.type === "file" || note.type === "image" || note.isLabelTruthy("disableVersioning")) { - return; - } - - const now = new Date(); - const revisionSnapshotTimeInterval = parseInt(optionService.getOption("revisionSnapshotTimeInterval")); - - const revisionCutoff = date_utils.utcDateTimeStr(new Date(now.getTime() - revisionSnapshotTimeInterval * 1000)); - - const existingRevisionId = sql.getValue("SELECT revisionId FROM revisions WHERE noteId = ? AND utcDateCreated >= ?", [note.noteId, revisionCutoff]); - - const msSinceDateCreated = now.getTime() - date_utils.parseDateTime(note.utcDateCreated).getTime(); - - if (!existingRevisionId && msSinceDateCreated >= revisionSnapshotTimeInterval * 1000) { - note.saveRevision(); - } -} - -function updateNoteData(noteId: string, content: string, attachments: AttachmentRow[] = []) { - const note = becca.getNote(noteId); - - if (!note || !note.isContentAvailable()) { - throw new Error(`Note '${noteId}' is not available for change!`); - } - - saveRevisionIfNeeded(note); - - const { forceFrontendReload, content: newContent } = saveLinks(note, content); - - note.setContent(newContent, { forceFrontendReload }); - - if (attachments?.length > 0) { - const existingAttachmentsByTitle = toMap(note.getAttachments(), "title"); - - for (const { attachmentId, role, mime, title, position, content } of attachments) { - const existingAttachment = existingAttachmentsByTitle.get(title); - if (attachmentId || !existingAttachment) { - note.saveAttachment({ attachmentId, role, mime, title, content, position }); - } else { - existingAttachment.role = role; - existingAttachment.mime = mime; - existingAttachment.position = position; - if (content) { - existingAttachment.setContent(content, { forceSave: true }); - } - } - } - } -} - -function undeleteNote(noteId: string, taskContext: TaskContext<"undeleteNotes">) { - const noteRow = sql.getRow<NoteRow>("SELECT * FROM notes WHERE noteId = ?", [noteId]); - - if (!noteRow.isDeleted || !noteRow.deleteId) { - log.error(`Note '${noteId}' is not deleted and thus cannot be undeleted.`); - return; - } - - const undeletedParentBranchIds = getUndeletedParentBranchIds(noteId, noteRow.deleteId); - - if (undeletedParentBranchIds.length === 0) { - // cannot undelete if there's no undeleted parent - return; - } - - for (const parentBranchId of undeletedParentBranchIds) { - undeleteBranch(parentBranchId, noteRow.deleteId, taskContext); - } -} - -function undeleteBranch(branchId: string, deleteId: string, taskContext: TaskContext<"undeleteNotes">) { - const branchRow = sql.getRow<BranchRow>("SELECT * FROM branches WHERE branchId = ?", [branchId]); - - if (!branchRow.isDeleted) { - return; - } - - const noteRow = sql.getRow<NoteRow>("SELECT * FROM notes WHERE noteId = ?", [branchRow.noteId]); - - if (noteRow.isDeleted && noteRow.deleteId !== deleteId) { - return; - } - - new BBranch(branchRow).save(); - - taskContext.increaseProgressCount(); - - if (noteRow.isDeleted && noteRow.deleteId === deleteId) { - // becca entity was already created as skeleton in "new Branch()" above - const noteEntity = becca.getNote(noteRow.noteId); - if (!noteEntity) { - throw new Error("Unable to find the just restored branch."); - } - - noteEntity.updateFromRow(noteRow); - noteEntity.save(); - - const attributeRows = sql.getRows<AttributeRow>( - ` - SELECT * FROM attributes - WHERE isDeleted = 1 - AND deleteId = ? - AND (noteId = ? - OR (type = 'relation' AND value = ?))`, - [deleteId, noteRow.noteId, noteRow.noteId] - ); - - for (const attributeRow of attributeRows) { - // relation might point to a note which hasn't been undeleted yet and would thus throw up - new BAttribute(attributeRow).save({ skipValidation: true }); - } - - const attachmentRows = sql.getRows<AttachmentRow>( - ` - SELECT * FROM attachments - WHERE isDeleted = 1 - AND deleteId = ? - AND ownerId = ?`, - [deleteId, noteRow.noteId] - ); - - for (const attachmentRow of attachmentRows) { - new BAttachment(attachmentRow).save(); - } - - const childBranchIds = sql.getColumn<string>( - ` - SELECT branches.branchId - FROM branches - WHERE branches.isDeleted = 1 - AND branches.deleteId = ? - AND branches.parentNoteId = ?`, - [deleteId, noteRow.noteId] - ); - - for (const childBranchId of childBranchIds) { - undeleteBranch(childBranchId, deleteId, taskContext); - } - } -} - -/** - * @returns return deleted branchIds of an undeleted parent note - */ -function getUndeletedParentBranchIds(noteId: string, deleteId: string) { - return sql.getColumn<string>( - ` - SELECT branches.branchId - FROM branches - JOIN notes AS parentNote ON parentNote.noteId = branches.parentNoteId - WHERE branches.noteId = ? - AND branches.isDeleted = 1 - AND branches.deleteId = ? - AND parentNote.isDeleted = 0`, - [noteId, deleteId] - ); -} - -function scanForLinks(note: BNote, content: string | Uint8Array) { - if (!note || !["text", "relationMap"].includes(note.type)) { - return; - } - - try { - sql.transactional(() => { - const { forceFrontendReload, content: newContent } = saveLinks(note, content); - - if (content !== newContent) { - note.setContent(newContent, { forceFrontendReload }); - } - }); - } catch (e: any) { - log.error(`Could not scan for links note '${note.noteId}': ${e.message} ${e.stack}`); - } -} - -/** - * Things which have to be executed after updating content, but asynchronously (separate transaction) - */ -async function asyncPostProcessContent(note: BNote, content: string | Uint8Array) { - if (cls.isMigrationRunning()) { - // this is rarely needed for migrations, but can cause trouble by e.g. triggering downloads - return; - } - - if (note.hasStringContent() && typeof content !== "string") { - content = content.toString(); - } - - scanForLinks(note, content); -} - -// all keys should be replaced by the corresponding values -function replaceByMap(str: string, mapObj: Record<string, string>) { - if (!mapObj) { - return str; - } - - const re = new RegExp(Object.keys(mapObj).join("|"), "g"); - - return str.replace(re, (matched) => mapObj[matched]); -} - -function duplicateSubtree(origNoteId: string, newParentNoteId: string) { - if (origNoteId === "root") { - throw new Error("Duplicating root is not possible"); - } - - log.info(`Duplicating '${origNoteId}' subtree into '${newParentNoteId}'`); - - const origNote = becca.notes[origNoteId]; - // might be null if orig note is not in the target newParentNoteId - const origBranch = origNote.getParentBranches().find((branch) => branch.parentNoteId === newParentNoteId); - - const noteIdMapping = getNoteIdMapping(origNote); - - const res = duplicateSubtreeInner(origNote, origBranch, newParentNoteId, noteIdMapping); - - const duplicateNoteSuffix = t("notes.duplicate-note-suffix"); - - if (!res.note.title.endsWith(duplicateNoteSuffix) && !res.note.title.startsWith(duplicateNoteSuffix)) { - res.note.title = t("notes.duplicate-note-title", { noteTitle: res.note.title, duplicateNoteSuffix }); - } - - res.note.save(); - - return res; -} - -function duplicateSubtreeWithoutRoot(origNoteId: string, newNoteId: string) { - if (origNoteId === "root") { - throw new Error("Duplicating root is not possible"); - } - - const origNote = becca.getNote(origNoteId); - if (origNote == null) { - throw new Error("Unable to find note to duplicate."); - } - - const noteIdMapping = getNoteIdMapping(origNote); - for (const childBranch of origNote.getChildBranches()) { - if (childBranch) { - duplicateSubtreeInner(childBranch.getNote(), childBranch, newNoteId, noteIdMapping); - } - } -} - -function duplicateSubtreeInner(origNote: BNote, origBranch: BBranch | null | undefined, newParentNoteId: string, noteIdMapping: Record<string, string>) { - if (origNote.isProtected && !protectedSessionService.isProtectedSessionAvailable()) { - throw new Error(`Cannot duplicate note '${origNote.noteId}' because it is protected and protected session is not available. Enter protected session and try again.`); - } - - const newNoteId = noteIdMapping[origNote.noteId]; - - function createDuplicatedBranch() { - return new BBranch({ - noteId: newNoteId, - parentNoteId: newParentNoteId, - // here increasing just by 1 to make sure it's directly after original - notePosition: origBranch ? origBranch.notePosition + 1 : null - }).save(); - } - - function createDuplicatedNote() { - const newNote = new BNote({ - ...origNote, - noteId: newNoteId, - dateCreated: date_utils.localNowDateTime(), - utcDateCreated: date_utils.utcNowDateTime() - }).save(); - - let content = origNote.getContent(); - - if (typeof content === "string" && ["text", "relationMap", "search"].includes(origNote.type)) { - // fix links in the content - content = replaceByMap(content, noteIdMapping); - } - - newNote.setContent(content); - - for (const attribute of origNote.getOwnedAttributes()) { - const attr = new BAttribute({ - ...attribute, - attributeId: undefined, - noteId: newNote.noteId - }); - - // if relation points to within the duplicated tree then replace the target to the duplicated note - // if it points outside of duplicated tree then keep the original target - if (attr.type === "relation" && attr.value in noteIdMapping) { - attr.value = noteIdMapping[attr.value]; - } - - // the relation targets may not be created yet, the mapping is pre-generated - attr.save({ skipValidation: true }); - } - - for (const childBranch of origNote.getChildBranches()) { - if (childBranch) { - duplicateSubtreeInner(childBranch.getNote(), childBranch, newNote.noteId, noteIdMapping); - } - } - - asyncPostProcessContent(newNote, content); - - return newNote; - } - - const existingNote = becca.notes[newNoteId]; - - if (existingNote && existingNote.title !== undefined) { - // checking that it's not just note's skeleton created because of Branch above - // note has multiple clones and was already created from another placement in the tree, - // so a branch is all we need for this clone - return { - note: existingNote, - branch: createDuplicatedBranch() - }; - } - return { - // order here is important, note needs to be created first to not mess up the becca - note: createDuplicatedNote(), - branch: createDuplicatedBranch() - }; - -} - -function getNoteIdMapping(origNote: BNote) { - const noteIdMapping: Record<string, string> = {}; - - // pregenerate new noteIds since we'll need to fix relation references even for not yet created notes - for (const origNoteId of origNote.getDescendantNoteIds()) { - noteIdMapping[origNoteId] = newEntityId(); - } - - return noteIdMapping; -} - -export default { - createNewNote, - createNewNoteWithTarget, - updateNoteData, - undeleteNote, - protectNoteRecursively, - duplicateSubtree, - duplicateSubtreeWithoutRoot, - getUndeletedParentBranchIds, - triggerNoteTitleChanged, - saveRevisionIfNeeded, - downloadImages, - asyncPostProcessContent -}; +import { note_service } from "@triliumnext/core"; +export default note_service; diff --git a/packages/trilium-core/src/index.ts b/packages/trilium-core/src/index.ts index 096820bdb0..3220233faa 100644 --- a/packages/trilium-core/src/index.ts +++ b/packages/trilium-core/src/index.ts @@ -44,6 +44,7 @@ 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 } from "./services/notes"; export function initializeCore({ dbConfig, executionContext, crypto }: { dbConfig: SqlServiceParams, diff --git a/packages/trilium-core/src/services/context.ts b/packages/trilium-core/src/services/context.ts index f1276bc95c..374eb1296c 100644 --- a/packages/trilium-core/src/services/context.ts +++ b/packages/trilium-core/src/services/context.ts @@ -23,12 +23,29 @@ export function getHoistedNoteId() { return getContext().get("hoistedNoteId") || "root"; } + +export function getComponentId() { + return getContext().get("componentId"); +} + export function isEntityEventsDisabled() { return !!getContext().get("disableEntityEvents"); } -export function getComponentId() { - return getContext().get("componentId"); +export function disableEntityEvents() { + getContext().set("disableEntityEvents", true); +} + +export function enableEntityEvents() { + getContext().set("disableEntityEvents", false); +} + +export function setMigrationRunning(running: boolean) { + getContext().set("migrationRunning", !!running); +} + +export function isMigrationRunning() { + return !!getContext().get("migrationRunning"); } export function putEntityChange(entityChange: EntityChange) { diff --git a/packages/trilium-core/src/services/notes.ts b/packages/trilium-core/src/services/notes.ts new file mode 100644 index 0000000000..d4e1f736be --- /dev/null +++ b/packages/trilium-core/src/services/notes.ts @@ -0,0 +1,1090 @@ +import type { AttachmentRow, AttributeRow, BranchRow, NoteRow } from "@triliumnext/commons"; +import { dayjs } from "@triliumnext/commons"; +import { date_utils, events as eventService, ValidationError } from "@triliumnext/core"; +import fs from "fs"; +import html2plaintext from "html2plaintext"; +import { t } from "i18next"; +import path from "path"; +import url from "url"; + +import becca from "../becca/becca.js"; +import BAttachment from "../becca/entities/battachment.js"; +import BAttribute from "../becca/entities/battribute.js"; +import BBranch from "../becca/entities/bbranch.js"; +import BNote from "../becca/entities/bnote.js"; +import * as cls from "../services/context.js"; +import protectedSessionService from "../services/protected_session.js"; +import { newEntityId, quoteRegex, toMap, unescapeHtml } from "./utils/index.js"; +import entityChangesService from "./entity_changes.js"; +import htmlSanitizer from "./html_sanitizer.js"; +import imageService from "./image.js"; +import noteTypesService from "./note_types.js"; +import type { NoteParams } from "./note-interface.js"; +import optionService from "./options.js"; +import request from "./request.js"; +import revisionService from "./revisions.js"; +import type TaskContext from "./task_context.js"; +import ws from "./ws.js"; +import { getSql } from "./sql/index.js"; +import { getLog } from "./log.js"; +import { decodeBase64 } from "./utils/binary.js"; + +interface FoundLink { + name: "imageLink" | "internalLink" | "includeNoteLink" | "relationMapLink"; + value: string; +} + +interface Attachment { + attachmentId?: string; + title: string; +} + +function getNewNotePosition(parentNote: BNote) { + if (parentNote.isLabelTruthy("newNotesOnTop")) { + const minNotePos = parentNote + .getChildBranches() + .filter((branch) => branch?.noteId !== "_hidden") // has "always last" note position + .reduce((min, note) => Math.min(min, note?.notePosition || 0), 0); + + return minNotePos - 10; + } + const maxNotePos = parentNote + .getChildBranches() + .filter((branch) => branch?.noteId !== "_hidden") // has "always last" note position + .reduce((max, note) => Math.max(max, note?.notePosition || 0), 0); + + return maxNotePos + 10; + +} + +function triggerNoteTitleChanged(note: BNote) { + eventService.emit(eventService.NOTE_TITLE_CHANGED, note); +} + +function deriveMime(type: string, mime?: string) { + if (!type) { + throw new Error(`Note type is a required param`); + } + + if (mime) { + return mime; + } + + return noteTypesService.getDefaultMimeForNoteType(type); +} + +function copyChildAttributes(parentNote: BNote, childNote: BNote) { + for (const attr of parentNote.getAttributes()) { + if (attr.name.startsWith("child:")) { + const name = attr.name.substring(6); + const hasAlreadyTemplate = childNote.hasRelation("template"); + + if (hasAlreadyTemplate && attr.type === "relation" && name === "template") { + // if the note already has a template, it means the template was chosen by the user explicitly + // in the menu. In that case, we should override the default templates defined in the child: attrs + continue; + } + + new BAttribute({ + noteId: childNote.noteId, + type: attr.type, + name, + value: attr.value, + position: attr.position, + isInheritable: attr.isInheritable + }).save(); + } + } +} + +function copyAttachments(origNote: BNote, newNote: BNote) { + for (const attachment of origNote.getAttachments()) { + if (attachment.role === "image") { + // Handled separately, see `checkImageAttachments`. + continue; + } + + const newAttachment = new BAttachment({ + ...attachment, + attachmentId: undefined, + ownerId: newNote.noteId + }); + + newAttachment.save(); + } +} + +function getNewNoteTitle(parentNote: BNote) { + let title = t("notes.new-note"); + + const titleTemplate = parentNote.getLabelValue("titleTemplate"); + + if (titleTemplate !== null) { + try { + const now = dayjs(date_utils.localNowDateTime() || new Date()); + + // "officially" injected values: + // - now + // - parentNote + + title = eval(`\`${titleTemplate}\``); + } catch (e: any) { + getLog().error(`Title template of note '${parentNote.noteId}' failed with: ${e.message}`); + } + } + + // this isn't in theory a good place to sanitize title, but this will catch a lot of XSS attempts. + // title is supposed to contain text only (not HTML) and be printed text only, but given the number of usages, + // it's difficult to guarantee correct handling in all cases + title = htmlSanitizer.sanitize(title); + + return title; +} + +interface GetValidateParams { + parentNoteId: string; + type: string; + ignoreForbiddenParents?: boolean; +} + +function getAndValidateParent(params: GetValidateParams) { + const parentNote = becca.notes[params.parentNoteId]; + + if (!parentNote) { + throw new ValidationError(`Parent note '${params.parentNoteId}' was not found.`); + } + + if (parentNote.type === "launcher" && parentNote.noteId !== "_lbBookmarks") { + throw new ValidationError(`Creating child notes into launcher notes is not allowed.`); + } + + if (["_lbAvailableLaunchers", "_lbVisibleLaunchers"].includes(params.parentNoteId) && params.type !== "launcher") { + throw new ValidationError(`Only 'launcher' notes can be created in parent '${params.parentNoteId}'`); + } + + if (!params.ignoreForbiddenParents) { + if (["_lbRoot", "_hidden"].includes(parentNote.noteId) + || parentNote.noteId.startsWith("_lbTpl") + || parentNote.noteId.startsWith("_help") + || parentNote.isOptions()) { + throw new ValidationError(`Creating child notes into '${parentNote.noteId}' is not allowed.`); + } + } + + return parentNote; +} + +function createNewNote(params: NoteParams): { + note: BNote; + branch: BBranch; +} { + const parentNote = getAndValidateParent(params); + + if (params.title === null || params.title === undefined) { + params.title = getNewNoteTitle(parentNote); + } + + if (params.content === null || params.content === undefined) { + throw new Error(`Note content must be set`); + } + + let error; + if ((error = date_utils.validateLocalDateTime(params.dateCreated))) { + throw new Error(error); + } + + if ((error = date_utils.validateUtcDateTime(params.utcDateCreated))) { + throw new Error(error); + } + + return getSql().transactional(() => { + let note, branch, isEntityEventsDisabled; + + try { + isEntityEventsDisabled = cls.isEntityEventsDisabled(); + + if (!isEntityEventsDisabled) { + // it doesn't make sense to run note creation events on a partially constructed note, so + // defer them until note creation is completed + cls.disableEntityEvents(); + } + + // TODO: think about what can happen if the note already exists with the forced ID + // I guess on DB it's going to be fine, but becca references between entities + // might get messed up (two note instances for the same ID existing in the references) + note = new BNote({ + noteId: params.noteId, // optionally can force specific noteId + title: params.title, + isProtected: !!params.isProtected, + type: params.type, + mime: deriveMime(params.type, params.mime), + dateCreated: params.dateCreated, + utcDateCreated: params.utcDateCreated + }).save(); + + note.setContent(params.content); + + branch = new BBranch({ + noteId: note.noteId, + parentNoteId: params.parentNoteId, + notePosition: params.notePosition !== undefined ? params.notePosition : getNewNotePosition(parentNote), + prefix: params.prefix || "", + isExpanded: !!params.isExpanded + }).save(); + } finally { + if (!isEntityEventsDisabled) { + // re-enable entity events only if they were previously enabled + // (they can be disabled in case of import) + cls.enableEntityEvents(); + } + } + + if (params.templateNoteId) { + const templateNote = becca.getNote(params.templateNoteId); + if (!templateNote) { + throw new Error(`Template note '${params.templateNoteId}' does not exist.`); + } + + note.addRelation("template", params.templateNoteId); + copyAttachments(templateNote, note); + + // no special handling for ~inherit since it doesn't matter if it's assigned with the note creation or later + } + + copyChildAttributes(parentNote, note); + + eventService.emit(eventService.ENTITY_CREATED, { entityName: "notes", entity: note }); + eventService.emit(eventService.ENTITY_CHANGED, { entityName: "notes", entity: note }); + triggerNoteTitleChanged(note); + // blobs entity doesn't use "created" event + eventService.emit(eventService.ENTITY_CHANGED, { entityName: "blobs", entity: note }); + eventService.emit(eventService.ENTITY_CREATED, { entityName: "branches", entity: branch }); + eventService.emit(eventService.ENTITY_CHANGED, { entityName: "branches", entity: branch }); + eventService.emit(eventService.CHILD_NOTE_CREATED, { childNote: note, parentNote }); + + getLog().info(`Created new note '${note.noteId}', branch '${branch.branchId}' of type '${note.type}', mime '${note.mime}'`); + + return { + note, + branch + }; + }); +} + +function createNewNoteWithTarget(target: "into" | "after" | "before", targetBranchId: string | undefined, params: NoteParams) { + if (!params.type) { + const parentNote = becca.notes[params.parentNoteId]; + + // code note type can be inherited, otherwise "text" is the default + params.type = parentNote.type === "code" ? "code" : "text"; + params.mime = parentNote.type === "code" ? parentNote.mime : "text/html"; + } + + if (target === "into") { + return createNewNote(params); + } else if (target === "after" && targetBranchId) { + const afterBranch = becca.branches[targetBranchId]; + + // not updating utcDateModified to avoid having to sync whole rows + getSql().execute("UPDATE branches SET notePosition = notePosition + 10 WHERE parentNoteId = ? AND notePosition > ? AND isDeleted = 0", [params.parentNoteId, afterBranch.notePosition]); + + params.notePosition = afterBranch.notePosition + 10; + + const retObject = createNewNote(params); + + entityChangesService.putNoteReorderingEntityChange(params.parentNoteId); + + return retObject; + } else if (target === "before" && targetBranchId) { + const beforeBranch = becca.branches[targetBranchId]; + + // not updating utcDateModified to avoid having to sync whole rows + getSql().execute("UPDATE branches SET notePosition = notePosition - 10 WHERE parentNoteId = ? AND notePosition < ? AND isDeleted = 0", [params.parentNoteId, beforeBranch.notePosition]); + + params.notePosition = beforeBranch.notePosition - 10; + + const retObject = createNewNote(params); + + entityChangesService.putNoteReorderingEntityChange(params.parentNoteId); + + return retObject; + } + throw new Error(`Unknown target '${target}'`); + +} + +function protectNoteRecursively(note: BNote, protect: boolean, includingSubTree: boolean, taskContext: TaskContext<"protectNotes">) { + protectNote(note, protect); + + taskContext.increaseProgressCount(); + + if (includingSubTree) { + for (const child of note.getChildNotes()) { + protectNoteRecursively(child, protect, includingSubTree, taskContext); + } + } +} + +function protectNote(note: BNote, protect: boolean) { + if (!protectedSessionService.isProtectedSessionAvailable()) { + throw new Error(`Cannot (un)protect note '${note.noteId}' with protect flag '${protect}' without active protected session`); + } + + const log = getLog(); + try { + if (protect !== note.isProtected) { + const content = note.getContent(); + + note.isProtected = protect; + note.setContent(content, { forceSave: true }); + } + + revisionService.protectRevisions(note); + + for (const attachment of note.getAttachments()) { + if (protect !== attachment.isProtected) { + try { + const content = attachment.getContent(); + + attachment.isProtected = protect; + attachment.setContent(content, { forceSave: true }); + } catch (e) { + log.error(`Could not un/protect attachment '${attachment.attachmentId}'`); + + throw e; + } + } + } + } catch (e) { + log.error(`Could not un/protect note '${note.noteId}'`); + + throw e; + } +} + +function checkImageAttachments(note: BNote, content: string) { + const foundAttachmentIds = new Set<string>(); + let match; + + const imgRegExp = /src="[^"]*api\/attachments\/([a-zA-Z0-9_]+)\/image/g; + while ((match = imgRegExp.exec(content))) { + foundAttachmentIds.add(match[1]); + } + + const linkRegExp = /href="[^"]+attachmentId=([a-zA-Z0-9_]+)/g; + while ((match = linkRegExp.exec(content))) { + foundAttachmentIds.add(match[1]); + } + + const attachments = note.getAttachments(); + + for (const attachment of attachments) { + const attachmentInContent = attachment.attachmentId && foundAttachmentIds.has(attachment.attachmentId); + + if (attachment.utcDateScheduledForErasureSince && attachmentInContent) { + attachment.utcDateScheduledForErasureSince = null; + attachment.save(); + } else if (!attachment.utcDateScheduledForErasureSince && !attachmentInContent) { + attachment.utcDateScheduledForErasureSince = date_utils.utcNowDateTime(); + attachment.save(); + } + } + + const existingAttachmentIds = new Set<string | undefined>(attachments.map((att) => att.attachmentId)); + const unknownAttachmentIds = Array.from(foundAttachmentIds).filter((foundAttId) => !existingAttachmentIds.has(foundAttId)); + const unknownAttachments = becca.getAttachments(unknownAttachmentIds); + + for (const unknownAttachment of unknownAttachments) { + // the attachment belongs to a different note (was copy-pasted). Attachments can be linked only from the note + // which owns it, so either find an existing attachment having the same content or make a copy. + let localAttachment = note.getAttachments().find((att) => att.role === unknownAttachment.role && att.blobId === unknownAttachment.blobId); + + if (localAttachment) { + if (localAttachment.utcDateScheduledForErasureSince) { + // the attachment is for sure linked now, so reset the scheduled deletion + localAttachment.utcDateScheduledForErasureSince = null; + localAttachment.save(); + } + + getLog().info( + `Found equivalent attachment '${localAttachment.attachmentId}' of note '${note.noteId}' for the linked foreign attachment '${unknownAttachment.attachmentId}' of note '${unknownAttachment.ownerId}'` + ); + } else { + localAttachment = unknownAttachment.copy(); + localAttachment.ownerId = note.noteId; + localAttachment.setContent(unknownAttachment.getContent(), { forceSave: true }); + + ws.sendMessageToAllClients({ type: "toast", message: `Attachment '${localAttachment.title}' has been copied to note '${note.title}'.` }); + getLog().info(`Copied attachment '${unknownAttachment.attachmentId}' of note '${unknownAttachment.ownerId}' to new '${localAttachment.attachmentId}' of note '${note.noteId}'`); + } + + // replace image links + content = content.replace(`api/attachments/${unknownAttachment.attachmentId}/image`, `api/attachments/${localAttachment.attachmentId}/image`); + // replace reference links + content = content.replace( + new RegExp(`href="[^"]+attachmentId=${unknownAttachment.attachmentId}[^"]*"`, "g"), + `href="#root/${localAttachment.ownerId}?viewMode=attachments&attachmentId=${localAttachment.attachmentId}"` + ); + } + + return { + forceFrontendReload: unknownAttachments.length > 0, + content + }; +} + +function findImageLinks(content: string, foundLinks: FoundLink[]) { + const re = /src="[^"]*api\/images\/([a-zA-Z0-9_]+)\//g; + let match; + + while ((match = re.exec(content))) { + foundLinks.push({ + name: "imageLink", + value: match[1] + }); + } + + // removing absolute references to server to keep it working between instances, + // we also omit / at the beginning to keep the paths relative + return content.replace(/src="[^"]*\/api\/images\//g, 'src="api/images/'); +} + +function findInternalLinks(content: string, foundLinks: FoundLink[]) { + const re = /href="[^"]*#root[a-zA-Z0-9_\/]*\/([a-zA-Z0-9_]+)\/?"/g; + let match; + + while ((match = re.exec(content))) { + foundLinks.push({ + name: "internalLink", + value: match[1] + }); + } + + // removing absolute references to server to keep it working between instances + return content.replace(/href="[^"]*#root/g, 'href="#root'); +} + +function findIncludeNoteLinks(content: string, foundLinks: FoundLink[]) { + const re = /<section class="include-note[^>]+data-note-id="([a-zA-Z0-9_]+)"[^>]*>/g; + let match; + + while ((match = re.exec(content))) { + foundLinks.push({ + name: "includeNoteLink", + value: match[1] + }); + } + + return content; +} + +function findRelationMapLinks(content: string, foundLinks: FoundLink[]) { + try { + const obj = JSON.parse(content); + + for (const note of obj.notes) { + foundLinks.push({ + name: "relationMapLink", + value: note.noteId + }); + } + } catch (e: any) { + getLog().error(`Could not scan for relation map links: ${ e.message}`); + } +} + +const imageUrlToAttachmentIdMapping: Record<string, string> = {}; + +async function downloadImage(noteId: string, imageUrl: string) { + const unescapedUrl = unescapeHtml(imageUrl); + const log = getLog(); + + try { + let imageBuffer: Uint8Array; + + if (imageUrl.toLowerCase().startsWith("file://")) { + imageBuffer = await new Promise((res, rej) => { + const localFilePath = imageUrl.substring("file://".length); + + return fs.readFile(localFilePath, (err, data) => { + if (err) { + rej(err); + } else { + res(data); + } + }); + }); + } else { + imageBuffer = await request.getImage(unescapedUrl); + } + + const parsedUrl = url.parse(unescapedUrl); + const title = path.basename(parsedUrl.pathname || ""); + + const attachment = imageService.saveImageToAttachment(noteId, imageBuffer, title, true, true); + + if (attachment.attachmentId) { + imageUrlToAttachmentIdMapping[imageUrl] = attachment.attachmentId; + } else { + log.error(`Download of '${imageUrl}' due to no attachment ID.`); + } + + log.info(`Download of '${imageUrl}' succeeded and was saved as image attachment '${attachment.attachmentId}' of note '${noteId}'`); + } catch (e: any) { + log.error(`Download of '${imageUrl}' for note '${noteId}' failed with error: ${e.message} ${e.stack}`); + } +} + +/** url => download promise */ +const downloadImagePromises: Record<string, Promise<void>> = {}; + +function replaceUrl(content: string, url: string, attachment: Attachment) { + const quotedUrl = quoteRegex(url); + + return content.replace(new RegExp(`\\s+src=[\"']${quotedUrl}[\"']`, "ig"), ` src="api/attachments/${attachment.attachmentId}/image/${encodeURIComponent(attachment.title)}"`); +} + +function downloadImages(noteId: string, content: string) { + const imageRe = /<img[^>]*?\ssrc=['"]([^'">]+)['"]/gi; + let imageMatch; + + while ((imageMatch = imageRe.exec(content))) { + const url = imageMatch[1]; + const inlineImageMatch = /^data:image\/[a-z]+;base64,/.exec(url); + + if (inlineImageMatch) { + const imageBase64 = url.substring(inlineImageMatch[0].length); + const imageBuffer = decodeBase64(imageBase64); + + const attachment = imageService.saveImageToAttachment(noteId, imageBuffer, "inline image", true, true); + + const encodedTitle = encodeURIComponent(attachment.title); + + content = `${content.substring(0, imageMatch.index)}<img src="api/attachments/${attachment.attachmentId}/image/${encodedTitle}"${content.substring(imageMatch.index + imageMatch[0].length)}`; + } else if ( + !url.includes("api/images/") && + !/api\/attachments\/.+\/image\/?.*/.test(url) && + // this is an exception for the web clipper's "imageId" + (url.length !== 20 || url.toLowerCase().startsWith("http")) + ) { + if (!optionService.getOptionBool("downloadImagesAutomatically")) { + continue; + } + + if (url in imageUrlToAttachmentIdMapping) { + const attachment = becca.getAttachment(imageUrlToAttachmentIdMapping[url]); + + if (!attachment) { + delete imageUrlToAttachmentIdMapping[url]; + } else { + content = replaceUrl(content, url, attachment); + continue; + } + } + + if (url in downloadImagePromises) { + // download is already in progress + continue; + } + + // this is done asynchronously, it would be too slow to wait for the download + // given that save can be triggered very often + downloadImagePromises[url] = downloadImage(noteId, url); + } + } + + Promise.all(Object.values(downloadImagePromises)).then(() => { + setTimeout(() => { + // the normal expected flow of the offline image saving is that users will paste the image(s) + // which will get asynchronously downloaded, during that time they keep editing the note + // once the download is finished, the image note representing the downloaded image will be used + // to replace the IMG link. + // However, there's another flow where the user pastes the image and leaves the note before the images + // are downloaded and the IMG references are not updated. For this occasion we have this code + // which upon the download of all the images will update the note if the links have not been fixed before + + getSql().transactional(() => { + const imageNotes = becca.getNotes(Object.values(imageUrlToAttachmentIdMapping), true); + const log = getLog(); + + const origNote = becca.getNote(noteId); + + if (!origNote) { + log.error(`Cannot find note '${noteId}' to replace image link.`); + return; + } + + const origContent = origNote.getContent(); + let updatedContent = origContent; + + if (typeof updatedContent !== "string") { + log.error(`Note '${noteId}' has a non-string content, cannot replace image link.`); + return; + } + + for (const url in imageUrlToAttachmentIdMapping) { + const imageNote = imageNotes.find((note) => note.noteId === imageUrlToAttachmentIdMapping[url]); + + if (imageNote) { + updatedContent = replaceUrl(updatedContent, url, imageNote); + } + } + + // update only if the links have not been already fixed. + if (updatedContent !== origContent) { + origNote.setContent(updatedContent); + + asyncPostProcessContent(origNote, updatedContent); + + console.log(`Fixed the image links for note '${noteId}' to the offline saved.`); + } + }); + }, 5000); + }); + + return content; +} + +function saveAttachments(note: BNote, content: string) { + const inlineAttachmentRe = /<a[^>]*?\shref=['"]data:([^;'">]+);base64,([^'">]+)['"][^>]*>(.*?)<\/a>/gim; + let attachmentMatch; + + while ((attachmentMatch = inlineAttachmentRe.exec(content))) { + const mime = attachmentMatch[1].toLowerCase(); + + const base64data = attachmentMatch[2]; + const buffer = decodeBase64(base64data); + + const title = html2plaintext(attachmentMatch[3]); + + const attachment = note.saveAttachment({ + role: "file", + mime, + title, + content: buffer + }); + + content = `${content.substring(0, attachmentMatch.index)}<a class="reference-link" href="#root/${note.noteId}?viewMode=attachments&attachmentId=${attachment.attachmentId}">${title}</a>${content.substring(attachmentMatch.index + attachmentMatch[0].length)}`; + } + + // removing absolute references to server to keep it working between instances, + // we also omit / at the beginning to keep the paths relative + content = content.replace(/src="[^"]*\/api\/attachments\//g, 'src="api/attachments/'); + + return content; +} + +function saveLinks(note: BNote, content: string | Uint8Array) { + if ((note.type !== "text" && note.type !== "relationMap") || (note.isProtected && !protectedSessionService.isProtectedSessionAvailable())) { + return { + forceFrontendReload: false, + content + }; + } + + const foundLinks: FoundLink[] = []; + let forceFrontendReload = false; + + if (note.type === "text" && typeof content === "string") { + content = downloadImages(note.noteId, content); + content = saveAttachments(note, content); + + content = findImageLinks(content, foundLinks); + content = findInternalLinks(content, foundLinks); + content = findIncludeNoteLinks(content, foundLinks); + + ({ forceFrontendReload, content } = checkImageAttachments(note, content)); + } else if (note.type === "relationMap" && typeof content === "string") { + findRelationMapLinks(content, foundLinks); + } else { + throw new Error(`Unrecognized type '${note.type}'`); + } + + const existingLinks = note.getRelations().filter((rel) => ["internalLink", "imageLink", "relationMapLink", "includeNoteLink"].includes(rel.name)); + + for (const foundLink of foundLinks) { + const targetNote = becca.notes[foundLink.value]; + if (!targetNote) { + continue; + } + + const existingLink = existingLinks.find((existingLink) => existingLink.value === foundLink.value && existingLink.name === foundLink.name); + + if (!existingLink) { + const newLink = new BAttribute({ + noteId: note.noteId, + type: "relation", + name: foundLink.name, + value: foundLink.value + }).save(); + + existingLinks.push(newLink); + } + // else the link exists, so we don't need to do anything + } + + // marking links as deleted if they are not present on the page anymore + const unusedLinks = existingLinks.filter((existingLink) => !foundLinks.some((foundLink) => existingLink.value === foundLink.value && existingLink.name === foundLink.name)); + + for (const unusedLink of unusedLinks) { + unusedLink.markAsDeleted(); + } + + return { forceFrontendReload, content }; +} + +function saveRevisionIfNeeded(note: BNote) { + // files and images are versioned separately + if (note.type === "file" || note.type === "image" || note.isLabelTruthy("disableVersioning")) { + return; + } + + const now = new Date(); + const revisionSnapshotTimeInterval = parseInt(optionService.getOption("revisionSnapshotTimeInterval")); + + const revisionCutoff = date_utils.utcDateTimeStr(new Date(now.getTime() - revisionSnapshotTimeInterval * 1000)); + + const existingRevisionId = getSql().getValue("SELECT revisionId FROM revisions WHERE noteId = ? AND utcDateCreated >= ?", [note.noteId, revisionCutoff]); + + const msSinceDateCreated = now.getTime() - date_utils.parseDateTime(note.utcDateCreated).getTime(); + + if (!existingRevisionId && msSinceDateCreated >= revisionSnapshotTimeInterval * 1000) { + note.saveRevision(); + } +} + +function updateNoteData(noteId: string, content: string, attachments: AttachmentRow[] = []) { + const note = becca.getNote(noteId); + + if (!note || !note.isContentAvailable()) { + throw new Error(`Note '${noteId}' is not available for change!`); + } + + saveRevisionIfNeeded(note); + + const { forceFrontendReload, content: newContent } = saveLinks(note, content); + + note.setContent(newContent, { forceFrontendReload }); + + if (attachments?.length > 0) { + const existingAttachmentsByTitle = toMap(note.getAttachments(), "title"); + + for (const { attachmentId, role, mime, title, position, content } of attachments) { + const existingAttachment = existingAttachmentsByTitle.get(title); + if (attachmentId || !existingAttachment) { + note.saveAttachment({ attachmentId, role, mime, title, content, position }); + } else { + existingAttachment.role = role; + existingAttachment.mime = mime; + existingAttachment.position = position; + if (content) { + existingAttachment.setContent(content, { forceSave: true }); + } + } + } + } +} + +function undeleteNote(noteId: string, taskContext: TaskContext<"undeleteNotes">) { + const noteRow = getSql().getRow<NoteRow>("SELECT * FROM notes WHERE noteId = ?", [noteId]); + + if (!noteRow.isDeleted || !noteRow.deleteId) { + getLog().error(`Note '${noteId}' is not deleted and thus cannot be undeleted.`); + return; + } + + const undeletedParentBranchIds = getUndeletedParentBranchIds(noteId, noteRow.deleteId); + + if (undeletedParentBranchIds.length === 0) { + // cannot undelete if there's no undeleted parent + return; + } + + for (const parentBranchId of undeletedParentBranchIds) { + undeleteBranch(parentBranchId, noteRow.deleteId, taskContext); + } +} + +function undeleteBranch(branchId: string, deleteId: string, taskContext: TaskContext<"undeleteNotes">) { + const sql = getSql(); + const branchRow = sql.getRow<BranchRow>("SELECT * FROM branches WHERE branchId = ?", [branchId]); + + if (!branchRow.isDeleted) { + return; + } + + const noteRow = sql.getRow<NoteRow>("SELECT * FROM notes WHERE noteId = ?", [branchRow.noteId]); + + if (noteRow.isDeleted && noteRow.deleteId !== deleteId) { + return; + } + + new BBranch(branchRow).save(); + + taskContext.increaseProgressCount(); + + if (noteRow.isDeleted && noteRow.deleteId === deleteId) { + // becca entity was already created as skeleton in "new Branch()" above + const noteEntity = becca.getNote(noteRow.noteId); + if (!noteEntity) { + throw new Error("Unable to find the just restored branch."); + } + + noteEntity.updateFromRow(noteRow); + noteEntity.save(); + + const attributeRows = sql.getRows<AttributeRow>( + ` + SELECT * FROM attributes + WHERE isDeleted = 1 + AND deleteId = ? + AND (noteId = ? + OR (type = 'relation' AND value = ?))`, + [deleteId, noteRow.noteId, noteRow.noteId] + ); + + for (const attributeRow of attributeRows) { + // relation might point to a note which hasn't been undeleted yet and would thus throw up + new BAttribute(attributeRow).save({ skipValidation: true }); + } + + const attachmentRows = sql.getRows<AttachmentRow>( + ` + SELECT * FROM attachments + WHERE isDeleted = 1 + AND deleteId = ? + AND ownerId = ?`, + [deleteId, noteRow.noteId] + ); + + for (const attachmentRow of attachmentRows) { + new BAttachment(attachmentRow).save(); + } + + const childBranchIds = sql.getColumn<string>( + ` + SELECT branches.branchId + FROM branches + WHERE branches.isDeleted = 1 + AND branches.deleteId = ? + AND branches.parentNoteId = ?`, + [deleteId, noteRow.noteId] + ); + + for (const childBranchId of childBranchIds) { + undeleteBranch(childBranchId, deleteId, taskContext); + } + } +} + +/** + * @returns return deleted branchIds of an undeleted parent note + */ +function getUndeletedParentBranchIds(noteId: string, deleteId: string) { + return getSql().getColumn<string>( + ` + SELECT branches.branchId + FROM branches + JOIN notes AS parentNote ON parentNote.noteId = branches.parentNoteId + WHERE branches.noteId = ? + AND branches.isDeleted = 1 + AND branches.deleteId = ? + AND parentNote.isDeleted = 0`, + [noteId, deleteId] + ); +} + +function scanForLinks(note: BNote, content: string | Uint8Array) { + if (!note || !["text", "relationMap"].includes(note.type)) { + return; + } + + try { + getSql().transactional(() => { + const { forceFrontendReload, content: newContent } = saveLinks(note, content); + + if (content !== newContent) { + note.setContent(newContent, { forceFrontendReload }); + } + }); + } catch (e: any) { + getLog().error(`Could not scan for links note '${note.noteId}': ${e.message} ${e.stack}`); + } +} + +/** + * Things which have to be executed after updating content, but asynchronously (separate transaction) + */ +async function asyncPostProcessContent(note: BNote, content: string | Uint8Array) { + if (cls.isMigrationRunning()) { + // this is rarely needed for migrations, but can cause trouble by e.g. triggering downloads + return; + } + + if (note.hasStringContent() && typeof content !== "string") { + content = content.toString(); + } + + scanForLinks(note, content); +} + +// all keys should be replaced by the corresponding values +function replaceByMap(str: string, mapObj: Record<string, string>) { + if (!mapObj) { + return str; + } + + const re = new RegExp(Object.keys(mapObj).join("|"), "g"); + + return str.replace(re, (matched) => mapObj[matched]); +} + +function duplicateSubtree(origNoteId: string, newParentNoteId: string) { + if (origNoteId === "root") { + throw new Error("Duplicating root is not possible"); + } + + getLog().info(`Duplicating '${origNoteId}' subtree into '${newParentNoteId}'`); + + const origNote = becca.notes[origNoteId]; + // might be null if orig note is not in the target newParentNoteId + const origBranch = origNote.getParentBranches().find((branch) => branch.parentNoteId === newParentNoteId); + + const noteIdMapping = getNoteIdMapping(origNote); + + const res = duplicateSubtreeInner(origNote, origBranch, newParentNoteId, noteIdMapping); + + const duplicateNoteSuffix = t("notes.duplicate-note-suffix"); + + if (!res.note.title.endsWith(duplicateNoteSuffix) && !res.note.title.startsWith(duplicateNoteSuffix)) { + res.note.title = t("notes.duplicate-note-title", { noteTitle: res.note.title, duplicateNoteSuffix }); + } + + res.note.save(); + + return res; +} + +function duplicateSubtreeWithoutRoot(origNoteId: string, newNoteId: string) { + if (origNoteId === "root") { + throw new Error("Duplicating root is not possible"); + } + + const origNote = becca.getNote(origNoteId); + if (origNote == null) { + throw new Error("Unable to find note to duplicate."); + } + + const noteIdMapping = getNoteIdMapping(origNote); + for (const childBranch of origNote.getChildBranches()) { + if (childBranch) { + duplicateSubtreeInner(childBranch.getNote(), childBranch, newNoteId, noteIdMapping); + } + } +} + +function duplicateSubtreeInner(origNote: BNote, origBranch: BBranch | null | undefined, newParentNoteId: string, noteIdMapping: Record<string, string>) { + if (origNote.isProtected && !protectedSessionService.isProtectedSessionAvailable()) { + throw new Error(`Cannot duplicate note '${origNote.noteId}' because it is protected and protected session is not available. Enter protected session and try again.`); + } + + const newNoteId = noteIdMapping[origNote.noteId]; + + function createDuplicatedBranch() { + return new BBranch({ + noteId: newNoteId, + parentNoteId: newParentNoteId, + // here increasing just by 1 to make sure it's directly after original + notePosition: origBranch ? origBranch.notePosition + 1 : null + }).save(); + } + + function createDuplicatedNote() { + const newNote = new BNote({ + ...origNote, + noteId: newNoteId, + dateCreated: date_utils.localNowDateTime(), + utcDateCreated: date_utils.utcNowDateTime() + }).save(); + + let content = origNote.getContent(); + + if (typeof content === "string" && ["text", "relationMap", "search"].includes(origNote.type)) { + // fix links in the content + content = replaceByMap(content, noteIdMapping); + } + + newNote.setContent(content); + + for (const attribute of origNote.getOwnedAttributes()) { + const attr = new BAttribute({ + ...attribute, + attributeId: undefined, + noteId: newNote.noteId + }); + + // if relation points to within the duplicated tree then replace the target to the duplicated note + // if it points outside of duplicated tree then keep the original target + if (attr.type === "relation" && attr.value in noteIdMapping) { + attr.value = noteIdMapping[attr.value]; + } + + // the relation targets may not be created yet, the mapping is pre-generated + attr.save({ skipValidation: true }); + } + + for (const childBranch of origNote.getChildBranches()) { + if (childBranch) { + duplicateSubtreeInner(childBranch.getNote(), childBranch, newNote.noteId, noteIdMapping); + } + } + + asyncPostProcessContent(newNote, content); + + return newNote; + } + + const existingNote = becca.notes[newNoteId]; + + if (existingNote && existingNote.title !== undefined) { + // checking that it's not just note's skeleton created because of Branch above + // note has multiple clones and was already created from another placement in the tree, + // so a branch is all we need for this clone + return { + note: existingNote, + branch: createDuplicatedBranch() + }; + } + return { + // order here is important, note needs to be created first to not mess up the becca + note: createDuplicatedNote(), + branch: createDuplicatedBranch() + }; + +} + +function getNoteIdMapping(origNote: BNote) { + const noteIdMapping: Record<string, string> = {}; + + // pregenerate new noteIds since we'll need to fix relation references even for not yet created notes + for (const origNoteId of origNote.getDescendantNoteIds()) { + noteIdMapping[origNoteId] = newEntityId(); + } + + return noteIdMapping; +} + +export default { + createNewNote, + createNewNoteWithTarget, + updateNoteData, + undeleteNote, + protectNoteRecursively, + duplicateSubtree, + duplicateSubtreeWithoutRoot, + getUndeletedParentBranchIds, + triggerNoteTitleChanged, + saveRevisionIfNeeded, + downloadImages, + asyncPostProcessContent +}; From de4d07e904a24e2d362277db54207c73b9749cca Mon Sep 17 00:00:00 2001 From: Elian Doran <contact@eliandoran.me> Date: Tue, 6 Jan 2026 15:15:12 +0200 Subject: [PATCH 46/72] chore(core): get rid of note_interface --- apps/server/src/services/note-interface.ts | 27 -------------------- packages/trilium-core/src/services/notes.ts | 28 ++++++++++++++++++++- 2 files changed, 27 insertions(+), 28 deletions(-) delete mode 100644 apps/server/src/services/note-interface.ts diff --git a/apps/server/src/services/note-interface.ts b/apps/server/src/services/note-interface.ts deleted file mode 100644 index 5ebaa6dfa7..0000000000 --- a/apps/server/src/services/note-interface.ts +++ /dev/null @@ -1,27 +0,0 @@ -import type { NoteType } from "@triliumnext/commons"; - -export interface NoteParams { - /** optionally can force specific noteId */ - noteId?: string; - branchId?: string; - parentNoteId: string; - templateNoteId?: string; - title: string; - content: string | Buffer; - /** text, code, file, image, search, book, relationMap, canvas, webView */ - type: NoteType; - /** default value is derived from default mimes for type */ - mime?: string; - /** default is false */ - isProtected?: boolean; - /** default is false */ - isExpanded?: boolean; - /** default is empty string */ - prefix?: string; - /** default is the last existing notePosition in a parent + 10 */ - notePosition?: number; - dateCreated?: string; - utcDateCreated?: string; - ignoreForbiddenParents?: boolean; - target?: "into"; -} diff --git a/packages/trilium-core/src/services/notes.ts b/packages/trilium-core/src/services/notes.ts index d4e1f736be..39cb59be91 100644 --- a/packages/trilium-core/src/services/notes.ts +++ b/packages/trilium-core/src/services/notes.ts @@ -1,4 +1,4 @@ -import type { AttachmentRow, AttributeRow, BranchRow, NoteRow } from "@triliumnext/commons"; +import type { AttachmentRow, AttributeRow, BranchRow, NoteRow, NoteType } from "@triliumnext/commons"; import { dayjs } from "@triliumnext/commons"; import { date_utils, events as eventService, ValidationError } from "@triliumnext/core"; import fs from "fs"; @@ -39,6 +39,32 @@ interface Attachment { title: string; } +interface NoteParams { + /** optionally can force specific noteId */ + noteId?: string; + branchId?: string; + parentNoteId: string; + templateNoteId?: string; + title: string; + content: string | Uint8Array; + /** text, code, file, image, search, book, relationMap, canvas, webView */ + type: NoteType; + /** default value is derived from default mimes for type */ + mime?: string; + /** default is false */ + isProtected?: boolean; + /** default is false */ + isExpanded?: boolean; + /** default is empty string */ + prefix?: string; + /** default is the last existing notePosition in a parent + 10 */ + notePosition?: number; + dateCreated?: string; + utcDateCreated?: string; + ignoreForbiddenParents?: boolean; + target?: "into"; +} + function getNewNotePosition(parentNote: BNote) { if (parentNote.isLabelTruthy("newNotesOnTop")) { const minNotePos = parentNote From f5535657ad5dfe00946ce75002ef07c4f53debc2 Mon Sep 17 00:00:00 2001 From: Elian Doran <contact@eliandoran.me> Date: Tue, 6 Jan 2026 15:16:42 +0200 Subject: [PATCH 47/72] chore(core): port note_types --- apps/server/src/services/note_types.ts | 36 ++----------------- packages/trilium-core/src/index.ts | 1 + .../trilium-core/src/services/note_types.ts | 34 ++++++++++++++++++ packages/trilium-core/src/services/notes.ts | 1 - 4 files changed, 37 insertions(+), 35 deletions(-) create mode 100644 packages/trilium-core/src/services/note_types.ts diff --git a/apps/server/src/services/note_types.ts b/apps/server/src/services/note_types.ts index 2aa86d0b64..ce31085123 100644 --- a/apps/server/src/services/note_types.ts +++ b/apps/server/src/services/note_types.ts @@ -1,34 +1,2 @@ -const noteTypes = [ - { type: "text", defaultMime: "text/html" }, - { type: "code", defaultMime: "text/plain" }, - { type: "render", defaultMime: "" }, - { type: "file", defaultMime: "application/octet-stream" }, - { type: "image", defaultMime: "" }, - { type: "search", defaultMime: "" }, - { type: "relationMap", defaultMime: "application/json" }, - { type: "book", defaultMime: "" }, - { type: "noteMap", defaultMime: "" }, - { type: "mermaid", defaultMime: "text/vnd.mermaid" }, - { type: "canvas", defaultMime: "application/json" }, - { type: "webView", defaultMime: "" }, - { type: "launcher", defaultMime: "" }, - { type: "doc", defaultMime: "" }, - { type: "contentWidget", defaultMime: "" }, - { type: "mindMap", defaultMime: "application/json" }, - { type: "aiChat", defaultMime: "application/json" } -]; - -function getDefaultMimeForNoteType(typeName: string) { - const typeRec = noteTypes.find((nt) => nt.type === typeName); - - if (!typeRec) { - throw new Error(`Cannot find note type '${typeName}'`); - } - - return typeRec.defaultMime; -} - -export default { - getNoteTypeNames: () => noteTypes.map((nt) => nt.type), - getDefaultMimeForNoteType -}; +import { note_types } from "@triliumnext/core"; +export default note_types; diff --git a/packages/trilium-core/src/index.ts b/packages/trilium-core/src/index.ts index 3220233faa..c478cac62a 100644 --- a/packages/trilium-core/src/index.ts +++ b/packages/trilium-core/src/index.ts @@ -24,6 +24,7 @@ export * as cls from "./services/context"; export * from "./errors"; export { default as getInstanceId } from "./services/instance_id"; export type { CryptoProvider } from "./services/encryption/crypto"; +export { default as note_types } from "./services/note_types"; export { default as becca } from "./becca/becca"; export { default as becca_loader } from "./becca/becca_loader"; diff --git a/packages/trilium-core/src/services/note_types.ts b/packages/trilium-core/src/services/note_types.ts new file mode 100644 index 0000000000..2aa86d0b64 --- /dev/null +++ b/packages/trilium-core/src/services/note_types.ts @@ -0,0 +1,34 @@ +const noteTypes = [ + { type: "text", defaultMime: "text/html" }, + { type: "code", defaultMime: "text/plain" }, + { type: "render", defaultMime: "" }, + { type: "file", defaultMime: "application/octet-stream" }, + { type: "image", defaultMime: "" }, + { type: "search", defaultMime: "" }, + { type: "relationMap", defaultMime: "application/json" }, + { type: "book", defaultMime: "" }, + { type: "noteMap", defaultMime: "" }, + { type: "mermaid", defaultMime: "text/vnd.mermaid" }, + { type: "canvas", defaultMime: "application/json" }, + { type: "webView", defaultMime: "" }, + { type: "launcher", defaultMime: "" }, + { type: "doc", defaultMime: "" }, + { type: "contentWidget", defaultMime: "" }, + { type: "mindMap", defaultMime: "application/json" }, + { type: "aiChat", defaultMime: "application/json" } +]; + +function getDefaultMimeForNoteType(typeName: string) { + const typeRec = noteTypes.find((nt) => nt.type === typeName); + + if (!typeRec) { + throw new Error(`Cannot find note type '${typeName}'`); + } + + return typeRec.defaultMime; +} + +export default { + getNoteTypeNames: () => noteTypes.map((nt) => nt.type), + getDefaultMimeForNoteType +}; diff --git a/packages/trilium-core/src/services/notes.ts b/packages/trilium-core/src/services/notes.ts index 39cb59be91..7fe87b681c 100644 --- a/packages/trilium-core/src/services/notes.ts +++ b/packages/trilium-core/src/services/notes.ts @@ -19,7 +19,6 @@ import entityChangesService from "./entity_changes.js"; import htmlSanitizer from "./html_sanitizer.js"; import imageService from "./image.js"; import noteTypesService from "./note_types.js"; -import type { NoteParams } from "./note-interface.js"; import optionService from "./options.js"; import request from "./request.js"; import revisionService from "./revisions.js"; From 674593b38ca7c72c60189d352780927b3f67ce23 Mon Sep 17 00:00:00 2001 From: Elian Doran <contact@eliandoran.me> Date: Tue, 6 Jan 2026 15:31:22 +0200 Subject: [PATCH 48/72] chore(core): integrate html_sanitizer --- apps/server/package.json | 7 ++--- apps/server/src/routes/api/clipper.ts | 17 ++++++----- apps/server/src/services/image.ts | 25 +++++++++-------- apps/server/src/services/import/enex.ts | 7 ++--- apps/server/src/services/import/markdown.ts | 4 +-- apps/server/src/services/import/opml.ts | 17 +++++------ apps/server/src/services/import/single.ts | 24 ++++++++-------- apps/server/src/services/import/zip.ts | 28 +++++++++---------- .../llm/context/modules/context_formatter.ts | 25 +++++++++-------- .../src/services/llm/context/note_content.ts | 27 +++++++++--------- .../services/llm/formatters/base_formatter.ts | 22 +++++++-------- .../llm/formatters/ollama_formatter.ts | 20 ++++++------- .../llm/formatters/openai_formatter.ts | 28 +++++++++---------- apps/server/src/share/content_renderer.ts | 4 +-- packages/trilium-core/package.json | 5 +++- packages/trilium-core/src/index.ts | 1 + packages/trilium-core/src/services/notes.ts | 4 +-- .../src/services/sanitizer.spec.ts | 6 ++-- .../trilium-core/src/services/sanitizer.ts | 21 +++++++------- 19 files changed, 147 insertions(+), 145 deletions(-) rename apps/server/src/services/html_sanitizer.spec.ts => packages/trilium-core/src/services/sanitizer.spec.ts (91%) rename apps/server/src/services/html_sanitizer.ts => packages/trilium-core/src/services/sanitizer.ts (87%) diff --git a/apps/server/package.json b/apps/server/package.json index 0f067c321e..d52068829b 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -35,8 +35,7 @@ "sucrase": "3.35.1" }, "devDependencies": { - "@anthropic-ai/sdk": "0.71.2", - "@braintree/sanitize-url": "7.1.1", + "@anthropic-ai/sdk": "0.71.2", "@electron/remote": "2.1.3", "@preact/preset-vite": "2.10.2", "@triliumnext/commons": "workspace:*", @@ -59,8 +58,7 @@ "@types/ini": "4.1.1", "@types/mime-types": "3.0.1", "@types/multer": "2.0.0", - "@types/safe-compare": "1.1.2", - "@types/sanitize-html": "2.16.0", + "@types/safe-compare": "1.1.2", "@types/sax": "1.2.7", "@types/serve-favicon": "2.5.7", "@types/serve-static": "2.2.0", @@ -118,7 +116,6 @@ "rand-token": "1.0.1", "safe-compare": "1.1.4", "sanitize-filename": "1.6.3", - "sanitize-html": "2.17.0", "sax": "1.4.3", "serve-favicon": "2.5.1", "stream-throttle": "0.1.3", diff --git a/apps/server/src/routes/api/clipper.ts b/apps/server/src/routes/api/clipper.ts index c73896f15d..fe2486059b 100644 --- a/apps/server/src/routes/api/clipper.ts +++ b/apps/server/src/routes/api/clipper.ts @@ -1,4 +1,4 @@ -import { ValidationError } from "@triliumnext/core"; +import { sanitize, ValidationError } from "@triliumnext/core"; import type { Request } from "express"; import { parse } from "node-html-parser"; import path from "path"; @@ -10,7 +10,6 @@ import attributeService from "../../services/attributes.js"; import cloneService from "../../services/cloning.js"; import dateNoteService from "../../services/date_notes.js"; import dateUtils from "../../services/date_utils.js"; -import htmlSanitizer from "../../services/html_sanitizer.js"; import imageService from "../../services/image.js"; import log from "../../services/log.js"; import noteService from "../../services/notes.js"; @@ -32,7 +31,7 @@ async function addClipping(req: Request) { const clipperInbox = await getClipperInboxNote(); - const pageUrl = htmlSanitizer.sanitizeUrl(req.body.pageUrl); + const pageUrl = sanitize.sanitizeUrl(req.body.pageUrl); let clippingNote = findClippingNote(clipperInbox, pageUrl, clipType); if (!clippingNote) { @@ -99,8 +98,8 @@ async function getClipperInboxNote() { async function createNote(req: Request) { const { content, images, labels } = req.body; - const clipType = htmlSanitizer.sanitize(req.body.clipType); - const pageUrl = htmlSanitizer.sanitizeUrl(req.body.pageUrl); + const clipType = sanitize.sanitizeHtml(req.body.clipType); + const pageUrl = sanitize.sanitizeUrl(req.body.pageUrl); const trimmedTitle = (typeof req.body.title === "string") ? req.body.title.trim() : ""; const title = trimmedTitle || `Clipped note from ${pageUrl}`; @@ -126,7 +125,7 @@ async function createNote(req: Request) { if (labels) { for (const labelName in labels) { - const labelValue = htmlSanitizer.sanitize(labels[labelName]); + const labelValue = sanitize.sanitizeHtml(labels[labelName]); note.setLabel(labelName, labelValue); } } @@ -147,7 +146,7 @@ async function createNote(req: Request) { } export function processContent(images: Image[], note: BNote, content: string) { - let rewrittenContent = htmlSanitizer.sanitize(content); + let rewrittenContent = sanitize.sanitizeHtml(content); if (images) { for (const { src, dataUrl, imageId } of images) { @@ -198,11 +197,11 @@ function openNote(req: Request) { return { result: "ok" }; - } + } return { result: "open-in-browser" }; - + } function handshake() { diff --git a/apps/server/src/services/image.ts b/apps/server/src/services/image.ts index 387fcaec54..e57811f748 100644 --- a/apps/server/src/services/image.ts +++ b/apps/server/src/services/image.ts @@ -1,17 +1,18 @@ -"use strict"; + + +import { sanitize } from "@triliumnext/core"; +import imageType from "image-type"; +import isAnimated from "is-animated"; +import isSvg from "is-svg"; +import { Jimp } from "jimp"; +import sanitizeFilename from "sanitize-filename"; import becca from "../becca/becca.js"; import log from "./log.js"; -import protectedSessionService from "./protected_session.js"; import noteService from "./notes.js"; import optionService from "./options.js"; +import protectedSessionService from "./protected_session.js"; import sql from "./sql.js"; -import { Jimp } from "jimp"; -import imageType from "image-type"; -import sanitizeFilename from "sanitize-filename"; -import isSvg from "is-svg"; -import isAnimated from "is-animated"; -import htmlSanitizer from "./html_sanitizer.js"; async function processImage(uploadBuffer: Buffer, originalName: string, shrinkImageSwitch: boolean) { const compressImages = optionService.getOptionBool("compressImages"); @@ -46,9 +47,9 @@ async function processImage(uploadBuffer: Buffer, originalName: string, shrinkIm async function getImageType(buffer: Buffer) { if (isSvg(buffer.toString())) { return { ext: "svg" }; - } else { - return (await imageType(buffer)) || { ext: "jpg" }; // optimistic JPG default - } + } + return (await imageType(buffer)) || { ext: "jpg" }; // optimistic JPG default + } function getImageMimeFromExtension(ext: string) { @@ -60,7 +61,7 @@ function getImageMimeFromExtension(ext: string) { function updateImage(noteId: string, uploadBuffer: Buffer, originalName: string) { log.info(`Updating image ${noteId}: ${originalName}`); - originalName = htmlSanitizer.sanitize(originalName); + originalName = sanitize.sanitizeHtml(originalName); const note = becca.getNote(noteId); if (!note) { diff --git a/apps/server/src/services/import/enex.ts b/apps/server/src/services/import/enex.ts index 19c6170b5e..d50f6e4174 100644 --- a/apps/server/src/services/import/enex.ts +++ b/apps/server/src/services/import/enex.ts @@ -1,13 +1,12 @@ import type { AttributeType } from "@triliumnext/commons"; import { dayjs } from "@triliumnext/commons"; -import { utils } from "@triliumnext/core"; +import { sanitize, utils } from "@triliumnext/core"; import sax from "sax"; import stream from "stream"; import { Throttle } from "stream-throttle"; import type BNote from "../../becca/entities/bnote.js"; import date_utils from "../date_utils.js"; -import htmlSanitizer from "../html_sanitizer.js"; import imageService from "../image.js"; import log from "../log.js"; import noteService from "../notes.js"; @@ -118,7 +117,7 @@ function importEnex(taskContext: TaskContext<"importNotes">, file: File, parentN "\u2611 " ); - content = htmlSanitizer.sanitize(content); + content = sanitize.sanitizeHtml(content); return content; } @@ -368,7 +367,7 @@ function importEnex(taskContext: TaskContext<"importNotes">, file: File, parentN } } - content = htmlSanitizer.sanitize(content); + content = sanitize.sanitizeHtml(content); // save updated content with links to files/images noteEntity.setContent(content); diff --git a/apps/server/src/services/import/markdown.ts b/apps/server/src/services/import/markdown.ts index e91670df66..dec4a68e7f 100644 --- a/apps/server/src/services/import/markdown.ts +++ b/apps/server/src/services/import/markdown.ts @@ -2,10 +2,10 @@ import { getMimeTypeFromMarkdownName, MIME_TYPE_AUTO } from "@triliumnext/commons"; import { normalizeMimeTypeForCKEditor } from "@triliumnext/commons"; +import { sanitize } from "@triliumnext/core"; import { parse, Renderer, type Tokens,use } from "marked"; import { ADMONITION_TYPE_MAPPINGS } from "../export/markdown.js"; -import htmlSanitizer from "../html_sanitizer.js"; import utils from "../utils.js"; import wikiLinkInternalLink from "./markdown/wikilink_internal_link.js"; import wikiLinkTransclusion from "./markdown/wikilink_transclusion.js"; @@ -151,7 +151,7 @@ function renderToHtml(content: string, title: string) { // h1 handling needs to come before sanitization html = importUtils.handleH1(html, title); - html = htmlSanitizer.sanitize(html); + html = sanitize.sanitizeHtml(html); // Add a trailing semicolon to CSS styles. html = html.replaceAll(/(<(img|figure|col).*?style=".*?)"/g, "$1;\""); diff --git a/apps/server/src/services/import/opml.ts b/apps/server/src/services/import/opml.ts index 130eb81974..d9a2ed8fbe 100644 --- a/apps/server/src/services/import/opml.ts +++ b/apps/server/src/services/import/opml.ts @@ -1,11 +1,12 @@ -"use strict"; -import noteService from "../../services/notes.js"; + +import { sanitize } from "@triliumnext/core"; import xml2js from "xml2js"; -import protectedSessionService from "../protected_session.js"; -import htmlSanitizer from "../html_sanitizer.js"; -import type TaskContext from "../task_context.js"; + import type BNote from "../../becca/entities/bnote.js"; +import noteService from "../../services/notes.js"; +import protectedSessionService from "../protected_session.js"; +import type TaskContext from "../task_context.js"; const parseString = xml2js.parseString; interface OpmlXml { @@ -29,8 +30,8 @@ interface OpmlOutline { } async function importOpml(taskContext: TaskContext<"importNotes">, fileBuffer: string | Buffer, parentNote: BNote) { - const xml = await new Promise<OpmlXml>(function (resolve, reject) { - parseString(fileBuffer, function (err: any, result: OpmlXml) { + const xml = await new Promise<OpmlXml>((resolve, reject) => { + parseString(fileBuffer, (err: any, result: OpmlXml) => { if (err) { reject(err); } else { @@ -64,7 +65,7 @@ async function importOpml(taskContext: TaskContext<"importNotes">, fileBuffer: s throw new Error(`Unrecognized OPML version ${opmlVersion}`); } - content = htmlSanitizer.sanitize(content || ""); + content = sanitize.sanitizeHtml(content || ""); const { note } = noteService.createNewNote({ parentNoteId, diff --git a/apps/server/src/services/import/single.ts b/apps/server/src/services/import/single.ts index ac52a43f49..942914027b 100644 --- a/apps/server/src/services/import/single.ts +++ b/apps/server/src/services/import/single.ts @@ -1,18 +1,16 @@ -"use strict"; +import type { NoteType } from "@triliumnext/commons"; +import { sanitize } from "@triliumnext/core"; import type BNote from "../../becca/entities/bnote.js"; -import type TaskContext from "../task_context.js"; - -import noteService from "../../services/notes.js"; import imageService from "../../services/image.js"; +import noteService from "../../services/notes.js"; +import { getNoteTitle, processStringOrBuffer } from "../../services/utils.js"; import protectedSessionService from "../protected_session.js"; +import type TaskContext from "../task_context.js"; +import type { File } from "./common.js"; import markdownService from "./markdown.js"; import mimeService from "./mime.js"; -import { getNoteTitle, processStringOrBuffer } from "../../services/utils.js"; import importUtils from "./utils.js"; -import htmlSanitizer from "../html_sanitizer.js"; -import type { File } from "./common.js"; -import type { NoteType } from "@triliumnext/commons"; function importSingleFile(taskContext: TaskContext<"importNotes">, file: File, parentNote: BNote) { const mime = mimeService.getMime(file.originalname) || file.mimetype; @@ -88,7 +86,7 @@ function importCodeNote(taskContext: TaskContext<"importNotes">, file: File, par title, content, type, - mime: mime, + mime, isProtected: parentNote.isProtected && protectedSessionService.isProtectedSessionAvailable() }); @@ -106,7 +104,7 @@ function importCustomType(taskContext: TaskContext<"importNotes">, file: File, p title, content, type, - mime: mime, + mime, isProtected: parentNote.isProtected && protectedSessionService.isProtectedSessionAvailable() }); @@ -157,7 +155,7 @@ function importMarkdown(taskContext: TaskContext<"importNotes">, file: File, par let htmlContent = markdownService.renderToHtml(markdownContent, title); if (taskContext.data?.safeImport) { - htmlContent = htmlSanitizer.sanitize(htmlContent); + htmlContent = sanitize.sanitizeHtml(htmlContent); } const { note } = noteService.createNewNote({ @@ -185,7 +183,7 @@ function importHtml(taskContext: TaskContext<"importNotes">, file: File, parentN content = importUtils.handleH1(content, title); if (taskContext?.data?.safeImport) { - content = htmlSanitizer.sanitize(content); + content = sanitize.sanitizeHtml(content); } const { note } = noteService.createNewNote({ @@ -214,7 +212,7 @@ function importAttachment(taskContext: TaskContext<"importNotes">, file: File, p title: file.originalname, content: file.buffer, role: "file", - mime: mime + mime }); taskContext.increaseProgressCount(); diff --git a/apps/server/src/services/import/zip.ts b/apps/server/src/services/import/zip.ts index 51bb38cd07..da27376d9c 100644 --- a/apps/server/src/services/import/zip.ts +++ b/apps/server/src/services/import/zip.ts @@ -1,6 +1,7 @@ import { ALLOWED_NOTE_TYPES, type NoteType } from "@triliumnext/commons"; +import { sanitize } from "@triliumnext/core"; import path from "path"; import type { Stream } from "stream"; import yauzl from "yauzl"; @@ -14,7 +15,6 @@ import attributeService from "../../services/attributes.js"; import log from "../../services/log.js"; import noteService from "../../services/notes.js"; import { getNoteTitle, newEntityId, processStringOrBuffer, removeTextFileExtension, unescapeHtml } from "../../services/utils.js"; -import htmlSanitizer from "../html_sanitizer.js"; import type AttributeMeta from "../meta/attribute_meta.js"; import type NoteMeta from "../meta/note_meta.js"; import protectedSessionService from "../protected_session.js"; @@ -217,8 +217,8 @@ async function importZip(taskContext: TaskContext<"importNotes">, fileBuffer: Bu } if (taskContext.data?.safeImport) { - attr.name = htmlSanitizer.sanitize(attr.name); - attr.value = htmlSanitizer.sanitize(attr.value); + attr.name = sanitize.sanitizeHtml(attr.name); + attr.value = sanitize.sanitizeHtml(attr.value); } attributes.push(attr); @@ -295,12 +295,12 @@ async function importZip(taskContext: TaskContext<"importNotes">, fileBuffer: Bu attachmentId: getNewAttachmentId(attachmentMeta.attachmentId), noteId: getNewNoteId(noteMeta.noteId) }; - } + } // don't check for noteMeta since it's not mandatory for notes return { noteId: getNoteId(noteMeta, absUrl) }; - + } function processTextNoteContent(content: string, noteTitle: string, filePath: string, noteMeta?: NoteMeta) { @@ -313,13 +313,13 @@ async function importZip(taskContext: TaskContext<"importNotes">, fileBuffer: Bu content = content.replace(/<h1>([^<]*)<\/h1>/gi, (match, text) => { if (noteTitle.trim() === text.trim()) { return ""; // remove whole H1 tag - } + } return `<h2>${text}</h2>`; - + }); if (taskContext.data?.safeImport) { - content = htmlSanitizer.sanitize(content); + content = sanitize.sanitizeHtml(content); } content = content.replace(/<html.*<body[^>]*>/gis, ""); @@ -348,9 +348,9 @@ async function importZip(taskContext: TaskContext<"importNotes">, fileBuffer: Bu return `src="api/attachments/${target.attachmentId}/image/${path.basename(url)}"`; } else if (target.noteId) { return `src="api/images/${target.noteId}/${path.basename(url)}"`; - } + } return match; - + }); content = content.replace(/href="([^"]*)"/g, (match, url) => { @@ -374,9 +374,9 @@ async function importZip(taskContext: TaskContext<"importNotes">, fileBuffer: Bu return `href="#root/${target.noteId}?viewMode=attachments&attachmentId=${target.attachmentId}"`; } else if (target.noteId) { return `href="#root/${target.noteId}"`; - } + } return match; - + }); if (noteMeta) { @@ -692,9 +692,9 @@ function resolveNoteType(type: string | undefined): NoteType { if (type && (ALLOWED_NOTE_TYPES as readonly string[]).includes(type)) { return type as NoteType; - } + } return "text"; - + } export function removeTriliumTags(content: string) { diff --git a/apps/server/src/services/llm/context/modules/context_formatter.ts b/apps/server/src/services/llm/context/modules/context_formatter.ts index 58198c9049..090df06c6f 100644 --- a/apps/server/src/services/llm/context/modules/context_formatter.ts +++ b/apps/server/src/services/llm/context/modules/context_formatter.ts @@ -1,11 +1,12 @@ -import sanitizeHtml from 'sanitize-html'; +import { sanitize } from '@triliumnext/core'; + import log from '../../../log.js'; +import type { Message } from '../../ai_interface.js'; import { CONTEXT_PROMPTS, FORMATTING_PROMPTS } from '../../constants/llm_prompt_constants.js'; import { LLM_CONSTANTS } from '../../constants/provider_constants.js'; import type { IContextFormatter, NoteSearchResult } from '../../interfaces/context_interfaces.js'; -import modelCapabilitiesService from '../../model_capabilities_service.js'; import { calculateAvailableContextSize } from '../../interfaces/model_capabilities.js'; -import type { Message } from '../../ai_interface.js'; +import modelCapabilitiesService from '../../model_capabilities_service.js'; // Use constants from the centralized file // const CONTEXT_WINDOW = { @@ -44,7 +45,7 @@ export class ContextFormatter implements IContextFormatter { try { // Get model name from provider - let modelName = providerId; + const modelName = providerId; // Look up model capabilities const modelCapabilities = await modelCapabilitiesService.getChatModelCapabilities(modelName); @@ -59,9 +60,9 @@ export class ContextFormatter implements IContextFormatter { // Use the calculated size or fall back to constants const maxTotalLength = availableContextSize || ( providerId === 'openai' ? LLM_CONSTANTS.CONTEXT_WINDOW.OPENAI : - providerId === 'anthropic' ? LLM_CONSTANTS.CONTEXT_WINDOW.ANTHROPIC : - providerId === 'ollama' ? LLM_CONSTANTS.CONTEXT_WINDOW.OLLAMA : - LLM_CONSTANTS.CONTEXT_WINDOW.DEFAULT + providerId === 'anthropic' ? LLM_CONSTANTS.CONTEXT_WINDOW.ANTHROPIC : + providerId === 'ollama' ? LLM_CONSTANTS.CONTEXT_WINDOW.OLLAMA : + LLM_CONSTANTS.CONTEXT_WINDOW.DEFAULT ); // DEBUG: Log context window size @@ -239,11 +240,11 @@ export class ContextFormatter implements IContextFormatter { // Handle line breaks .replace(/<br\s*\/?>/gi, '\n'); - // Then use sanitize-html to remove remaining HTML - const sanitized = sanitizeHtml(contentWithMarkdown, { + // Then sanitize to remove remaining HTML + const sanitized = sanitize.sanitizeHtmlCustom(contentWithMarkdown, { allowedTags: [], // No tags allowed (strip all HTML) allowedAttributes: {}, // No attributes allowed - textFilter: function(text) { + textFilter(text) { return text .replace(/ /g, ' ') .replace(/</g, '<') @@ -264,7 +265,7 @@ export class ContextFormatter implements IContextFormatter { if (type === 'code' || mime?.includes('application/')) { // For code, limit to a reasonable size if (content.length > 2000) { - return content.substring(0, 2000) + '...\n\n[Content truncated for brevity]'; + return `${content.substring(0, 2000) }...\n\n[Content truncated for brevity]`; } return content; } @@ -288,7 +289,7 @@ export class ContextFormatter implements IContextFormatter { try { // First remove any HTML - let plaintext = sanitizeHtml(content, { + let plaintext = sanitize.sanitizeHtmlCustom(content, { allowedTags: [], allowedAttributes: {}, textFilter: (text) => text diff --git a/apps/server/src/services/llm/context/note_content.ts b/apps/server/src/services/llm/context/note_content.ts index 575aaadd24..2de9aec8ef 100644 --- a/apps/server/src/services/llm/context/note_content.ts +++ b/apps/server/src/services/llm/context/note_content.ts @@ -1,4 +1,5 @@ -import sanitizeHtml from 'sanitize-html'; +import { sanitize } from '@triliumnext/core'; + import becca from '../../../becca/becca.js'; // Define interfaces for JSON structures @@ -98,13 +99,13 @@ export function formatNoteContent(content: string, type: string, mime: string, t switch (type) { case 'text': // Remove HTML formatting for text notes - formattedContent += sanitizeHtml(content); + formattedContent += sanitize.sanitizeHtml(content); break; case 'code': // For code, we'll handle this in code_handlers.ts // Just use basic formatting here - formattedContent += '```\n' + content + '\n```'; + formattedContent += `\`\`\`\n${ content }\n\`\`\``; break; case 'canvas': @@ -119,7 +120,7 @@ export function formatNoteContent(content: string, type: string, mime: string, t .filter((element) => element.type === 'text' && element.text) .map((element) => element.text as string); - formattedContent += 'Canvas content:\n' + texts.join('\n'); + formattedContent += `Canvas content:\n${ texts.join('\n')}`; } else { formattedContent += '[Empty canvas]'; } @@ -154,7 +155,7 @@ export function formatNoteContent(content: string, type: string, mime: string, t }; if (jsonContent.root) { - formattedContent += 'Mind map content:\n' + extractMindMapNodes(jsonContent.root).join('\n'); + formattedContent += `Mind map content:\n${ extractMindMapNodes(jsonContent.root).join('\n')}`; } else { formattedContent += '[Empty mind map]'; } @@ -178,14 +179,14 @@ export function formatNoteContent(content: string, type: string, mime: string, t let result = 'Relation map content:\n'; if (jsonContent.notes && Array.isArray(jsonContent.notes)) { - result += 'Notes: ' + jsonContent.notes + result += `Notes: ${ jsonContent.notes .map((note) => note.title || note.name) .filter(Boolean) - .join(', ') + '\n'; + .join(', ') }\n`; } if (jsonContent.relations && Array.isArray(jsonContent.relations)) { - result += 'Relations: ' + jsonContent.relations + result += `Relations: ${ jsonContent.relations .map((rel) => { const sourceNote = jsonContent.notes?.find((n) => n.noteId === rel.sourceNoteId); const targetNote = jsonContent.notes?.find((n) => n.noteId === rel.targetNoteId); @@ -193,7 +194,7 @@ export function formatNoteContent(content: string, type: string, mime: string, t const target = targetNote ? (targetNote.title || targetNote.name) : 'unknown'; return `${source} → ${rel.name || ''} → ${target}`; }) - .join('; '); + .join('; ')}`; } formattedContent += result; @@ -219,7 +220,7 @@ export function formatNoteContent(content: string, type: string, mime: string, t if (jsonContent.markers.length > 0) { result += jsonContent.markers .map((marker) => { - return `Location: ${marker.title || ''} (${marker.lat}, ${marker.lng})${marker.description ? ' - ' + marker.description : ''}`; + return `Location: ${marker.title || ''} (${marker.lat}, ${marker.lng})${marker.description ? ` - ${ marker.description}` : ''}`; }) .join('\n'); } else { @@ -242,7 +243,7 @@ export function formatNoteContent(content: string, type: string, mime: string, t case 'mermaid': // Format mermaid diagrams as code blocks - formattedContent += '```mermaid\n' + content + '\n```'; + formattedContent += `\`\`\`mermaid\n${ content }\n\`\`\``; break; case 'image': @@ -252,7 +253,7 @@ export function formatNoteContent(content: string, type: string, mime: string, t default: // For other notes, just use the content as is - formattedContent += sanitizeHtml(content); + formattedContent += sanitize.sanitizeHtml(content); } return formattedContent; @@ -265,7 +266,7 @@ export function sanitizeHtmlContent(html: string): string { if (!html) return ''; // Use sanitizeHtml to remove all HTML tags - let content = sanitizeHtml(html, { + let content = sanitize.sanitizeHtmlCustom(html, { allowedTags: [], allowedAttributes: {}, textFilter: (text) => { diff --git a/apps/server/src/services/llm/formatters/base_formatter.ts b/apps/server/src/services/llm/formatters/base_formatter.ts index fe4c97f42c..f45c459636 100644 --- a/apps/server/src/services/llm/formatters/base_formatter.ts +++ b/apps/server/src/services/llm/formatters/base_formatter.ts @@ -1,16 +1,16 @@ -import sanitizeHtml from 'sanitize-html'; +import { sanitize } from '@triliumnext/core'; + import type { Message } from '../ai_interface.js'; -import type { MessageFormatter } from '../interfaces/message_formatter.js'; -import { DEFAULT_SYSTEM_PROMPT, PROVIDER_PROMPTS } from '../constants/llm_prompt_constants.js'; import { - HTML_ALLOWED_TAGS, - HTML_ALLOWED_ATTRIBUTES, - HTML_TRANSFORMS, - HTML_TO_MARKDOWN_PATTERNS, - HTML_ENTITY_REPLACEMENTS, ENCODING_FIXES, - FORMATTER_LOGS -} from '../constants/formatter_constants.js'; + FORMATTER_LOGS, + HTML_ALLOWED_ATTRIBUTES, + HTML_ALLOWED_TAGS, + HTML_ENTITY_REPLACEMENTS, + HTML_TO_MARKDOWN_PATTERNS, + HTML_TRANSFORMS} from '../constants/formatter_constants.js'; +import { DEFAULT_SYSTEM_PROMPT, PROVIDER_PROMPTS } from '../constants/llm_prompt_constants.js'; +import type { MessageFormatter } from '../interfaces/message_formatter.js'; /** * Base formatter with common functionality for all providers @@ -49,7 +49,7 @@ export abstract class BaseMessageFormatter implements MessageFormatter { const fixedContent = this.fixEncodingIssues(content); // Convert HTML to markdown for better readability - const cleaned = sanitizeHtml(fixedContent, { + const cleaned = sanitize.sanitizeHtmlCustom(fixedContent, { allowedTags: HTML_ALLOWED_TAGS.STANDARD, allowedAttributes: HTML_ALLOWED_ATTRIBUTES.STANDARD, transformTags: HTML_TRANSFORMS.STANDARD diff --git a/apps/server/src/services/llm/formatters/ollama_formatter.ts b/apps/server/src/services/llm/formatters/ollama_formatter.ts index eb780f7602..a85ec718ea 100644 --- a/apps/server/src/services/llm/formatters/ollama_formatter.ts +++ b/apps/server/src/services/llm/formatters/ollama_formatter.ts @@ -1,15 +1,15 @@ +import { sanitize } from '@triliumnext/core'; + +import log from '../../log.js'; import type { Message } from '../ai_interface.js'; -import { BaseMessageFormatter } from './base_formatter.js'; -import sanitizeHtml from 'sanitize-html'; +import { + FORMATTER_LOGS, + HTML_ALLOWED_ATTRIBUTES, + HTML_ALLOWED_TAGS, + OLLAMA_CLEANING} from '../constants/formatter_constants.js'; import { PROVIDER_PROMPTS } from '../constants/llm_prompt_constants.js'; import { LLM_CONSTANTS } from '../constants/provider_constants.js'; -import { - HTML_ALLOWED_TAGS, - HTML_ALLOWED_ATTRIBUTES, - OLLAMA_CLEANING, - FORMATTER_LOGS -} from '../constants/formatter_constants.js'; -import log from '../../log.js'; +import { BaseMessageFormatter } from './base_formatter.js'; /** * Ollama-specific message formatter @@ -196,7 +196,7 @@ export class OllamaMessageFormatter extends BaseMessageFormatter { // Then apply Ollama-specific aggressive cleaning // Remove any remaining HTML using sanitizeHtml while keeping our markers - let plaintext = sanitizeHtml(sanitized, { + let plaintext = sanitize.sanitizeHtmlCustom(sanitized, { allowedTags: HTML_ALLOWED_TAGS.NONE, allowedAttributes: HTML_ALLOWED_ATTRIBUTES.NONE, textFilter: (text) => text diff --git a/apps/server/src/services/llm/formatters/openai_formatter.ts b/apps/server/src/services/llm/formatters/openai_formatter.ts index d09a3675a1..9d551e6183 100644 --- a/apps/server/src/services/llm/formatters/openai_formatter.ts +++ b/apps/server/src/services/llm/formatters/openai_formatter.ts @@ -1,16 +1,16 @@ -import sanitizeHtml from 'sanitize-html'; +import { sanitize } from '@triliumnext/core'; + +import log from '../../log.js'; import type { Message } from '../ai_interface.js'; -import { BaseMessageFormatter } from './base_formatter.js'; +import { + FORMATTER_LOGS, + HTML_ALLOWED_ATTRIBUTES, + HTML_ALLOWED_TAGS, + HTML_ENTITY_REPLACEMENTS, + HTML_TO_MARKDOWN_PATTERNS} from '../constants/formatter_constants.js'; import { PROVIDER_PROMPTS } from '../constants/llm_prompt_constants.js'; import { LLM_CONSTANTS } from '../constants/provider_constants.js'; -import { - HTML_ALLOWED_TAGS, - HTML_ALLOWED_ATTRIBUTES, - HTML_TO_MARKDOWN_PATTERNS, - HTML_ENTITY_REPLACEMENTS, - FORMATTER_LOGS -} from '../constants/formatter_constants.js'; -import log from '../../log.js'; +import { BaseMessageFormatter } from './base_formatter.js'; /** * OpenAI-specific message formatter @@ -54,18 +54,18 @@ export class OpenAIMessageFormatter extends BaseMessageFormatter { // If we don't have explicit context but have a system prompt else if (!hasSystemMessage && systemPrompt) { let baseSystemPrompt = systemPrompt || PROVIDER_PROMPTS.COMMON.DEFAULT_ASSISTANT_INTRO; - + // Check if this is a tool-using conversation const hasPreviousToolCalls = messages.some(msg => msg.tool_calls && msg.tool_calls.length > 0); const hasToolResults = messages.some(msg => msg.role === 'tool'); const isToolUsingConversation = useTools || hasPreviousToolCalls || hasToolResults; - + // Add tool instructions for OpenAI when tools are being used if (isToolUsingConversation && PROVIDER_PROMPTS.OPENAI.TOOL_INSTRUCTIONS) { log.info('Adding tool instructions to system prompt for OpenAI'); baseSystemPrompt = `${baseSystemPrompt}\n\n${PROVIDER_PROMPTS.OPENAI.TOOL_INSTRUCTIONS}`; } - + formattedMessages.push({ role: 'system', content: baseSystemPrompt @@ -111,7 +111,7 @@ export class OpenAIMessageFormatter extends BaseMessageFormatter { try { // Convert HTML to Markdown for better readability - const cleaned = sanitizeHtml(content, { + const cleaned = sanitize.sanitizeHtmlCustom(content, { allowedTags: HTML_ALLOWED_TAGS.STANDARD, allowedAttributes: HTML_ALLOWED_ATTRIBUTES.STANDARD }); diff --git a/apps/server/src/share/content_renderer.ts b/apps/server/src/share/content_renderer.ts index 2de68222e2..c777c5cbcb 100644 --- a/apps/server/src/share/content_renderer.ts +++ b/apps/server/src/share/content_renderer.ts @@ -1,4 +1,4 @@ -import { sanitizeUrl } from "@braintree/sanitize-url"; +import { sanitize } from "@triliumnext/core"; import { highlightAuto } from "@triliumnext/highlightjs"; import ejs from "ejs"; import escapeHtml from "escape-html"; @@ -491,7 +491,7 @@ function renderWebView(note: SNote | BNote, result: Result) { const url = note.getLabelValue("webViewSrc"); if (!url) return; - result.content = `<iframe class="webview" src="${sanitizeUrl(url)}" sandbox="allow-same-origin allow-scripts allow-popups"></iframe>`; + result.content = `<iframe class="webview" src="${sanitize.sanitizeUrl(url)}" sandbox="allow-same-origin allow-scripts allow-popups"></iframe>`; } export default { diff --git a/packages/trilium-core/package.json b/packages/trilium-core/package.json index 1bc92db237..c01697bfd4 100644 --- a/packages/trilium-core/package.json +++ b/packages/trilium-core/package.json @@ -7,6 +7,9 @@ "test": "echo \"Error: no test specified\" && exit 1" }, "dependencies": { - "@triliumnext/commons": "workspace:*" + "@triliumnext/commons": "workspace:*", + "sanitize-html": "2.17.0", + "@types/sanitize-html": "2.16.0", + "@braintree/sanitize-url": "7.1.1" } } diff --git a/packages/trilium-core/src/index.ts b/packages/trilium-core/src/index.ts index c478cac62a..4fb9a1f7d1 100644 --- a/packages/trilium-core/src/index.ts +++ b/packages/trilium-core/src/index.ts @@ -46,6 +46,7 @@ export type { NotePojo } from "./becca/becca-interface"; export { default as NoteSet } from "./services/search/note_set"; export { default as note_service } from "./services/notes"; +export * as sanitize from "./services/sanitizer"; export function initializeCore({ dbConfig, executionContext, crypto }: { dbConfig: SqlServiceParams, diff --git a/packages/trilium-core/src/services/notes.ts b/packages/trilium-core/src/services/notes.ts index 7fe87b681c..e8a2f0ab31 100644 --- a/packages/trilium-core/src/services/notes.ts +++ b/packages/trilium-core/src/services/notes.ts @@ -16,7 +16,6 @@ import * as cls from "../services/context.js"; import protectedSessionService from "../services/protected_session.js"; import { newEntityId, quoteRegex, toMap, unescapeHtml } from "./utils/index.js"; import entityChangesService from "./entity_changes.js"; -import htmlSanitizer from "./html_sanitizer.js"; import imageService from "./image.js"; import noteTypesService from "./note_types.js"; import optionService from "./options.js"; @@ -27,6 +26,7 @@ import ws from "./ws.js"; import { getSql } from "./sql/index.js"; import { getLog } from "./log.js"; import { decodeBase64 } from "./utils/binary.js"; +import { sanitizeHtml } from "./sanitizer.js"; interface FoundLink { name: "imageLink" | "internalLink" | "includeNoteLink" | "relationMapLink"; @@ -161,7 +161,7 @@ function getNewNoteTitle(parentNote: BNote) { // this isn't in theory a good place to sanitize title, but this will catch a lot of XSS attempts. // title is supposed to contain text only (not HTML) and be printed text only, but given the number of usages, // it's difficult to guarantee correct handling in all cases - title = htmlSanitizer.sanitize(title); + title = sanitizeHtml(title); return title; } diff --git a/apps/server/src/services/html_sanitizer.spec.ts b/packages/trilium-core/src/services/sanitizer.spec.ts similarity index 91% rename from apps/server/src/services/html_sanitizer.spec.ts rename to packages/trilium-core/src/services/sanitizer.spec.ts index dfbba8fd71..807e73c70b 100644 --- a/apps/server/src/services/html_sanitizer.spec.ts +++ b/packages/trilium-core/src/services/sanitizer.spec.ts @@ -1,12 +1,12 @@ import { describe, expect, it } from "vitest"; -import html_sanitizer from "./html_sanitizer.js"; +import { sanitizeHtml } from "./sanitizer.js"; import { trimIndentation } from "@triliumnext/commons"; describe("sanitize", () => { it("filters out position inline CSS", () => { const dirty = `<div style="z-index:999999999;margin:0px;left:250px;height:100px;display:table;background:none;position:fixed;top:250px;"></div>`; const clean = `<div></div>`; - expect(html_sanitizer.sanitize(dirty)).toBe(clean); + expect(sanitizeHtml(dirty)).toBe(clean); }); it("keeps inline styles defined in CKEDitor", () => { @@ -48,6 +48,6 @@ describe("sanitize", () => { </tbody> </table> </figure>`; - expect(html_sanitizer.sanitize(dirty)).toBe(clean); + expect(sanitizeHtml(dirty)).toBe(clean); }); }); diff --git a/apps/server/src/services/html_sanitizer.ts b/packages/trilium-core/src/services/sanitizer.ts similarity index 87% rename from apps/server/src/services/html_sanitizer.ts rename to packages/trilium-core/src/services/sanitizer.ts index 7e2e41b150..0301214f2b 100644 --- a/apps/server/src/services/html_sanitizer.ts +++ b/packages/trilium-core/src/services/sanitizer.ts @@ -1,14 +1,14 @@ -import { sanitizeUrl } from "@braintree/sanitize-url"; +import { sanitizeUrl as sanitizeUrlInternal } from "@braintree/sanitize-url"; import { ALLOWED_PROTOCOLS, SANITIZER_DEFAULT_ALLOWED_TAGS } from "@triliumnext/commons"; -import sanitizeHtml from "sanitize-html"; import optionService from "./options.js"; +import sanitize from "sanitize-html"; // intended mainly as protection against XSS via import // secondarily, it (partly) protects against "CSS takeover" // sanitize also note titles, label values etc. - there are so many usages which make it difficult // to guarantee all of them are properly handled -function sanitize(dirtyHtml: string) { +export function sanitizeHtml(dirtyHtml: string) { if (!dirtyHtml) { return dirtyHtml; } @@ -38,7 +38,7 @@ function sanitize(dirtyHtml: string) { const sizeRegex = [/^\d+\.?\d*(?:px|em|%)$/]; // to minimize document changes, compress H - return sanitizeHtml(dirtyHtml, { + return sanitizeHtmlCustom(dirtyHtml, { allowedTags: allowedTags as string[], allowedAttributes: { "*": ["class", "style", "title", "src", "href", "hash", "disabled", "align", "alt", "center", "data-*"], @@ -81,9 +81,10 @@ function sanitize(dirtyHtml: string) { }); } -export default { - sanitize, - sanitizeUrl: (url: string) => { - return sanitizeUrl(url).trim(); - } -}; +export function sanitizeHtmlCustom(dirtyHtml: string, config: sanitize.IOptions) { + return sanitize(dirtyHtml, config); +} + +export function sanitizeUrl(url: string) { + return sanitizeUrlInternal(url).trim(); +} From 299c06c1a62b9d48b59968485178bac20721c17d Mon Sep 17 00:00:00 2001 From: Elian Doran <contact@eliandoran.me> Date: Tue, 6 Jan 2026 15:33:48 +0200 Subject: [PATCH 49/72] chore(core): fix inaccessible NoteParams --- apps/server/src/etapi/notes.ts | 29 ++++++++++--------- .../server/src/services/backend_script_api.ts | 3 +- packages/trilium-core/src/index.ts | 2 +- packages/trilium-core/src/services/notes.ts | 2 +- 4 files changed, 18 insertions(+), 18 deletions(-) diff --git a/apps/server/src/etapi/notes.ts b/apps/server/src/etapi/notes.ts index 9fae830704..735291c861 100644 --- a/apps/server/src/etapi/notes.ts +++ b/apps/server/src/etapi/notes.ts @@ -1,20 +1,21 @@ -import becca from "../becca/becca.js"; -import utils from "../services/utils.js"; -import eu from "./etapi_utils.js"; -import mappers from "./mappers.js"; -import noteService from "../services/notes.js"; -import TaskContext from "../services/task_context.js"; -import v from "./validators.js"; -import searchService from "../services/search/services/search.js"; -import SearchContext from "../services/search/search_context.js"; -import zipExportService from "../services/export/zip.js"; -import zipImportService from "../services/import/zip.js"; +import { NoteParams } from "@triliumnext/core"; import type { Request, Router } from "express"; import type { ParsedQs } from "qs"; -import type { NoteParams } from "../services/note-interface.js"; -import type { SearchParams } from "../services/search/services/types.js"; -import type { ValidatorMap } from "./etapi-interface.js"; + +import becca from "../becca/becca.js"; +import zipExportService from "../services/export/zip.js"; import type { ExportFormat } from "../services/export/zip/abstract_provider.js"; +import zipImportService from "../services/import/zip.js"; +import noteService from "../services/notes.js"; +import SearchContext from "../services/search/search_context.js"; +import searchService from "../services/search/services/search.js"; +import type { SearchParams } from "../services/search/services/types.js"; +import TaskContext from "../services/task_context.js"; +import utils from "../services/utils.js"; +import eu from "./etapi_utils.js"; +import type { ValidatorMap } from "./etapi-interface.js"; +import mappers from "./mappers.js"; +import v from "./validators.js"; function register(router: Router) { eu.route(router, "get", "/etapi/notes", (req, res, next) => { diff --git a/apps/server/src/services/backend_script_api.ts b/apps/server/src/services/backend_script_api.ts index b7df6ccea8..5d1d86331f 100644 --- a/apps/server/src/services/backend_script_api.ts +++ b/apps/server/src/services/backend_script_api.ts @@ -1,5 +1,5 @@ import { type AttributeRow, dayjs, formatLogMessage } from "@triliumnext/commons"; -import { type AbstractBeccaEntity, Becca } from "@triliumnext/core"; +import { type AbstractBeccaEntity, Becca, NoteParams } from "@triliumnext/core"; import axios from "axios"; import * as cheerio from "cheerio"; import xml2js from "xml2js"; @@ -22,7 +22,6 @@ import config from "./config.js"; import dateNoteService from "./date_notes.js"; import exportService from "./export/zip.js"; import log from "./log.js"; -import type { NoteParams } from "./note-interface.js"; import noteService from "./notes.js"; import optionsService from "./options.js"; import SearchContext from "./search/search_context.js"; diff --git a/packages/trilium-core/src/index.ts b/packages/trilium-core/src/index.ts index 4fb9a1f7d1..1d9bd74591 100644 --- a/packages/trilium-core/src/index.ts +++ b/packages/trilium-core/src/index.ts @@ -45,7 +45,7 @@ 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 } from "./services/notes"; +export { default as note_service, NoteParams } from "./services/notes"; export * as sanitize from "./services/sanitizer"; export function initializeCore({ dbConfig, executionContext, crypto }: { diff --git a/packages/trilium-core/src/services/notes.ts b/packages/trilium-core/src/services/notes.ts index e8a2f0ab31..56f01e6d6a 100644 --- a/packages/trilium-core/src/services/notes.ts +++ b/packages/trilium-core/src/services/notes.ts @@ -38,7 +38,7 @@ interface Attachment { title: string; } -interface NoteParams { +export interface NoteParams { /** optionally can force specific noteId */ noteId?: string; branchId?: string; From c6197e520d61d4eb0e1ca88eb9093aeb66e1084f Mon Sep 17 00:00:00 2001 From: Elian Doran <contact@eliandoran.me> Date: Tue, 6 Jan 2026 15:41:34 +0200 Subject: [PATCH 50/72] chore(core): integrate some more utils --- apps/server/package.json | 7 ++-- apps/server/src/services/utils.ts | 34 +++---------------- packages/trilium-core/package.json | 9 +++-- .../src/becca/entities/battachment.ts | 4 +-- .../trilium-core/src/becca/entities/bnote.ts | 4 +-- .../src/services/entity_changes.ts | 6 ++-- .../trilium-core/src/services/sanitizer.ts | 5 +++ .../trilium-core/src/services/utils/index.ts | 34 +++++++++++++++++++ 8 files changed, 60 insertions(+), 43 deletions(-) diff --git a/apps/server/package.json b/apps/server/package.json index d52068829b..f16b11ec74 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -55,8 +55,7 @@ "@types/express-session": "1.18.2", "@types/fs-extra": "11.0.4", "@types/html": "1.0.4", - "@types/ini": "4.1.1", - "@types/mime-types": "3.0.1", + "@types/ini": "4.1.1", "@types/multer": "2.0.0", "@types/safe-compare": "1.1.2", "@types/sax": "1.2.7", @@ -108,14 +107,12 @@ "jimp": "1.6.0", "lorem-ipsum": "2.0.8", "marked": "17.0.1", - "mime-types": "3.0.2", "multer": "2.0.2", "normalize-strings": "1.1.1", "ollama": "0.6.3", "openai": "6.15.0", "rand-token": "1.0.1", - "safe-compare": "1.1.4", - "sanitize-filename": "1.6.3", + "safe-compare": "1.1.4", "sax": "1.4.3", "serve-favicon": "2.5.1", "stream-throttle": "0.1.3", diff --git a/apps/server/src/services/utils.ts b/apps/server/src/services/utils.ts index e0e40094d8..ee1e18302b 100644 --- a/apps/server/src/services/utils.ts +++ b/apps/server/src/services/utils.ts @@ -5,11 +5,8 @@ import chardet from "chardet"; import crypto from "crypto"; import escape from "escape-html"; import { t } from "i18next"; -import mimeTypes from "mime-types"; import { release as osRelease } from "os"; import path from "path"; -import { generator } from "rand-token"; -import sanitize from "sanitize-filename"; import stripBom from "strip-bom"; import unescape from "unescape"; @@ -140,12 +137,12 @@ export async function crash(message: string) { } } +/** @deprecated */ export function getContentDisposition(filename: string) { - const sanitizedFilename = sanitize(filename).trim() || "file"; - const uriEncodedFilename = encodeURIComponent(sanitizedFilename); - return `file; filename="${uriEncodedFilename}"; filename*=UTF-8''${uriEncodedFilename}`; + return coreUtils.getContentDisposition(filename); } +/** @deprecated */ export function isStringNote(type: string | undefined, mime: string) { return coreUtils.isStringNote(type, mime); } @@ -160,30 +157,9 @@ export function replaceAll(string: string, replaceWhat: string, replaceWith: str return coreUtils.replaceAll(string, replaceWhat, replaceWith); } +/** @deprecated */ export function formatDownloadTitle(fileName: string, type: string | null, mime: string) { - const fileNameBase = !fileName ? "untitled" : sanitize(fileName); - - const getExtension = () => { - if (type === "text") return ".html"; - if (type === "relationMap" || type === "canvas" || type === "search") return ".json"; - if (!mime) return ""; - - const mimeLc = mime.toLowerCase(); - - // better to just return the current name without a fake extension - // it's possible that the title still preserves the correct extension anyways - if (mimeLc === "application/octet-stream") return ""; - - // if fileName has an extension matching the mime already - reuse it - const mimeTypeFromFileName = mimeTypes.lookup(fileName); - if (mimeTypeFromFileName === mimeLc) return ""; - - // as last resort try to get extension from mimeType - const extensions = mimeTypes.extension(mime); - return extensions ? `.${extensions}` : ""; - }; - - return `${fileNameBase}${getExtension()}`; + return coreUtils.formatDownloadTitle(fileName, type, mime); } export function removeTextFileExtension(filePath: string) { diff --git a/packages/trilium-core/package.json b/packages/trilium-core/package.json index c01697bfd4..147aaa0ef2 100644 --- a/packages/trilium-core/package.json +++ b/packages/trilium-core/package.json @@ -8,8 +8,13 @@ }, "dependencies": { "@triliumnext/commons": "workspace:*", - "sanitize-html": "2.17.0", + "sanitize-html": "2.17.0", + "@braintree/sanitize-url": "7.1.1", + "sanitize-filename": "1.6.3", + "mime-types": "3.0.2" + }, + "devDependencies": { "@types/sanitize-html": "2.16.0", - "@braintree/sanitize-url": "7.1.1" + "@types/mime-types": "3.0.1" } } diff --git a/packages/trilium-core/src/becca/entities/battachment.ts b/packages/trilium-core/src/becca/entities/battachment.ts index bdc28d15de..027413a65d 100644 --- a/packages/trilium-core/src/becca/entities/battachment.ts +++ b/packages/trilium-core/src/becca/entities/battachment.ts @@ -10,7 +10,7 @@ import AbstractBeccaEntity from "./abstract_becca_entity.js"; import type BBranch from "./bbranch.js"; import type BNote from "./bnote.js"; import { getSql } from "../../services/sql/index.js"; -import { isStringNote, replaceAll } from "../../services/utils"; +import { formatDownloadTitle, isStringNote, replaceAll } from "../../services/utils"; const attachmentRoleToNoteTypeMapping = { image: "image", @@ -201,7 +201,7 @@ class BAttachment extends AbstractBeccaEntity<BAttachment> { getFileName() { const type = this.role === "image" ? "image" : "file"; - return utils.formatDownloadTitle(this.title, type, this.mime); + return formatDownloadTitle(this.title, type, this.mime); } override beforeSaving() { diff --git a/packages/trilium-core/src/becca/entities/bnote.ts b/packages/trilium-core/src/becca/entities/bnote.ts index f573b0e0e1..ad97d93fbf 100644 --- a/packages/trilium-core/src/becca/entities/bnote.ts +++ b/packages/trilium-core/src/becca/entities/bnote.ts @@ -19,7 +19,7 @@ import BAttribute from "./battribute.js"; import type BBranch from "./bbranch.js"; import BRevision from "./brevision.js"; import { getSql } from "../../services/sql/index.js"; -import { isStringNote, normalize, randomString, replaceAll } from "../../services/utils"; +import { formatDownloadTitle, isStringNote, normalize, randomString, replaceAll } from "../../services/utils"; const LABEL = "label"; const RELATION = "relation"; @@ -1654,7 +1654,7 @@ class BNote extends AbstractBeccaEntity<BNote> { } getFileName() { - return utils.formatDownloadTitle(this.title, this.type, this.mime); + return formatDownloadTitle(this.title, this.type, this.mime); } override beforeSaving() { diff --git a/packages/trilium-core/src/services/entity_changes.ts b/packages/trilium-core/src/services/entity_changes.ts index d77bd4a58a..c2f6ebb1f2 100644 --- a/packages/trilium-core/src/services/entity_changes.ts +++ b/packages/trilium-core/src/services/entity_changes.ts @@ -2,10 +2,10 @@ import type { BlobRow, EntityChange } from "@triliumnext/commons"; import becca from "../becca/becca.js"; import dateUtils from "./utils/date.js"; -import { getLog } from "./log.js"; +import { getLog } from "./log.js"; import { randomString } from "./utils/index.js"; import { getSql } from "./sql/index.js"; -import { getComponentId } from "./context.js"; +import * as cls from "./context.js"; import events from "./events.js"; import blobService from "./blob.js"; import getInstanceId from "./instance_id.js"; @@ -33,7 +33,7 @@ function putEntityChange(origEntityChange: EntityChange) { ec.changeId = randomString(12); } - ec.componentId = ec.componentId || getComponentId() || "NA"; // NA = not available + ec.componentId = ec.componentId || cls.getComponentId() || "NA"; // NA = not available ec.instanceId = ec.instanceId || getInstanceId(); ec.isSynced = ec.isSynced ? 1 : 0; ec.isErased = ec.isErased ? 1 : 0; diff --git a/packages/trilium-core/src/services/sanitizer.ts b/packages/trilium-core/src/services/sanitizer.ts index 0301214f2b..3d2446d370 100644 --- a/packages/trilium-core/src/services/sanitizer.ts +++ b/packages/trilium-core/src/services/sanitizer.ts @@ -3,6 +3,7 @@ import { ALLOWED_PROTOCOLS, SANITIZER_DEFAULT_ALLOWED_TAGS } from "@triliumnext/ import optionService from "./options.js"; import sanitize from "sanitize-html"; +import sanitizeFileNameInternal from "sanitize-filename"; // intended mainly as protection against XSS via import // secondarily, it (partly) protects against "CSS takeover" @@ -88,3 +89,7 @@ export function sanitizeHtmlCustom(dirtyHtml: string, config: sanitize.IOptions) export function sanitizeUrl(url: string) { return sanitizeUrlInternal(url).trim(); } + +export function sanitizeFileName(fileName: string) { + return sanitizeFileNameInternal(fileName); +} diff --git a/packages/trilium-core/src/services/utils/index.ts b/packages/trilium-core/src/services/utils/index.ts index fc39276f1a..6cd7c7f06e 100644 --- a/packages/trilium-core/src/services/utils/index.ts +++ b/packages/trilium-core/src/services/utils/index.ts @@ -1,5 +1,7 @@ import { getCrypto } from "../encryption/crypto"; +import { sanitizeFileName } from "../sanitizer"; import { encodeBase64 } from "./binary"; +import mimeTypes from "mime-types"; // render and book are string note in the sense that they are expected to contain empty string const STRING_NOTE_TYPES = new Set(["text", "code", "relationMap", "search", "render", "book", "mermaid", "canvas", "webView"]); @@ -65,3 +67,35 @@ export function sanitizeAttributeName(origName: string) { return fixedName; } + +export function getContentDisposition(filename: string) { + const sanitizedFilename = sanitizeFileName(filename).trim() || "file"; + const uriEncodedFilename = encodeURIComponent(sanitizedFilename); + return `file; filename="${uriEncodedFilename}"; filename*=UTF-8''${uriEncodedFilename}`; +} + +export function formatDownloadTitle(fileName: string, type: string | null, mime: string) { + const fileNameBase = !fileName ? "untitled" : sanitizeFileName(fileName); + + const getExtension = () => { + if (type === "text") return ".html"; + if (type === "relationMap" || type === "canvas" || type === "search") return ".json"; + if (!mime) return ""; + + const mimeLc = mime.toLowerCase(); + + // better to just return the current name without a fake extension + // it's possible that the title still preserves the correct extension anyways + if (mimeLc === "application/octet-stream") return ""; + + // if fileName has an extension matching the mime already - reuse it + const mimeTypeFromFileName = mimeTypes.lookup(fileName); + if (mimeTypeFromFileName === mimeLc) return ""; + + // as last resort try to get extension from mimeType + const extensions = mimeTypes.extension(mime); + return extensions ? `.${extensions}` : ""; + }; + + return `${fileNameBase}${getExtension()}`; +} From 78262e55ec025b238b204aa77c361758920e7241 Mon Sep 17 00:00:00 2001 From: Elian Doran <contact@eliandoran.me> Date: Tue, 6 Jan 2026 15:43:36 +0200 Subject: [PATCH 51/72] chore(core): integrate escape/unescape & toMap --- apps/server/package.json | 7 ++----- apps/server/src/services/utils.ts | 20 +++++-------------- packages/trilium-core/package.json | 7 +++++-- .../trilium-core/src/services/utils/index.ts | 18 +++++++++++++++++ 4 files changed, 30 insertions(+), 22 deletions(-) diff --git a/apps/server/package.json b/apps/server/package.json index f16b11ec74..8c6765924d 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -49,8 +49,7 @@ "@types/compression": "1.8.1", "@types/cookie-parser": "1.4.10", "@types/debounce": "1.2.4", - "@types/ejs": "3.1.5", - "@types/escape-html": "1.0.4", + "@types/ejs": "3.1.5", "@types/express-http-proxy": "1.6.7", "@types/express-session": "1.18.2", "@types/fs-extra": "11.0.4", @@ -84,8 +83,7 @@ "ejs": "3.1.10", "electron": "39.2.7", "electron-debug": "4.1.0", - "electron-window-state": "5.0.3", - "escape-html": "1.0.3", + "electron-window-state": "5.0.3", "express": "5.2.1", "express-http-proxy": "2.1.2", "express-openid-connect": "2.19.3", @@ -123,7 +121,6 @@ "time2fa": "1.4.2", "tmp": "0.2.5", "turndown": "7.2.2", - "unescape": "1.0.1", "vite": "7.3.0", "ws": "8.18.3", "xml2js": "0.6.2", diff --git a/apps/server/src/services/utils.ts b/apps/server/src/services/utils.ts index ee1e18302b..861a8396fc 100644 --- a/apps/server/src/services/utils.ts +++ b/apps/server/src/services/utils.ts @@ -3,12 +3,10 @@ import { utils as coreUtils } from "@triliumnext/core"; import chardet from "chardet"; import crypto from "crypto"; -import escape from "escape-html"; import { t } from "i18next"; import { release as osRelease } from "os"; import path from "path"; import stripBom from "strip-bom"; -import unescape from "unescape"; import log from "./log.js"; import type NoteMeta from "./meta/note_meta.js"; @@ -102,10 +100,6 @@ export function sanitizeSqlIdentifier(str: string) { return str.replace(/[^A-Za-z0-9_]/g, ""); } -export const escapeHtml = escape; - -export const unescapeHtml = unescape; - export function toObject<T, K extends string | number | symbol, V>(array: T[], fn: (item: T) => [K, V]): Record<K, V> { const obj: Record<K, V> = {} as Record<K, V>; // TODO: unsafe? @@ -227,16 +221,9 @@ export function normalize(str: string) { return coreUtils.normalize(str); } +/** @deprecated */ export function toMap<T extends Record<string, any>>(list: T[], key: keyof T) { - const map = new Map<string, T>(); - for (const el of list) { - const keyForMap = el[key]; - if (!keyForMap) continue; - // TriliumNextTODO: do we need to handle the case when the same key is used? - // currently this will overwrite the existing entry in the map - map.set(keyForMap, el); - } - return map; + return coreUtils.toMap(list, key); } // try to turn 'true' and 'false' strings from process.env variables into boolean values or undefined @@ -470,6 +457,9 @@ function slugify(text: string) { .replace(/(^-|-$)+/g, ""); // trim dashes } +export const escapeHtml = coreUtils.escapeHtml; +export const unescapeHtml = coreUtils.unescapeHtml; + export default { compareVersions, constantTimeCompare, diff --git a/packages/trilium-core/package.json b/packages/trilium-core/package.json index 147aaa0ef2..fd2f90a18c 100644 --- a/packages/trilium-core/package.json +++ b/packages/trilium-core/package.json @@ -11,10 +11,13 @@ "sanitize-html": "2.17.0", "@braintree/sanitize-url": "7.1.1", "sanitize-filename": "1.6.3", - "mime-types": "3.0.2" + "mime-types": "3.0.2", + "unescape": "1.0.1", + "escape-html": "1.0.3" }, "devDependencies": { "@types/sanitize-html": "2.16.0", - "@types/mime-types": "3.0.1" + "@types/mime-types": "3.0.1", + "@types/escape-html": "1.0.4" } } diff --git a/packages/trilium-core/src/services/utils/index.ts b/packages/trilium-core/src/services/utils/index.ts index 6cd7c7f06e..f33ccb0391 100644 --- a/packages/trilium-core/src/services/utils/index.ts +++ b/packages/trilium-core/src/services/utils/index.ts @@ -2,6 +2,8 @@ import { getCrypto } from "../encryption/crypto"; import { sanitizeFileName } from "../sanitizer"; import { encodeBase64 } from "./binary"; import mimeTypes from "mime-types"; +import escape from "escape-html"; +import unescape from "unescape"; // render and book are string note in the sense that they are expected to contain empty string const STRING_NOTE_TYPES = new Set(["text", "code", "relationMap", "search", "render", "book", "mermaid", "canvas", "webView"]); @@ -99,3 +101,19 @@ export function formatDownloadTitle(fileName: string, type: string | null, mime: return `${fileNameBase}${getExtension()}`; } + +export function toMap<T extends Record<string, any>>(list: T[], key: keyof T) { + const map = new Map<string, T>(); + for (const el of list) { + const keyForMap = el[key]; + if (!keyForMap) continue; + // TriliumNextTODO: do we need to handle the case when the same key is used? + // currently this will overwrite the existing entry in the map + map.set(keyForMap, el); + } + return map; +} + +export const escapeHtml = escape; + +export const unescapeHtml = unescape; From a8f6db4b20c79118574df57383d58d1114fcbfa2 Mon Sep 17 00:00:00 2001 From: Elian Doran <contact@eliandoran.me> Date: Tue, 6 Jan 2026 15:45:07 +0200 Subject: [PATCH 52/72] chore(core): fix some imports --- packages/trilium-core/src/services/notes.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/trilium-core/src/services/notes.ts b/packages/trilium-core/src/services/notes.ts index 56f01e6d6a..d1b908fdf7 100644 --- a/packages/trilium-core/src/services/notes.ts +++ b/packages/trilium-core/src/services/notes.ts @@ -1,6 +1,8 @@ import type { AttachmentRow, AttributeRow, BranchRow, NoteRow, NoteType } from "@triliumnext/commons"; import { dayjs } from "@triliumnext/commons"; -import { date_utils, events as eventService, ValidationError } from "@triliumnext/core"; +import date_utils from "../services/utils/date"; +import eventService from "../services/events"; +import { ValidationError } from "../errors.js"; import fs from "fs"; import html2plaintext from "html2plaintext"; import { t } from "i18next"; From ecb27fe9f7b2583e2c7abefaae47132b488c227a Mon Sep 17 00:00:00 2001 From: Elian Doran <contact@eliandoran.me> Date: Tue, 6 Jan 2026 15:48:48 +0200 Subject: [PATCH 53/72] chore(core): integrate tree service --- apps/server/src/services/tree.ts | 282 +----------------- packages/trilium-core/src/index.ts | 1 + .../trilium-core}/src/services/tree.spec.ts | 0 packages/trilium-core/src/services/tree.ts | 282 ++++++++++++++++++ 4 files changed, 285 insertions(+), 280 deletions(-) rename {apps/server => packages/trilium-core}/src/services/tree.spec.ts (100%) create mode 100644 packages/trilium-core/src/services/tree.ts diff --git a/apps/server/src/services/tree.ts b/apps/server/src/services/tree.ts index 05c9ecdd9b..98b8892922 100644 --- a/apps/server/src/services/tree.ts +++ b/apps/server/src/services/tree.ts @@ -1,280 +1,2 @@ -"use strict"; - -import sql from "./sql.js"; -import log from "./log.js"; -import BBranch from "../becca/entities/bbranch.js"; -import entityChangesService from "./entity_changes.js"; -import becca from "../becca/becca.js"; -import type BNote from "../becca/entities/bnote.js"; - -export interface ValidationResponse { - branch: BBranch | null; - success: boolean; - message?: string; -} - -function validateParentChild(parentNoteId: string, childNoteId: string, branchId: string | null = null): ValidationResponse { - if (["root", "_hidden", "_share", "_lbRoot", "_lbAvailableLaunchers", "_lbVisibleLaunchers"].includes(childNoteId)) { - return { branch: null, success: false, message: `Cannot change this note's location.` }; - } - - if (parentNoteId === "none") { - // this shouldn't happen - return { branch: null, success: false, message: `Cannot move anything into 'none' parent.` }; - } - - const existingBranch = becca.getBranchFromChildAndParent(childNoteId, parentNoteId); - - if (existingBranch && existingBranch.branchId !== branchId) { - const parentNote = becca.getNote(parentNoteId); - const childNote = becca.getNote(childNoteId); - - return { - branch: existingBranch, - success: false, - message: `Note "${childNote?.title}" note already exists in the "${parentNote?.title}".` - }; - } - - if (wouldAddingBranchCreateCycle(parentNoteId, childNoteId)) { - return { - branch: null, - success: false, - message: "Moving/cloning note here would create cycle." - }; - } - - if (parentNoteId !== "_lbBookmarks" && becca.getNote(parentNoteId)?.type === "launcher") { - return { - branch: null, - success: false, - message: "Launcher note cannot have any children." - }; - } - - return { branch: null, success: true }; -} - -/** - * Tree cycle can be created when cloning or when moving existing clone. This method should detect both cases. - */ -function wouldAddingBranchCreateCycle(parentNoteId: string, childNoteId: string) { - if (parentNoteId === childNoteId) { - return true; - } - - const childNote = becca.getNote(childNoteId); - const parentNote = becca.getNote(parentNoteId); - - if (!childNote || !parentNote) { - return false; - } - - // we'll load the whole subtree - because the cycle can start in one of the notes in the subtree - const childSubtreeNoteIds = new Set(childNote.getSubtreeNoteIds()); - const parentAncestorNoteIds = parentNote.getAncestorNoteIds(); - - return parentAncestorNoteIds.some((parentAncestorNoteId) => childSubtreeNoteIds.has(parentAncestorNoteId)); -} - -function sortNotes(parentNoteId: string, customSortBy: string = "title", reverse = false, foldersFirst = false, sortNatural = false, _sortLocale?: string | null) { - if (!customSortBy) { - customSortBy = "title"; - } - - // sortLocale can not be empty string or null value, default value must be set to undefined. - const sortLocale = _sortLocale || undefined; - - sql.transactional(() => { - const note = becca.getNote(parentNoteId); - if (!note) { - throw new Error("Unable to find note"); - } - - const notes = note.getChildNotes(); - - function normalize<T>(obj: T | string) { - return obj && typeof obj === "string" ? obj.toLowerCase() : obj; - } - - notes.sort((a, b) => { - if (foldersFirst) { - const aHasChildren = a.hasChildren(); - const bHasChildren = b.hasChildren(); - - if ((aHasChildren && !bHasChildren) || (!aHasChildren && bHasChildren)) { - // exactly one note of the two is a directory, so the sorting will be done based on this status - return aHasChildren ? -1 : 1; - } - } - - function fetchValue(note: BNote, key: string) { - let rawValue: string | null; - - if (key === "title") { - const branch = note.getParentBranches().find((branch) => branch.parentNoteId === parentNoteId); - const prefix = branch?.prefix; - rawValue = prefix ? `${prefix} - ${note.title}` : note.title; - } else { - rawValue = ["dateCreated", "dateModified"].includes(key) ? (note as any)[key] : note.getLabelValue(key); - } - - return normalize(rawValue); - } - - function compare(a: string, b: string) { - if (!sortNatural) { - // alphabetical sort - return b === null || b === undefined || a < b ? -1 : 1; - } else { - // natural sort - return a.localeCompare(b, sortLocale, { numeric: true, sensitivity: "base" }); - } - } - - const topAEl = fetchValue(a, "top"); - const topBEl = fetchValue(b, "top"); - - if (topAEl !== topBEl) { - if (topAEl === null) return reverse ? -1 : 1; - if (topBEl === null) return reverse ? 1 : -1; - - // since "top" should not be reversible, we'll reverse it once more to nullify this effect - return compare(topAEl, topBEl) * (reverse ? -1 : 1); - } - - const bottomAEl = fetchValue(a, "bottom"); - const bottomBEl = fetchValue(b, "bottom"); - - if (bottomAEl !== bottomBEl) { - if (bottomAEl === null) return reverse ? 1 : -1; - if (bottomBEl === null) return reverse ? -1 : 1; - - // since "bottom" should not be reversible, we'll reverse it once more to nullify this effect - return compare(bottomBEl, bottomAEl) * (reverse ? -1 : 1); - } - - const customAEl = fetchValue(a, customSortBy) ?? fetchValue(a, "title") as string; - const customBEl = fetchValue(b, customSortBy) ?? fetchValue(b, "title") as string; - - if (customAEl !== customBEl) { - return compare(customAEl, customBEl); - } - - const titleAEl = fetchValue(a, "title") as string; - const titleBEl = fetchValue(b, "title") as string; - - return compare(titleAEl, titleBEl); - }); - - if (reverse) { - notes.reverse(); - } - - let position = 10; - let someBranchUpdated = false; - - for (const note of notes) { - const branch = note.getParentBranches().find((b) => b.parentNoteId === parentNoteId); - if (!branch) { - continue; - } - - if (branch.noteId === "_hidden") { - position = 999_999_999; - } - - if (branch.notePosition !== position) { - sql.execute("UPDATE branches SET notePosition = ? WHERE branchId = ?", [position, branch.branchId]); - - branch.notePosition = position; - someBranchUpdated = true; - } - - position += 10; - } - - if (someBranchUpdated) { - entityChangesService.putNoteReorderingEntityChange(parentNoteId); - } - }); -} - -function sortNotesIfNeeded(parentNoteId: string) { - const parentNote = becca.getNote(parentNoteId); - if (!parentNote) { - return; - } - - const sortedLabel = parentNote.getLabel("sorted"); - - if (!sortedLabel || sortedLabel.value === "off") { - return; - } - - const sortReversed = parentNote.getLabelValue("sortDirection")?.toLowerCase() === "desc"; - const sortFoldersFirst = parentNote.isLabelTruthy("sortFoldersFirst"); - const sortNatural = parentNote.isLabelTruthy("sortNatural"); - const sortLocale = parentNote.getLabelValue("sortLocale"); - - sortNotes(parentNoteId, sortedLabel.value, sortReversed, sortFoldersFirst, sortNatural, sortLocale); -} - -/** - * @deprecated this will be removed in the future - */ -function setNoteToParent(noteId: string, prefix: string, parentNoteId: string) { - const parentNote = becca.getNote(parentNoteId); - - if (parentNoteId && !parentNote) { - // null parentNoteId is a valid value - throw new Error(`Cannot move note to deleted / missing parent note '${parentNoteId}'`); - } - - // case where there might be more such branches is ignored. It's expected there should be just one - const branchId = sql.getValue<string>("SELECT branchId FROM branches WHERE isDeleted = 0 AND noteId = ? AND prefix = ?", [noteId, prefix]); - const branch = becca.getBranch(branchId); - - if (branch) { - if (!parentNoteId) { - log.info(`Removing note '${noteId}' from parent '${parentNoteId}'`); - - branch.markAsDeleted(); - } else { - const newBranch = branch.createClone(parentNoteId); - newBranch.save(); - - branch.markAsDeleted(); - } - } else if (parentNoteId) { - const note = becca.getNote(noteId); - if (!note) { - throw new Error(`Cannot find note '${noteId}.`); - } - - if (note.isDeleted) { - throw new Error(`Cannot create a branch for '${noteId}' which is deleted.`); - } - - const branchId = sql.getValue<string>("SELECT branchId FROM branches WHERE isDeleted = 0 AND noteId = ? AND parentNoteId = ?", [noteId, parentNoteId]); - const branch = becca.getBranch(branchId); - - if (branch) { - branch.prefix = prefix; - branch.save(); - } else { - new BBranch({ - noteId: noteId, - parentNoteId: parentNoteId, - prefix: prefix - }).save(); - } - } -} - -export default { - validateParentChild, - sortNotes, - sortNotesIfNeeded, - setNoteToParent -}; +import { tree } from "@triliumnext/core"; +export default tree; diff --git a/packages/trilium-core/src/index.ts b/packages/trilium-core/src/index.ts index 1d9bd74591..f459227b81 100644 --- a/packages/trilium-core/src/index.ts +++ b/packages/trilium-core/src/index.ts @@ -25,6 +25,7 @@ export * from "./errors"; export { default as getInstanceId } from "./services/instance_id"; export type { CryptoProvider } from "./services/encryption/crypto"; export { default as note_types } from "./services/note_types"; +export { default as tree } from "./services/tree"; export { default as becca } from "./becca/becca"; export { default as becca_loader } from "./becca/becca_loader"; diff --git a/apps/server/src/services/tree.spec.ts b/packages/trilium-core/src/services/tree.spec.ts similarity index 100% rename from apps/server/src/services/tree.spec.ts rename to packages/trilium-core/src/services/tree.spec.ts diff --git a/packages/trilium-core/src/services/tree.ts b/packages/trilium-core/src/services/tree.ts new file mode 100644 index 0000000000..896af1014d --- /dev/null +++ b/packages/trilium-core/src/services/tree.ts @@ -0,0 +1,282 @@ +"use strict"; + +import { getLog } from "./log.js"; +import BBranch from "../becca/entities/bbranch.js"; +import entityChangesService from "./entity_changes.js"; +import becca from "../becca/becca.js"; +import type BNote from "../becca/entities/bnote.js"; +import { getSql } from "./sql/index.js"; + +export interface ValidationResponse { + branch: BBranch | null; + success: boolean; + message?: string; +} + +function validateParentChild(parentNoteId: string, childNoteId: string, branchId: string | null = null): ValidationResponse { + if (["root", "_hidden", "_share", "_lbRoot", "_lbAvailableLaunchers", "_lbVisibleLaunchers"].includes(childNoteId)) { + return { branch: null, success: false, message: `Cannot change this note's location.` }; + } + + if (parentNoteId === "none") { + // this shouldn't happen + return { branch: null, success: false, message: `Cannot move anything into 'none' parent.` }; + } + + const existingBranch = becca.getBranchFromChildAndParent(childNoteId, parentNoteId); + + if (existingBranch && existingBranch.branchId !== branchId) { + const parentNote = becca.getNote(parentNoteId); + const childNote = becca.getNote(childNoteId); + + return { + branch: existingBranch, + success: false, + message: `Note "${childNote?.title}" note already exists in the "${parentNote?.title}".` + }; + } + + if (wouldAddingBranchCreateCycle(parentNoteId, childNoteId)) { + return { + branch: null, + success: false, + message: "Moving/cloning note here would create cycle." + }; + } + + if (parentNoteId !== "_lbBookmarks" && becca.getNote(parentNoteId)?.type === "launcher") { + return { + branch: null, + success: false, + message: "Launcher note cannot have any children." + }; + } + + return { branch: null, success: true }; +} + +/** + * Tree cycle can be created when cloning or when moving existing clone. This method should detect both cases. + */ +function wouldAddingBranchCreateCycle(parentNoteId: string, childNoteId: string) { + if (parentNoteId === childNoteId) { + return true; + } + + const childNote = becca.getNote(childNoteId); + const parentNote = becca.getNote(parentNoteId); + + if (!childNote || !parentNote) { + return false; + } + + // we'll load the whole subtree - because the cycle can start in one of the notes in the subtree + const childSubtreeNoteIds = new Set(childNote.getSubtreeNoteIds()); + const parentAncestorNoteIds = parentNote.getAncestorNoteIds(); + + return parentAncestorNoteIds.some((parentAncestorNoteId) => childSubtreeNoteIds.has(parentAncestorNoteId)); +} + +function sortNotes(parentNoteId: string, customSortBy: string = "title", reverse = false, foldersFirst = false, sortNatural = false, _sortLocale?: string | null) { + if (!customSortBy) { + customSortBy = "title"; + } + + // sortLocale can not be empty string or null value, default value must be set to undefined. + const sortLocale = _sortLocale || undefined; + + const sql = getSql(); + sql.transactional(() => { + const note = becca.getNote(parentNoteId); + if (!note) { + throw new Error("Unable to find note"); + } + + const notes = note.getChildNotes(); + + function normalize<T>(obj: T | string) { + return obj && typeof obj === "string" ? obj.toLowerCase() : obj; + } + + notes.sort((a, b) => { + if (foldersFirst) { + const aHasChildren = a.hasChildren(); + const bHasChildren = b.hasChildren(); + + if ((aHasChildren && !bHasChildren) || (!aHasChildren && bHasChildren)) { + // exactly one note of the two is a directory, so the sorting will be done based on this status + return aHasChildren ? -1 : 1; + } + } + + function fetchValue(note: BNote, key: string) { + let rawValue: string | null; + + if (key === "title") { + const branch = note.getParentBranches().find((branch) => branch.parentNoteId === parentNoteId); + const prefix = branch?.prefix; + rawValue = prefix ? `${prefix} - ${note.title}` : note.title; + } else { + rawValue = ["dateCreated", "dateModified"].includes(key) ? (note as any)[key] : note.getLabelValue(key); + } + + return normalize(rawValue); + } + + function compare(a: string, b: string) { + if (!sortNatural) { + // alphabetical sort + return b === null || b === undefined || a < b ? -1 : 1; + } else { + // natural sort + return a.localeCompare(b, sortLocale, { numeric: true, sensitivity: "base" }); + } + } + + const topAEl = fetchValue(a, "top"); + const topBEl = fetchValue(b, "top"); + + if (topAEl !== topBEl) { + if (topAEl === null) return reverse ? -1 : 1; + if (topBEl === null) return reverse ? 1 : -1; + + // since "top" should not be reversible, we'll reverse it once more to nullify this effect + return compare(topAEl, topBEl) * (reverse ? -1 : 1); + } + + const bottomAEl = fetchValue(a, "bottom"); + const bottomBEl = fetchValue(b, "bottom"); + + if (bottomAEl !== bottomBEl) { + if (bottomAEl === null) return reverse ? 1 : -1; + if (bottomBEl === null) return reverse ? -1 : 1; + + // since "bottom" should not be reversible, we'll reverse it once more to nullify this effect + return compare(bottomBEl, bottomAEl) * (reverse ? -1 : 1); + } + + const customAEl = fetchValue(a, customSortBy) ?? fetchValue(a, "title") as string; + const customBEl = fetchValue(b, customSortBy) ?? fetchValue(b, "title") as string; + + if (customAEl !== customBEl) { + return compare(customAEl, customBEl); + } + + const titleAEl = fetchValue(a, "title") as string; + const titleBEl = fetchValue(b, "title") as string; + + return compare(titleAEl, titleBEl); + }); + + if (reverse) { + notes.reverse(); + } + + let position = 10; + let someBranchUpdated = false; + + for (const note of notes) { + const branch = note.getParentBranches().find((b) => b.parentNoteId === parentNoteId); + if (!branch) { + continue; + } + + if (branch.noteId === "_hidden") { + position = 999_999_999; + } + + if (branch.notePosition !== position) { + sql.execute("UPDATE branches SET notePosition = ? WHERE branchId = ?", [position, branch.branchId]); + + branch.notePosition = position; + someBranchUpdated = true; + } + + position += 10; + } + + if (someBranchUpdated) { + entityChangesService.putNoteReorderingEntityChange(parentNoteId); + } + }); +} + +function sortNotesIfNeeded(parentNoteId: string) { + const parentNote = becca.getNote(parentNoteId); + if (!parentNote) { + return; + } + + const sortedLabel = parentNote.getLabel("sorted"); + + if (!sortedLabel || sortedLabel.value === "off") { + return; + } + + const sortReversed = parentNote.getLabelValue("sortDirection")?.toLowerCase() === "desc"; + const sortFoldersFirst = parentNote.isLabelTruthy("sortFoldersFirst"); + const sortNatural = parentNote.isLabelTruthy("sortNatural"); + const sortLocale = parentNote.getLabelValue("sortLocale"); + + sortNotes(parentNoteId, sortedLabel.value, sortReversed, sortFoldersFirst, sortNatural, sortLocale); +} + +/** + * @deprecated this will be removed in the future + */ +function setNoteToParent(noteId: string, prefix: string, parentNoteId: string) { + const parentNote = becca.getNote(parentNoteId); + + if (parentNoteId && !parentNote) { + // null parentNoteId is a valid value + throw new Error(`Cannot move note to deleted / missing parent note '${parentNoteId}'`); + } + + // case where there might be more such branches is ignored. It's expected there should be just one + const sql = getSql(); + const branchId = sql.getValue<string>("SELECT branchId FROM branches WHERE isDeleted = 0 AND noteId = ? AND prefix = ?", [noteId, prefix]); + const branch = becca.getBranch(branchId); + + if (branch) { + if (!parentNoteId) { + getLog().info(`Removing note '${noteId}' from parent '${parentNoteId}'`); + + branch.markAsDeleted(); + } else { + const newBranch = branch.createClone(parentNoteId); + newBranch.save(); + + branch.markAsDeleted(); + } + } else if (parentNoteId) { + const note = becca.getNote(noteId); + if (!note) { + throw new Error(`Cannot find note '${noteId}.`); + } + + if (note.isDeleted) { + throw new Error(`Cannot create a branch for '${noteId}' which is deleted.`); + } + + const branchId = sql.getValue<string>("SELECT branchId FROM branches WHERE isDeleted = 0 AND noteId = ? AND parentNoteId = ?", [noteId, parentNoteId]); + const branch = becca.getBranch(branchId); + + if (branch) { + branch.prefix = prefix; + branch.save(); + } else { + new BBranch({ + noteId: noteId, + parentNoteId: parentNoteId, + prefix: prefix + }).save(); + } + } +} + +export default { + validateParentChild, + sortNotes, + sortNotesIfNeeded, + setNoteToParent +}; From e905c1ec1172b2ef1e60b49422b21d50346c239b Mon Sep 17 00:00:00 2001 From: Elian Doran <contact@eliandoran.me> Date: Tue, 6 Jan 2026 15:50:23 +0200 Subject: [PATCH 54/72] chore(core): integrate cloning service --- apps/server/src/services/cloning.ts | 192 +----------------- packages/trilium-core/src/index.ts | 1 + packages/trilium-core/src/services/cloning.ts | 190 +++++++++++++++++ 3 files changed, 193 insertions(+), 190 deletions(-) create mode 100644 packages/trilium-core/src/services/cloning.ts diff --git a/apps/server/src/services/cloning.ts b/apps/server/src/services/cloning.ts index fc5552b42c..475ee81bc9 100644 --- a/apps/server/src/services/cloning.ts +++ b/apps/server/src/services/cloning.ts @@ -1,190 +1,2 @@ -"use strict"; - -import sql from "./sql.js"; -import eventChangesService from "./entity_changes.js"; -import treeService from "./tree.js"; -import BBranch from "../becca/entities/bbranch.js"; -import becca from "../becca/becca.js"; -import log from "./log.js"; -import { CloneResponse } from "@triliumnext/commons"; - -function cloneNoteToParentNote(noteId: string, parentNoteId: string, prefix: string | null = null): CloneResponse { - if (!(noteId in becca.notes) || !(parentNoteId in becca.notes)) { - return { success: false, message: "Note cannot be cloned because either the cloned note or the intended parent is deleted." }; - } - - const parentNote = becca.getNote(parentNoteId); - if (!parentNote) { - return { success: false, message: "Note cannot be cloned because the parent note could not be found." }; - } - - if (parentNote.type === "search") { - return { - success: false, - message: "Can't clone into a search note" - }; - } - - const validationResult = treeService.validateParentChild(parentNoteId, noteId); - - if (!validationResult.success) { - return validationResult; - } - - const branch = new BBranch({ - noteId: noteId, - parentNoteId: parentNoteId, - prefix: prefix, - isExpanded: false - }).save(); - - log.info(`Cloned note '${noteId}' to a new parent note '${parentNoteId}' with prefix '${prefix}'`); - - return { - success: true, - branchId: branch.branchId, - notePath: `${parentNote.getBestNotePathString()}/${noteId}` - }; -} - -function cloneNoteToBranch(noteId: string, parentBranchId: string, prefix?: string) { - const parentBranch = becca.getBranch(parentBranchId); - - if (!parentBranch) { - return { success: false, message: `Parent branch '${parentBranchId}' does not exist.` }; - } - - const ret = cloneNoteToParentNote(noteId, parentBranch.noteId, prefix); - - parentBranch.isExpanded = true; // the new target should be expanded, so it immediately shows up to the user - parentBranch.save(); - - return ret; -} - -function ensureNoteIsPresentInParent(noteId: string, parentNoteId: string, prefix?: string) { - if (!(noteId in becca.notes)) { - return { branch: null, success: false, message: `Note '${noteId}' is deleted.` }; - } else if (!(parentNoteId in becca.notes)) { - return { branch: null, success: false, message: `Note '${parentNoteId}' is deleted.` }; - } - - const parentNote = becca.getNote(parentNoteId); - - if (!parentNote) { - return { branch: null, success: false, message: "Can't find parent note." }; - } - if (parentNote.type === "search") { - return { branch: null, success: false, message: "Can't clone into a search note" }; - } - - const validationResult = treeService.validateParentChild(parentNoteId, noteId); - - if (!validationResult.success) { - return validationResult; - } - - const branch = new BBranch({ - noteId: noteId, - parentNoteId: parentNoteId, - prefix: prefix, - isExpanded: false - }).save(); - - log.info(`Ensured note '${noteId}' is in parent note '${parentNoteId}' with prefix '${branch.prefix}'`); - - return { branch: branch, success: true }; -} - -function ensureNoteIsAbsentFromParent(noteId: string, parentNoteId: string) { - const branchId = sql.getValue<string>(/*sql*/`SELECT branchId FROM branches WHERE noteId = ? AND parentNoteId = ? AND isDeleted = 0`, [noteId, parentNoteId]); - const branch = becca.getBranch(branchId); - - if (branch) { - if (!branch.isWeak && branch.getNote().getStrongParentBranches().length <= 1) { - return { - success: false, - message: `Cannot remove branch '${branch.branchId}' between child '${noteId}' and parent '${parentNoteId}' because this would delete the note as well.` - }; - } - - branch.deleteBranch(); - - log.info(`Ensured note '${noteId}' is NOT in parent note '${parentNoteId}'`); - - return { success: true }; - } -} - -function toggleNoteInParent(present: boolean, noteId: string, parentNoteId: string, prefix?: string) { - if (present) { - return ensureNoteIsPresentInParent(noteId, parentNoteId, prefix); - } else { - return ensureNoteIsAbsentFromParent(noteId, parentNoteId); - } -} - -function cloneNoteAfter(noteId: string, afterBranchId: string) { - if (["_hidden", "root"].includes(noteId)) { - return { success: false, message: `Cloning the note '${noteId}' is forbidden.` }; - } - - const afterBranch = becca.getBranch(afterBranchId); - - if (!afterBranch) { - return { success: false, message: `Branch '${afterBranchId}' does not exist.` }; - } - - if (afterBranch.noteId === "_hidden") { - return { success: false, message: "Cannot clone after the hidden branch." }; - } - - const afterNote = becca.getBranch(afterBranchId); - - if (!(noteId in becca.notes)) { - return { success: false, message: `Note to be cloned '${noteId}' is deleted or does not exist.` }; - } else if (!afterNote || !(afterNote.parentNoteId in becca.notes)) { - return { success: false, message: `After note '${afterNote?.parentNoteId}' is deleted or does not exist.` }; - } - - const parentNote = becca.getNote(afterNote.parentNoteId); - - if (!parentNote || parentNote.type === "search") { - return { - success: false, - message: "Can't clone into a search note" - }; - } - - const validationResult = treeService.validateParentChild(afterNote.parentNoteId, noteId); - - if (!validationResult.success) { - return validationResult; - } - - // we don't change utcDateModified, so other changes are prioritized in case of conflict - // also we would have to sync all those modified branches otherwise hash checks would fail - sql.execute("UPDATE branches SET notePosition = notePosition + 10 WHERE parentNoteId = ? AND notePosition > ? AND isDeleted = 0", [afterNote.parentNoteId, afterNote.notePosition]); - - eventChangesService.putNoteReorderingEntityChange(afterNote.parentNoteId); - - const branch = new BBranch({ - noteId: noteId, - parentNoteId: afterNote.parentNoteId, - notePosition: afterNote.notePosition + 10, - isExpanded: false - }).save(); - - log.info(`Cloned note '${noteId}' into parent note '${afterNote.parentNoteId}' after note '${afterNote.noteId}', branch '${afterBranchId}'`); - - return { success: true, branchId: branch.branchId }; -} - -export default { - cloneNoteToBranch, - cloneNoteToParentNote, - ensureNoteIsPresentInParent, - ensureNoteIsAbsentFromParent, - toggleNoteInParent, - cloneNoteAfter -}; +import { cloning } from "@triliumnext/core"; +export default cloning; diff --git a/packages/trilium-core/src/index.ts b/packages/trilium-core/src/index.ts index f459227b81..9bc718b96b 100644 --- a/packages/trilium-core/src/index.ts +++ b/packages/trilium-core/src/index.ts @@ -26,6 +26,7 @@ export { default as getInstanceId } from "./services/instance_id"; export type { CryptoProvider } from "./services/encryption/crypto"; export { default as note_types } from "./services/note_types"; export { default as tree } from "./services/tree"; +export { default as cloning } from "./services/cloning"; export { default as becca } from "./becca/becca"; export { default as becca_loader } from "./becca/becca_loader"; diff --git a/packages/trilium-core/src/services/cloning.ts b/packages/trilium-core/src/services/cloning.ts new file mode 100644 index 0000000000..741723bfa7 --- /dev/null +++ b/packages/trilium-core/src/services/cloning.ts @@ -0,0 +1,190 @@ +"use strict"; + +import eventChangesService from "./entity_changes.js"; +import treeService from "./tree.js"; +import BBranch from "../becca/entities/bbranch.js"; +import becca from "../becca/becca.js"; +import { getLog } from "./log.js"; +import { CloneResponse } from "@triliumnext/commons"; +import { getSql } from "./sql/index.js"; + +function cloneNoteToParentNote(noteId: string, parentNoteId: string, prefix: string | null = null): CloneResponse { + if (!(noteId in becca.notes) || !(parentNoteId in becca.notes)) { + return { success: false, message: "Note cannot be cloned because either the cloned note or the intended parent is deleted." }; + } + + const parentNote = becca.getNote(parentNoteId); + if (!parentNote) { + return { success: false, message: "Note cannot be cloned because the parent note could not be found." }; + } + + if (parentNote.type === "search") { + return { + success: false, + message: "Can't clone into a search note" + }; + } + + const validationResult = treeService.validateParentChild(parentNoteId, noteId); + + if (!validationResult.success) { + return validationResult; + } + + const branch = new BBranch({ + noteId: noteId, + parentNoteId: parentNoteId, + prefix: prefix, + isExpanded: false + }).save(); + + getLog().info(`Cloned note '${noteId}' to a new parent note '${parentNoteId}' with prefix '${prefix}'`); + + return { + success: true, + branchId: branch.branchId, + notePath: `${parentNote.getBestNotePathString()}/${noteId}` + }; +} + +function cloneNoteToBranch(noteId: string, parentBranchId: string, prefix?: string) { + const parentBranch = becca.getBranch(parentBranchId); + + if (!parentBranch) { + return { success: false, message: `Parent branch '${parentBranchId}' does not exist.` }; + } + + const ret = cloneNoteToParentNote(noteId, parentBranch.noteId, prefix); + + parentBranch.isExpanded = true; // the new target should be expanded, so it immediately shows up to the user + parentBranch.save(); + + return ret; +} + +function ensureNoteIsPresentInParent(noteId: string, parentNoteId: string, prefix?: string) { + if (!(noteId in becca.notes)) { + return { branch: null, success: false, message: `Note '${noteId}' is deleted.` }; + } else if (!(parentNoteId in becca.notes)) { + return { branch: null, success: false, message: `Note '${parentNoteId}' is deleted.` }; + } + + const parentNote = becca.getNote(parentNoteId); + + if (!parentNote) { + return { branch: null, success: false, message: "Can't find parent note." }; + } + if (parentNote.type === "search") { + return { branch: null, success: false, message: "Can't clone into a search note" }; + } + + const validationResult = treeService.validateParentChild(parentNoteId, noteId); + + if (!validationResult.success) { + return validationResult; + } + + const branch = new BBranch({ + noteId: noteId, + parentNoteId: parentNoteId, + prefix: prefix, + isExpanded: false + }).save(); + + getLog().info(`Ensured note '${noteId}' is in parent note '${parentNoteId}' with prefix '${branch.prefix}'`); + + return { branch: branch, success: true }; +} + +function ensureNoteIsAbsentFromParent(noteId: string, parentNoteId: string) { + const branchId = getSql().getValue<string>(/*sql*/`SELECT branchId FROM branches WHERE noteId = ? AND parentNoteId = ? AND isDeleted = 0`, [noteId, parentNoteId]); + const branch = becca.getBranch(branchId); + + if (branch) { + if (!branch.isWeak && branch.getNote().getStrongParentBranches().length <= 1) { + return { + success: false, + message: `Cannot remove branch '${branch.branchId}' between child '${noteId}' and parent '${parentNoteId}' because this would delete the note as well.` + }; + } + + branch.deleteBranch(); + + getLog().info(`Ensured note '${noteId}' is NOT in parent note '${parentNoteId}'`); + + return { success: true }; + } +} + +function toggleNoteInParent(present: boolean, noteId: string, parentNoteId: string, prefix?: string) { + if (present) { + return ensureNoteIsPresentInParent(noteId, parentNoteId, prefix); + } else { + return ensureNoteIsAbsentFromParent(noteId, parentNoteId); + } +} + +function cloneNoteAfter(noteId: string, afterBranchId: string) { + if (["_hidden", "root"].includes(noteId)) { + return { success: false, message: `Cloning the note '${noteId}' is forbidden.` }; + } + + const afterBranch = becca.getBranch(afterBranchId); + + if (!afterBranch) { + return { success: false, message: `Branch '${afterBranchId}' does not exist.` }; + } + + if (afterBranch.noteId === "_hidden") { + return { success: false, message: "Cannot clone after the hidden branch." }; + } + + const afterNote = becca.getBranch(afterBranchId); + + if (!(noteId in becca.notes)) { + return { success: false, message: `Note to be cloned '${noteId}' is deleted or does not exist.` }; + } else if (!afterNote || !(afterNote.parentNoteId in becca.notes)) { + return { success: false, message: `After note '${afterNote?.parentNoteId}' is deleted or does not exist.` }; + } + + const parentNote = becca.getNote(afterNote.parentNoteId); + + if (!parentNote || parentNote.type === "search") { + return { + success: false, + message: "Can't clone into a search note" + }; + } + + const validationResult = treeService.validateParentChild(afterNote.parentNoteId, noteId); + + if (!validationResult.success) { + return validationResult; + } + + // we don't change utcDateModified, so other changes are prioritized in case of conflict + // also we would have to sync all those modified branches otherwise hash checks would fail + getSql().execute("UPDATE branches SET notePosition = notePosition + 10 WHERE parentNoteId = ? AND notePosition > ? AND isDeleted = 0", [afterNote.parentNoteId, afterNote.notePosition]); + + eventChangesService.putNoteReorderingEntityChange(afterNote.parentNoteId); + + const branch = new BBranch({ + noteId: noteId, + parentNoteId: afterNote.parentNoteId, + notePosition: afterNote.notePosition + 10, + isExpanded: false + }).save(); + + getLog().info(`Cloned note '${noteId}' into parent note '${afterNote.parentNoteId}' after note '${afterNote.noteId}', branch '${afterBranchId}'`); + + return { success: true, branchId: branch.branchId }; +} + +export default { + cloneNoteToBranch, + cloneNoteToParentNote, + ensureNoteIsPresentInParent, + ensureNoteIsAbsentFromParent, + toggleNoteInParent, + cloneNoteAfter +}; From 0b528e993705d7a2c444afc80acf2bcd11182b05 Mon Sep 17 00:00:00 2001 From: Elian Doran <contact@eliandoran.me> Date: Tue, 6 Jan 2026 15:57:36 +0200 Subject: [PATCH 55/72] chore(core): integrate handlers --- apps/server/src/services/handlers.ts | 253 +----------------- packages/trilium-core/src/index.ts | 1 + .../trilium-core/src/services/handlers.ts | 252 +++++++++++++++++ .../src/services/one_time_timer.ts | 0 4 files changed, 255 insertions(+), 251 deletions(-) create mode 100644 packages/trilium-core/src/services/handlers.ts rename {apps/server => packages/trilium-core}/src/services/one_time_timer.ts (100%) diff --git a/apps/server/src/services/handlers.ts b/apps/server/src/services/handlers.ts index 8a7d6d12f3..0562603fbe 100644 --- a/apps/server/src/services/handlers.ts +++ b/apps/server/src/services/handlers.ts @@ -1,251 +1,2 @@ -import { DefinitionObject } from "@triliumnext/commons"; -import { type AbstractBeccaEntity, events as eventService } from "@triliumnext/core"; - -import becca from "../becca/becca.js"; -import BAttribute from "../becca/entities/battribute.js"; -import type BNote from "../becca/entities/bnote.js"; -import hiddenSubtreeService from "./hidden_subtree.js"; -import noteService from "./notes.js"; -import oneTimeTimer from "./one_time_timer.js"; -import scriptService from "./script.js"; -import treeService from "./tree.js"; - -type Handler = (definition: DefinitionObject, note: BNote, targetNote: BNote) => void; - -function runAttachedRelations(note: BNote, relationName: string, originEntity: AbstractBeccaEntity<any>) { - if (!note) { - return; - } - - // the same script note can get here with multiple ways, but execute only once - const notesToRun = new Set( - note - .getRelations(relationName) - .map((relation) => relation.getTargetNote()) - .filter((note) => !!note) as BNote[] - ); - - for (const noteToRun of notesToRun) { - scriptService.executeNoteNoException(noteToRun, { originEntity }); - } -} - -eventService.subscribe(eventService.NOTE_TITLE_CHANGED, (note) => { - runAttachedRelations(note, "runOnNoteTitleChange", note); - - if (!note.isRoot()) { - const noteFromCache = becca.notes[note.noteId]; - - if (!noteFromCache) { - return; - } - - for (const parentNote of noteFromCache.parents) { - if (parentNote.hasLabel("sorted")) { - treeService.sortNotesIfNeeded(parentNote.noteId); - } - } - } -}); - -eventService.subscribe([eventService.ENTITY_CHANGED, eventService.ENTITY_DELETED], ({ entityName, entity }) => { - if (entityName === "attributes") { - runAttachedRelations(entity.getNote(), "runOnAttributeChange", entity); - - if (entity.type === "label" && ["sorted", "sortDirection", "sortFoldersFirst", "sortNatural", "sortLocale"].includes(entity.name)) { - handleSortedAttribute(entity); - } else if (entity.type === "label") { - handleMaybeSortingLabel(entity); - } - } else if (entityName === "notes") { - // ENTITY_DELETED won't trigger anything since all branches/attributes are already deleted at this point - runAttachedRelations(entity, "runOnNoteChange", entity); - } -}); - -eventService.subscribe(eventService.ENTITY_CHANGED, ({ entityName, entity }) => { - if (entityName === "branches") { - const parentNote = becca.getNote(entity.parentNoteId); - - if (parentNote?.hasLabel("sorted")) { - treeService.sortNotesIfNeeded(parentNote.noteId); - } - - const childNote = becca.getNote(entity.noteId); - - if (childNote) { - runAttachedRelations(childNote, "runOnBranchChange", entity); - } - } -}); - -eventService.subscribe(eventService.NOTE_CONTENT_CHANGE, ({ entity }) => { - runAttachedRelations(entity, "runOnNoteContentChange", entity); -}); - -eventService.subscribe(eventService.ENTITY_CREATED, ({ entityName, entity }) => { - if (entityName === "attributes") { - runAttachedRelations(entity.getNote(), "runOnAttributeCreation", entity); - - if (entity.type === "relation" && entity.name === "template") { - const note = becca.getNote(entity.noteId); - if (!note) { - return; - } - - const templateNote = becca.getNote(entity.value); - - if (!templateNote) { - return; - } - - const content = note.getContent(); - - if ( - ["text", "code", "mermaid", "canvas", "relationMap", "mindMap", "webView"].includes(note.type) && - typeof content === "string" && - // if the note has already content we're not going to overwrite it with template's one - (!content || content.trim().length === 0) && - templateNote.hasStringContent() - ) { - const templateNoteContent = templateNote.getContent(); - - if (templateNoteContent) { - note.setContent(templateNoteContent); - } - - note.type = templateNote.type; - note.mime = templateNote.mime; - note.save(); - } - - // we'll copy the children notes only if there's none so far - // this protects against e.g. multiple assignment of template relation resulting in having multiple copies of the subtree - if (note.getChildNotes().length === 0 && !note.isDescendantOfNote(templateNote.noteId)) { - noteService.duplicateSubtreeWithoutRoot(templateNote.noteId, note.noteId); - } - } else if (entity.type === "label" && ["sorted", "sortDirection", "sortFoldersFirst", "sortNatural", "sortLocale"].includes(entity.name)) { - handleSortedAttribute(entity); - } else if (entity.type === "label") { - handleMaybeSortingLabel(entity); - } - } else if (entityName === "branches") { - runAttachedRelations(entity.getNote(), "runOnBranchCreation", entity); - - if (entity.parentNote?.hasLabel("sorted")) { - treeService.sortNotesIfNeeded(entity.parentNoteId); - } - } else if (entityName === "notes") { - runAttachedRelations(entity, "runOnNoteCreation", entity); - } -}); - -eventService.subscribe(eventService.CHILD_NOTE_CREATED, ({ parentNote, childNote }) => { - runAttachedRelations(parentNote, "runOnChildNoteCreation", childNote); -}); - -function processInverseRelations(entityName: string, entity: BAttribute, handler: Handler) { - if (entityName === "attributes" && entity.type === "relation") { - const note = entity.getNote(); - const relDefinitions = note.getLabels(`relation:${entity.name}`); - - for (const relDefinition of relDefinitions) { - const definition = relDefinition.getDefinition(); - - if (definition.inverseRelation && definition.inverseRelation.trim()) { - const targetNote = entity.getTargetNote(); - - if (targetNote) { - handler(definition, note, targetNote); - } - } - } - } -} - -function handleSortedAttribute(entity: BAttribute) { - treeService.sortNotesIfNeeded(entity.noteId); - - if (entity.isInheritable) { - const note = becca.notes[entity.noteId]; - - if (note) { - for (const noteId of note.getSubtreeNoteIds()) { - treeService.sortNotesIfNeeded(noteId); - } - } - } -} - -function handleMaybeSortingLabel(entity: BAttribute) { - // check if this label is used for sorting, if yes force re-sort - const note = becca.notes[entity.noteId]; - - // this will not work on deleted notes, but in that case we don't really need to re-sort - if (note) { - for (const parentNote of note.getParentNotes()) { - const sorted = parentNote.getLabelValue("sorted"); - if (sorted === null) { - // checking specifically for null since that means the label doesn't exist - // empty valued "sorted" is still valid - continue; - } - - if ( - sorted.includes(entity.name) || // hacky check if this label is used in the sort - entity.name === "top" || - entity.name === "bottom" - ) { - treeService.sortNotesIfNeeded(parentNote.noteId); - } - } - } -} - -eventService.subscribe(eventService.ENTITY_CHANGED, ({ entityName, entity }) => { - processInverseRelations(entityName, entity, (definition, note, targetNote) => { - // we need to make sure that also target's inverse attribute exists and if not, then create it - // inverse attribute has to target our note as well - const hasInverseAttribute = targetNote.getRelations(definition.inverseRelation).some((attr) => attr.value === note.noteId); - - if (!hasInverseAttribute) { - new BAttribute({ - noteId: targetNote.noteId, - type: "relation", - name: definition.inverseRelation || "", - value: note.noteId, - isInheritable: entity.isInheritable - }).save(); - - // becca will not be updated before we'll check from the other side which would create infinite relation creation (#2269) - targetNote.invalidateThisCache(); - } - }); -}); - -eventService.subscribe(eventService.ENTITY_DELETED, ({ entityName, entity }) => { - processInverseRelations(entityName, entity, (definition: DefinitionObject, note: BNote, targetNote: BNote) => { - // if one inverse attribute is deleted, then the other should be deleted as well - const relations = targetNote.getOwnedRelations(definition.inverseRelation); - - for (const relation of relations) { - if (relation.value === note.noteId) { - relation.markAsDeleted(); - } - } - }); - - if (entityName === "branches") { - runAttachedRelations(entity.getNote(), "runOnBranchDeletion", entity); - } - - if (entityName === "notes" && entity.noteId.startsWith("_")) { - // "named" note has been deleted, we will probably need to rebuild the hidden subtree - // scheduling so that bulk deletes won't trigger so many checks - oneTimeTimer.scheduleExecution("hidden-subtree-check", 1000, () => hiddenSubtreeService.checkHiddenSubtree()); - } -}); - -export default { - runAttachedRelations -}; +import { handlers } from "@triliumnext/core"; +export default handlers; diff --git a/packages/trilium-core/src/index.ts b/packages/trilium-core/src/index.ts index 9bc718b96b..128574ab31 100644 --- a/packages/trilium-core/src/index.ts +++ b/packages/trilium-core/src/index.ts @@ -27,6 +27,7 @@ export type { CryptoProvider } from "./services/encryption/crypto"; export { default as note_types } from "./services/note_types"; export { default as tree } from "./services/tree"; export { default as cloning } from "./services/cloning"; +export { default as handlers } from "./services/handlers"; export { default as becca } from "./becca/becca"; export { default as becca_loader } from "./becca/becca_loader"; diff --git a/packages/trilium-core/src/services/handlers.ts b/packages/trilium-core/src/services/handlers.ts new file mode 100644 index 0000000000..ab2a345244 --- /dev/null +++ b/packages/trilium-core/src/services/handlers.ts @@ -0,0 +1,252 @@ +import { DefinitionObject } from "@triliumnext/commons"; + +import becca from "../becca/becca.js"; +import BAttribute from "../becca/entities/battribute.js"; +import type BNote from "../becca/entities/bnote.js"; +import hiddenSubtreeService from "./hidden_subtree.js"; +import noteService from "./notes.js"; +import oneTimeTimer from "./one_time_timer.js"; +import scriptService from "./script.js"; +import treeService from "./tree.js"; +import eventService from "./events.js"; +import AbstractBeccaEntity from "../becca/entities/abstract_becca_entity.js"; + +type Handler = (definition: DefinitionObject, note: BNote, targetNote: BNote) => void; + +function runAttachedRelations(note: BNote, relationName: string, originEntity: AbstractBeccaEntity<any>) { + if (!note) { + return; + } + + // the same script note can get here with multiple ways, but execute only once + const notesToRun = new Set( + note + .getRelations(relationName) + .map((relation) => relation.getTargetNote()) + .filter((note) => !!note) as BNote[] + ); + + for (const noteToRun of notesToRun) { + scriptService.executeNoteNoException(noteToRun, { originEntity }); + } +} + +eventService.subscribe(eventService.NOTE_TITLE_CHANGED, (note) => { + runAttachedRelations(note, "runOnNoteTitleChange", note); + + if (!note.isRoot()) { + const noteFromCache = becca.notes[note.noteId]; + + if (!noteFromCache) { + return; + } + + for (const parentNote of noteFromCache.parents) { + if (parentNote.hasLabel("sorted")) { + treeService.sortNotesIfNeeded(parentNote.noteId); + } + } + } +}); + +eventService.subscribe([eventService.ENTITY_CHANGED, eventService.ENTITY_DELETED], ({ entityName, entity }) => { + if (entityName === "attributes") { + runAttachedRelations(entity.getNote(), "runOnAttributeChange", entity); + + if (entity.type === "label" && ["sorted", "sortDirection", "sortFoldersFirst", "sortNatural", "sortLocale"].includes(entity.name)) { + handleSortedAttribute(entity); + } else if (entity.type === "label") { + handleMaybeSortingLabel(entity); + } + } else if (entityName === "notes") { + // ENTITY_DELETED won't trigger anything since all branches/attributes are already deleted at this point + runAttachedRelations(entity, "runOnNoteChange", entity); + } +}); + +eventService.subscribe(eventService.ENTITY_CHANGED, ({ entityName, entity }) => { + if (entityName === "branches") { + const parentNote = becca.getNote(entity.parentNoteId); + + if (parentNote?.hasLabel("sorted")) { + treeService.sortNotesIfNeeded(parentNote.noteId); + } + + const childNote = becca.getNote(entity.noteId); + + if (childNote) { + runAttachedRelations(childNote, "runOnBranchChange", entity); + } + } +}); + +eventService.subscribe(eventService.NOTE_CONTENT_CHANGE, ({ entity }) => { + runAttachedRelations(entity, "runOnNoteContentChange", entity); +}); + +eventService.subscribe(eventService.ENTITY_CREATED, ({ entityName, entity }) => { + if (entityName === "attributes") { + runAttachedRelations(entity.getNote(), "runOnAttributeCreation", entity); + + if (entity.type === "relation" && entity.name === "template") { + const note = becca.getNote(entity.noteId); + if (!note) { + return; + } + + const templateNote = becca.getNote(entity.value); + + if (!templateNote) { + return; + } + + const content = note.getContent(); + + if ( + ["text", "code", "mermaid", "canvas", "relationMap", "mindMap", "webView"].includes(note.type) && + typeof content === "string" && + // if the note has already content we're not going to overwrite it with template's one + (!content || content.trim().length === 0) && + templateNote.hasStringContent() + ) { + const templateNoteContent = templateNote.getContent(); + + if (templateNoteContent) { + note.setContent(templateNoteContent); + } + + note.type = templateNote.type; + note.mime = templateNote.mime; + note.save(); + } + + // we'll copy the children notes only if there's none so far + // this protects against e.g. multiple assignment of template relation resulting in having multiple copies of the subtree + if (note.getChildNotes().length === 0 && !note.isDescendantOfNote(templateNote.noteId)) { + noteService.duplicateSubtreeWithoutRoot(templateNote.noteId, note.noteId); + } + } else if (entity.type === "label" && ["sorted", "sortDirection", "sortFoldersFirst", "sortNatural", "sortLocale"].includes(entity.name)) { + handleSortedAttribute(entity); + } else if (entity.type === "label") { + handleMaybeSortingLabel(entity); + } + } else if (entityName === "branches") { + runAttachedRelations(entity.getNote(), "runOnBranchCreation", entity); + + if (entity.parentNote?.hasLabel("sorted")) { + treeService.sortNotesIfNeeded(entity.parentNoteId); + } + } else if (entityName === "notes") { + runAttachedRelations(entity, "runOnNoteCreation", entity); + } +}); + +eventService.subscribe(eventService.CHILD_NOTE_CREATED, ({ parentNote, childNote }) => { + runAttachedRelations(parentNote, "runOnChildNoteCreation", childNote); +}); + +function processInverseRelations(entityName: string, entity: BAttribute, handler: Handler) { + if (entityName === "attributes" && entity.type === "relation") { + const note = entity.getNote(); + const relDefinitions = note.getLabels(`relation:${entity.name}`); + + for (const relDefinition of relDefinitions) { + const definition = relDefinition.getDefinition(); + + if (definition.inverseRelation && definition.inverseRelation.trim()) { + const targetNote = entity.getTargetNote(); + + if (targetNote) { + handler(definition, note, targetNote); + } + } + } + } +} + +function handleSortedAttribute(entity: BAttribute) { + treeService.sortNotesIfNeeded(entity.noteId); + + if (entity.isInheritable) { + const note = becca.notes[entity.noteId]; + + if (note) { + for (const noteId of note.getSubtreeNoteIds()) { + treeService.sortNotesIfNeeded(noteId); + } + } + } +} + +function handleMaybeSortingLabel(entity: BAttribute) { + // check if this label is used for sorting, if yes force re-sort + const note = becca.notes[entity.noteId]; + + // this will not work on deleted notes, but in that case we don't really need to re-sort + if (note) { + for (const parentNote of note.getParentNotes()) { + const sorted = parentNote.getLabelValue("sorted"); + if (sorted === null) { + // checking specifically for null since that means the label doesn't exist + // empty valued "sorted" is still valid + continue; + } + + if ( + sorted.includes(entity.name) || // hacky check if this label is used in the sort + entity.name === "top" || + entity.name === "bottom" + ) { + treeService.sortNotesIfNeeded(parentNote.noteId); + } + } + } +} + +eventService.subscribe(eventService.ENTITY_CHANGED, ({ entityName, entity }) => { + processInverseRelations(entityName, entity, (definition, note, targetNote) => { + // we need to make sure that also target's inverse attribute exists and if not, then create it + // inverse attribute has to target our note as well + const hasInverseAttribute = targetNote.getRelations(definition.inverseRelation).some((attr) => attr.value === note.noteId); + + if (!hasInverseAttribute) { + new BAttribute({ + noteId: targetNote.noteId, + type: "relation", + name: definition.inverseRelation || "", + value: note.noteId, + isInheritable: entity.isInheritable + }).save(); + + // becca will not be updated before we'll check from the other side which would create infinite relation creation (#2269) + targetNote.invalidateThisCache(); + } + }); +}); + +eventService.subscribe(eventService.ENTITY_DELETED, ({ entityName, entity }) => { + processInverseRelations(entityName, entity, (definition: DefinitionObject, note: BNote, targetNote: BNote) => { + // if one inverse attribute is deleted, then the other should be deleted as well + const relations = targetNote.getOwnedRelations(definition.inverseRelation); + + for (const relation of relations) { + if (relation.value === note.noteId) { + relation.markAsDeleted(); + } + } + }); + + if (entityName === "branches") { + runAttachedRelations(entity.getNote(), "runOnBranchDeletion", entity); + } + + if (entityName === "notes" && entity.noteId.startsWith("_")) { + // "named" note has been deleted, we will probably need to rebuild the hidden subtree + // scheduling so that bulk deletes won't trigger so many checks + oneTimeTimer.scheduleExecution("hidden-subtree-check", 1000, () => hiddenSubtreeService.checkHiddenSubtree()); + } +}); + +export default { + runAttachedRelations +}; diff --git a/apps/server/src/services/one_time_timer.ts b/packages/trilium-core/src/services/one_time_timer.ts similarity index 100% rename from apps/server/src/services/one_time_timer.ts rename to packages/trilium-core/src/services/one_time_timer.ts From 263c9028e2cfb659390dd7308ff27d7192be66a6 Mon Sep 17 00:00:00 2001 From: Elian Doran <contact@eliandoran.me> Date: Tue, 6 Jan 2026 16:04:56 +0200 Subject: [PATCH 56/72] chore(core): integrate hidden_subtree --- apps/server/src/services/hidden_subtree.ts | 500 +----------------- packages/trilium-core/src/index.ts | 1 + .../src/services/hidden_subtree.ts | 499 +++++++++++++++++ .../services/hidden_subtree_launcherbar.ts | 12 +- .../src/services/hidden_subtree_templates.ts | 0 .../trilium-core/src/services/in_app_help.ts | 8 + .../trilium-core/src/services/migration.ts | 4 + 7 files changed, 520 insertions(+), 504 deletions(-) create mode 100644 packages/trilium-core/src/services/hidden_subtree.ts rename {apps/server => packages/trilium-core}/src/services/hidden_subtree_launcherbar.ts (98%) rename {apps/server => packages/trilium-core}/src/services/hidden_subtree_templates.ts (100%) create mode 100644 packages/trilium-core/src/services/in_app_help.ts create mode 100644 packages/trilium-core/src/services/migration.ts diff --git a/apps/server/src/services/hidden_subtree.ts b/apps/server/src/services/hidden_subtree.ts index a95955f466..4c3d52aa0d 100644 --- a/apps/server/src/services/hidden_subtree.ts +++ b/apps/server/src/services/hidden_subtree.ts @@ -1,498 +1,2 @@ -import BAttribute from "../becca/entities/battribute.js"; -import BBranch from "../becca/entities/bbranch.js"; -import type { HiddenSubtreeItem } from "@triliumnext/commons"; - -import becca from "../becca/becca.js"; -import noteService from "./notes.js"; -import log from "./log.js"; -import migrationService from "./migration.js"; -import { t } from "i18next"; -import { cleanUpHelp, getHelpHiddenSubtreeData } from "./in_app_help.js"; -import buildLaunchBarConfig from "./hidden_subtree_launcherbar.js"; -import buildHiddenSubtreeTemplates from "./hidden_subtree_templates.js"; - -export const LBTPL_ROOT = "_lbTplRoot"; -export const LBTPL_BASE = "_lbTplBase"; -export const LBTPL_HEADER = "_lbTplHeader"; -export const LBTPL_NOTE_LAUNCHER = "_lbTplLauncherNote"; -export const LBTPL_WIDGET = "_lbTplLauncherWidget"; -export const LBTPL_COMMAND = "_lbTplLauncherCommand"; -export const LBTPL_SCRIPT = "_lbTplLauncherScript"; -export const LBTPL_SPACER = "_lbTplSpacer"; -export const LBTPL_CUSTOM_WIDGET = "_lbTplCustomWidget"; - -/* - * Hidden subtree is generated as a "predictable structure" which means that it avoids generating random IDs to always - * produce the same structure. This is needed because it is run on multiple instances in the sync cluster which might produce - * duplicate subtrees. This way, all instances will generate the same structure with the same IDs. - */ - -let hiddenSubtreeDefinition: HiddenSubtreeItem; - -function buildHiddenSubtreeDefinition(helpSubtree: HiddenSubtreeItem[]): HiddenSubtreeItem { - const launchbarConfig = buildLaunchBarConfig(); - - return { - id: "_hidden", - title: t("hidden-subtree.root-title"), - type: "doc", - icon: "bx bx-hide", - // we want to keep the hidden subtree always last, otherwise there will be problems with e.g., keyboard navigation - // over tree when it's in the middle - notePosition: 999_999_999, - enforceAttributes: true, - attributes: [ - { type: "label", name: "docName", value: "hidden" } - ], - children: [ - { - id: "_search", - title: t("hidden-subtree.search-history-title"), - type: "doc" - }, - { - id: "_globalNoteMap", - title: t("hidden-subtree.note-map-title"), - type: "noteMap", - attributes: [ - { type: "label", name: "mapRootNoteId", value: "hoisted" }, - { type: "label", name: "keepCurrentHoisting" } - ] - }, - { - id: "_sqlConsole", - title: t("hidden-subtree.sql-console-history-title"), - type: "doc", - icon: "bx-data" - }, - { - id: "_share", - title: t("hidden-subtree.shared-notes-title"), - type: "doc", - attributes: [{ type: "label", name: "docName", value: "share" }] - }, - { - id: "_bulkAction", - title: t("hidden-subtree.bulk-action-title"), - type: "doc" - }, - { - id: "_backendLog", - title: t("hidden-subtree.backend-log-title"), - type: "contentWidget", - icon: "bx-terminal", - attributes: [ - { type: "label", name: "keepCurrentHoisting" }, - { type: "label", name: "fullContentWidth" } - ] - }, - { - // place for user scripts hidden stuff (scripts should not create notes directly under hidden root) - id: "_userHidden", - title: t("hidden-subtree.user-hidden-title"), - type: "doc", - attributes: [{ type: "label", name: "docName", value: "user_hidden" }] - }, - { - id: LBTPL_ROOT, - title: t("hidden-subtree.launch-bar-templates-title"), - type: "doc", - children: [ - { - id: LBTPL_BASE, - title: t("hidden-subtree.base-abstract-launcher-title"), - type: "doc" - }, - { - id: LBTPL_COMMAND, - title: t("hidden-subtree.command-launcher-title"), - type: "doc", - attributes: [ - { type: "relation", name: "template", value: LBTPL_BASE }, - { type: "label", name: "launcherType", value: "command" }, - { type: "label", name: "docName", value: "launchbar_command_launcher" } - ] - }, - { - id: LBTPL_NOTE_LAUNCHER, - title: t("hidden-subtree.note-launcher-title"), - type: "doc", - attributes: [ - { type: "relation", name: "template", value: LBTPL_BASE }, - { type: "label", name: "launcherType", value: "note" }, - { type: "label", name: "relation:target", value: "promoted" }, - { type: "label", name: "relation:hoistedNote", value: "promoted" }, - { type: "label", name: "label:keyboardShortcut", value: "promoted,text" }, - { type: "label", name: "docName", value: "launchbar_note_launcher" } - ] - }, - { - id: LBTPL_SCRIPT, - title: t("hidden-subtree.script-launcher-title"), - type: "doc", - attributes: [ - { type: "relation", name: "template", value: LBTPL_BASE }, - { type: "label", name: "launcherType", value: "script" }, - { type: "label", name: "relation:script", value: "promoted" }, - { type: "label", name: "label:keyboardShortcut", value: "promoted,text" }, - { type: "label", name: "docName", value: "launchbar_script_launcher" } - ] - }, - { - id: LBTPL_WIDGET, - title: t("hidden-subtree.built-in-widget-title"), - type: "doc", - attributes: [ - { type: "relation", name: "template", value: LBTPL_BASE }, - { type: "label", name: "launcherType", value: "builtinWidget" } - ] - }, - { - id: LBTPL_SPACER, - title: t("hidden-subtree.spacer-title"), - type: "doc", - icon: "bx-move-vertical", - attributes: [ - { type: "relation", name: "template", value: LBTPL_WIDGET }, - { type: "label", name: "builtinWidget", value: "spacer" }, - { type: "label", name: "label:baseSize", value: "promoted,number" }, - { type: "label", name: "label:growthFactor", value: "promoted,number" }, - { type: "label", name: "docName", value: "launchbar_spacer" } - ] - }, - { - id: LBTPL_CUSTOM_WIDGET, - title: t("hidden-subtree.custom-widget-title"), - type: "doc", - attributes: [ - { type: "relation", name: "template", value: LBTPL_BASE }, - { type: "label", name: "launcherType", value: "customWidget" }, - { type: "label", name: "relation:widget", value: "promoted" }, - { type: "label", name: "docName", value: "launchbar_widget_launcher" } - ] - } - ] - }, - { - id: "_lbRoot", - title: t("hidden-subtree.launch-bar-title"), - type: "doc", - icon: "bx-sidebar", - isExpanded: true, - attributes: [{ type: "label", name: "docName", value: "launchbar_intro" }], - children: [ - { - id: "_lbAvailableLaunchers", - title: t("hidden-subtree.available-launchers-title"), - type: "doc", - icon: "bx-hide", - isExpanded: true, - attributes: [{ type: "label", name: "docName", value: "launchbar_intro" }], - children: launchbarConfig.desktopAvailableLaunchers - }, - { - id: "_lbVisibleLaunchers", - title: t("hidden-subtree.visible-launchers-title"), - type: "doc", - icon: "bx-show", - isExpanded: true, - attributes: [{ type: "label", name: "docName", value: "launchbar_intro" }], - children: launchbarConfig.desktopVisibleLaunchers - } - ] - }, - { - id: "_lbMobileRoot", - title: "Mobile Launch Bar", - type: "doc", - icon: "bx-mobile", - isExpanded: true, - attributes: [{ type: "label", name: "docName", value: "launchbar_intro" }], - children: [ - { - id: "_lbMobileAvailableLaunchers", - title: t("hidden-subtree.available-launchers-title"), - type: "doc", - icon: "bx-hide", - isExpanded: true, - attributes: [{ type: "label", name: "docName", value: "launchbar_intro" }], - children: launchbarConfig.mobileAvailableLaunchers - }, - { - id: "_lbMobileVisibleLaunchers", - title: t("hidden-subtree.visible-launchers-title"), - type: "doc", - icon: "bx-show", - isExpanded: true, - attributes: [{ type: "label", name: "docName", value: "launchbar_intro" }], - children: launchbarConfig.mobileVisibleLaunchers - } - ] - }, - { - id: "_options", - title: t("hidden-subtree.options-title"), - type: "book", - icon: "bx-cog", - children: [ - { id: "_optionsAppearance", title: t("hidden-subtree.appearance-title"), type: "contentWidget", icon: "bx-layout" }, - { id: "_optionsShortcuts", title: t("hidden-subtree.shortcuts-title"), type: "contentWidget", icon: "bxs-keyboard" }, - { id: "_optionsTextNotes", title: t("hidden-subtree.text-notes"), type: "contentWidget", icon: "bx-text" }, - { id: "_optionsCodeNotes", title: t("hidden-subtree.code-notes-title"), type: "contentWidget", icon: "bx-code" }, - { id: "_optionsImages", title: t("hidden-subtree.images-title"), type: "contentWidget", icon: "bx-image" }, - { id: "_optionsSpellcheck", title: t("hidden-subtree.spellcheck-title"), type: "contentWidget", icon: "bx-check-double" }, - { id: "_optionsPassword", title: t("hidden-subtree.password-title"), type: "contentWidget", icon: "bx-lock" }, - { id: '_optionsMFA', title: t('hidden-subtree.multi-factor-authentication-title'), type: 'contentWidget', icon: 'bx-lock ' }, - { id: "_optionsEtapi", title: t("hidden-subtree.etapi-title"), type: "contentWidget", icon: "bx-extension" }, - { id: "_optionsBackup", title: t("hidden-subtree.backup-title"), type: "contentWidget", icon: "bx-data" }, - { id: "_optionsSync", title: t("hidden-subtree.sync-title"), type: "contentWidget", icon: "bx-wifi" }, - { id: "_optionsAi", title: t("hidden-subtree.ai-llm-title"), type: "contentWidget", icon: "bx-bot" }, - { id: "_optionsOther", title: t("hidden-subtree.other"), type: "contentWidget", icon: "bx-dots-horizontal" }, - { id: "_optionsLocalization", title: t("hidden-subtree.localization"), type: "contentWidget", icon: "bx-world" }, - { id: "_optionsAdvanced", title: t("hidden-subtree.advanced-title"), type: "contentWidget" } - ] - }, - { - id: "_help", - title: t("hidden-subtree.user-guide"), - type: "book", - icon: "bx-help-circle", - children: helpSubtree, - isExpanded: true - }, - buildHiddenSubtreeTemplates() - ] - }; -} - -interface CheckHiddenExtraOpts { - restoreNames?: boolean; -} - -function checkHiddenSubtree(force = false, extraOpts: CheckHiddenExtraOpts = {}) { - if (!force && !migrationService.isDbUpToDate()) { - // on-delete hook might get triggered during some future migration and cause havoc - log.info("Will not check hidden subtree until migration is finished."); - return; - } - - const helpSubtree = getHelpHiddenSubtreeData(); - if (!hiddenSubtreeDefinition || force) { - hiddenSubtreeDefinition = buildHiddenSubtreeDefinition(helpSubtree); - } - - checkHiddenSubtreeRecursively("root", hiddenSubtreeDefinition, extraOpts); - - try { - cleanUpHelp(helpSubtree); - } catch (e) { - // Non-critical operation should something go wrong. - console.error(e); - } -} - -/** - * Get all expected parent IDs for a given note ID from the hidden subtree definition - */ -function getExpectedParentIds(noteId: string, subtree: HiddenSubtreeItem): string[] { - const expectedParents: string[] = []; - - function traverse(item: HiddenSubtreeItem, parentId: string) { - if (item.id === noteId) { - expectedParents.push(parentId); - } - - if (item.children) { - for (const child of item.children) { - traverse(child, item.id); - } - } - } - - // Start traversal from root - if (subtree.id === noteId) { - expectedParents.push("root"); - } - - if (subtree.children) { - for (const child of subtree.children) { - traverse(child, subtree.id); - } - } - - return expectedParents; -} - -/** - * Check if a note ID is within the hidden subtree structure - */ -function isWithinHiddenSubtree(noteId: string): boolean { - // Consider a note to be within hidden subtree if it starts with underscore - // This is the convention used for hidden subtree notes - return noteId.startsWith("_") || noteId === "root"; -} - -function checkHiddenSubtreeRecursively(parentNoteId: string, item: HiddenSubtreeItem, extraOpts: CheckHiddenExtraOpts = {}) { - if (!item.id || !item.type || !item.title) { - throw new Error(`Item does not contain mandatory properties: ${JSON.stringify(item)}`); - } - - if (item.id.charAt(0) !== "_") { - throw new Error(`ID has to start with underscore, given '${item.id}'`); - } - - let note = becca.notes[item.id]; - let branch; - - if (!note) { - // Missing item, add it. - ({ note, branch } = noteService.createNewNote({ - noteId: item.id, - title: item.title, - type: item.type, - parentNoteId: parentNoteId, - content: item.content ?? "", - ignoreForbiddenParents: true - })); - } else { - // Existing item, check if it's in the right state. - branch = note.getParentBranches().find((branch) => branch.parentNoteId === parentNoteId); - - if (item.content && note.getContent() !== item.content) { - log.info(`Updating content of ${item.id}.`); - note.setContent(item.content); - } - - // Clean up any branches that shouldn't exist according to the meta definition - // For hidden subtree notes, we want to ensure they only exist in their designated locations - if (item.enforceBranches || item.id.startsWith("_help")) { - // If the note exists but doesn't have a branch in the expected parent, - // create the missing branch to ensure it's in the correct location - if (!branch) { - log.info(`Creating missing branch for note ${item.id} under parent ${parentNoteId}.`); - branch = new BBranch({ - noteId: item.id, - parentNoteId: parentNoteId, - notePosition: item.notePosition !== undefined ? item.notePosition : undefined, - isExpanded: item.isExpanded !== undefined ? item.isExpanded : false - }).save(); - } - - // Remove any branches that are not in the expected parent. - const expectedParents = getExpectedParentIds(item.id, hiddenSubtreeDefinition); - const currentBranches = note.getParentBranches(); - - for (const currentBranch of currentBranches) { - // Only delete branches that are not in the expected locations - // and are within the hidden subtree structure (avoid touching user-created clones) - if (!expectedParents.includes(currentBranch.parentNoteId) && - isWithinHiddenSubtree(currentBranch.parentNoteId)) { - log.info(`Removing unexpected branch for note '${item.id}' from parent '${currentBranch.parentNoteId}'`); - currentBranch.markAsDeleted(); - } - } - } - } - - const attrs = [...(item.attributes || [])]; - - if (item.icon) { - attrs.push({ type: "label", name: "iconClass", value: `bx ${item.icon}` }); - } - - if (item.type === "launcher") { - if (item.command) { - attrs.push({ type: "relation", name: "template", value: LBTPL_COMMAND }); - attrs.push({ type: "label", name: "command", value: item.command }); - } else if (item.builtinWidget) { - if (item.builtinWidget === "spacer") { - attrs.push({ type: "relation", name: "template", value: LBTPL_SPACER }); - attrs.push({ type: "label", name: "baseSize", value: item.baseSize }); - attrs.push({ type: "label", name: "growthFactor", value: item.growthFactor }); - } else { - attrs.push({ type: "relation", name: "template", value: LBTPL_WIDGET }); - } - - attrs.push({ type: "label", name: "builtinWidget", value: item.builtinWidget }); - } else if (item.targetNoteId) { - attrs.push({ type: "relation", name: "template", value: LBTPL_NOTE_LAUNCHER }); - attrs.push({ type: "relation", name: "target", value: item.targetNoteId }); - } else { - throw new Error(`No action defined for launcher ${JSON.stringify(item)}`); - } - } - - const shouldRestoreNames = extraOpts.restoreNames || note.noteId.startsWith("_help") || item.id.startsWith("_lb") || item.id.startsWith("_template"); - if (shouldRestoreNames && note.title !== item.title) { - note.title = item.title; - note.save(); - } - - if (note.type !== item.type) { - // enforce a correct note type - note.type = item.type; - note.save(); - } - - if (branch) { - // in case of launchers the branch ID is not preserved and should not be relied upon - launchers which move between - // visible and available will change branch since the branch's parent-child relationship is immutable - if (item.notePosition !== undefined && branch.notePosition !== item.notePosition) { - branch.notePosition = item.notePosition; - branch.save(); - } - - if (item.isExpanded !== undefined && branch.isExpanded !== item.isExpanded) { - branch.isExpanded = item.isExpanded; - branch.save(); - } - } - - // Enforce attribute structure if needed. - if (item.enforceAttributes) { - for (const attribute of note.getAttributes()) { - // Remove unwanted attributes. - const attrDef = attrs.find(a => a.name === attribute.name); - if (!attrDef) { - attribute.markAsDeleted(); - continue; - } - - // Ensure value is consistent. - if (attribute.value !== attrDef.value) { - note.setAttributeValueById(attribute.attributeId, attrDef.value); - } - } - } - - for (const attr of attrs) { - const attrId = note.noteId + "_" + attr.type.charAt(0) + attr.name; - - const existingAttribute = note.getAttributes().find((attr) => attr.attributeId === attrId); - - if (!existingAttribute) { - new BAttribute({ - attributeId: attrId, - noteId: note.noteId, - type: attr.type, - name: attr.name, - value: attr.value, - isInheritable: attr.isInheritable - }).save(); - } else if (attr.name === "docName" || (existingAttribute.noteId.startsWith("_help") && attr.name === "iconClass")) { - if (existingAttribute.value !== attr.value) { - log.info(`Updating attribute ${attrId} from "${existingAttribute.value}" to "${attr.value}"`); - existingAttribute.value = attr.value ?? ""; - existingAttribute.save(); - } - } - } - - for (const child of item.children || []) { - checkHiddenSubtreeRecursively(item.id, child, extraOpts); - } -} - -export default { - checkHiddenSubtree -}; +import { hidden_subtree } from "@triliumnext/core"; +export default hidden_subtree; diff --git a/packages/trilium-core/src/index.ts b/packages/trilium-core/src/index.ts index 128574ab31..635f44c0bd 100644 --- a/packages/trilium-core/src/index.ts +++ b/packages/trilium-core/src/index.ts @@ -19,6 +19,7 @@ export { default as options_init } from "./services/options_init"; 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 { getContext, type ExecutionContext } from "./services/context"; export * as cls from "./services/context"; export * from "./errors"; diff --git a/packages/trilium-core/src/services/hidden_subtree.ts b/packages/trilium-core/src/services/hidden_subtree.ts new file mode 100644 index 0000000000..ba8da6da1f --- /dev/null +++ b/packages/trilium-core/src/services/hidden_subtree.ts @@ -0,0 +1,499 @@ +import BAttribute from "../becca/entities/battribute.js"; +import BBranch from "../becca/entities/bbranch.js"; +import type { HiddenSubtreeItem } from "@triliumnext/commons"; + +import becca from "../becca/becca.js"; +import noteService from "./notes.js"; +import { getLog } from "./log.js"; +import * as migrationService from "./migration.js"; +import { t } from "i18next"; +import { cleanUpHelp, getHelpHiddenSubtreeData } from "./in_app_help.js"; +import buildLaunchBarConfig from "./hidden_subtree_launcherbar.js"; +import buildHiddenSubtreeTemplates from "./hidden_subtree_templates.js"; + +export const LBTPL_ROOT = "_lbTplRoot"; +export const LBTPL_BASE = "_lbTplBase"; +export const LBTPL_HEADER = "_lbTplHeader"; +export const LBTPL_NOTE_LAUNCHER = "_lbTplLauncherNote"; +export const LBTPL_WIDGET = "_lbTplLauncherWidget"; +export const LBTPL_COMMAND = "_lbTplLauncherCommand"; +export const LBTPL_SCRIPT = "_lbTplLauncherScript"; +export const LBTPL_SPACER = "_lbTplSpacer"; +export const LBTPL_CUSTOM_WIDGET = "_lbTplCustomWidget"; + +/* + * Hidden subtree is generated as a "predictable structure" which means that it avoids generating random IDs to always + * produce the same structure. This is needed because it is run on multiple instances in the sync cluster which might produce + * duplicate subtrees. This way, all instances will generate the same structure with the same IDs. + */ + +let hiddenSubtreeDefinition: HiddenSubtreeItem; + +function buildHiddenSubtreeDefinition(helpSubtree: HiddenSubtreeItem[]): HiddenSubtreeItem { + const launchbarConfig = buildLaunchBarConfig(); + + return { + id: "_hidden", + title: t("hidden-subtree.root-title"), + type: "doc", + icon: "bx bx-hide", + // we want to keep the hidden subtree always last, otherwise there will be problems with e.g., keyboard navigation + // over tree when it's in the middle + notePosition: 999_999_999, + enforceAttributes: true, + attributes: [ + { type: "label", name: "docName", value: "hidden" } + ], + children: [ + { + id: "_search", + title: t("hidden-subtree.search-history-title"), + type: "doc" + }, + { + id: "_globalNoteMap", + title: t("hidden-subtree.note-map-title"), + type: "noteMap", + attributes: [ + { type: "label", name: "mapRootNoteId", value: "hoisted" }, + { type: "label", name: "keepCurrentHoisting" } + ] + }, + { + id: "_sqlConsole", + title: t("hidden-subtree.sql-console-history-title"), + type: "doc", + icon: "bx-data" + }, + { + id: "_share", + title: t("hidden-subtree.shared-notes-title"), + type: "doc", + attributes: [{ type: "label", name: "docName", value: "share" }] + }, + { + id: "_bulkAction", + title: t("hidden-subtree.bulk-action-title"), + type: "doc" + }, + { + id: "_backendLog", + title: t("hidden-subtree.backend-log-title"), + type: "contentWidget", + icon: "bx-terminal", + attributes: [ + { type: "label", name: "keepCurrentHoisting" }, + { type: "label", name: "fullContentWidth" } + ] + }, + { + // place for user scripts hidden stuff (scripts should not create notes directly under hidden root) + id: "_userHidden", + title: t("hidden-subtree.user-hidden-title"), + type: "doc", + attributes: [{ type: "label", name: "docName", value: "user_hidden" }] + }, + { + id: LBTPL_ROOT, + title: t("hidden-subtree.launch-bar-templates-title"), + type: "doc", + children: [ + { + id: LBTPL_BASE, + title: t("hidden-subtree.base-abstract-launcher-title"), + type: "doc" + }, + { + id: LBTPL_COMMAND, + title: t("hidden-subtree.command-launcher-title"), + type: "doc", + attributes: [ + { type: "relation", name: "template", value: LBTPL_BASE }, + { type: "label", name: "launcherType", value: "command" }, + { type: "label", name: "docName", value: "launchbar_command_launcher" } + ] + }, + { + id: LBTPL_NOTE_LAUNCHER, + title: t("hidden-subtree.note-launcher-title"), + type: "doc", + attributes: [ + { type: "relation", name: "template", value: LBTPL_BASE }, + { type: "label", name: "launcherType", value: "note" }, + { type: "label", name: "relation:target", value: "promoted" }, + { type: "label", name: "relation:hoistedNote", value: "promoted" }, + { type: "label", name: "label:keyboardShortcut", value: "promoted,text" }, + { type: "label", name: "docName", value: "launchbar_note_launcher" } + ] + }, + { + id: LBTPL_SCRIPT, + title: t("hidden-subtree.script-launcher-title"), + type: "doc", + attributes: [ + { type: "relation", name: "template", value: LBTPL_BASE }, + { type: "label", name: "launcherType", value: "script" }, + { type: "label", name: "relation:script", value: "promoted" }, + { type: "label", name: "label:keyboardShortcut", value: "promoted,text" }, + { type: "label", name: "docName", value: "launchbar_script_launcher" } + ] + }, + { + id: LBTPL_WIDGET, + title: t("hidden-subtree.built-in-widget-title"), + type: "doc", + attributes: [ + { type: "relation", name: "template", value: LBTPL_BASE }, + { type: "label", name: "launcherType", value: "builtinWidget" } + ] + }, + { + id: LBTPL_SPACER, + title: t("hidden-subtree.spacer-title"), + type: "doc", + icon: "bx-move-vertical", + attributes: [ + { type: "relation", name: "template", value: LBTPL_WIDGET }, + { type: "label", name: "builtinWidget", value: "spacer" }, + { type: "label", name: "label:baseSize", value: "promoted,number" }, + { type: "label", name: "label:growthFactor", value: "promoted,number" }, + { type: "label", name: "docName", value: "launchbar_spacer" } + ] + }, + { + id: LBTPL_CUSTOM_WIDGET, + title: t("hidden-subtree.custom-widget-title"), + type: "doc", + attributes: [ + { type: "relation", name: "template", value: LBTPL_BASE }, + { type: "label", name: "launcherType", value: "customWidget" }, + { type: "label", name: "relation:widget", value: "promoted" }, + { type: "label", name: "docName", value: "launchbar_widget_launcher" } + ] + } + ] + }, + { + id: "_lbRoot", + title: t("hidden-subtree.launch-bar-title"), + type: "doc", + icon: "bx-sidebar", + isExpanded: true, + attributes: [{ type: "label", name: "docName", value: "launchbar_intro" }], + children: [ + { + id: "_lbAvailableLaunchers", + title: t("hidden-subtree.available-launchers-title"), + type: "doc", + icon: "bx-hide", + isExpanded: true, + attributes: [{ type: "label", name: "docName", value: "launchbar_intro" }], + children: launchbarConfig.desktopAvailableLaunchers + }, + { + id: "_lbVisibleLaunchers", + title: t("hidden-subtree.visible-launchers-title"), + type: "doc", + icon: "bx-show", + isExpanded: true, + attributes: [{ type: "label", name: "docName", value: "launchbar_intro" }], + children: launchbarConfig.desktopVisibleLaunchers + } + ] + }, + { + id: "_lbMobileRoot", + title: "Mobile Launch Bar", + type: "doc", + icon: "bx-mobile", + isExpanded: true, + attributes: [{ type: "label", name: "docName", value: "launchbar_intro" }], + children: [ + { + id: "_lbMobileAvailableLaunchers", + title: t("hidden-subtree.available-launchers-title"), + type: "doc", + icon: "bx-hide", + isExpanded: true, + attributes: [{ type: "label", name: "docName", value: "launchbar_intro" }], + children: launchbarConfig.mobileAvailableLaunchers + }, + { + id: "_lbMobileVisibleLaunchers", + title: t("hidden-subtree.visible-launchers-title"), + type: "doc", + icon: "bx-show", + isExpanded: true, + attributes: [{ type: "label", name: "docName", value: "launchbar_intro" }], + children: launchbarConfig.mobileVisibleLaunchers + } + ] + }, + { + id: "_options", + title: t("hidden-subtree.options-title"), + type: "book", + icon: "bx-cog", + children: [ + { id: "_optionsAppearance", title: t("hidden-subtree.appearance-title"), type: "contentWidget", icon: "bx-layout" }, + { id: "_optionsShortcuts", title: t("hidden-subtree.shortcuts-title"), type: "contentWidget", icon: "bxs-keyboard" }, + { id: "_optionsTextNotes", title: t("hidden-subtree.text-notes"), type: "contentWidget", icon: "bx-text" }, + { id: "_optionsCodeNotes", title: t("hidden-subtree.code-notes-title"), type: "contentWidget", icon: "bx-code" }, + { id: "_optionsImages", title: t("hidden-subtree.images-title"), type: "contentWidget", icon: "bx-image" }, + { id: "_optionsSpellcheck", title: t("hidden-subtree.spellcheck-title"), type: "contentWidget", icon: "bx-check-double" }, + { id: "_optionsPassword", title: t("hidden-subtree.password-title"), type: "contentWidget", icon: "bx-lock" }, + { id: '_optionsMFA', title: t('hidden-subtree.multi-factor-authentication-title'), type: 'contentWidget', icon: 'bx-lock ' }, + { id: "_optionsEtapi", title: t("hidden-subtree.etapi-title"), type: "contentWidget", icon: "bx-extension" }, + { id: "_optionsBackup", title: t("hidden-subtree.backup-title"), type: "contentWidget", icon: "bx-data" }, + { id: "_optionsSync", title: t("hidden-subtree.sync-title"), type: "contentWidget", icon: "bx-wifi" }, + { id: "_optionsAi", title: t("hidden-subtree.ai-llm-title"), type: "contentWidget", icon: "bx-bot" }, + { id: "_optionsOther", title: t("hidden-subtree.other"), type: "contentWidget", icon: "bx-dots-horizontal" }, + { id: "_optionsLocalization", title: t("hidden-subtree.localization"), type: "contentWidget", icon: "bx-world" }, + { id: "_optionsAdvanced", title: t("hidden-subtree.advanced-title"), type: "contentWidget" } + ] + }, + { + id: "_help", + title: t("hidden-subtree.user-guide"), + type: "book", + icon: "bx-help-circle", + children: helpSubtree, + isExpanded: true + }, + buildHiddenSubtreeTemplates() + ] + }; +} + +interface CheckHiddenExtraOpts { + restoreNames?: boolean; +} + +function checkHiddenSubtree(force = false, extraOpts: CheckHiddenExtraOpts = {}) { + if (!force && !migrationService.isDbUpToDate()) { + // on-delete hook might get triggered during some future migration and cause havoc + getLog().info("Will not check hidden subtree until migration is finished."); + return; + } + + const helpSubtree = getHelpHiddenSubtreeData(); + if (!hiddenSubtreeDefinition || force) { + hiddenSubtreeDefinition = buildHiddenSubtreeDefinition(helpSubtree); + } + + checkHiddenSubtreeRecursively("root", hiddenSubtreeDefinition, extraOpts); + + try { + cleanUpHelp(helpSubtree); + } catch (e) { + // Non-critical operation should something go wrong. + console.error(e); + } +} + +/** + * Get all expected parent IDs for a given note ID from the hidden subtree definition + */ +function getExpectedParentIds(noteId: string, subtree: HiddenSubtreeItem): string[] { + const expectedParents: string[] = []; + + function traverse(item: HiddenSubtreeItem, parentId: string) { + if (item.id === noteId) { + expectedParents.push(parentId); + } + + if (item.children) { + for (const child of item.children) { + traverse(child, item.id); + } + } + } + + // Start traversal from root + if (subtree.id === noteId) { + expectedParents.push("root"); + } + + if (subtree.children) { + for (const child of subtree.children) { + traverse(child, subtree.id); + } + } + + return expectedParents; +} + +/** + * Check if a note ID is within the hidden subtree structure + */ +function isWithinHiddenSubtree(noteId: string): boolean { + // Consider a note to be within hidden subtree if it starts with underscore + // This is the convention used for hidden subtree notes + return noteId.startsWith("_") || noteId === "root"; +} + +function checkHiddenSubtreeRecursively(parentNoteId: string, item: HiddenSubtreeItem, extraOpts: CheckHiddenExtraOpts = {}) { + if (!item.id || !item.type || !item.title) { + throw new Error(`Item does not contain mandatory properties: ${JSON.stringify(item)}`); + } + + if (item.id.charAt(0) !== "_") { + throw new Error(`ID has to start with underscore, given '${item.id}'`); + } + + let note = becca.notes[item.id]; + let branch; + const log = getLog(); + + if (!note) { + // Missing item, add it. + ({ note, branch } = noteService.createNewNote({ + noteId: item.id, + title: item.title, + type: item.type, + parentNoteId: parentNoteId, + content: item.content ?? "", + ignoreForbiddenParents: true + })); + } else { + // Existing item, check if it's in the right state. + branch = note.getParentBranches().find((branch) => branch.parentNoteId === parentNoteId); + + if (item.content && note.getContent() !== item.content) { + log.info(`Updating content of ${item.id}.`); + note.setContent(item.content); + } + + // Clean up any branches that shouldn't exist according to the meta definition + // For hidden subtree notes, we want to ensure they only exist in their designated locations + if (item.enforceBranches || item.id.startsWith("_help")) { + // If the note exists but doesn't have a branch in the expected parent, + // create the missing branch to ensure it's in the correct location + if (!branch) { + log.info(`Creating missing branch for note ${item.id} under parent ${parentNoteId}.`); + branch = new BBranch({ + noteId: item.id, + parentNoteId: parentNoteId, + notePosition: item.notePosition !== undefined ? item.notePosition : undefined, + isExpanded: item.isExpanded !== undefined ? item.isExpanded : false + }).save(); + } + + // Remove any branches that are not in the expected parent. + const expectedParents = getExpectedParentIds(item.id, hiddenSubtreeDefinition); + const currentBranches = note.getParentBranches(); + + for (const currentBranch of currentBranches) { + // Only delete branches that are not in the expected locations + // and are within the hidden subtree structure (avoid touching user-created clones) + if (!expectedParents.includes(currentBranch.parentNoteId) && + isWithinHiddenSubtree(currentBranch.parentNoteId)) { + log.info(`Removing unexpected branch for note '${item.id}' from parent '${currentBranch.parentNoteId}'`); + currentBranch.markAsDeleted(); + } + } + } + } + + const attrs = [...(item.attributes || [])]; + + if (item.icon) { + attrs.push({ type: "label", name: "iconClass", value: `bx ${item.icon}` }); + } + + if (item.type === "launcher") { + if (item.command) { + attrs.push({ type: "relation", name: "template", value: LBTPL_COMMAND }); + attrs.push({ type: "label", name: "command", value: item.command }); + } else if (item.builtinWidget) { + if (item.builtinWidget === "spacer") { + attrs.push({ type: "relation", name: "template", value: LBTPL_SPACER }); + attrs.push({ type: "label", name: "baseSize", value: item.baseSize }); + attrs.push({ type: "label", name: "growthFactor", value: item.growthFactor }); + } else { + attrs.push({ type: "relation", name: "template", value: LBTPL_WIDGET }); + } + + attrs.push({ type: "label", name: "builtinWidget", value: item.builtinWidget }); + } else if (item.targetNoteId) { + attrs.push({ type: "relation", name: "template", value: LBTPL_NOTE_LAUNCHER }); + attrs.push({ type: "relation", name: "target", value: item.targetNoteId }); + } else { + throw new Error(`No action defined for launcher ${JSON.stringify(item)}`); + } + } + + const shouldRestoreNames = extraOpts.restoreNames || note.noteId.startsWith("_help") || item.id.startsWith("_lb") || item.id.startsWith("_template"); + if (shouldRestoreNames && note.title !== item.title) { + note.title = item.title; + note.save(); + } + + if (note.type !== item.type) { + // enforce a correct note type + note.type = item.type; + note.save(); + } + + if (branch) { + // in case of launchers the branch ID is not preserved and should not be relied upon - launchers which move between + // visible and available will change branch since the branch's parent-child relationship is immutable + if (item.notePosition !== undefined && branch.notePosition !== item.notePosition) { + branch.notePosition = item.notePosition; + branch.save(); + } + + if (item.isExpanded !== undefined && branch.isExpanded !== item.isExpanded) { + branch.isExpanded = item.isExpanded; + branch.save(); + } + } + + // Enforce attribute structure if needed. + if (item.enforceAttributes) { + for (const attribute of note.getAttributes()) { + // Remove unwanted attributes. + const attrDef = attrs.find(a => a.name === attribute.name); + if (!attrDef) { + attribute.markAsDeleted(); + continue; + } + + // Ensure value is consistent. + if (attribute.value !== attrDef.value) { + note.setAttributeValueById(attribute.attributeId, attrDef.value); + } + } + } + + for (const attr of attrs) { + const attrId = note.noteId + "_" + attr.type.charAt(0) + attr.name; + + const existingAttribute = note.getAttributes().find((attr) => attr.attributeId === attrId); + + if (!existingAttribute) { + new BAttribute({ + attributeId: attrId, + noteId: note.noteId, + type: attr.type, + name: attr.name, + value: attr.value, + isInheritable: attr.isInheritable + }).save(); + } else if (attr.name === "docName" || (existingAttribute.noteId.startsWith("_help") && attr.name === "iconClass")) { + if (existingAttribute.value !== attr.value) { + log.info(`Updating attribute ${attrId} from "${existingAttribute.value}" to "${attr.value}"`); + existingAttribute.value = attr.value ?? ""; + existingAttribute.save(); + } + } + } + + for (const child of item.children || []) { + checkHiddenSubtreeRecursively(item.id, child, extraOpts); + } +} + +export default { + checkHiddenSubtree +}; diff --git a/apps/server/src/services/hidden_subtree_launcherbar.ts b/packages/trilium-core/src/services/hidden_subtree_launcherbar.ts similarity index 98% rename from apps/server/src/services/hidden_subtree_launcherbar.ts rename to packages/trilium-core/src/services/hidden_subtree_launcherbar.ts index d68c10c1c5..d233bde28b 100644 --- a/apps/server/src/services/hidden_subtree_launcherbar.ts +++ b/packages/trilium-core/src/services/hidden_subtree_launcherbar.ts @@ -48,7 +48,7 @@ export default function buildLaunchBarConfig() { id: "_lbBackInHistory", ...sharedLaunchers.backInHistory }, - { + { id: "_lbForwardInHistory", ...sharedLaunchers.forwardInHistory }, @@ -59,12 +59,12 @@ export default function buildLaunchBarConfig() { command: "commandPalette", icon: "bx bx-chevron-right-square" }, - { + { id: "_lbBackendLog", title: t("hidden-subtree.backend-log-title"), type: "launcher", targetNoteId: "_backendLog", - icon: "bx bx-detail" + icon: "bx bx-detail" }, { id: "_zenMode", @@ -128,7 +128,7 @@ export default function buildLaunchBarConfig() { baseSize: "50", growthFactor: "0" }, - { + { id: "_lbBookmarks", title: t("hidden-subtree.bookmarks-title"), type: "launcher", @@ -139,7 +139,7 @@ export default function buildLaunchBarConfig() { id: "_lbToday", ...sharedLaunchers.openToday }, - { + { id: "_lbSpacer2", title: t("hidden-subtree.spacer-title"), type: "launcher", @@ -214,4 +214,4 @@ export default function buildLaunchBarConfig() { mobileAvailableLaunchers, mobileVisibleLaunchers }; -} \ No newline at end of file +} diff --git a/apps/server/src/services/hidden_subtree_templates.ts b/packages/trilium-core/src/services/hidden_subtree_templates.ts similarity index 100% rename from apps/server/src/services/hidden_subtree_templates.ts rename to packages/trilium-core/src/services/hidden_subtree_templates.ts diff --git a/packages/trilium-core/src/services/in_app_help.ts b/packages/trilium-core/src/services/in_app_help.ts new file mode 100644 index 0000000000..4f377f0a6a --- /dev/null +++ b/packages/trilium-core/src/services/in_app_help.ts @@ -0,0 +1,8 @@ +export function cleanUpHelp(items: unknown[]) { + // TODO: implement. +} + +export function getHelpHiddenSubtreeData() { + // TODO: implement. + return []; +} diff --git a/packages/trilium-core/src/services/migration.ts b/packages/trilium-core/src/services/migration.ts new file mode 100644 index 0000000000..fe61ef7b92 --- /dev/null +++ b/packages/trilium-core/src/services/migration.ts @@ -0,0 +1,4 @@ +export function isDbUpToDate() { + // TODO: Implement. + return true; +} From 18416eb89ab3d6e3b2cbcab4d9956923e251ed79 Mon Sep 17 00:00:00 2001 From: Elian Doran <contact@eliandoran.me> Date: Tue, 6 Jan 2026 16:07:30 +0200 Subject: [PATCH 57/72] chore(core): no op script --- packages/trilium-core/src/services/handlers.ts | 2 +- packages/trilium-core/src/services/script.ts | 3 +++ 2 files changed, 4 insertions(+), 1 deletion(-) create mode 100644 packages/trilium-core/src/services/script.ts diff --git a/packages/trilium-core/src/services/handlers.ts b/packages/trilium-core/src/services/handlers.ts index ab2a345244..1192fbc8f5 100644 --- a/packages/trilium-core/src/services/handlers.ts +++ b/packages/trilium-core/src/services/handlers.ts @@ -6,7 +6,7 @@ import type BNote from "../becca/entities/bnote.js"; import hiddenSubtreeService from "./hidden_subtree.js"; import noteService from "./notes.js"; import oneTimeTimer from "./one_time_timer.js"; -import scriptService from "./script.js"; +import * as scriptService from "./script.js"; import treeService from "./tree.js"; import eventService from "./events.js"; import AbstractBeccaEntity from "../becca/entities/abstract_becca_entity.js"; diff --git a/packages/trilium-core/src/services/script.ts b/packages/trilium-core/src/services/script.ts new file mode 100644 index 0000000000..4760e21cc1 --- /dev/null +++ b/packages/trilium-core/src/services/script.ts @@ -0,0 +1,3 @@ +export function executeNoteNoException(script: unknown) { + console.warn("Skipped script execution"); +} From c94c54c64175a3f7736c8a6548b191ff084cdc6f Mon Sep 17 00:00:00 2001 From: Elian Doran <contact@eliandoran.me> Date: Tue, 6 Jan 2026 16:09:21 +0200 Subject: [PATCH 58/72] chore(core): integrate task_context with ws no-op --- apps/server/src/services/task_context.ts | 79 +------------------ packages/trilium-core/src/index.ts | 1 + .../trilium-core/src/services/task_context.ts | 79 +++++++++++++++++++ packages/trilium-core/src/services/ws.ts | 5 ++ 4 files changed, 86 insertions(+), 78 deletions(-) create mode 100644 packages/trilium-core/src/services/task_context.ts create mode 100644 packages/trilium-core/src/services/ws.ts diff --git a/apps/server/src/services/task_context.ts b/apps/server/src/services/task_context.ts index 79122895bd..59bd04f058 100644 --- a/apps/server/src/services/task_context.ts +++ b/apps/server/src/services/task_context.ts @@ -1,79 +1,2 @@ -"use strict"; - -import type { TaskData, TaskResult, TaskType, WebSocketMessage } from "@triliumnext/commons"; -import ws from "./ws.js"; - -// taskId => TaskContext -const taskContexts: Record<string, TaskContext<any>> = {}; - -class TaskContext<T extends TaskType> { - private taskId: string; - private taskType: TaskType; - private progressCount: number; - private lastSentCountTs: number; - data: TaskData<T>; - noteDeletionHandlerTriggered: boolean; - - constructor(taskId: string, taskType: T, data: TaskData<T>) { - this.taskId = taskId; - this.taskType = taskType; - this.data = data; - this.noteDeletionHandlerTriggered = false; - - // progressCount is meant to represent just some progress - to indicate the task is not stuck - this.progressCount = -1; // we're incrementing immediately - this.lastSentCountTs = 0; // 0 will guarantee the first message will be sent - - // just the fact this has been initialized is a progress which should be sent to clients - // this is esp. important when importing big files/images which take a long time to upload/process - // which means that first "real" increaseProgressCount() will be called quite late and user is without - // feedback until then - this.increaseProgressCount(); - } - - static getInstance<T extends TaskType>(taskId: string, taskType: T, data: TaskData<T>): TaskContext<T> { - if (!taskContexts[taskId]) { - taskContexts[taskId] = new TaskContext(taskId, taskType, data); - } - - return taskContexts[taskId]; - } - - increaseProgressCount() { - this.progressCount++; - - if (Date.now() - this.lastSentCountTs >= 300 && this.taskId !== "no-progress-reporting") { - this.lastSentCountTs = Date.now(); - - ws.sendMessageToAllClients({ - type: "taskProgressCount", - taskId: this.taskId, - taskType: this.taskType, - data: this.data, - progressCount: this.progressCount - } as WebSocketMessage); - } - } - - reportError(message: string) { - ws.sendMessageToAllClients({ - type: "taskError", - taskId: this.taskId, - taskType: this.taskType, - data: this.data, - message - } as WebSocketMessage); - } - - taskSucceeded(result: TaskResult<T>) { - ws.sendMessageToAllClients({ - type: "taskSucceeded", - taskId: this.taskId, - taskType: this.taskType, - data: this.data, - result - } as WebSocketMessage); - } -} - +import { TaskContext } from "@triliumnext/core"; export default TaskContext; diff --git a/packages/trilium-core/src/index.ts b/packages/trilium-core/src/index.ts index 635f44c0bd..21aff83f3f 100644 --- a/packages/trilium-core/src/index.ts +++ b/packages/trilium-core/src/index.ts @@ -29,6 +29,7 @@ export { default as note_types } from "./services/note_types"; export { default as tree } from "./services/tree"; export { default as cloning } from "./services/cloning"; export { default as handlers } from "./services/handlers"; +export { default as TaskContext } from "./services/task_context"; export { default as becca } from "./becca/becca"; export { default as becca_loader } from "./becca/becca_loader"; diff --git a/packages/trilium-core/src/services/task_context.ts b/packages/trilium-core/src/services/task_context.ts new file mode 100644 index 0000000000..79122895bd --- /dev/null +++ b/packages/trilium-core/src/services/task_context.ts @@ -0,0 +1,79 @@ +"use strict"; + +import type { TaskData, TaskResult, TaskType, WebSocketMessage } from "@triliumnext/commons"; +import ws from "./ws.js"; + +// taskId => TaskContext +const taskContexts: Record<string, TaskContext<any>> = {}; + +class TaskContext<T extends TaskType> { + private taskId: string; + private taskType: TaskType; + private progressCount: number; + private lastSentCountTs: number; + data: TaskData<T>; + noteDeletionHandlerTriggered: boolean; + + constructor(taskId: string, taskType: T, data: TaskData<T>) { + this.taskId = taskId; + this.taskType = taskType; + this.data = data; + this.noteDeletionHandlerTriggered = false; + + // progressCount is meant to represent just some progress - to indicate the task is not stuck + this.progressCount = -1; // we're incrementing immediately + this.lastSentCountTs = 0; // 0 will guarantee the first message will be sent + + // just the fact this has been initialized is a progress which should be sent to clients + // this is esp. important when importing big files/images which take a long time to upload/process + // which means that first "real" increaseProgressCount() will be called quite late and user is without + // feedback until then + this.increaseProgressCount(); + } + + static getInstance<T extends TaskType>(taskId: string, taskType: T, data: TaskData<T>): TaskContext<T> { + if (!taskContexts[taskId]) { + taskContexts[taskId] = new TaskContext(taskId, taskType, data); + } + + return taskContexts[taskId]; + } + + increaseProgressCount() { + this.progressCount++; + + if (Date.now() - this.lastSentCountTs >= 300 && this.taskId !== "no-progress-reporting") { + this.lastSentCountTs = Date.now(); + + ws.sendMessageToAllClients({ + type: "taskProgressCount", + taskId: this.taskId, + taskType: this.taskType, + data: this.data, + progressCount: this.progressCount + } as WebSocketMessage); + } + } + + reportError(message: string) { + ws.sendMessageToAllClients({ + type: "taskError", + taskId: this.taskId, + taskType: this.taskType, + data: this.data, + message + } as WebSocketMessage); + } + + taskSucceeded(result: TaskResult<T>) { + ws.sendMessageToAllClients({ + type: "taskSucceeded", + taskId: this.taskId, + taskType: this.taskType, + data: this.data, + result + } as WebSocketMessage); + } +} + +export default TaskContext; diff --git a/packages/trilium-core/src/services/ws.ts b/packages/trilium-core/src/services/ws.ts new file mode 100644 index 0000000000..95a41ee201 --- /dev/null +++ b/packages/trilium-core/src/services/ws.ts @@ -0,0 +1,5 @@ +export default { + sendMessageToAllClients(message: object) { + console.warn("Ignored ws", message); + } +} From f1e0d5558c1af7d94fe4e3d5b0adfd70d55853c8 Mon Sep 17 00:00:00 2001 From: Elian Doran <contact@eliandoran.me> Date: Tue, 6 Jan 2026 16:16:54 +0200 Subject: [PATCH 59/72] chore(core): integrate erase --- apps/server/src/services/cls.ts | 14 ++------- packages/trilium-core/src/services/context.ts | 11 ++++++- .../trilium-core}/src/services/erase.ts | 31 ++++++++++++------- 3 files changed, 31 insertions(+), 25 deletions(-) rename {apps/server => packages/trilium-core}/src/services/erase.ts (88%) diff --git a/apps/server/src/services/cls.ts b/apps/server/src/services/cls.ts index f5bb25ef1e..a9c91ede94 100644 --- a/apps/server/src/services/cls.ts +++ b/apps/server/src/services/cls.ts @@ -1,22 +1,10 @@ import type { EntityChange } from "@triliumnext/commons"; import { cls } from "@triliumnext/core"; -type Callback = (...args: any[]) => any; - function init<T>(callback: () => T) { return cls.getContext().init(callback); } -function wrap(callback: Callback) { - return () => { - try { - init(callback); - } catch (e: any) { - console.log(`Error occurred: ${e.message}: ${e.stack}`); - } - }; -} - function getHoistedNoteId() { return cls.getHoistedNoteId(); } @@ -77,6 +65,8 @@ function reset() { cls.getContext().reset(); } +export const wrap = cls.wrap; + export default { init, wrap, diff --git a/packages/trilium-core/src/services/context.ts b/packages/trilium-core/src/services/context.ts index 374eb1296c..91db0e8365 100644 --- a/packages/trilium-core/src/services/context.ts +++ b/packages/trilium-core/src/services/context.ts @@ -19,11 +19,20 @@ export function getContext(): ExecutionContext { return ctx; } +export function wrap(callback: (...args: any[]) => any) { + return () => { + try { + getContext().init(callback); + } catch (e: any) { + console.log(`Error occurred: ${e.message}: ${e.stack}`); + } + }; +} + export function getHoistedNoteId() { return getContext().get("hoistedNoteId") || "root"; } - export function getComponentId() { return getContext().get("componentId"); } diff --git a/apps/server/src/services/erase.ts b/packages/trilium-core/src/services/erase.ts similarity index 88% rename from apps/server/src/services/erase.ts rename to packages/trilium-core/src/services/erase.ts index 92b28e5735..4816877266 100644 --- a/apps/server/src/services/erase.ts +++ b/packages/trilium-core/src/services/erase.ts @@ -1,17 +1,18 @@ -import sql from "./sql.js"; -import log from "./log.js"; +import { getLog } from "./log.js"; import entityChangesService from "./entity_changes.js"; import optionService from "./options.js"; -import dateUtils from "./date_utils.js"; +import dateUtils from "./utils/date.js"; import sqlInit from "./sql_init.js"; -import cls from "./cls.js"; +import * as cls from "./context.js"; import type { EntityChange } from "@triliumnext/commons"; +import { getSql } from "./sql/index.js"; function eraseNotes(noteIdsToErase: string[]) { if (noteIdsToErase.length === 0) { return; } + const sql = getSql(); sql.executeMany(/*sql*/`DELETE FROM notes WHERE noteId IN (???)`, noteIdsToErase); setEntityChangesAsErased(sql.getManyRows(/*sql*/`SELECT * FROM entity_changes WHERE entityName = 'notes' AND entityId IN (???)`, noteIdsToErase)); @@ -28,8 +29,7 @@ function eraseNotes(noteIdsToErase: string[]) { eraseRevisions(revisionIdsToErase); - - log.info(`Erased notes: ${JSON.stringify(noteIdsToErase)}`); + getLog().info(`Erased notes: ${JSON.stringify(noteIdsToErase)}`); } function setEntityChangesAsErased(entityChanges: EntityChange[]) { @@ -48,11 +48,12 @@ function eraseBranches(branchIdsToErase: string[]) { return; } + const sql = getSql(); sql.executeMany(/*sql*/`DELETE FROM branches WHERE branchId IN (???)`, branchIdsToErase); setEntityChangesAsErased(sql.getManyRows(/*sql*/`SELECT * FROM entity_changes WHERE entityName = 'branches' AND entityId IN (???)`, branchIdsToErase)); - log.info(`Erased branches: ${JSON.stringify(branchIdsToErase)}`); + getLog().info(`Erased branches: ${JSON.stringify(branchIdsToErase)}`); } function eraseAttributes(attributeIdsToErase: string[]) { @@ -60,11 +61,12 @@ function eraseAttributes(attributeIdsToErase: string[]) { return; } + const sql = getSql(); sql.executeMany(/*sql*/`DELETE FROM attributes WHERE attributeId IN (???)`, attributeIdsToErase); setEntityChangesAsErased(sql.getManyRows(/*sql*/`SELECT * FROM entity_changes WHERE entityName = 'attributes' AND entityId IN (???)`, attributeIdsToErase)); - log.info(`Erased attributes: ${JSON.stringify(attributeIdsToErase)}`); + getLog().info(`Erased attributes: ${JSON.stringify(attributeIdsToErase)}`); } function eraseAttachments(attachmentIdsToErase: string[]) { @@ -72,11 +74,12 @@ function eraseAttachments(attachmentIdsToErase: string[]) { return; } + const sql = getSql(); sql.executeMany(/*sql*/`DELETE FROM attachments WHERE attachmentId IN (???)`, attachmentIdsToErase); setEntityChangesAsErased(sql.getManyRows(/*sql*/`SELECT * FROM entity_changes WHERE entityName = 'attachments' AND entityId IN (???)`, attachmentIdsToErase)); - log.info(`Erased attachments: ${JSON.stringify(attachmentIdsToErase)}`); + getLog().info(`Erased attachments: ${JSON.stringify(attachmentIdsToErase)}`); } function eraseRevisions(revisionIdsToErase: string[]) { @@ -84,14 +87,16 @@ function eraseRevisions(revisionIdsToErase: string[]) { return; } + const sql = getSql(); sql.executeMany(/*sql*/`DELETE FROM revisions WHERE revisionId IN (???)`, revisionIdsToErase); setEntityChangesAsErased(sql.getManyRows(/*sql*/`SELECT * FROM entity_changes WHERE entityName = 'revisions' AND entityId IN (???)`, revisionIdsToErase)); - log.info(`Removed revisions: ${JSON.stringify(revisionIdsToErase)}`); + getLog().info(`Removed revisions: ${JSON.stringify(revisionIdsToErase)}`); } function eraseUnusedBlobs() { + const sql = getSql(); const unusedBlobIds = sql.getColumn(` SELECT blobs.blobId FROM blobs @@ -111,10 +116,11 @@ function eraseUnusedBlobs() { // this is because technically every keystroke can create a new blob and there would be just too many sql.executeMany(/*sql*/`DELETE FROM entity_changes WHERE entityName = 'blobs' AND entityId IN (???)`, unusedBlobIds); - log.info(`Erased unused blobs: ${JSON.stringify(unusedBlobIds)}`); + getLog().info(`Erased unused blobs: ${JSON.stringify(unusedBlobIds)}`); } function eraseDeletedEntities(eraseEntitiesAfterTimeInSeconds: number | null = null) { + const sql = getSql(); // this is important also so that the erased entity changes are sent to the connected clients sql.transactional(() => { if (eraseEntitiesAfterTimeInSeconds === null) { @@ -140,6 +146,7 @@ function eraseDeletedEntities(eraseEntitiesAfterTimeInSeconds: number | null = n } function eraseNotesWithDeleteId(deleteId: string) { + const sql = getSql(); const noteIdsToErase = sql.getColumn<string>("SELECT noteId FROM notes WHERE isDeleted = 1 AND deleteId = ?", [deleteId]); eraseNotes(noteIdsToErase); @@ -170,7 +177,7 @@ function eraseScheduledAttachments(eraseUnusedAttachmentsAfterSeconds: number | } const cutOffDate = dateUtils.utcDateTimeStr(new Date(Date.now() - eraseUnusedAttachmentsAfterSeconds * 1000)); - const attachmentIdsToErase = sql.getColumn<string>("SELECT attachmentId FROM attachments WHERE utcDateScheduledForErasureSince < ?", [cutOffDate]); + const attachmentIdsToErase = getSql().getColumn<string>("SELECT attachmentId FROM attachments WHERE utcDateScheduledForErasureSince < ?", [cutOffDate]); eraseAttachments(attachmentIdsToErase); } From 4668fdc15c366862a0a389ddd17cb4b681eb91fc Mon Sep 17 00:00:00 2001 From: Elian Doran <contact@eliandoran.me> Date: Tue, 6 Jan 2026 16:18:06 +0200 Subject: [PATCH 60/72] chore(core): no-op sqlInit --- packages/trilium-core/src/services/sql_init.ts | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 packages/trilium-core/src/services/sql_init.ts diff --git a/packages/trilium-core/src/services/sql_init.ts b/packages/trilium-core/src/services/sql_init.ts new file mode 100644 index 0000000000..fe59c0dff7 --- /dev/null +++ b/packages/trilium-core/src/services/sql_init.ts @@ -0,0 +1,5 @@ +import { deferred } from "@triliumnext/commons"; + +export const dbReady = deferred<void>(); + +dbReady.resolve(); From e1e294914afcb8a22b626f2dedaa7918f24a9d08 Mon Sep 17 00:00:00 2001 From: Elian Doran <contact@eliandoran.me> Date: Tue, 6 Jan 2026 16:20:10 +0200 Subject: [PATCH 61/72] chore(core): no-op search --- .../src/services/search/services/search.ts | 12 ++++++++++++ 1 file changed, 12 insertions(+) create mode 100644 packages/trilium-core/src/services/search/services/search.ts diff --git a/packages/trilium-core/src/services/search/services/search.ts b/packages/trilium-core/src/services/search/services/search.ts new file mode 100644 index 0000000000..a6c04adc93 --- /dev/null +++ b/packages/trilium-core/src/services/search/services/search.ts @@ -0,0 +1,12 @@ +import BNote from "src/becca/entities/bnote"; + +export default { + searchFromNote(note: BNote) { + console.warn("Ignore search ", note.title); + }, + + searchNotes(searchString: string) { + console.warn("Ignore search", searchString); + return []; + } +} From 1edab8e8da59c0d181d6e20776861738f232ce26 Mon Sep 17 00:00:00 2001 From: Elian Doran <contact@eliandoran.me> Date: Tue, 6 Jan 2026 16:21:42 +0200 Subject: [PATCH 62/72] chore(core): no-op image service --- packages/trilium-core/src/services/image.ts | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 packages/trilium-core/src/services/image.ts diff --git a/packages/trilium-core/src/services/image.ts b/packages/trilium-core/src/services/image.ts new file mode 100644 index 0000000000..e140ca0507 --- /dev/null +++ b/packages/trilium-core/src/services/image.ts @@ -0,0 +1,5 @@ +export default { + saveImageToAttachment(noteId: string, imageBuffer: Uint8Array, title: string, b1: boolean, b2: boolean) { + console.warn("Image save ignored", noteId, title); + } +} From 51d0d848c5586cc0d6e26812fad1f4e8c697e507 Mon Sep 17 00:00:00 2001 From: Elian Doran <contact@eliandoran.me> Date: Tue, 6 Jan 2026 16:22:47 +0200 Subject: [PATCH 63/72] chore(core): no-op request service --- packages/trilium-core/src/services/request.ts | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 packages/trilium-core/src/services/request.ts diff --git a/packages/trilium-core/src/services/request.ts b/packages/trilium-core/src/services/request.ts new file mode 100644 index 0000000000..bf67168e0c --- /dev/null +++ b/packages/trilium-core/src/services/request.ts @@ -0,0 +1,5 @@ +export default { + getImage(url: string) { + console.warn("Image download ignored ", url); + } +} From edac58f3fab6b747a8eb1dd5f611a6529751c857 Mon Sep 17 00:00:00 2001 From: Elian Doran <contact@eliandoran.me> Date: Tue, 6 Jan 2026 16:24:14 +0200 Subject: [PATCH 64/72] chore(core): integrate revisions --- apps/server/src/services/revisions.ts | 51 +------------------ packages/trilium-core/src/index.ts | 1 + .../trilium-core/src/services/revisions.ts | 46 +++++++++++++++++ 3 files changed, 49 insertions(+), 49 deletions(-) create mode 100644 packages/trilium-core/src/services/revisions.ts diff --git a/apps/server/src/services/revisions.ts b/apps/server/src/services/revisions.ts index 1265d49272..ae8f826b0c 100644 --- a/apps/server/src/services/revisions.ts +++ b/apps/server/src/services/revisions.ts @@ -1,49 +1,2 @@ -"use strict"; - -import log from "./log.js"; -import sql from "./sql.js"; -import protectedSessionService from "./protected_session.js"; -import dateUtils from "./date_utils.js"; -import type BNote from "../becca/entities/bnote.js"; - -function protectRevisions(note: BNote) { - if (!protectedSessionService.isProtectedSessionAvailable()) { - throw new Error(`Cannot (un)protect revisions of note '${note.noteId}' without active protected session`); - } - - for (const revision of note.getRevisions()) { - if (note.isProtected !== revision.isProtected) { - try { - const content = revision.getContent(); - - revision.isProtected = !!note.isProtected; - - // this will force de/encryption - revision.setContent(content, { forceSave: true }); - } catch (e) { - log.error(`Could not un/protect note revision '${revision.revisionId}'`); - - throw e; - } - } - - for (const attachment of revision.getAttachments()) { - if (note.isProtected !== attachment.isProtected) { - try { - const content = attachment.getContent(); - - attachment.isProtected = note.isProtected; - attachment.setContent(content, { forceSave: true }); - } catch (e) { - log.error(`Could not un/protect attachment '${attachment.attachmentId}'`); - - throw e; - } - } - } - } -} - -export default { - protectRevisions -}; +import { revisions } from "@triliumnext/core"; +export default revisions; diff --git a/packages/trilium-core/src/index.ts b/packages/trilium-core/src/index.ts index 21aff83f3f..482ba098f8 100644 --- a/packages/trilium-core/src/index.ts +++ b/packages/trilium-core/src/index.ts @@ -30,6 +30,7 @@ export { default as tree } from "./services/tree"; export { default as cloning } from "./services/cloning"; export { default as handlers } from "./services/handlers"; export { default as TaskContext } from "./services/task_context"; +export { default as revisions } from "./services/revisions"; export { default as becca } from "./becca/becca"; export { default as becca_loader } from "./becca/becca_loader"; diff --git a/packages/trilium-core/src/services/revisions.ts b/packages/trilium-core/src/services/revisions.ts new file mode 100644 index 0000000000..b450e05d5b --- /dev/null +++ b/packages/trilium-core/src/services/revisions.ts @@ -0,0 +1,46 @@ +import { getLog } from "./log.js"; +import protectedSessionService from "./protected_session.js"; +import type BNote from "../becca/entities/bnote.js"; + +function protectRevisions(note: BNote) { + if (!protectedSessionService.isProtectedSessionAvailable()) { + throw new Error(`Cannot (un)protect revisions of note '${note.noteId}' without active protected session`); + } + + const log = getLog(); + for (const revision of note.getRevisions()) { + if (note.isProtected !== revision.isProtected) { + try { + const content = revision.getContent(); + + revision.isProtected = !!note.isProtected; + + // this will force de/encryption + revision.setContent(content, { forceSave: true }); + } catch (e) { + log.error(`Could not un/protect note revision '${revision.revisionId}'`); + + throw e; + } + } + + for (const attachment of revision.getAttachments()) { + if (note.isProtected !== attachment.isProtected) { + try { + const content = attachment.getContent(); + + attachment.isProtected = note.isProtected; + attachment.setContent(content, { forceSave: true }); + } catch (e) { + log.error(`Could not un/protect attachment '${attachment.attachmentId}'`); + + throw e; + } + } + } + } +} + +export default { + protectRevisions +}; From 8399600e79ba030610a6f4bdc8a850906d453a5d Mon Sep 17 00:00:00 2001 From: Elian Doran <contact@eliandoran.me> Date: Tue, 6 Jan 2026 16:29:30 +0200 Subject: [PATCH 65/72] chore(core): address some missing methods in utils --- apps/server/src/services/utils.ts | 5 +---- packages/trilium-core/src/services/keyboard_actions.ts | 2 +- packages/trilium-core/src/services/options_init.ts | 2 +- packages/trilium-core/src/services/utils/index.ts | 9 +++++++++ 4 files changed, 12 insertions(+), 6 deletions(-) diff --git a/apps/server/src/services/utils.ts b/apps/server/src/services/utils.ts index 861a8396fc..5ad481211d 100644 --- a/apps/server/src/services/utils.ts +++ b/apps/server/src/services/utils.ts @@ -33,10 +33,6 @@ export function randomString(length: number): string { return coreUtils.randomString(length); } -export function randomSecureToken(bytes = 32) { - return crypto.randomBytes(bytes).toString("base64"); -} - export function md5(content: crypto.BinaryLike) { return crypto.createHash("md5").update(content).digest("hex"); } @@ -459,6 +455,7 @@ function slugify(text: string) { export const escapeHtml = coreUtils.escapeHtml; export const unescapeHtml = coreUtils.unescapeHtml; +export const randomSecureToken = coreUtils.randomSecureToken; export default { compareVersions, diff --git a/packages/trilium-core/src/services/keyboard_actions.ts b/packages/trilium-core/src/services/keyboard_actions.ts index 0d20a886c8..704a639adb 100644 --- a/packages/trilium-core/src/services/keyboard_actions.ts +++ b/packages/trilium-core/src/services/keyboard_actions.ts @@ -2,7 +2,7 @@ import optionService from "./options.js"; import { getLog } from "./log.js"; -import { isElectron, isMac } from "./utils.js"; +import { isElectron, isMac } from "./utils/index.js"; import type { ActionKeyboardShortcut, KeyboardShortcut } from "@triliumnext/commons"; import { t } from "i18next"; diff --git a/packages/trilium-core/src/services/options_init.ts b/packages/trilium-core/src/services/options_init.ts index a77727ec33..9b9ed9099c 100644 --- a/packages/trilium-core/src/services/options_init.ts +++ b/packages/trilium-core/src/services/options_init.ts @@ -5,7 +5,7 @@ import dateUtils from "./utils/date.js"; import keyboardActions from "./keyboard_actions.js"; import { getLog } from "./log.js"; import optionService from "./options.js"; -import { isWindows, randomSecureToken } from "./utils.js"; +import { isWindows, randomSecureToken } from "./utils/index.js"; function initDocumentOptions() { optionService.createOption("documentId", randomSecureToken(16), false); diff --git a/packages/trilium-core/src/services/utils/index.ts b/packages/trilium-core/src/services/utils/index.ts index f33ccb0391..e966f72374 100644 --- a/packages/trilium-core/src/services/utils/index.ts +++ b/packages/trilium-core/src/services/utils/index.ts @@ -5,6 +5,11 @@ import mimeTypes from "mime-types"; import escape from "escape-html"; import unescape from "unescape"; +// TODO: Implement platform detection. +export const isElectron = false; +export const isMac = false; +export const isWindows = false; + // render and book are string note in the sense that they are expected to contain empty string const STRING_NOTE_TYPES = new Set(["text", "code", "relationMap", "search", "render", "book", "mermaid", "canvas", "webView"]); const STRING_MIME_TYPES = new Set(["application/javascript", "application/x-javascript", "application/json", "application/x-sql", "image/svg+xml"]); @@ -117,3 +122,7 @@ export function toMap<T extends Record<string, any>>(list: T[], key: keyof T) { export const escapeHtml = escape; export const unescapeHtml = unescape; + +export function randomSecureToken(bytes = 32) { + return encodeBase64(getCrypto().randomBytes(32)); +} From 7c16aeca4a11b645da58ca7dc6fa6ffc825bf774 Mon Sep 17 00:00:00 2001 From: Elian Doran <contact@eliandoran.me> Date: Tue, 6 Jan 2026 16:30:45 +0200 Subject: [PATCH 66/72] chore(core): crash due to dbReady before CLS init --- packages/trilium-core/src/services/sql_init.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/trilium-core/src/services/sql_init.ts b/packages/trilium-core/src/services/sql_init.ts index fe59c0dff7..ec44193cb7 100644 --- a/packages/trilium-core/src/services/sql_init.ts +++ b/packages/trilium-core/src/services/sql_init.ts @@ -2,4 +2,7 @@ import { deferred } from "@triliumnext/commons"; export const dbReady = deferred<void>(); -dbReady.resolve(); +// TODO: Proper impl. +setTimeout(() => { + dbReady.resolve(); +}, 850); From 8523c369e1e66a49668aafc3be649cf6d72469ce Mon Sep 17 00:00:00 2001 From: Elian Doran <contact@eliandoran.me> Date: Tue, 6 Jan 2026 19:10:41 +0200 Subject: [PATCH 67/72] fix(server): imports preventing start-up --- apps/server/src/app.ts | 7 +++---- apps/server/src/routes/api/branches.ts | 3 +-- apps/server/src/routes/api/notes.ts | 3 +-- apps/server/src/routes/api/revisions.ts | 5 +---- apps/server/src/services/bulk_actions.ts | 15 ++++++++------- apps/server/src/services/consistency_checks.ts | 3 +-- apps/server/src/services/content_hash.ts | 3 +-- packages/trilium-core/src/index.ts | 1 + packages/trilium-core/src/services/erase.ts | 8 ++++---- .../trilium-core/src/services/keyboard_actions.ts | 3 ++- 10 files changed, 23 insertions(+), 28 deletions(-) diff --git a/apps/server/src/app.ts b/apps/server/src/app.ts index 7f44e60246..c239684858 100644 --- a/apps/server/src/app.ts +++ b/apps/server/src/app.ts @@ -1,6 +1,6 @@ -import "./services/handlers.js"; -import "./becca/becca_loader.js"; +import("@triliumnext/core"); +import { erase } from "@triliumnext/core"; import compression from "compression"; import cookieParser from "cookie-parser"; import express from "express"; @@ -15,7 +15,6 @@ import custom from "./routes/custom.js"; import error_handlers from "./routes/error_handlers.js"; import routes from "./routes/routes.js"; import config from "./services/config.js"; -import { startScheduledCleanup } from "./services/erase.js"; import log from "./services/log.js"; import openID from "./services/open_id.js"; import { RESOURCE_DIR } from "./services/resource_dir.js"; @@ -108,7 +107,7 @@ export default async function buildApp() { await import("./services/scheduler.js"); - startScheduledCleanup(); + erase.startScheduledCleanup(); if (utils.isElectron) { (await import("@electron/remote/main/index.js")).initialize(); diff --git a/apps/server/src/routes/api/branches.ts b/apps/server/src/routes/api/branches.ts index 8a21f8420c..26f030e471 100644 --- a/apps/server/src/routes/api/branches.ts +++ b/apps/server/src/routes/api/branches.ts @@ -1,10 +1,9 @@ -import { events as eventService, ValidationError } from "@triliumnext/core"; +import { erase as eraseService, events as eventService, ValidationError } from "@triliumnext/core"; import type { Request } from "express"; import becca from "../../becca/becca.js"; import branchService from "../../services/branches.js"; import entityChangesService from "../../services/entity_changes.js"; -import eraseService from "../../services/erase.js"; import log from "../../services/log.js"; import sql from "../../services/sql.js"; import TaskContext from "../../services/task_context.js"; diff --git a/apps/server/src/routes/api/notes.ts b/apps/server/src/routes/api/notes.ts index 9ca191c8e2..cb5de84780 100644 --- a/apps/server/src/routes/api/notes.ts +++ b/apps/server/src/routes/api/notes.ts @@ -1,10 +1,9 @@ import type { AttributeRow, CreateChildrenResponse, DeleteNotesPreview, MetadataResponse } from "@triliumnext/commons"; -import { blob as blobService, ValidationError } from "@triliumnext/core"; +import { blob as blobService, erase as eraseService, ValidationError } from "@triliumnext/core"; import type { Request } from "express"; import becca from "../../becca/becca.js"; import type BBranch from "../../becca/entities/bbranch.js"; -import eraseService from "../../services/erase.js"; import log from "../../services/log.js"; import noteService from "../../services/notes.js"; import sql from "../../services/sql.js"; diff --git a/apps/server/src/routes/api/revisions.ts b/apps/server/src/routes/api/revisions.ts index b0df419223..afa5a3c39b 100644 --- a/apps/server/src/routes/api/revisions.ts +++ b/apps/server/src/routes/api/revisions.ts @@ -1,7 +1,5 @@ - - import { EditedNotesResponse, RevisionItem, RevisionPojo } from "@triliumnext/commons"; -import { becca_service, binary_utils, blob as blobService, NotePojo } from "@triliumnext/core"; +import { becca_service, binary_utils, blob as blobService, erase as eraseService, NotePojo } from "@triliumnext/core"; import type { Request, Response } from "express"; import path from "path"; @@ -9,7 +7,6 @@ import becca from "../../becca/becca.js"; import type BNote from "../../becca/entities/bnote.js"; import type BRevision from "../../becca/entities/brevision.js"; import cls from "../../services/cls.js"; -import eraseService from "../../services/erase.js"; import sql from "../../services/sql.js"; import utils from "../../services/utils.js"; diff --git a/apps/server/src/services/bulk_actions.ts b/apps/server/src/services/bulk_actions.ts index 531bd9976e..2c662fba2d 100644 --- a/apps/server/src/services/bulk_actions.ts +++ b/apps/server/src/services/bulk_actions.ts @@ -1,11 +1,12 @@ -import log from "./log.js"; -import becca from "../becca/becca.js"; -import cloningService from "./cloning.js"; -import branchService from "./branches.js"; -import { randomString } from "./utils.js"; -import eraseService from "./erase.js"; -import type BNote from "../becca/entities/bnote.js"; import { ActionHandlers, BulkAction, BulkActionData } from "@triliumnext/commons"; +import { erase as eraseService } from "@triliumnext/core"; + +import becca from "../becca/becca.js"; +import type BNote from "../becca/entities/bnote.js"; +import branchService from "./branches.js"; +import cloningService from "./cloning.js"; +import log from "./log.js"; +import { randomString } from "./utils.js"; type ActionHandler<T> = (action: T, note: BNote) => void; diff --git a/apps/server/src/services/consistency_checks.ts b/apps/server/src/services/consistency_checks.ts index a7fe6d3a6e..b144d41bfb 100644 --- a/apps/server/src/services/consistency_checks.ts +++ b/apps/server/src/services/consistency_checks.ts @@ -1,10 +1,9 @@ import type { BranchRow } from "@triliumnext/commons"; import type { EntityChange } from "@triliumnext/commons"; -import { becca_loader, utils } from "@triliumnext/core"; +import { becca_loader, erase as eraseService, utils } from "@triliumnext/core"; import becca from "../becca/becca.js"; import BBranch from "../becca/entities/bbranch.js"; -import eraseService from "../services/erase.js"; import noteTypesService from "../services/note_types.js"; import { hashedBlobId, randomString } from "../services/utils.js"; import cls from "./cls.js"; diff --git a/apps/server/src/services/content_hash.ts b/apps/server/src/services/content_hash.ts index 41bd27d29e..6b189bbe61 100644 --- a/apps/server/src/services/content_hash.ts +++ b/apps/server/src/services/content_hash.ts @@ -1,6 +1,5 @@ -import { utils } from "@triliumnext/core"; +import { erase as eraseService,utils } from "@triliumnext/core"; -import eraseService from "./erase.js"; import log from "./log.js"; import sql from "./sql.js"; diff --git a/packages/trilium-core/src/index.ts b/packages/trilium-core/src/index.ts index 482ba098f8..97aba9f2a4 100644 --- a/packages/trilium-core/src/index.ts +++ b/packages/trilium-core/src/index.ts @@ -31,6 +31,7 @@ export { default as cloning } from "./services/cloning"; export { default as handlers } from "./services/handlers"; export { default as TaskContext } from "./services/task_context"; export { default as revisions } from "./services/revisions"; +export { default as erase } from "./services/erase"; export { default as becca } from "./becca/becca"; export { default as becca_loader } from "./becca/becca_loader"; diff --git a/packages/trilium-core/src/services/erase.ts b/packages/trilium-core/src/services/erase.ts index 4816877266..91e63cd6a9 100644 --- a/packages/trilium-core/src/services/erase.ts +++ b/packages/trilium-core/src/services/erase.ts @@ -2,7 +2,7 @@ import { getLog } from "./log.js"; import entityChangesService from "./entity_changes.js"; import optionService from "./options.js"; import dateUtils from "./utils/date.js"; -import sqlInit from "./sql_init.js"; +import * as sqlInit from "./sql_init.js"; import * as cls from "./context.js"; import type { EntityChange } from "@triliumnext/commons"; import { getSql } from "./sql/index.js"; @@ -182,8 +182,7 @@ function eraseScheduledAttachments(eraseUnusedAttachmentsAfterSeconds: number | eraseAttachments(attachmentIdsToErase); } - -export function startScheduledCleanup() { +function startScheduledCleanup() { sqlInit.dbReady.then(() => { // first cleanup kickoff 5 minutes after startup setTimeout( @@ -212,5 +211,6 @@ export default { eraseNotesWithDeleteId, eraseUnusedBlobs, eraseAttachments, - eraseRevisions + eraseRevisions, + startScheduledCleanup }; diff --git a/packages/trilium-core/src/services/keyboard_actions.ts b/packages/trilium-core/src/services/keyboard_actions.ts index 704a639adb..0129be0c0c 100644 --- a/packages/trilium-core/src/services/keyboard_actions.ts +++ b/packages/trilium-core/src/services/keyboard_actions.ts @@ -8,7 +8,8 @@ import { t } from "i18next"; function getDefaultKeyboardActions() { if (!t("keyboard_actions.note-navigation")) { - throw new Error("Keyboard actions loaded before translations."); + // TODO: Re-enable. + // throw new Error("Keyboard actions loaded before translations."); } const DEFAULT_KEYBOARD_ACTIONS: KeyboardShortcut[] = [ From 43d087f886a8fd3de92a641d06704fbb2f394afe Mon Sep 17 00:00:00 2001 From: Elian Doran <contact@eliandoran.me> Date: Mon, 12 Jan 2026 21:32:06 +0200 Subject: [PATCH 68/72] chore(deps): update lock file --- pnpm-lock.yaml | 250 +++++++++++++++++++++++-------------------------- 1 file changed, 117 insertions(+), 133 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 79ac163d82..ac5ab1742b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -516,9 +516,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 +555,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 +570,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 +657,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 +720,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 @@ -749,22 +731,16 @@ importers: version: 0.6.3 openai: specifier: 6.16.0 - version: 6.16.0(ws@8.19.0(bufferutil@4.0.9)(utf-8-validate@6.0.5))(zod@4.1.12) + version: 6.16.0(ws@8.18.3(bufferutil@4.0.9)(utf-8-validate@6.0.5))(zod@4.1.12) rand-token: specifier: 1.0.1 version: 1.0.1 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.4 - version: 1.4.4 + specifier: 1.4.3 + version: 1.4.3 serve-favicon: specifier: 2.5.1 version: 2.5.1 @@ -792,15 +768,12 @@ importers: turndown: specifier: 7.2.2 version: 7.2.2 - unescape: - specifier: 1.0.1 - version: 1.0.1 vite: - specifier: 7.3.1 - version: 7.3.1(@types/node@24.10.7)(jiti@2.6.1)(less@4.1.3)(lightningcss@1.30.2)(sass-embedded@1.91.0)(sass@1.91.0)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.1) + specifier: 7.3.0 + version: 7.3.0(@types/node@24.10.7)(jiti@2.6.1)(less@4.1.3)(lightningcss@1.30.2)(sass-embedded@1.91.0)(sass@1.91.0)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.1) ws: - specifier: 8.19.0 - version: 8.19.0(bufferutil@4.0.9)(utf-8-validate@6.0.5) + specifier: 8.18.3 + version: 8.18.3(bufferutil@4.0.9)(utf-8-validate@6.0.5) xml2js: specifier: 0.6.2 version: 0.6.2 @@ -1449,9 +1422,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: @@ -11657,10 +11658,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} @@ -12432,9 +12429,8 @@ packages: engines: {node: '>=14.0.0'} hasBin: true - sax@1.4.4: - resolution: {integrity: sha512-1n3r/tGXO6b6VXMdFT54SHzT9ytu9yr7TaELowdYpMqY/Ao7EnlQGmAQ1+RatX7Tkkdm6hONI2owqNx2aZj5Sw==} - engines: {node: '>=11.0.0'} + sax@1.4.3: + resolution: {integrity: sha512-yqYn1JhPczigF94DMS+shiDMjDowYO6y9+wB/4WgO0Y19jWYk0lQ4tuG5KI7kj4FTp1wxPj5IFfcrz/s1c3jjQ==} saxes@5.0.1: resolution: {integrity: sha512-5LBh1Tls8c9xgGjw3QrMwETmTMVk0oFgvrFSvWx62llR2hcEInrKNZ2GZCCuuy2lvWrdl5jhbpeqc5hRYKFOcw==} @@ -13820,6 +13816,46 @@ packages: peerDependencies: vite: 5.x || 6.x || 7.x + vite@7.3.0: + resolution: {integrity: sha512-dZwN5L1VlUBewiP6H9s2+B3e3Jg96D0vzN+Ry73sOefebhYr9f94wwkMNN/9ouoU8pV1BqA1d1zGk8928cx0rg==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@types/node': ^20.19.0 || >=22.12.0 + jiti: '>=1.21.0' + less: ^4.0.0 + lightningcss: ^1.21.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: '>=0.54.8' + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + jiti: + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + vite@7.3.1: resolution: {integrity: sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==} engines: {node: ^20.19.0 || >=22.12.0} @@ -14193,8 +14229,8 @@ packages: utf-8-validate: optional: true - ws@8.19.0: - resolution: {integrity: sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==} + ws@8.18.3: + resolution: {integrity: sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==} engines: {node: '>=10.0.0'} peerDependencies: bufferutil: ^4.0.1 @@ -15105,8 +15141,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: @@ -15117,8 +15151,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: @@ -15175,6 +15207,8 @@ snapshots: '@ckeditor/ckeditor5-utils': 47.3.0 '@ckeditor/ckeditor5-widget': 47.3.0 es-toolkit: 1.39.5 + transitivePeerDependencies: + - supports-color '@ckeditor/ckeditor5-cloud-services@47.3.0': dependencies: @@ -15432,8 +15466,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: @@ -15446,8 +15478,6 @@ snapshots: ckeditor5: 47.3.0 es-toolkit: 1.39.5 fuzzysort: 3.1.0 - transitivePeerDependencies: - - supports-color '@ckeditor/ckeditor5-engine@47.3.0': dependencies: @@ -15490,8 +15520,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: @@ -15516,8 +15544,6 @@ 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: @@ -15527,8 +15553,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: @@ -15559,8 +15583,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: @@ -15571,8 +15593,6 @@ snapshots: '@ckeditor/ckeditor5-ui': 47.3.0 '@ckeditor/ckeditor5-utils': 47.3.0 ckeditor5: 47.3.0 - transitivePeerDependencies: - - supports-color '@ckeditor/ckeditor5-highlight@47.3.0': dependencies: @@ -15581,8 +15601,6 @@ snapshots: '@ckeditor/ckeditor5-ui': 47.3.0 '@ckeditor/ckeditor5-utils': 47.3.0 ckeditor5: 47.3.0 - transitivePeerDependencies: - - supports-color '@ckeditor/ckeditor5-horizontal-line@47.3.0': dependencies: @@ -15592,8 +15610,6 @@ 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: @@ -15603,8 +15619,6 @@ snapshots: '@ckeditor/ckeditor5-utils': 47.3.0 '@ckeditor/ckeditor5-widget': 47.3.0 ckeditor5: 47.3.0 - transitivePeerDependencies: - - supports-color '@ckeditor/ckeditor5-html-support@47.3.0': dependencies: @@ -15620,8 +15634,6 @@ snapshots: '@ckeditor/ckeditor5-widget': 47.3.0 ckeditor5: 47.3.0 es-toolkit: 1.39.5 - transitivePeerDependencies: - - supports-color '@ckeditor/ckeditor5-icons@47.3.0': {} @@ -15639,8 +15651,6 @@ snapshots: '@ckeditor/ckeditor5-widget': 47.3.0 ckeditor5: 47.3.0 es-toolkit: 1.39.5 - transitivePeerDependencies: - - supports-color '@ckeditor/ckeditor5-import-word@47.3.0': dependencies: @@ -15653,8 +15663,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: @@ -15666,8 +15674,6 @@ snapshots: '@ckeditor/ckeditor5-ui': 47.3.0 '@ckeditor/ckeditor5-utils': 47.3.0 ckeditor5: 47.3.0 - transitivePeerDependencies: - - supports-color '@ckeditor/ckeditor5-inspector@5.0.0': {} @@ -15677,8 +15683,6 @@ snapshots: '@ckeditor/ckeditor5-ui': 47.3.0 '@ckeditor/ckeditor5-utils': 47.3.0 ckeditor5: 47.3.0 - transitivePeerDependencies: - - supports-color '@ckeditor/ckeditor5-line-height@47.3.0': dependencies: @@ -15703,8 +15707,6 @@ snapshots: '@ckeditor/ckeditor5-widget': 47.3.0 ckeditor5: 47.3.0 es-toolkit: 1.39.5 - transitivePeerDependencies: - - supports-color '@ckeditor/ckeditor5-list-multi-level@47.3.0': dependencies: @@ -15728,8 +15730,6 @@ snapshots: '@ckeditor/ckeditor5-ui': 47.3.0 '@ckeditor/ckeditor5-utils': 47.3.0 ckeditor5: 47.3.0 - transitivePeerDependencies: - - supports-color '@ckeditor/ckeditor5-markdown-gfm@47.3.0': dependencies: @@ -15767,8 +15767,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: @@ -15778,8 +15776,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: @@ -15792,8 +15788,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: @@ -15802,8 +15796,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: @@ -15858,8 +15850,6 @@ snapshots: '@ckeditor/ckeditor5-utils': 47.3.0 '@ckeditor/ckeditor5-widget': 47.3.0 ckeditor5: 47.3.0 - transitivePeerDependencies: - - supports-color '@ckeditor/ckeditor5-pagination@47.3.0': dependencies: @@ -15893,8 +15883,6 @@ snapshots: '@ckeditor/ckeditor5-core': 47.3.0 '@ckeditor/ckeditor5-engine': 47.3.0 ckeditor5: 47.3.0 - transitivePeerDependencies: - - supports-color '@ckeditor/ckeditor5-real-time-collaboration@47.3.0(bufferutil@4.0.9)(utf-8-validate@6.0.5)': dependencies: @@ -15925,8 +15913,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: @@ -15971,8 +15957,6 @@ snapshots: '@ckeditor/ckeditor5-ui': 47.3.0 '@ckeditor/ckeditor5-utils': 47.3.0 ckeditor5: 47.3.0 - transitivePeerDependencies: - - supports-color '@ckeditor/ckeditor5-slash-command@47.3.0': dependencies: @@ -15985,8 +15969,6 @@ snapshots: '@ckeditor/ckeditor5-ui': 47.3.0 '@ckeditor/ckeditor5-utils': 47.3.0 ckeditor5: 47.3.0 - transitivePeerDependencies: - - supports-color '@ckeditor/ckeditor5-source-editing-enhanced@47.3.0': dependencies: @@ -16013,8 +15995,6 @@ snapshots: '@ckeditor/ckeditor5-ui': 47.3.0 '@ckeditor/ckeditor5-utils': 47.3.0 ckeditor5: 47.3.0 - transitivePeerDependencies: - - supports-color '@ckeditor/ckeditor5-special-characters@47.3.0': dependencies: @@ -16036,8 +16016,6 @@ snapshots: '@ckeditor/ckeditor5-utils': 47.3.0 ckeditor5: 47.3.0 es-toolkit: 1.39.5 - transitivePeerDependencies: - - supports-color '@ckeditor/ckeditor5-table@47.3.0': dependencies: @@ -16050,8 +16028,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: @@ -16162,8 +16138,6 @@ snapshots: '@ckeditor/ckeditor5-engine': 47.3.0 '@ckeditor/ckeditor5-utils': 47.3.0 es-toolkit: 1.39.5 - transitivePeerDependencies: - - supports-color '@ckeditor/ckeditor5-widget@47.3.0': dependencies: @@ -16183,8 +16157,6 @@ snapshots: '@ckeditor/ckeditor5-utils': 47.3.0 ckeditor5: 47.3.0 es-toolkit: 1.39.5 - transitivePeerDependencies: - - supports-color '@codemirror/autocomplete@6.18.6': dependencies: @@ -17220,7 +17192,7 @@ snapshots: '@file-type/xml@0.4.3': dependencies: - sax: 1.4.4 + sax: 1.4.3 strtok3: 10.2.2 '@floating-ui/core@1.6.9': @@ -20533,7 +20505,7 @@ snapshots: '@uploadcare/upload-client@6.14.3(bufferutil@4.0.9)(utf-8-validate@6.0.5)': dependencies: form-data: 4.0.4 - ws: 8.19.0(bufferutil@4.0.9)(utf-8-validate@6.0.5) + ws: 8.18.3(bufferutil@4.0.9)(utf-8-validate@6.0.5) transitivePeerDependencies: - bufferutil - utf-8-validate @@ -20541,7 +20513,7 @@ snapshots: '@uploadcare/upload-client@6.17.0(bufferutil@4.0.9)(utf-8-validate@6.0.5)': dependencies: form-data: 4.0.4 - ws: 8.19.0(bufferutil@4.0.9)(utf-8-validate@6.0.5) + ws: 8.18.3(bufferutil@4.0.9)(utf-8-validate@6.0.5) transitivePeerDependencies: - bufferutil - utf-8-validate @@ -20567,7 +20539,7 @@ snapshots: sirv: 3.0.2 tinyrainbow: 3.0.3 vitest: 4.0.16(@opentelemetry/api@1.9.0)(@types/node@24.10.7)(@vitest/browser-webdriverio@4.0.16)(@vitest/ui@4.0.16)(happy-dom@20.0.11)(jiti@2.6.1)(jsdom@26.1.0(bufferutil@4.0.9)(utf-8-validate@6.0.5))(less@4.1.3)(lightningcss@1.30.2)(msw@2.7.5(@types/node@24.10.7)(typescript@5.9.3))(sass-embedded@1.91.0)(sass@1.91.0)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.1) - ws: 8.19.0(bufferutil@4.0.9)(utf-8-validate@6.0.5) + ws: 8.18.3(bufferutil@4.0.9)(utf-8-validate@6.0.5) transitivePeerDependencies: - bufferutil - msw @@ -21708,8 +21680,6 @@ snapshots: ckeditor5-collaboration@47.3.0: dependencies: '@ckeditor/ckeditor5-collaboration-core': 47.3.0 - transitivePeerDependencies: - - supports-color ckeditor5-premium-features@47.3.0(bufferutil@4.0.9)(ckeditor5@47.3.0)(utf-8-validate@6.0.5): dependencies: @@ -25362,7 +25332,7 @@ snapshots: whatwg-encoding: 3.1.1 whatwg-mimetype: 4.0.0 whatwg-url: 14.2.0 - ws: 8.19.0(bufferutil@4.0.9)(utf-8-validate@6.0.5) + ws: 8.18.3(bufferutil@4.0.9)(utf-8-validate@6.0.5) xml-name-validator: 5.0.0 transitivePeerDependencies: - bufferutil @@ -26688,7 +26658,7 @@ snapshots: needle@3.3.1: dependencies: iconv-lite: 0.6.3 - sax: 1.4.4 + sax: 1.4.3 optional: true negotiator@0.6.3: {} @@ -27016,9 +26986,9 @@ snapshots: is-inside-container: 1.0.0 wsl-utils: 0.1.0 - openai@6.16.0(ws@8.19.0(bufferutil@4.0.9)(utf-8-validate@6.0.5))(zod@4.1.12): + openai@6.16.0(ws@8.18.3(bufferutil@4.0.9)(utf-8-validate@6.0.5))(zod@4.1.12): optionalDependencies: - ws: 8.19.0(bufferutil@4.0.9)(utf-8-validate@6.0.5) + ws: 8.18.3(bufferutil@4.0.9)(utf-8-validate@6.0.5) zod: 4.1.12 openapi-sampler@1.6.2: @@ -27806,12 +27776,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 @@ -28616,7 +28580,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: @@ -28722,7 +28686,7 @@ snapshots: '@parcel/watcher': 2.5.1 optional: true - sax@1.4.4: {} + sax@1.4.3: {} saxes@5.0.1: dependencies: @@ -29680,7 +29644,7 @@ snapshots: css-what: 6.2.2 csso: 5.0.5 picocolors: 1.1.1 - sax: 1.4.4 + sax: 1.4.3 swagger-jsdoc@6.2.8(openapi-types@12.1.3): dependencies: @@ -30464,6 +30428,26 @@ snapshots: stack-trace: 1.0.0-pre2 vite: 7.3.1(@types/node@24.10.7)(jiti@2.6.1)(less@4.1.3)(lightningcss@1.30.2)(sass-embedded@1.91.0)(sass@1.91.0)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.1) + vite@7.3.0(@types/node@24.10.7)(jiti@2.6.1)(less@4.1.3)(lightningcss@1.30.2)(sass-embedded@1.91.0)(sass@1.91.0)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.1): + dependencies: + esbuild: 0.27.2 + fdir: 6.5.0(picomatch@4.0.3) + picomatch: 4.0.3 + postcss: 8.5.6 + rollup: 4.52.0 + tinyglobby: 0.2.15 + optionalDependencies: + '@types/node': 24.10.7 + fsevents: 2.3.3 + jiti: 2.6.1 + less: 4.1.3 + lightningcss: 1.30.2 + sass: 1.91.0 + sass-embedded: 1.91.0 + terser: 5.44.0 + tsx: 4.21.0 + yaml: 2.8.1 + vite@7.3.1(@types/node@24.10.7)(jiti@2.6.1)(less@4.1.3)(lightningcss@1.30.2)(sass-embedded@1.91.0)(sass@1.91.0)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.1): dependencies: esbuild: 0.27.2 @@ -30611,7 +30595,7 @@ snapshots: deepmerge-ts: 7.1.5 https-proxy-agent: 7.0.6 undici: 6.21.3 - ws: 8.19.0(bufferutil@4.0.9)(utf-8-validate@6.0.5) + ws: 8.18.3(bufferutil@4.0.9)(utf-8-validate@6.0.5) transitivePeerDependencies: - bare-buffer - bufferutil @@ -30700,7 +30684,7 @@ snapshots: sockjs: 0.3.24 spdy: 4.0.2 webpack-dev-middleware: 7.4.3(webpack@5.101.3(@swc/core@1.11.29(@swc/helpers@0.5.17))(esbuild@0.27.2)) - ws: 8.19.0(bufferutil@4.0.9)(utf-8-validate@6.0.5) + ws: 8.18.3(bufferutil@4.0.9)(utf-8-validate@6.0.5) optionalDependencies: webpack: 5.101.3(@swc/core@1.11.29(@swc/helpers@0.5.17))(esbuild@0.27.2) transitivePeerDependencies: @@ -30947,7 +30931,7 @@ snapshots: bufferutil: 4.0.9 utf-8-validate: 6.0.5 - ws@8.19.0(bufferutil@4.0.9)(utf-8-validate@6.0.5): + ws@8.18.3(bufferutil@4.0.9)(utf-8-validate@6.0.5): optionalDependencies: bufferutil: 4.0.9 utf-8-validate: 6.0.5 @@ -30965,12 +30949,12 @@ snapshots: xml2js@0.5.0: dependencies: - sax: 1.4.4 + sax: 1.4.3 xmlbuilder: 11.0.1 xml2js@0.6.2: dependencies: - sax: 1.4.4 + sax: 1.4.3 xmlbuilder: 11.0.1 xmlbuilder@11.0.1: {} From daf41804d44d4a737e4f9b8d1e90de2ac41a3073 Mon Sep 17 00:00:00 2001 From: Elian Doran <contact@eliandoran.me> Date: Mon, 12 Jan 2026 21:43:57 +0200 Subject: [PATCH 69/72] chore(core): address requested changes --- .../src/widgets/attribute_widgets/UserAttributesList.tsx | 2 +- apps/server/package.json | 6 +++--- packages/trilium-core/src/becca/entities/bnote.ts | 2 +- packages/trilium-core/src/services/log.ts | 4 ++-- packages/trilium-core/src/services/notes.ts | 2 +- packages/trilium-core/src/services/options_init.ts | 2 +- 6 files changed, 9 insertions(+), 9 deletions(-) diff --git a/apps/client/src/widgets/attribute_widgets/UserAttributesList.tsx b/apps/client/src/widgets/attribute_widgets/UserAttributesList.tsx index 93ed5356c9..a95887b7af 100644 --- a/apps/client/src/widgets/attribute_widgets/UserAttributesList.tsx +++ b/apps/client/src/widgets/attribute_widgets/UserAttributesList.tsx @@ -48,7 +48,7 @@ function useNoteAttributesWithDefinitions(note: FNote, attributesToIgnore: stri } function UserAttribute({ attr, children, style }: { attr: AttributeWithDefinitions, children: ComponentChildren, style?: CSSProperties }) { - const className = `${attr.type === "label" ? `label` + ` ${ attr.def.labelType}` : "relation"}`; + const className = attr.type === "label" ? `label ${attr.def.labelType}` : "relation"; return ( <span key={attr.friendlyName} className={`user-attribute type-${className}`} style={style}> diff --git a/apps/server/package.json b/apps/server/package.json index daa44d3c81..841645dd60 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -110,7 +110,7 @@ "openai": "6.16.0", "rand-token": "1.0.1", "safe-compare": "1.1.4", - "sax": "1.4.3", + "sax": "1.4.4", "serve-favicon": "2.5.1", "stream-throttle": "0.1.3", "strip-bom": "5.0.0", @@ -120,8 +120,8 @@ "time2fa": "1.4.2", "tmp": "0.2.5", "turndown": "7.2.2", - "vite": "7.3.0", - "ws": "8.18.3", + "vite": "7.3.1", + "ws": "8.19.0", "xml2js": "0.6.2", "yauzl": "3.2.0" } diff --git a/packages/trilium-core/src/becca/entities/bnote.ts b/packages/trilium-core/src/becca/entities/bnote.ts index ad97d93fbf..7be573166f 100644 --- a/packages/trilium-core/src/becca/entities/bnote.ts +++ b/packages/trilium-core/src/becca/entities/bnote.ts @@ -1602,7 +1602,7 @@ class BNote extends AbstractBeccaEntity<BNote> { // Limit the number of Snapshots to revisionSnapshotNumberLimit // Delete older Snapshots that exceed the limit eraseExcessRevisionSnapshots() { - // lable has a higher priority + // label has a higher priority let revisionSnapshotNumberLimit = parseInt(this.getLabelValue("versioningLimit") ?? ""); if (!Number.isInteger(revisionSnapshotNumberLimit)) { revisionSnapshotNumberLimit = parseInt(optionService.getOption("revisionSnapshotNumberLimit")); diff --git a/packages/trilium-core/src/services/log.ts b/packages/trilium-core/src/services/log.ts index 540cdc87a7..686d497ad3 100644 --- a/packages/trilium-core/src/services/log.ts +++ b/packages/trilium-core/src/services/log.ts @@ -5,11 +5,11 @@ export default class LogService { } info(message: string | Error) { - this.log(message); + console.info(message); } error(message: string | Error | unknown) { - this.log(`ERROR: ${message}`); + console.error("ERROR: ", message); } } diff --git a/packages/trilium-core/src/services/notes.ts b/packages/trilium-core/src/services/notes.ts index d1b908fdf7..a62a8e7d56 100644 --- a/packages/trilium-core/src/services/notes.ts +++ b/packages/trilium-core/src/services/notes.ts @@ -553,7 +553,7 @@ async function downloadImage(noteId: string, imageUrl: string) { if (attachment.attachmentId) { imageUrlToAttachmentIdMapping[imageUrl] = attachment.attachmentId; } else { - log.error(`Download of '${imageUrl}' due to no attachment ID.`); + log.error(`Download of '${imageUrl}' for note '${noteId}' failed due to no attachment ID.`); } log.info(`Download of '${imageUrl}' succeeded and was saved as image attachment '${attachment.attachmentId}' of note '${noteId}'`); diff --git a/packages/trilium-core/src/services/options_init.ts b/packages/trilium-core/src/services/options_init.ts index 9b9ed9099c..3e605711d8 100644 --- a/packages/trilium-core/src/services/options_init.ts +++ b/packages/trilium-core/src/services/options_init.ts @@ -28,7 +28,7 @@ interface DefaultOption { /** * The value to initialize the option with, if the option is not already present in the database. * - * If a function is passed Gin instead, the function is called if the option does not exist (with access to the current options) and the return value is used instead. Useful to migrate a new option with a value depending on some other option that might be initialized. + * If a function is passed in instead, the function is called if the option does not exist (with access to the current options) and the return value is used instead. Useful to migrate a new option with a value depending on some other option that might be initialized. */ value: string | ((options: OptionMap) => string); isSynced: boolean; From 7e00634f3d2de024cde56b6ee7d8937152798604 Mon Sep 17 00:00:00 2001 From: Elian Doran <contact@eliandoran.me> Date: Mon, 12 Jan 2026 21:44:25 +0200 Subject: [PATCH 70/72] chore(deps): align package lock --- pnpm-lock.yaml | 141 +++++++++++++++++++------------------------------ 1 file changed, 55 insertions(+), 86 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ac5ab1742b..35d6de04d2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -731,7 +731,7 @@ importers: version: 0.6.3 openai: specifier: 6.16.0 - version: 6.16.0(ws@8.18.3(bufferutil@4.0.9)(utf-8-validate@6.0.5))(zod@4.1.12) + version: 6.16.0(ws@8.19.0(bufferutil@4.0.9)(utf-8-validate@6.0.5))(zod@4.1.12) rand-token: specifier: 1.0.1 version: 1.0.1 @@ -739,8 +739,8 @@ importers: specifier: 1.1.4 version: 1.1.4 sax: - specifier: 1.4.3 - version: 1.4.3 + specifier: 1.4.4 + version: 1.4.4 serve-favicon: specifier: 2.5.1 version: 2.5.1 @@ -769,11 +769,11 @@ importers: specifier: 7.2.2 version: 7.2.2 vite: - specifier: 7.3.0 - version: 7.3.0(@types/node@24.10.7)(jiti@2.6.1)(less@4.1.3)(lightningcss@1.30.2)(sass-embedded@1.91.0)(sass@1.91.0)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.1) + specifier: 7.3.1 + version: 7.3.1(@types/node@24.10.7)(jiti@2.6.1)(less@4.1.3)(lightningcss@1.30.2)(sass-embedded@1.91.0)(sass@1.91.0)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.1) ws: - specifier: 8.18.3 - version: 8.18.3(bufferutil@4.0.9)(utf-8-validate@6.0.5) + specifier: 8.19.0 + version: 8.19.0(bufferutil@4.0.9)(utf-8-validate@6.0.5) xml2js: specifier: 0.6.2 version: 0.6.2 @@ -12429,8 +12429,9 @@ packages: engines: {node: '>=14.0.0'} hasBin: true - sax@1.4.3: - resolution: {integrity: sha512-yqYn1JhPczigF94DMS+shiDMjDowYO6y9+wB/4WgO0Y19jWYk0lQ4tuG5KI7kj4FTp1wxPj5IFfcrz/s1c3jjQ==} + sax@1.4.4: + resolution: {integrity: sha512-1n3r/tGXO6b6VXMdFT54SHzT9ytu9yr7TaELowdYpMqY/Ao7EnlQGmAQ1+RatX7Tkkdm6hONI2owqNx2aZj5Sw==} + engines: {node: '>=11.0.0'} saxes@5.0.1: resolution: {integrity: sha512-5LBh1Tls8c9xgGjw3QrMwETmTMVk0oFgvrFSvWx62llR2hcEInrKNZ2GZCCuuy2lvWrdl5jhbpeqc5hRYKFOcw==} @@ -13816,46 +13817,6 @@ packages: peerDependencies: vite: 5.x || 6.x || 7.x - vite@7.3.0: - resolution: {integrity: sha512-dZwN5L1VlUBewiP6H9s2+B3e3Jg96D0vzN+Ry73sOefebhYr9f94wwkMNN/9ouoU8pV1BqA1d1zGk8928cx0rg==} - engines: {node: ^20.19.0 || >=22.12.0} - hasBin: true - peerDependencies: - '@types/node': ^20.19.0 || >=22.12.0 - jiti: '>=1.21.0' - less: ^4.0.0 - lightningcss: ^1.21.0 - sass: ^1.70.0 - sass-embedded: ^1.70.0 - stylus: '>=0.54.8' - sugarss: ^5.0.0 - terser: ^5.16.0 - tsx: ^4.8.1 - yaml: ^2.4.2 - peerDependenciesMeta: - '@types/node': - optional: true - jiti: - optional: true - less: - optional: true - lightningcss: - optional: true - sass: - optional: true - sass-embedded: - optional: true - stylus: - optional: true - sugarss: - optional: true - terser: - optional: true - tsx: - optional: true - yaml: - optional: true - vite@7.3.1: resolution: {integrity: sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==} engines: {node: ^20.19.0 || >=22.12.0} @@ -14229,8 +14190,8 @@ packages: utf-8-validate: optional: true - ws@8.18.3: - resolution: {integrity: sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==} + ws@8.19.0: + resolution: {integrity: sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==} engines: {node: '>=10.0.0'} peerDependencies: bufferutil: ^4.0.1 @@ -15141,6 +15102,8 @@ 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: @@ -15151,6 +15114,8 @@ 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: @@ -15478,6 +15443,8 @@ snapshots: ckeditor5: 47.3.0 es-toolkit: 1.39.5 fuzzysort: 3.1.0 + transitivePeerDependencies: + - supports-color '@ckeditor/ckeditor5-engine@47.3.0': dependencies: @@ -15544,6 +15511,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: @@ -15553,6 +15522,8 @@ 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: @@ -15583,6 +15554,8 @@ 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: @@ -15593,6 +15566,8 @@ snapshots: '@ckeditor/ckeditor5-ui': 47.3.0 '@ckeditor/ckeditor5-utils': 47.3.0 ckeditor5: 47.3.0 + transitivePeerDependencies: + - supports-color '@ckeditor/ckeditor5-highlight@47.3.0': dependencies: @@ -15601,6 +15576,8 @@ snapshots: '@ckeditor/ckeditor5-ui': 47.3.0 '@ckeditor/ckeditor5-utils': 47.3.0 ckeditor5: 47.3.0 + transitivePeerDependencies: + - supports-color '@ckeditor/ckeditor5-horizontal-line@47.3.0': dependencies: @@ -15610,6 +15587,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: @@ -15619,6 +15598,8 @@ snapshots: '@ckeditor/ckeditor5-utils': 47.3.0 '@ckeditor/ckeditor5-widget': 47.3.0 ckeditor5: 47.3.0 + transitivePeerDependencies: + - supports-color '@ckeditor/ckeditor5-html-support@47.3.0': dependencies: @@ -15634,6 +15615,8 @@ snapshots: '@ckeditor/ckeditor5-widget': 47.3.0 ckeditor5: 47.3.0 es-toolkit: 1.39.5 + transitivePeerDependencies: + - supports-color '@ckeditor/ckeditor5-icons@47.3.0': {} @@ -15651,6 +15634,8 @@ snapshots: '@ckeditor/ckeditor5-widget': 47.3.0 ckeditor5: 47.3.0 es-toolkit: 1.39.5 + transitivePeerDependencies: + - supports-color '@ckeditor/ckeditor5-import-word@47.3.0': dependencies: @@ -15674,6 +15659,8 @@ snapshots: '@ckeditor/ckeditor5-ui': 47.3.0 '@ckeditor/ckeditor5-utils': 47.3.0 ckeditor5: 47.3.0 + transitivePeerDependencies: + - supports-color '@ckeditor/ckeditor5-inspector@5.0.0': {} @@ -15776,6 +15763,8 @@ 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: @@ -17192,7 +17181,7 @@ snapshots: '@file-type/xml@0.4.3': dependencies: - sax: 1.4.3 + sax: 1.4.4 strtok3: 10.2.2 '@floating-ui/core@1.6.9': @@ -20505,7 +20494,7 @@ snapshots: '@uploadcare/upload-client@6.14.3(bufferutil@4.0.9)(utf-8-validate@6.0.5)': dependencies: form-data: 4.0.4 - ws: 8.18.3(bufferutil@4.0.9)(utf-8-validate@6.0.5) + ws: 8.19.0(bufferutil@4.0.9)(utf-8-validate@6.0.5) transitivePeerDependencies: - bufferutil - utf-8-validate @@ -20513,7 +20502,7 @@ snapshots: '@uploadcare/upload-client@6.17.0(bufferutil@4.0.9)(utf-8-validate@6.0.5)': dependencies: form-data: 4.0.4 - ws: 8.18.3(bufferutil@4.0.9)(utf-8-validate@6.0.5) + ws: 8.19.0(bufferutil@4.0.9)(utf-8-validate@6.0.5) transitivePeerDependencies: - bufferutil - utf-8-validate @@ -20539,7 +20528,7 @@ snapshots: sirv: 3.0.2 tinyrainbow: 3.0.3 vitest: 4.0.16(@opentelemetry/api@1.9.0)(@types/node@24.10.7)(@vitest/browser-webdriverio@4.0.16)(@vitest/ui@4.0.16)(happy-dom@20.0.11)(jiti@2.6.1)(jsdom@26.1.0(bufferutil@4.0.9)(utf-8-validate@6.0.5))(less@4.1.3)(lightningcss@1.30.2)(msw@2.7.5(@types/node@24.10.7)(typescript@5.9.3))(sass-embedded@1.91.0)(sass@1.91.0)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.1) - ws: 8.18.3(bufferutil@4.0.9)(utf-8-validate@6.0.5) + ws: 8.19.0(bufferutil@4.0.9)(utf-8-validate@6.0.5) transitivePeerDependencies: - bufferutil - msw @@ -25332,7 +25321,7 @@ snapshots: whatwg-encoding: 3.1.1 whatwg-mimetype: 4.0.0 whatwg-url: 14.2.0 - ws: 8.18.3(bufferutil@4.0.9)(utf-8-validate@6.0.5) + ws: 8.19.0(bufferutil@4.0.9)(utf-8-validate@6.0.5) xml-name-validator: 5.0.0 transitivePeerDependencies: - bufferutil @@ -26658,7 +26647,7 @@ snapshots: needle@3.3.1: dependencies: iconv-lite: 0.6.3 - sax: 1.4.3 + sax: 1.4.4 optional: true negotiator@0.6.3: {} @@ -26986,9 +26975,9 @@ snapshots: is-inside-container: 1.0.0 wsl-utils: 0.1.0 - openai@6.16.0(ws@8.18.3(bufferutil@4.0.9)(utf-8-validate@6.0.5))(zod@4.1.12): + openai@6.16.0(ws@8.19.0(bufferutil@4.0.9)(utf-8-validate@6.0.5))(zod@4.1.12): optionalDependencies: - ws: 8.18.3(bufferutil@4.0.9)(utf-8-validate@6.0.5) + ws: 8.19.0(bufferutil@4.0.9)(utf-8-validate@6.0.5) zod: 4.1.12 openapi-sampler@1.6.2: @@ -28686,7 +28675,7 @@ snapshots: '@parcel/watcher': 2.5.1 optional: true - sax@1.4.3: {} + sax@1.4.4: {} saxes@5.0.1: dependencies: @@ -29644,7 +29633,7 @@ snapshots: css-what: 6.2.2 csso: 5.0.5 picocolors: 1.1.1 - sax: 1.4.3 + sax: 1.4.4 swagger-jsdoc@6.2.8(openapi-types@12.1.3): dependencies: @@ -30428,26 +30417,6 @@ snapshots: stack-trace: 1.0.0-pre2 vite: 7.3.1(@types/node@24.10.7)(jiti@2.6.1)(less@4.1.3)(lightningcss@1.30.2)(sass-embedded@1.91.0)(sass@1.91.0)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.1) - vite@7.3.0(@types/node@24.10.7)(jiti@2.6.1)(less@4.1.3)(lightningcss@1.30.2)(sass-embedded@1.91.0)(sass@1.91.0)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.1): - dependencies: - esbuild: 0.27.2 - fdir: 6.5.0(picomatch@4.0.3) - picomatch: 4.0.3 - postcss: 8.5.6 - rollup: 4.52.0 - tinyglobby: 0.2.15 - optionalDependencies: - '@types/node': 24.10.7 - fsevents: 2.3.3 - jiti: 2.6.1 - less: 4.1.3 - lightningcss: 1.30.2 - sass: 1.91.0 - sass-embedded: 1.91.0 - terser: 5.44.0 - tsx: 4.21.0 - yaml: 2.8.1 - vite@7.3.1(@types/node@24.10.7)(jiti@2.6.1)(less@4.1.3)(lightningcss@1.30.2)(sass-embedded@1.91.0)(sass@1.91.0)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.1): dependencies: esbuild: 0.27.2 @@ -30595,7 +30564,7 @@ snapshots: deepmerge-ts: 7.1.5 https-proxy-agent: 7.0.6 undici: 6.21.3 - ws: 8.18.3(bufferutil@4.0.9)(utf-8-validate@6.0.5) + ws: 8.19.0(bufferutil@4.0.9)(utf-8-validate@6.0.5) transitivePeerDependencies: - bare-buffer - bufferutil @@ -30684,7 +30653,7 @@ snapshots: sockjs: 0.3.24 spdy: 4.0.2 webpack-dev-middleware: 7.4.3(webpack@5.101.3(@swc/core@1.11.29(@swc/helpers@0.5.17))(esbuild@0.27.2)) - ws: 8.18.3(bufferutil@4.0.9)(utf-8-validate@6.0.5) + ws: 8.19.0(bufferutil@4.0.9)(utf-8-validate@6.0.5) optionalDependencies: webpack: 5.101.3(@swc/core@1.11.29(@swc/helpers@0.5.17))(esbuild@0.27.2) transitivePeerDependencies: @@ -30931,7 +30900,7 @@ snapshots: bufferutil: 4.0.9 utf-8-validate: 6.0.5 - ws@8.18.3(bufferutil@4.0.9)(utf-8-validate@6.0.5): + ws@8.19.0(bufferutil@4.0.9)(utf-8-validate@6.0.5): optionalDependencies: bufferutil: 4.0.9 utf-8-validate: 6.0.5 @@ -30949,12 +30918,12 @@ snapshots: xml2js@0.5.0: dependencies: - sax: 1.4.3 + sax: 1.4.4 xmlbuilder: 11.0.1 xml2js@0.6.2: dependencies: - sax: 1.4.3 + sax: 1.4.4 xmlbuilder: 11.0.1 xmlbuilder@11.0.1: {} From 51753ad82a70f24018c295d24b1a9fc79bab1176 Mon Sep 17 00:00:00 2001 From: Elian Doran <contact@eliandoran.me> Date: Mon, 12 Jan 2026 21:51:26 +0200 Subject: [PATCH 71/72] chore(ci): run tests on standalone branch as well --- .github/workflows/dev.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/dev.yml b/.github/workflows/dev.yml index 041a31ea4a..5c91c8e24e 100644 --- a/.github/workflows/dev.yml +++ b/.github/workflows/dev.yml @@ -1,9 +1,9 @@ name: Dev on: push: - branches: [ main ] + branches: [ main, standalone ] pull_request: - branches: [ main ] + branches: [ main, standalone ] concurrency: group: ${{ github.workflow }}-${{ github.ref }} From 9dd7616f7d8c7671147d30bb634004e3762fe459 Mon Sep 17 00:00:00 2001 From: Elian Doran <contact@eliandoran.me> Date: Wed, 14 Jan 2026 17:41:24 +0200 Subject: [PATCH 72/72] chore(client-standalone): address requested changes --- apps/server/src/services/export/zip.ts | 6 ++++-- apps/server/src/services/export/zip/share_theme.ts | 8 +++++--- packages/trilium-core/src/services/blob.ts | 3 +-- packages/trilium-core/src/services/events.ts | 2 +- 4 files changed, 11 insertions(+), 8 deletions(-) diff --git a/apps/server/src/services/export/zip.ts b/apps/server/src/services/export/zip.ts index a0c32ef6bd..e9eec69bf5 100644 --- a/apps/server/src/services/export/zip.ts +++ b/apps/server/src/services/export/zip.ts @@ -343,7 +343,9 @@ async function exportToZip(taskContext: TaskContext<"export">, branch: BBranch, content = prepareContent(noteMeta.title, content, noteMeta, undefined); - archive.append(content as Buffer, { name: filePathPrefix + noteMeta.dataFileName }); + archive.append(typeof content === "string" ? content : Buffer.from(content), { + name: filePathPrefix + noteMeta.dataFileName + }); return; } @@ -375,7 +377,7 @@ async function exportToZip(taskContext: TaskContext<"export">, branch: BBranch, const attachment = note.getAttachmentById(attachmentMeta.attachmentId); const content = attachment.getContent(); - archive.append(content as Buffer, { + archive.append(typeof content === "string" ? content : Buffer.from(content), { name: filePathPrefix + attachmentMeta.dataFileName, date: dateUtils.parseDateTime(note.utcDateModified) }); diff --git a/apps/server/src/services/export/zip/share_theme.ts b/apps/server/src/services/export/zip/share_theme.ts index 534ccf785b..79fff7606e 100644 --- a/apps/server/src/services/export/zip/share_theme.ts +++ b/apps/server/src/services/export/zip/share_theme.ts @@ -149,8 +149,8 @@ export default class ShareThemeExportProvider extends ZipExportProvider { } const note = this.branch.getNote(); - const fullHtml = this.prepareContent(rootMeta.title ?? "", note.getContent(), rootMeta, note, this.branch); - this.archive.append(fullHtml as Buffer, { name: this.indexMeta.dataFileName }); + const content = this.prepareContent(rootMeta.title ?? "", note.getContent(), rootMeta, note, this.branch); + this.archive.append(typeof content === "string" ? content : Buffer.from(content), { name: this.indexMeta.dataFileName }); } #saveAssets(rootMeta: NoteMeta, assetsMeta: NoteMeta[]) { @@ -178,7 +178,9 @@ export default class ShareThemeExportProvider extends ZipExportProvider { continue; }; const fontFileName = `assets/icon-pack-${iconPack.prefix.toLowerCase()}.${extension}`; - this.archive.append(fontData as Buffer, { name: fontFileName }); + this.archive.append(typeof fontData === "string" ? fontData : Buffer.from(fontData), { + name: fontFileName + }); } } diff --git a/packages/trilium-core/src/services/blob.ts b/packages/trilium-core/src/services/blob.ts index 6792c75270..09e72ee951 100644 --- a/packages/trilium-core/src/services/blob.ts +++ b/packages/trilium-core/src/services/blob.ts @@ -39,8 +39,7 @@ function processContent(content: Uint8Array | string | null, isProtected: boolea if (isStringContent) { if (content === null) return ""; - if (typeof content === "string") return content; - return decodeUtf8(content as Uint8Array); + return decodeUtf8(content); } // see https://github.com/zadam/trilium/issues/3523 // IIRC a zero-sized buffer can be returned as null from the database diff --git a/packages/trilium-core/src/services/events.ts b/packages/trilium-core/src/services/events.ts index dcdc5793c4..f37eef1d6e 100644 --- a/packages/trilium-core/src/services/events.ts +++ b/packages/trilium-core/src/services/events.ts @@ -1,4 +1,4 @@ -import log, { getLog } from "./log.js"; +import { getLog } from "./log.js"; const NOTE_TITLE_CHANGED = "NOTE_TITLE_CHANGED"; const ENTER_PROTECTED_SESSION = "ENTER_PROTECTED_SESSION";