From c7f0d541c2de2b88265059334fe455d05507750f Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Tue, 6 Jan 2026 10:41:50 +0200 Subject: [PATCH 01/58] 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 6804f1ecb..2f123cb03 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 02/58] 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 e07443024..d1eb91a9e 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 5835385be..4d75ca67d 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 0cbe69cfa..0e38fb1ea 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 4964a5797..a4e1ed3cf 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 7e6df61d3..1bc92db23 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 eee6b2d65..042bde8b2 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 000000000..657eea11e --- /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 fc5d57faf..2984cdb3b 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 03/58] 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 8023338c9..7f44e6024 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 f7faf1309..3d5e7c320 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 3b30eccd8..0792c6bd4 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 7f271e88c..2ed302ccc 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 73ce03a7a..bf28fff98 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 22c0e6ab0..b7dc4eace 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 c0a97c7d6..d6e86ef0e 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 f32bf6ddd..39387c799 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 bd47b4327..5a1614647 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 a4e1ed3cf..7b368e3b9 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 93452669f..1b6911760 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 9d4ff5c4c..9e88e5796 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 c0834cb8b..4e374a784 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 042bde8b2..f38fc9692 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 04/58] 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 1a8203f43..a70a07e1e 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 3d5e7c320..e2cd1804c 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 92967da34..a5a4001a9 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 0792c6bd4..b224349ca 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 924dd972a..cfd0a638b 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 6ff1246fc..5e4028fe6 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 d1eb91a9e..4c7be548b 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 c355d2c8f..d76ede6bd 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 2ed302ccc..ea3c60803 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 7c841931b..f0cd473e9 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 bfaa46544..d950df369 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 88f647db2..2df8a4c9e 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 10a0e706d..2696c9c51 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 05/58] 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 3e62665b0..000000000 --- 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 2ab806d8b..000000000 --- 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 44f718a2c..000000000 --- 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 0206a17f3..000000000 --- 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 25cdd509e..000000000 --- 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 944eee841..fc328b444 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 b9621d5d0..6c1c3c684 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 05b05f6a4..146d28fbe 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 6f3326074..d2629e526 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 a70a07e1e..8c8099cd4 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 000000000..f2127f280 --- /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 f38fc9692..6e0c44f98 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 2f123cb03..912ed6416 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 06/58] 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 000000000..a2c6aaa80 --- /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 6e0c44f98..2922f6c81 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 07/58] 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 b2c877fcb..a466847a1 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 55c3e3e29..3b0ac8326 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 9915f58cb..6d3deb33a 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 bf28fff98..8a21f8420 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 133c35a88..c73896f15 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 c29f6e9aa..8ec4b10f4 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 4a6e17382..2095f3b71 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 273dc1e1d..45e3f5f0b 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 3c6db4054..c3f1746a6 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 e7377bdfd..123c04804 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 8a9c93940..959357893 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 cbd584529..fa2f8a259 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 33cd61b5e..71c404a9d 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 5e1c53041..da55e4ba4 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 2a505a993..bcb81ad85 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 4a9f14041..dbab81687 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 7b368e3b9..8c4a66f96 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 08/58] 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 000000000..45a2e0cac --- /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 000000000..f7c6f70c3 --- /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 000000000..0c687237b --- /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 000000000..114aa7400 --- /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 000000000..523e4e004 --- /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 000000000..e17b896ff --- /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 000000000..e8cee478d --- /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 000000000..c11de52a4 --- /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 c1ac90b91..51bb38cd0 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 2922f6c81..c73eb6c80 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 09/58] 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 5d9bb8033..7dd11fa64 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 9e06644c3..f20e21cb8 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 7bcf55ebe..ccd32b1b0 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 8ec4b10f4..2fe001a0b 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 45e3f5f0b..a37124bbb 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 7b4ba72ad..887b8990d 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 1b6911760..c50b81acf 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 ef3bd6cba..1ded101a3 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 c73eb6c80..c5b9fa317 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 10/58] 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 6d3deb33a..8b75dd7a6 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 9700e7f78..6505a32c7 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 fa2f8a259..d406524ed 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 b9ad19c36..18bb0944d 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 bf8a33524..984a6d97a 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 5ca4bda4a..16eeb70cc 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 504e81b21..95d158fa3 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 c5b9fa317..67dde2bfa 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 11/58] 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 d2629e526..dc11dfb41 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 b322926ef..d8e0a63c0 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 4dce6e6a4..c7729ba66 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 39387c799..b76417554 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 9dfcbc019..32e7f9c55 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 67dde2bfa..1aacf6bee 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 12/58] 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 ea3c60803..3d9a9fe2c 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 13/58] 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 6505a32c7..1eb691645 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 d8e0a63c0..b7df6ccea 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 1aacf6bee..dcc228c4a 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 14/58] 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 a466847a1..9e409e6f9 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 c3f1746a6..9ca191c8e 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 1eb691645..ee4041ece 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 d6e86ef0e..7d0b53e57 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 dcc228c4a..4a4ed3111 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 15/58] 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 e78fe0017..aea817831 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 6b9cbb926..afa845b10 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 a191b782b..5126a0cf5 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 1ded101a3..665e140e3 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 9e88e5796..0e28f8b01 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 da72cd419..ecc1d3191 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 4a4ed3111..e8f5e95f4 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 16/58] 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 809e36481..d1e8a0e40 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 31ab54c99..754cce2a3 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 699ed8438..de726af21 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 17/58] 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 d1e8a0e40..03599a8b6 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 a15192fd2..5a78d3676 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 67beb0b42..5b2b164a3 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 b224349ca..d14626781 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 cfd0a638b..b8b86ace1 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 2cff185d5..a3ec26138 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 3d9a9fe2c..4f7b70a3c 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 2df8a4c9e..c09e9ff21 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 912ed6416..06e76d5bd 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 e6bb9d1e8..ffae2c1e9 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 eff81ae23..e4bd5cb1c 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 5861ac906..94272039f 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 18/58] 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 86ebfb0d6..cf1797361 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 de726af21..e56c0502f 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 19/58] 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 4f7b70a3c..20d7dd970 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 06e76d5bd..bdf04e01c 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 20/58] 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 a0e605278..000000000 --- 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 7d0b53e57..65c941e1c 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 11d3af096..1f4f1ae90 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 ecc1d3191..6f0b9c8c6 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 bdf04e01c..9de989abc 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 21/58] 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 e8f5e95f4..cbdcba64d 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 ae4d69029..107bf298e 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 22/58] 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 4d75ca67d..8ecb3132f 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 a5a4001a9..dcd67c709 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 4c7be548b..78e8bb199 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 99cf35665..7f783853c 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 23/58] 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 ee4041ece..b0df41922 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 665e140e3..6b52fc60d 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 1f4f1ae90..090eaf734 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 6f0b9c8c6..ab51a434f 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 94272039f..54b734ca7 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 24/58] 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 2095f3b71..fbfa63a0d 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 aa24e7aae..ec7228d7b 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 16d36807e..e115e0e54 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 dbab81687..a0c32ef6b 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 5eda4b076..d3c5ea222 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 14fb44acc..0ba9ed213 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 c876a5c16..382bef874 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 316984d50..534ccf785 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 ddcad559f..0230e59da 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 8c4a66f96..de144196c 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 97dd99898..4b335f11d 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 89ba1bc98..4bd3408f9 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 e37ac8ab4..2de68222e 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 25/58] 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 8ecb3132f..ecbef741e 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 7f783853c..870596ae3 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 26/58] 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 20d7dd970..e59be195c 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 27/58] 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 c51b7bc9d..6ddeecf9f 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 887b8990d..23727c30f 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 6c8365e2b..41bd27d29 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 370f9297f..e0e40094d 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 d14626781..74f944248 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 b8b86ace1..bd6a1ff45 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 78e8bb199..b7603b270 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 e59be195c..14402d779 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 c09e9ff21..9a5f79740 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 cbdcba64d..5abb13025 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 9de989abc..6792c7527 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 94ebcb1ad..87cd8fb6c 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 000000000..06f730dcb --- /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 28/58] 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 2837e8de7..497bbb102 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 326672eeb..d6d70b6bc 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 1cc67df5a..68c69c5ab 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 b23e532a8..ce14b7741 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 5a78d3676..17d8f1471 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 5abb13025..7a0d5ac69 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 000000000..269b15b35 --- /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 000000000..326672eeb --- /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 000000000..a2ff346ea --- /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 000000000..832fb9278 --- /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 29/58] 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 326672eeb..0d20a886c 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 832fb9278..a77727ec3 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 30/58] 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 efdf6817a..a4c834846 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 23727c30f..a7fe6d3a6 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 5f9166fce..19c6170b5 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 3e597e94a..000000000 --- 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 5e4028fe6..280799580 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 3755da5a7..368a81c03 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 06f730dcb..fc39276f1 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 31/58] 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 0d93aae3c..292e64438 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 f01e70b49..93ed5356c 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 74db6ddb7..3c2e48763 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 b76417554..8a7d6d12f 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 2f68aa7ac..000000000 --- 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 17d8f1471..a26a8f286 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 630c57a72..bc9c66bbc 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 32/58] 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 ecbef741e..ebf693559 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 65c941e1c..24b210d95 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 31e620a8f..a61218020 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 7a0d5ac69..5c10216df 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 870596ae3..f1276bc95 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 000000000..b389806d4 --- /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 000000000..cd8257edf --- /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 33/58] 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 bab76afa5..10ecb4134 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 8c8099cd4..dd4092fb5 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 5c10216df..e6940a14f 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 000000000..c62101778 --- /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 34/58] 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 b7dc4eace..6106fc52d 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 a61218020..000000000 --- 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 6b52fc60d..7720e13f7 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 e6940a14f..096820bdb 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 b389806d4..d77bd4a58 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 cd8257edf..7925d5465 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 35/58] 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 dd4092fb5..0c2c3690d 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 e2cd1804c..5ee7e252b 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 dcd67c709..c7b5731e2 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 74f944248..dd84718e8 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 bd6a1ff45..bdc28d15d 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 280799580..915601779 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 b7603b270..1468e0459 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 14402d779..f573b0e0e 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 9a5f79740..a3a9b4ce0 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 36/58] 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 ebf693559..f5bb25ef1 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 de144196c..97756a383 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 096820bdb..3220233fa 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 f1276bc95..374eb1296 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 000000000..d4e1f736b --- /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 37/58] 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 5ebaa6dfa..000000000 --- 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 d4e1f736b..39cb59be9 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 38/58] 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 2aa86d0b6..ce3108512 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 3220233fa..c478cac62 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 000000000..2aa86d0b6 --- /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 39cb59be9..7fe87b681 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 39/58] 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 0f067c321..d52068829 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 c73896f15..fe2486059 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 387fcaec5..e57811f74 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 19c6170b5..d50f6e417 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 e91670df6..dec4a68e7 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 130eb8197..d9a2ed8fb 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 ac52a43f4..942914027 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 51bb38cd0..da27376d9 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 58198c904..090df06c6 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 575aaadd2..2de9aec8e 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 fe4c97f42..f45c45963 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 eb780f760..a85ec718e 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 d09a3675a..9d551e618 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 2de68222e..c777c5cbc 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 1bc92db23..c01697bfd 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 c478cac62..4fb9a1f7d 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 7fe87b681..e8a2f0ab3 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 dfbba8fd7..807e73c70 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 7e2e41b15..0301214f2 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 40/58] 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 9fae83070..735291c86 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 b7df6ccea..5d1d86331 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 4fb9a1f7d..1d9bd7459 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 e8a2f0ab3..56f01e6d6 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 41/58] 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 d52068829..f16b11ec7 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 e0e40094d..ee1e18302 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 c01697bfd..147aaa0ef 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 bdc28d15d..027413a65 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 f573b0e0e..ad97d93fb 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 d77bd4a58..c2f6ebb1f 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 0301214f2..3d2446d37 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 fc39276f1..6cd7c7f06 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 42/58] 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 f16b11ec7..8c6765924 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 ee1e18302..861a8396f 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 147aaa0ef..fd2f90a18 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 6cd7c7f06..f33ccb039 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 43/58] 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 56f01e6d6..d1b908fdf 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 44/58] 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 05c9ecdd9..98b889292 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 1d9bd7459..f459227b8 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 000000000..896af1014 --- /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 45/58] 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 fc5552b42..475ee81bc 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 f459227b8..9bc718b96 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 000000000..741723bfa --- /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 46/58] 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 8a7d6d12f..0562603fb 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 9bc718b96..128574ab3 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 000000000..ab2a34524 --- /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 47/58] 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 a95955f46..4c3d52aa0 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 128574ab3..635f44c0b 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 000000000..ba8da6da1 --- /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 d68c10c1c..d233bde28 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 000000000..4f377f0a6 --- /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 000000000..fe61ef7b9 --- /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 48/58] 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 ab2a34524..1192fbc8f 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 000000000..4760e21cc --- /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 49/58] 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 79122895b..59bd04f05 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 635f44c0b..21aff83f3 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 000000000..79122895b --- /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 000000000..95a41ee20 --- /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 50/58] 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 f5bb25ef1..a9c91ede9 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 374eb1296..91db0e836 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 92b28e573..481687726 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 51/58] 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 000000000..fe59c0dff --- /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 52/58] 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 000000000..a6c04adc9 --- /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 53/58] 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 000000000..e140ca050 --- /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 54/58] 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 000000000..bf67168e0 --- /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 55/58] 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 1265d4927..ae8f826b0 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 21aff83f3..482ba098f 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 000000000..b450e05d5 --- /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 56/58] 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 861a8396f..5ad481211 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 0d20a886c..704a639ad 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 a77727ec3..9b9ed9099 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 f33ccb039..e966f7237 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 57/58] 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 fe59c0dff..ec44193cb 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 58/58] 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 7f44e6024..c23968485 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 8a21f8420..26f030e47 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 9ca191c8e..cb5de8478 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 b0df41922..afa5a3c39 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 531bd9976..2c662fba2 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 a7fe6d3a6..b144d41bf 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 41bd27d29..6b189bbe6 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 482ba098f..97aba9f2a 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 481687726..91e63cd6a 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 704a639ad..0129be0c0 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[] = [