diff --git a/.github/workflows/dev.yml b/.github/workflows/dev.yml
index 041a31ea4a..5c91c8e24e 100644
--- a/.github/workflows/dev.yml
+++ b/.github/workflows/dev.yml
@@ -1,9 +1,9 @@
name: Dev
on:
push:
- branches: [ main ]
+ branches: [ main, standalone ]
pull_request:
- branches: [ main ]
+ branches: [ main, standalone ]
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
diff --git a/apps/client/src/services/promoted_attribute_definition_parser.ts b/apps/client/src/services/promoted_attribute_definition_parser.ts
index 0d93aae3c5..292e64438b 100644
--- a/apps/client/src/services/promoted_attribute_definition_parser.ts
+++ b/apps/client/src/services/promoted_attribute_definition_parser.ts
@@ -1,14 +1,4 @@
-export type LabelType = "text" | "number" | "boolean" | "date" | "datetime" | "time" | "url" | "color";
-type Multiplicity = "single" | "multi";
-
-export interface DefinitionObject {
- isPromoted?: boolean;
- labelType?: LabelType;
- multiplicity?: Multiplicity;
- numberPrecision?: number;
- promotedAlias?: string;
- inverseRelation?: string;
-}
+import { DefinitionObject, LabelType, Multiplicity } from "@triliumnext/commons";
function parse(value: string) {
const tokens = value.split(",").map((t) => t.trim());
diff --git a/apps/client/src/widgets/attribute_widgets/UserAttributesList.tsx b/apps/client/src/widgets/attribute_widgets/UserAttributesList.tsx
index f01e70b491..a95887b7af 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
{userAttributes?.map(attr => buildUserAttribute(attr))}
- )
+ );
}
@@ -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 (
{children}
- )
+ );
}
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}>;
}
- return {content}
+ return {content};
}
function getAttributesWithDefinitions(note: FNote, attributesToIgnore: string[] = []): AttributeWithDefinitions[] {
diff --git a/apps/client/src/widgets/collections/table/columns.tsx b/apps/client/src/widgets/collections/table/columns.tsx
index 74db6ddb71..3c2e487634 100644
--- a/apps/client/src/widgets/collections/table/columns.tsx
+++ b/apps/client/src/widgets/collections/table/columns.tsx
@@ -1,11 +1,12 @@
-import type { CellComponent, ColumnDefinition, EmptyCallback, FormatterParams, ValueBooleanCallback, ValueVoidCallback } from "tabulator-tables";
-import { LabelType } from "../../../services/promoted_attribute_definition_parser.js";
+import { LabelType } from "@triliumnext/commons";
import { JSX } from "preact";
-import { renderReactWidget } from "../../react/react_utils.jsx";
-import Icon from "../../react/Icon.jsx";
import { useEffect, useRef, useState } from "preact/hooks";
+import type { CellComponent, ColumnDefinition, EmptyCallback, FormatterParams, ValueBooleanCallback, ValueVoidCallback } from "tabulator-tables";
+
import froca from "../../../services/froca.js";
+import Icon from "../../react/Icon.jsx";
import NoteAutocomplete from "../../react/NoteAutocomplete.jsx";
+import { renderReactWidget } from "../../react/react_utils.jsx";
type ColumnType = LabelType | "relation";
@@ -78,7 +79,7 @@ export function buildColumnDefinitions({ info, movableRows, existingColumnData,
rowHandle: movableRows,
width: calculateIndexColumnWidth(rowNumberHint, movableRows),
formatter: wrapFormatter(({ cell, formatterParams }) =>
- {(formatterParams as RowNumberFormatterParams).movableRows && <>{" "}>}
+ {(formatterParams as RowNumberFormatterParams).movableRows && <>{" "}>}
{cell.getRow().getPosition(true)}
),
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 =
+ const elWithParams = ;
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/package.json b/apps/server/package.json
index 3bd95c60d0..841645dd60 100644
--- a/apps/server/package.json
+++ b/apps/server/package.json
@@ -35,10 +35,10 @@
"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",
"@triliumnext/commons": "workspace:*",
+ "@triliumnext/core": "workspace:*",
"@triliumnext/express-partial-content": "workspace:*",
"@triliumnext/highlightjs": "workspace:*",
"@triliumnext/turndown-plugin-gfm": "workspace:*",
@@ -48,17 +48,14 @@
"@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",
"@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/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",
@@ -85,8 +82,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.4",
@@ -108,15 +104,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.16.0",
"rand-token": "1.0.1",
- "safe-compare": "1.1.4",
- "sanitize-filename": "1.6.3",
- "sanitize-html": "2.17.0",
+ "safe-compare": "1.1.4",
"sax": "1.4.4",
"serve-favicon": "2.5.1",
"stream-throttle": "0.1.3",
@@ -127,7 +120,6 @@
"time2fa": "1.4.2",
"tmp": "0.2.5",
"turndown": "7.2.2",
- "unescape": "1.0.1",
"vite": "7.3.1",
"ws": "8.19.0",
"xml2js": "0.6.2",
diff --git a/apps/server/src/anonymize.ts b/apps/server/src/anonymize.ts
index e78fe00179..aea817831e 100644
--- a/apps/server/src/anonymize.ts
+++ b/apps/server/src/anonymize.ts
@@ -1,6 +1,6 @@
import anonymizationService from "./services/anonymization.js";
import sqlInit from "./services/sql_init.js";
-await import("./becca/entity_constructor.js");
+await import("@triliumnext/core");
sqlInit.dbReady.then(async () => {
try {
diff --git a/apps/server/src/app.ts b/apps/server/src/app.ts
index 8023338c97..c239684858 100644
--- a/apps/server/src/app.ts
+++ b/apps/server/src/app.ts
@@ -1,25 +1,25 @@
+import("@triliumnext/core");
+
+import { erase } from "@triliumnext/core";
+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 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 routes from "./routes/routes.js";
+import config from "./services/config.js";
import log from "./services/log.js";
-import "./services/handlers.js";
-import "./becca/becca_loader.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();
@@ -107,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/becca/becca.ts b/apps/server/src/becca/becca.ts
index d4270fe09d..a2c6aaa800 100644
--- a/apps/server/src/becca/becca.ts
+++ b/apps/server/src/becca/becca.ts
@@ -1,7 +1,2 @@
-"use strict";
-
-import Becca from "./becca-interface.js";
-
-const becca = new Becca();
-
+import { becca } from "@triliumnext/core";
export default becca;
diff --git a/apps/server/src/becca/entities/battachment.ts b/apps/server/src/becca/entities/battachment.ts
index 924dd972ab..45a2e0cacf 100644
--- a/apps/server/src/becca/entities/battachment.ts
+++ b/apps/server/src/becca/entities/battachment.ts
@@ -1,260 +1,2 @@
-
-
-import type { AttachmentRow } from "@triliumnext/commons";
-
-import dateUtils from "../../services/date_utils.js";
-import log from "../../services/log.js";
-import noteService from "../../services/notes.js";
-import protectedSessionService from "../../services/protected_session.js";
-import sql from "../../services/sql.js";
-import utils from "../../services/utils.js";
-import AbstractBeccaEntity from "./abstract_becca_entity.js";
-import type BBranch from "./bbranch.js";
-import type BNote from "./bnote.js";
-
-const attachmentRoleToNoteTypeMapping = {
- image: "image",
- file: "file"
-};
-
-interface ContentOpts {
- // TODO: Found in bnote.ts, to check if it's actually used and not a typo.
- forceSave?: boolean;
-
- /** will also save this BAttachment entity */
- forceFullSave?: boolean;
- /** override frontend heuristics on when to reload, instruct to reload */
- forceFrontendReload?: boolean;
-}
-
-/**
- * Attachment represent data related/attached to the note. Conceptually similar to attributes, but intended for
- * larger amounts of data and generally not accessible to the user.
- */
-class BAttachment extends AbstractBeccaEntity {
- static get entityName() {
- return "attachments";
- }
- static get primaryKeyName() {
- return "attachmentId";
- }
- static get hashedProperties() {
- return ["attachmentId", "ownerId", "role", "mime", "title", "blobId", "utcDateScheduledForErasureSince"];
- }
-
- noteId?: number;
- attachmentId?: string;
- /** either noteId or revisionId to which this attachment belongs */
- ownerId!: string;
- role!: string;
- mime!: string;
- title!: string;
- type?: keyof typeof attachmentRoleToNoteTypeMapping;
- position?: number;
- utcDateScheduledForErasureSince?: string | null;
- /** optionally added to the entity */
- contentLength?: number;
- isDecrypted?: boolean;
-
- constructor(row: AttachmentRow) {
- super();
-
- this.updateFromRow(row);
- this.decrypt();
- }
-
- updateFromRow(row: AttachmentRow): void {
- if (!row.ownerId?.trim()) {
- throw new Error("'ownerId' must be given to initialize a Attachment entity");
- } else if (!row.role?.trim()) {
- throw new Error("'role' must be given to initialize a Attachment entity");
- } else if (!row.mime?.trim()) {
- throw new Error("'mime' must be given to initialize a Attachment entity");
- } else if (!row.title?.trim()) {
- throw new Error("'title' must be given to initialize a Attachment entity");
- }
-
- this.attachmentId = row.attachmentId;
- this.ownerId = row.ownerId;
- this.role = row.role;
- this.mime = row.mime;
- this.title = row.title;
- this.position = row.position;
- this.blobId = row.blobId;
- this.isProtected = !!row.isProtected;
- this.dateModified = row.dateModified;
- this.utcDateModified = row.utcDateModified;
- this.utcDateScheduledForErasureSince = row.utcDateScheduledForErasureSince;
- this.contentLength = row.contentLength;
- }
-
- copy(): BAttachment {
- return new BAttachment({
- ownerId: this.ownerId,
- role: this.role,
- mime: this.mime,
- title: this.title,
- blobId: this.blobId,
- isProtected: this.isProtected
- });
- }
-
- getNote(): BNote {
- return this.becca.notes[this.ownerId];
- }
-
- /** @returns true if the note has string content (not binary) */
- override hasStringContent(): boolean {
- return utils.isStringNote(this.type, this.mime); // here was !== undefined && utils.isStringNote(this.type, this.mime); I dont know why we need !=undefined. But it filters out canvas libary items
- }
-
- isContentAvailable() {
- return (
- !this.attachmentId || // new attachment which was not encrypted yet
- !this.isProtected ||
- protectedSessionService.isProtectedSessionAvailable()
- );
- }
-
- getTitleOrProtected() {
- return this.isContentAvailable() ? this.title : "[protected]";
- }
-
- decrypt() {
- if (!this.isProtected || !this.attachmentId) {
- this.isDecrypted = true;
- return;
- }
-
- if (!this.isDecrypted && protectedSessionService.isProtectedSessionAvailable()) {
- try {
- this.title = protectedSessionService.decryptString(this.title) || "";
- this.isDecrypted = true;
- } catch (e: any) {
- log.error(`Could not decrypt attachment ${this.attachmentId}: ${e.message} ${e.stack}`);
- }
- }
- }
-
- getContent(): Buffer {
- return this._getContent() as Buffer;
- }
-
- setContent(content: string | Buffer, opts?: ContentOpts) {
- this._setContent(content, opts);
- }
-
- convertToNote(): { note: BNote; branch: BBranch } {
- // TODO: can this ever be "search"?
- if ((this.type as string) === "search") {
- throw new Error(`Note of type search cannot have child notes`);
- }
-
- if (!this.getNote()) {
- throw new Error("Cannot find note of this attachment. It is possible that this is note revision's attachment. " + "Converting note revision's attachments to note is not (yet) supported.");
- }
-
- if (!(this.role in attachmentRoleToNoteTypeMapping)) {
- throw new Error(`Mapping from attachment role '${this.role}' to note's type is not defined`);
- }
-
- if (!this.isContentAvailable()) {
- // isProtected is the same for attachment
- throw new Error(`Cannot convert protected attachment outside of protected session`);
- }
-
- const { note, branch } = noteService.createNewNote({
- parentNoteId: this.ownerId,
- title: this.title,
- type: (attachmentRoleToNoteTypeMapping as any)[this.role],
- mime: this.mime,
- content: this.getContent(),
- isProtected: this.isProtected
- });
-
- this.markAsDeleted();
-
- const parentNote = this.getNote();
-
- if (this.role === "image" && parentNote.type === "text") {
- const origContent = parentNote.getContent();
-
- if (typeof origContent !== "string") {
- throw new Error(`Note with ID '${note.noteId} has a text type but non-string content.`);
- }
-
- const oldAttachmentUrl = `api/attachments/${this.attachmentId}/image/`;
- const newNoteUrl = `api/images/${note.noteId}/`;
-
- const fixedContent = utils.replaceAll(origContent, oldAttachmentUrl, newNoteUrl);
-
- if (fixedContent !== origContent) {
- parentNote.setContent(fixedContent);
- }
-
- noteService.asyncPostProcessContent(note, fixedContent);
- }
-
- return { note, branch };
- }
-
- getFileName() {
- const type = this.role === "image" ? "image" : "file";
-
- return utils.formatDownloadTitle(this.title, type, this.mime);
- }
-
- override beforeSaving() {
- super.beforeSaving();
-
- if (this.position === undefined || this.position === null) {
- this.position =
- 10 +
- sql.getValue(
- /*sql*/`SELECT COALESCE(MAX(position), 0)
- FROM attachments
- WHERE ownerId = ?`,
- [this.noteId]
- );
- }
-
- this.dateModified = dateUtils.localNowDateTime();
- this.utcDateModified = dateUtils.utcNowDateTime();
- }
-
- getPojo() {
- return {
- attachmentId: this.attachmentId,
- ownerId: this.ownerId,
- role: this.role,
- mime: this.mime,
- title: this.title || undefined,
- position: this.position,
- blobId: this.blobId,
- isProtected: !!this.isProtected,
- isDeleted: false,
- dateModified: this.dateModified,
- utcDateModified: this.utcDateModified,
- utcDateScheduledForErasureSince: this.utcDateScheduledForErasureSince,
- contentLength: this.contentLength
- };
- }
-
- override getPojoToSave() {
- const pojo = this.getPojo();
- delete pojo.contentLength;
-
- if (pojo.isProtected) {
- if (this.isDecrypted) {
- pojo.title = protectedSessionService.encrypt(pojo.title || "") || undefined;
- } else {
- // updating protected note outside of protected session means we will keep original ciphertexts
- delete pojo.title;
- }
- }
-
- return pojo;
- }
-}
-
+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
index 6ff1246fcf..f7c6f70c3c 100644
--- a/apps/server/src/becca/entities/battribute.ts
+++ b/apps/server/src/becca/entities/battribute.ts
@@ -1,227 +1,2 @@
-"use strict";
-
-import BNote from "./bnote.js";
-import AbstractBeccaEntity from "./abstract_becca_entity.js";
-import dateUtils from "../../services/date_utils.js";
-import promotedAttributeDefinitionParser from "../../services/promoted_attribute_definition_parser.js";
-import sanitizeAttributeName from "../../services/sanitize_attribute_name.js";
-import type { AttributeRow, AttributeType } from "@triliumnext/commons";
-
-interface SavingOpts {
- skipValidation?: boolean;
-}
-
-/**
- * Attribute is an abstract concept which has two real uses - label (key - value pair)
- * and relation (representing named relationship between source and target note)
- */
-class BAttribute extends AbstractBeccaEntity {
- static get entityName() {
- return "attributes";
- }
- static get primaryKeyName() {
- return "attributeId";
- }
- static get hashedProperties() {
- return ["attributeId", "noteId", "type", "name", "value", "isInheritable"];
- }
-
- attributeId!: string;
- noteId!: string;
- type!: AttributeType;
- name!: string;
- position!: number;
- value!: string;
- isInheritable!: boolean;
-
- constructor(row?: AttributeRow) {
- super();
-
- if (!row) {
- return;
- }
-
- this.updateFromRow(row);
- this.init();
- }
-
- updateFromRow(row: AttributeRow) {
- this.update([row.attributeId, row.noteId, row.type, row.name, row.value, row.isInheritable, row.position, row.utcDateModified]);
- }
-
- update([attributeId, noteId, type, name, value, isInheritable, position, utcDateModified]: any) {
- this.attributeId = attributeId;
- this.noteId = noteId;
- this.type = type;
- this.name = name;
- this.position = position;
- this.value = value || "";
- this.isInheritable = !!isInheritable;
- this.utcDateModified = utcDateModified;
-
- return this;
- }
-
- override init() {
- if (this.attributeId) {
- this.becca.attributes[this.attributeId] = this;
- }
-
- if (!(this.noteId in this.becca.notes)) {
- // entities can come out of order in sync, create skeleton which will be filled later
- this.becca.addNote(this.noteId, new BNote({ noteId: this.noteId }));
- }
-
- this.becca.notes[this.noteId].ownedAttributes.push(this);
-
- const key = `${this.type}-${this.name.toLowerCase()}`;
- this.becca.attributeIndex[key] = this.becca.attributeIndex[key] || [];
- this.becca.attributeIndex[key].push(this);
-
- const targetNote = this.targetNote;
-
- if (targetNote) {
- targetNote.targetRelations.push(this);
- }
- }
-
- validate() {
- if (!["label", "relation"].includes(this.type)) {
- throw new Error(`Invalid attribute type '${this.type}' in attribute '${this.attributeId}' of note '${this.noteId}'`);
- }
-
- if (!this.name?.trim()) {
- throw new Error(`Invalid empty name in attribute '${this.attributeId}' of note '${this.noteId}'`);
- }
-
- if (this.type === "relation" && !(this.value in this.becca.notes)) {
- throw new Error(`Cannot save relation '${this.name}' of note '${this.noteId}' since it targets not existing note '${this.value}'.`);
- }
- }
-
- get isAffectingSubtree() {
- return this.isInheritable || (this.type === "relation" && ["template", "inherit"].includes(this.name));
- }
-
- get targetNoteId() {
- // alias
- return this.type === "relation" ? this.value : undefined;
- }
-
- isAutoLink() {
- return this.type === "relation" && ["internalLink", "imageLink", "relationMapLink", "includeNoteLink"].includes(this.name);
- }
-
- get note() {
- return this.becca.notes[this.noteId];
- }
-
- get targetNote() {
- if (this.type === "relation") {
- return this.becca.notes[this.value];
- }
- }
-
- getNote() {
- const note = this.becca.getNote(this.noteId);
-
- if (!note) {
- throw new Error(`Note '${this.noteId}' of attribute '${this.attributeId}', type '${this.type}', name '${this.name}' does not exist.`);
- }
-
- return note;
- }
-
- getTargetNote() {
- if (this.type !== "relation") {
- throw new Error(`Attribute '${this.attributeId}' is not a relation.`);
- }
-
- if (!this.value) {
- return null;
- }
-
- return this.becca.getNote(this.value);
- }
-
- isDefinition() {
- return this.type === "label" && (this.name.startsWith("label:") || this.name.startsWith("relation:"));
- }
-
- getDefinition() {
- return promotedAttributeDefinitionParser.parse(this.value);
- }
-
- getDefinedName() {
- if (this.type === "label" && this.name.startsWith("label:")) {
- return this.name.substr(6);
- } else if (this.type === "label" && this.name.startsWith("relation:")) {
- return this.name.substr(9);
- } else {
- return this.name;
- }
- }
-
- override get isDeleted() {
- return !(this.attributeId in this.becca.attributes);
- }
-
- override beforeSaving(opts: SavingOpts = {}) {
- if (!opts.skipValidation) {
- this.validate();
- }
-
- this.name = sanitizeAttributeName(this.name);
-
- if (!this.value) {
- // null value isn't allowed
- this.value = "";
- }
-
- if (this.position === undefined || this.position === null) {
- const maxExistingPosition = this.getNote()
- .getAttributes()
- .reduce((maxPosition, attr) => Math.max(maxPosition, attr.position || 0), 0);
-
- this.position = maxExistingPosition + 10;
- }
-
- if (!this.isInheritable) {
- this.isInheritable = false;
- }
-
- this.utcDateModified = dateUtils.utcNowDateTime();
-
- super.beforeSaving();
-
- this.becca.attributes[this.attributeId] = this;
- }
-
- getPojo() {
- return {
- attributeId: this.attributeId,
- noteId: this.noteId,
- type: this.type,
- name: this.name,
- position: this.position,
- value: this.value,
- isInheritable: this.isInheritable,
- utcDateModified: this.utcDateModified,
- isDeleted: false
- };
- }
-
- createClone(type: AttributeType, name: string, value: string, isInheritable?: boolean) {
- return new BAttribute({
- noteId: this.noteId,
- type: type,
- name: name,
- value: value,
- position: this.position,
- isInheritable: isInheritable,
- utcDateModified: this.utcDateModified
- });
- }
-}
-
+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
index e074430240..0c687237b1 100644
--- a/apps/server/src/becca/entities/bbranch.ts
+++ b/apps/server/src/becca/entities/bbranch.ts
@@ -1,288 +1,2 @@
-"use strict";
-
-import BNote from "./bnote.js";
-import AbstractBeccaEntity from "./abstract_becca_entity.js";
-import dateUtils from "../../services/date_utils.js";
-import utils from "../../services/utils.js";
-import TaskContext from "../../services/task_context.js";
-import cls from "../../services/cls.js";
-import log from "../../services/log.js";
-import type { BranchRow } from "@triliumnext/commons";
-import handlers from "../../services/handlers.js";
-
-/**
- * Branch represents a relationship between a child note and its parent note. Trilium allows a note to have multiple
- * parents.
- *
- * Note that you should not rely on the branch's identity, since it can change easily with a note's move.
- * Always check noteId instead.
- */
-class BBranch extends AbstractBeccaEntity {
- static get entityName() {
- return "branches";
- }
- static get primaryKeyName() {
- return "branchId";
- }
- // notePosition is not part of hash because it would produce a lot of updates in case of reordering
- static get hashedProperties() {
- return ["branchId", "noteId", "parentNoteId", "prefix"];
- }
-
- branchId?: string;
- noteId!: string;
- parentNoteId!: string;
- prefix!: string | null;
- notePosition!: number;
- isExpanded!: boolean;
-
- constructor(row?: BranchRow) {
- super();
-
- if (!row) {
- return;
- }
-
- this.updateFromRow(row);
- this.init();
- }
-
- updateFromRow(row: BranchRow) {
- this.update([row.branchId, row.noteId, row.parentNoteId, row.prefix, row.notePosition, row.isExpanded, row.utcDateModified]);
- }
-
- update([branchId, noteId, parentNoteId, prefix, notePosition, isExpanded, utcDateModified]: any) {
- this.branchId = branchId;
- this.noteId = noteId;
- this.parentNoteId = parentNoteId;
- this.prefix = prefix;
- this.notePosition = notePosition;
- this.isExpanded = !!isExpanded;
- this.utcDateModified = utcDateModified;
-
- return this;
- }
-
- override init() {
- if (this.branchId) {
- this.becca.branches[this.branchId] = this;
- }
-
- this.becca.childParentToBranch[`${this.noteId}-${this.parentNoteId}`] = this;
-
- const childNote = this.childNote;
-
- if (!childNote.parentBranches.includes(this)) {
- childNote.parentBranches.push(this);
- }
-
- if (this.noteId === "root") {
- return;
- }
-
- const parentNote = this.parentNote;
- if (parentNote) {
- if (!childNote.parents.includes(parentNote)) {
- childNote.parents.push(parentNote);
- }
-
- if (!parentNote.children.includes(childNote)) {
- parentNote.children.push(childNote);
- }
- }
- }
-
- get childNote(): BNote {
- if (!(this.noteId in this.becca.notes)) {
- // entities can come out of order in sync/import, create skeleton which will be filled later
- this.becca.addNote(this.noteId, new BNote({ noteId: this.noteId }));
- }
-
- return this.becca.notes[this.noteId];
- }
-
- getNote(): BNote {
- return this.childNote;
- }
-
- /** @returns root branch will have undefined parent, all other branches have to have a parent note */
- get parentNote(): BNote | undefined {
- if (!(this.parentNoteId in this.becca.notes) && this.parentNoteId !== "none") {
- // entities can come out of order in sync/import, create skeleton which will be filled later
- this.becca.addNote(this.parentNoteId, new BNote({ noteId: this.parentNoteId }));
- }
-
- return this.becca.notes[this.parentNoteId];
- }
-
- override get isDeleted() {
- return this.branchId == undefined || !(this.branchId in this.becca.branches);
- }
-
- /**
- * Branch is weak when its existence should not hinder deletion of its note.
- * As a result, note with only weak branches should be immediately deleted.
- * An example is shared or bookmarked clones - they are created automatically and exist for technical reasons,
- * not as user-intended actions. From user perspective, they don't count as real clones and for the purpose
- * of deletion should not act as a clone.
- */
- get isWeak() {
- return ["_share", "_lbBookmarks"].includes(this.parentNoteId);
- }
-
- /**
- * Delete a branch. If this is a last note's branch, delete the note as well.
- *
- * @param deleteId - optional delete identified
- *
- * @returns true if note has been deleted, false otherwise
- */
- deleteBranch(deleteId?: string, taskContext?: TaskContext<"deleteNotes">): boolean {
- if (!deleteId) {
- deleteId = utils.randomString(10);
- }
-
- if (!taskContext) {
- taskContext = new TaskContext("no-progress-reporting", "deleteNotes", null);
- }
-
- taskContext.increaseProgressCount();
-
- const note = this.getNote();
-
- if (!taskContext.noteDeletionHandlerTriggered) {
- const parentBranches = note.getParentBranches();
-
- if (parentBranches.length === 1 && parentBranches[0] === this) {
- // needs to be run before branches and attributes are deleted and thus attached relations disappear
- handlers.runAttachedRelations(note, "runOnNoteDeletion", note);
- }
- }
-
- if ((this.noteId === "root" || this.noteId === cls.getHoistedNoteId()) && !this.isWeak) {
- throw new Error("Can't delete root or hoisted branch/note");
- }
-
- this.markAsDeleted(deleteId);
-
- const notDeletedBranches = note.getStrongParentBranches();
-
- if (notDeletedBranches.length === 0) {
- for (const weakBranch of note.getParentBranches()) {
- weakBranch.markAsDeleted(deleteId);
- }
-
- for (const childBranch of note.getChildBranches()) {
- if (childBranch) {
- childBranch.deleteBranch(deleteId, taskContext);
- }
- }
-
- // first delete children and then parent - this will show up better in recent changes
-
- log.info(`Deleting note '${note.noteId}'`);
-
- this.becca.notes[note.noteId].isBeingDeleted = true;
-
- for (const attribute of note.getOwnedAttributes().slice()) {
- attribute.markAsDeleted(deleteId);
- }
-
- for (const relation of note.getTargetRelations()) {
- relation.markAsDeleted(deleteId);
- }
-
- for (const attachment of note.getAttachments()) {
- attachment.markAsDeleted(deleteId);
- }
-
- note.markAsDeleted(deleteId);
-
- return true;
- } else {
- return false;
- }
- }
-
- override beforeSaving() {
- if (!this.noteId || !this.parentNoteId) {
- throw new Error(`noteId and parentNoteId are mandatory properties for Branch`);
- }
-
- this.branchId = `${this.parentNoteId}_${this.noteId}`;
-
- if (this.notePosition === undefined || this.notePosition === null) {
- let maxNotePos = 0;
-
- if (this.parentNote) {
- for (const childBranch of this.parentNote.getChildBranches()) {
- if (!childBranch) {
- continue;
- }
-
- if (
- maxNotePos < childBranch.notePosition &&
- childBranch.noteId !== "_hidden" // hidden has a very large notePosition to always stay last
- ) {
- maxNotePos = childBranch.notePosition;
- }
- }
- }
-
- this.notePosition = maxNotePos + 10;
- }
-
- if (!this.isExpanded) {
- this.isExpanded = false;
- }
-
- if (!this.prefix?.trim()) {
- this.prefix = null;
- }
-
- this.utcDateModified = dateUtils.utcNowDateTime();
-
- super.beforeSaving();
-
- this.becca.branches[this.branchId] = this;
- }
-
- getPojo() {
- return {
- branchId: this.branchId,
- noteId: this.noteId,
- parentNoteId: this.parentNoteId,
- prefix: this.prefix,
- notePosition: this.notePosition,
- isExpanded: this.isExpanded,
- isDeleted: false,
- utcDateModified: this.utcDateModified
- };
- }
-
- createClone(parentNoteId: string, notePosition?: number) {
- const existingBranch = this.becca.getBranchFromChildAndParent(this.noteId, parentNoteId);
-
- if (existingBranch) {
- if (notePosition) {
- existingBranch.notePosition = notePosition;
- }
- return existingBranch;
- } else {
- return new BBranch({
- noteId: this.noteId,
- parentNoteId: parentNoteId,
- notePosition: notePosition || null,
- prefix: this.prefix,
- isExpanded: this.isExpanded
- });
- }
- }
-
- getParentNote() {
- return this.parentNote;
- }
-
-}
-
+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
index c355d2c8f0..114aa74003 100644
--- a/apps/server/src/becca/entities/betapi_token.ts
+++ b/apps/server/src/becca/entities/betapi_token.ts
@@ -1,89 +1,2 @@
-"use strict";
-
-import type { EtapiTokenRow } from "@triliumnext/commons";
-
-import dateUtils from "../../services/date_utils.js";
-import AbstractBeccaEntity from "./abstract_becca_entity.js";
-
-/**
- * EtapiToken is an entity representing token used to authenticate against Trilium REST API from client applications.
- * Used by:
- * - Trilium Sender
- * - ETAPI clients
- *
- * The format user is presented with is "_". This is also called "authToken" to distinguish it
- * from tokenHash and token.
- */
-class BEtapiToken extends AbstractBeccaEntity {
- static get entityName() {
- return "etapi_tokens";
- }
- static get primaryKeyName() {
- return "etapiTokenId";
- }
- static get hashedProperties() {
- return ["etapiTokenId", "name", "tokenHash", "utcDateCreated", "utcDateModified", "isDeleted"];
- }
-
- etapiTokenId?: string;
- name!: string;
- tokenHash!: string;
- private _isDeleted?: boolean;
-
- constructor(row?: EtapiTokenRow) {
- super();
-
- if (!row) {
- return;
- }
-
- this.updateFromRow(row);
- this.init();
- }
-
- override get isDeleted() {
- return !!this._isDeleted;
- }
-
- updateFromRow(row: EtapiTokenRow) {
- this.etapiTokenId = row.etapiTokenId;
- this.name = row.name;
- this.tokenHash = row.tokenHash;
- this.utcDateCreated = row.utcDateCreated || dateUtils.utcNowDateTime();
- this.utcDateModified = row.utcDateModified || this.utcDateCreated;
- this._isDeleted = !!row.isDeleted;
-
- if (this.etapiTokenId) {
- this.becca.etapiTokens[this.etapiTokenId] = this;
- }
- }
-
- override init() {
- if (this.etapiTokenId) {
- this.becca.etapiTokens[this.etapiTokenId] = this;
- }
- }
-
- getPojo() {
- return {
- etapiTokenId: this.etapiTokenId,
- name: this.name,
- tokenHash: this.tokenHash,
- utcDateCreated: this.utcDateCreated,
- utcDateModified: this.utcDateModified,
- isDeleted: this.isDeleted
- };
- }
-
- override beforeSaving() {
- this.utcDateModified = dateUtils.utcNowDateTime();
-
- super.beforeSaving();
-
- if (this.etapiTokenId) {
- this.becca.etapiTokens[this.etapiTokenId] = this;
- }
- }
-}
-
+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
index 7f271e88cb..523e4e0042 100644
--- a/apps/server/src/becca/entities/bnote.ts
+++ b/apps/server/src/becca/entities/bnote.ts
@@ -1,1806 +1,2 @@
-import type { AttachmentRow, AttributeType, CloneResponse, NoteRow, NoteType, RevisionRow } from "@triliumnext/commons";
-import { dayjs } from "@triliumnext/commons";
-
-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";
-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";
-import AbstractBeccaEntity from "./abstract_becca_entity.js";
-import BAttachment from "./battachment.js";
-import BAttribute from "./battribute.js";
-import type BBranch from "./bbranch.js";
-import BRevision from "./brevision.js";
-
-const LABEL = "label";
-const RELATION = "relation";
-
-// TODO: Deduplicate with fnote
-export const NOTE_TYPE_ICONS = {
- file: "bx bx-file",
- image: "bx bx-image",
- code: "bx bx-code",
- render: "bx bx-extension",
- search: "bx bx-file-find",
- relationMap: "bx bxs-network-chart",
- book: "bx bx-book",
- noteMap: "bx bxs-network-chart",
- mermaid: "bx bx-selection",
- canvas: "bx bx-pen",
- webView: "bx bx-globe-alt",
- launcher: "bx bx-link",
- doc: "bx bxs-file-doc",
- contentWidget: "bx bxs-widget",
- mindMap: "bx bx-sitemap",
- geoMap: "bx bx-map-alt"
-};
-
-interface NotePathRecord {
- isArchived: boolean;
- isInHoistedSubTree: boolean;
- notePath: string[];
- isHidden: boolean;
-}
-
-interface ContentOpts {
- /** will also save this BNote entity */
- forceSave?: boolean;
- /** override frontend heuristics on when to reload, instruct to reload */
- forceFrontendReload?: boolean;
-}
-
-interface Relationship {
- parentNoteId: string;
- childNoteId: string;
-}
-
-interface ConvertOpts {
- /** if true, the action is not triggered by user, but e.g. by migration, and only perfect candidates will be migrated */
- autoConversion?: boolean;
-}
-
-/**
- * Trilium's main entity, which can represent text note, image, code note, file attachment etc.
- */
-class BNote extends AbstractBeccaEntity {
- static get entityName() {
- return "notes";
- }
- static get primaryKeyName() {
- return "noteId";
- }
- static get hashedProperties() {
- return ["noteId", "title", "isProtected", "type", "mime", "blobId"];
- }
-
- noteId!: string;
- title!: string;
- type!: NoteType;
- mime!: string;
- /** set during the deletion operation, before it is completed (removed from becca completely). */
- isBeingDeleted!: boolean;
- isDecrypted!: boolean;
-
- ownedAttributes!: BAttribute[];
- parentBranches!: BBranch[];
- parents!: BNote[];
- children!: BNote[];
- targetRelations!: BAttribute[];
-
- __flatTextCache!: string | null;
-
- private __attributeCache!: BAttribute[] | null;
- private __inheritableAttributeCache!: BAttribute[] | null;
- private __ancestorCache!: BNote[] | null;
-
- // following attributes are filled during searching in the database
- /** size of the content in bytes */
- contentSize!: number | null;
- /** size of the note content, attachment contents in bytes */
- contentAndAttachmentsSize!: number | null;
- /** size of the note content, attachment contents and revision contents in bytes */
- contentAndAttachmentsAndRevisionsSize!: number | null;
- /** number of note revisions for this note */
- revisionCount!: number | null;
-
- constructor(row?: Partial) {
- super();
-
- if (!row) {
- return;
- }
-
- this.updateFromRow(row);
- this.init();
- }
-
- updateFromRow(row: Partial) {
- this.update([row.noteId, row.title, row.type, row.mime, row.isProtected, row.blobId, row.dateCreated, row.dateModified, row.utcDateCreated, row.utcDateModified]);
- }
-
- update([noteId, title, type, mime, isProtected, blobId, dateCreated, dateModified, utcDateCreated, utcDateModified]: any) {
- // ------ Database persisted attributes ------
-
- this.noteId = noteId;
- this.title = title;
- this.type = type;
- this.mime = mime;
- this.isProtected = !!isProtected;
- this.blobId = blobId;
- this.dateCreated = dateCreated || dateUtils.localNowDateTime();
- this.dateModified = dateModified;
- this.utcDateCreated = utcDateCreated || dateUtils.utcNowDateTime();
- this.utcDateModified = utcDateModified;
- this.isBeingDeleted = false;
-
- // ------ Derived attributes ------
-
- this.isDecrypted = !this.noteId || !this.isProtected;
-
- this.decrypt();
-
- this.__flatTextCache = null;
-
- return this;
- }
-
- override init() {
- this.parentBranches = [];
- this.parents = [];
- this.children = [];
- this.ownedAttributes = [];
- this.__attributeCache = null;
- this.__inheritableAttributeCache = null;
- this.targetRelations = [];
-
- this.becca.addNote(this.noteId, this);
- this.__ancestorCache = null;
-
- this.contentSize = null;
- this.contentAndAttachmentsSize = null;
- this.contentAndAttachmentsAndRevisionsSize = null;
- this.revisionCount = null;
- }
-
- isContentAvailable() {
- return (
- !this.noteId || // new note which was not encrypted yet
- !this.isProtected ||
- protectedSessionService.isProtectedSessionAvailable()
- );
- }
-
- getTitleOrProtected() {
- return this.isContentAvailable() ? this.title : "[protected]";
- }
-
- getParentBranches() {
- return this.parentBranches;
- }
-
- /**
- * Returns strong (as opposed to weak) parent branches. See isWeak for details.
- */
- getStrongParentBranches() {
- return this.getParentBranches().filter((branch) => !branch.isWeak);
- }
-
- /**
- * @deprecated use getParentBranches() instead
- */
- getBranches() {
- return this.parentBranches;
- }
-
- getParentNotes() {
- return this.parents;
- }
-
- getChildNotes() {
- return this.children;
- }
-
- hasChildren() {
- return this.children && this.children.length > 0;
- }
-
- getChildBranches(): BBranch[] {
- return this.children.map((childNote) => this.becca.getBranchFromChildAndParent(childNote.noteId, this.noteId)) as BBranch[];
- }
-
- /**
- * Note content has quite special handling - it's not a separate entity, but a lazily loaded
- * part of Note entity with its own sync. Reasons behind this hybrid design has been:
- *
- * - content can be quite large, and it's not necessary to load it / fill memory for any note access even if we don't need a content, especially for bulk operations like search
- * - changes in the note metadata or title should not trigger note content sync (so we keep separate utcDateModified and entity changes records)
- * - but to the user note content and title changes are one and the same - single dateModified (so all changes must go through Note and content is not a separate entity)
- */
- getContent() {
- return this._getContent();
- }
-
- /**
- * @throws Error in case of invalid JSON
- */
- getJsonContent(): any | null {
- const content = this.getContent();
-
- if (typeof content !== "string" || !content || !content.trim()) {
- return null;
- }
-
- return JSON.parse(content);
- }
-
- /** @returns valid object or null if the content cannot be parsed as JSON */
- getJsonContentSafely() {
- try {
- return this.getJsonContent();
- } catch (e) {
- return null;
- }
- }
-
- setContent(content: Buffer | string, opts: ContentOpts = {}) {
- this._setContent(content, opts);
-
- eventService.emit(eventService.NOTE_CONTENT_CHANGE, { entity: this });
- }
-
- setJsonContent(content: {}) {
- this.setContent(JSON.stringify(content, null, "\t"));
- }
-
- get dateCreatedObj() {
- return this.dateCreated === null ? null : dayjs(this.dateCreated);
- }
-
- get utcDateCreatedObj() {
- return this.utcDateCreated === null ? null : dayjs.utc(this.utcDateCreated);
- }
-
- get dateModifiedObj() {
- return this.dateModified === null ? null : dayjs(this.dateModified);
- }
-
- get utcDateModifiedObj() {
- return this.utcDateModified === null ? null : dayjs.utc(this.utcDateModified);
- }
-
- /** @returns true if this note is the root of the note tree. Root note has "root" noteId */
- isRoot() {
- return this.noteId === "root";
- }
-
- /** @returns true if this note is of application/json content type */
- isJson() {
- return this.mime === "application/json";
- }
-
- /** @returns true if this note is JavaScript (code or attachment) */
- isJavaScript() {
- return (
- (this.type === "code" || this.type === "file" || this.type === "launcher") &&
- (this.mime.startsWith("application/javascript") || this.mime === "application/x-javascript" || this.mime === "text/javascript")
- );
- }
-
- isJsx() {
- return (this.type === "code" && this.mime === "text/jsx");
- }
-
- /** @returns true if this note is HTML */
- isHtml() {
- return ["code", "file", "render"].includes(this.type) && this.mime === "text/html";
- }
-
- /** @returns true if this note is an image */
- isImage() {
- return this.type === "image" || (this.type === "file" && this.mime?.startsWith("image/"));
- }
-
- /** @deprecated use hasStringContent() instead */
- isStringNote() {
- return this.hasStringContent();
- }
-
- /** @returns true if the note has string content (not binary) */
- override hasStringContent() {
- return utils.isStringNote(this.type, this.mime);
- }
-
- /** @returns JS script environment - either "frontend" or "backend" */
- getScriptEnv() {
- if (this.isHtml() || (this.isJavaScript() && this.mime.endsWith("env=frontend")) || this.isJsx()) {
- return "frontend";
- }
-
- if (this.type === "render") {
- return "frontend";
- }
-
- if (this.isJavaScript() && this.mime.endsWith("env=backend")) {
- return "backend";
- }
-
- return null;
- }
-
- /**
- * Beware that the method must not create a copy of the array, but actually returns its internal array
- * (for performance reasons)
- *
- * @param type - (optional) attribute type to filter
- * @param name - (optional) attribute name to filter
- * @returns all note's attributes, including inherited ones
- */
- getAttributes(type?: string, name?: string): BAttribute[] {
- this.__validateTypeName(type, name);
- this.__ensureAttributeCacheIsAvailable();
-
- if (!this.__attributeCache) {
- throw new Error("Attribute cache not available.");
- }
-
- if (type && name) {
- return this.__attributeCache.filter((attr) => attr.name === name && attr.type === type);
- } else if (type) {
- return this.__attributeCache.filter((attr) => attr.type === type);
- } else if (name) {
- return this.__attributeCache.filter((attr) => attr.name === name);
- }
- return this.__attributeCache;
- }
-
- private __ensureAttributeCacheIsAvailable() {
- if (!this.__attributeCache) {
- this.__getAttributes([]);
- }
- }
-
- private __getAttributes(path: string[]) {
- if (path.includes(this.noteId)) {
- return [];
- }
-
- if (!this.__attributeCache) {
- const parentAttributes = this.ownedAttributes.slice();
- const newPath = [...path, this.noteId];
-
- // inheritable attrs on root are typically not intended to be applied to hidden subtree #3537
- if (this.noteId !== "root" && this.noteId !== "_hidden") {
- for (const parentNote of this.parents) {
- parentAttributes.push(...parentNote.__getInheritableAttributes(newPath));
- }
- }
-
- const templateAttributes: BAttribute[] = [];
-
- for (const ownedAttr of parentAttributes) {
- // parentAttributes so we process also inherited templates
- if (ownedAttr.type === "relation" && ["template", "inherit"].includes(ownedAttr.name)) {
- const templateNote = this.becca.notes[ownedAttr.value];
-
- if (templateNote) {
- templateAttributes.push(
- ...templateNote
- .__getAttributes(newPath)
- // template attr is used as a marker for templates, but it's not meant to be inherited
- .filter((attr) => !(attr.type === "label" && (attr.name === "template" || attr.name === "workspacetemplate")))
- );
- }
- }
- }
-
- this.__attributeCache = [];
-
- const addedAttributeIds = new Set();
-
- for (const attr of parentAttributes.concat(templateAttributes)) {
- if (!addedAttributeIds.has(attr.attributeId)) {
- addedAttributeIds.add(attr.attributeId);
-
- this.__attributeCache.push(attr);
- }
- }
-
- this.__inheritableAttributeCache = [];
-
- for (const attr of this.__attributeCache) {
- if (attr.isInheritable) {
- this.__inheritableAttributeCache.push(attr);
- }
- }
- }
-
- return this.__attributeCache;
- }
-
- private __getInheritableAttributes(path: string[]): BAttribute[] {
- if (path.includes(this.noteId)) {
- return [];
- }
-
- if (!this.__inheritableAttributeCache) {
- this.__getAttributes(path); // will refresh also this.__inheritableAttributeCache
- }
-
- return this.__inheritableAttributeCache || [];
- }
-
- __validateTypeName(type?: string | null, name?: string | null) {
- if (type && type !== "label" && type !== "relation") {
- throw new Error(`Unrecognized attribute type '${type}'. Only 'label' and 'relation' are possible values.`);
- }
-
- if (name) {
- const firstLetter = name.charAt(0);
- if (firstLetter === "#" || firstLetter === "~") {
- throw new Error(`Detect '#' or '~' in the attribute's name. In the API, attribute names should be set without these characters.`);
- }
- }
- }
-
- hasAttribute(type: string, name: string, value: string | null = null): boolean {
- return !!this.getAttributes().find((attr) => attr.name === name && (value === undefined || value === null || attr.value === value) && attr.type === type);
- }
-
- getAttributeCaseInsensitive(type: string, name: string, value?: string | null) {
- name = name.toLowerCase();
- value = value ? value.toLowerCase() : null;
-
- return this.getAttributes().find((attr) => attr.name.toLowerCase() === name && (!value || attr.value.toLowerCase() === value) && attr.type === type);
- }
-
- getRelationTarget(name: string) {
- const relation = this.getAttributes().find((attr) => attr.name === name && attr.type === "relation");
-
- return relation ? relation.targetNote : null;
- }
-
- /**
- * @param name - label name
- * @param value - label value
- * @returns true if label exists (including inherited)
- */
- hasLabel(name: string, value?: string): boolean {
- return this.hasAttribute(LABEL, name, value);
- }
-
- /**
- * @param name - label name
- * @returns true if label exists (including inherited) and does not have "false" value.
- */
- isLabelTruthy(name: string): boolean {
- const label = this.getLabel(name);
-
- if (!label) {
- return false;
- }
-
- return label && label.value !== "false";
- }
-
- /**
- * @param name - label name
- * @param value - label value
- * @returns true if label exists (excluding inherited)
- */
- hasOwnedLabel(name: string, value?: string): boolean {
- return this.hasOwnedAttribute(LABEL, name, value);
- }
-
- /**
- * @param name - relation name
- * @param value - relation value
- * @returns true if relation exists (including inherited)
- */
- hasRelation(name: string, value?: string): boolean {
- return this.hasAttribute(RELATION, name, value);
- }
-
- /**
- * @param name - relation name
- * @param value - relation value
- * @returns true if relation exists (excluding inherited)
- */
- hasOwnedRelation(name: string, value?: string): boolean {
- return this.hasOwnedAttribute(RELATION, name, value);
- }
-
- /**
- * @param name - label name
- * @returns label if it exists, null otherwise
- */
- getLabel(name: string): BAttribute | null {
- return this.getAttribute(LABEL, name);
- }
-
- /**
- * @param name - label name
- * @returns label if it exists, null otherwise
- */
- getOwnedLabel(name: string): BAttribute | null {
- return this.getOwnedAttribute(LABEL, name);
- }
-
- /**
- * @param name - relation name
- * @returns relation if it exists, null otherwise
- */
- getRelation(name: string): BAttribute | null {
- return this.getAttribute(RELATION, name);
- }
-
- /**
- * @param name - relation name
- * @returns relation if it exists, null otherwise
- */
- getOwnedRelation(name: string): BAttribute | null {
- return this.getOwnedAttribute(RELATION, name);
- }
-
- /**
- * @param name - label name
- * @returns label value if label exists, null otherwise
- */
- getLabelValue(name: string): string | null {
- return this.getAttributeValue(LABEL, name);
- }
-
- /**
- * @param name - label name
- * @returns label value if label exists, null otherwise
- */
- getOwnedLabelValue(name: string): string | null {
- return this.getOwnedAttributeValue(LABEL, name);
- }
-
- /**
- * @param name - relation name
- * @returns relation value if relation exists, null otherwise
- */
- getRelationValue(name: string): string | null {
- return this.getAttributeValue(RELATION, name);
- }
-
- /**
- * @param name - relation name
- * @returns relation value if relation exists, null otherwise
- */
- getOwnedRelationValue(name: string): string | null {
- return this.getOwnedAttributeValue(RELATION, name);
- }
-
- /**
- * @param attribute type (label, relation, etc.)
- * @param name - attribute name
- * @param value - attribute value
- * @returns true if note has an attribute with given type and name (excluding inherited)
- */
- hasOwnedAttribute(type: string, name: string, value?: string): boolean {
- return !!this.getOwnedAttribute(type, name, value);
- }
-
- /**
- * @param type - attribute type (label, relation, etc.)
- * @param name - attribute name
- * @returns attribute of the given type and name. If there are more such attributes, first is returned.
- * Returns null if there's no such attribute belonging to this note.
- */
- getAttribute(type: string, name: string): BAttribute | null {
- const attributes = this.getAttributes();
-
- return attributes.find((attr) => attr.name === name && attr.type === type) || null;
- }
-
- /**
- * @param type - attribute type (label, relation, etc.)
- * @param name - attribute name
- * @returns attribute value of given type and name or null if no such attribute exists.
- */
- getAttributeValue(type: string, name: string): string | null {
- const attr = this.getAttribute(type, name);
-
- return attr ? attr.value : null;
- }
-
- /**
- * @param type - attribute type (label, relation, etc.)
- * @param name - attribute name
- * @returns attribute value of given type and name or null if no such attribute exists.
- */
- getOwnedAttributeValue(type: string, name: string): string | null {
- const attr = this.getOwnedAttribute(type, name);
-
- return attr ? attr.value : null;
- }
-
- /**
- * @param name - label name to filter
- * @returns all note's labels (attributes with type label), including inherited ones
- */
- getLabels(name?: string): BAttribute[] {
- return this.getAttributes(LABEL, name);
- }
-
- /**
- * @param name - label name to filter
- * @returns all note's label values, including inherited ones
- */
- getLabelValues(name: string): string[] {
- return this.getLabels(name).map((l) => l.value);
- }
-
- /**
- * @param name - label name to filter
- * @returns all note's labels (attributes with type label), excluding inherited ones
- */
- getOwnedLabels(name: string): BAttribute[] {
- return this.getOwnedAttributes(LABEL, name);
- }
-
- /**
- * @param name - label name to filter
- * @returns all note's label values, excluding inherited ones
- */
- getOwnedLabelValues(name: string): string[] {
- return this.getOwnedAttributes(LABEL, name).map((l) => l.value);
- }
-
- /**
- * @param name - relation name to filter
- * @returns all note's relations (attributes with type relation), including inherited ones
- */
- getRelations(name?: string): BAttribute[] {
- return this.getAttributes(RELATION, name);
- }
-
- /**
- * @param name - relation name to filter
- * @returns all note's relations (attributes with type relation), excluding inherited ones
- */
- getOwnedRelations(name?: string | null): BAttribute[] {
- return this.getOwnedAttributes(RELATION, name);
- }
-
- /**
- * Beware that the method must not create a copy of the array, but actually returns its internal array
- * (for performance reasons)
- *
- * @param type - (optional) attribute type to filter
- * @param name - (optional) attribute name to filter
- * @param value - (optional) attribute value to filter
- * @returns note's "owned" attributes - excluding inherited ones
- */
- getOwnedAttributes(type: string | null = null, name: string | null = null, value: string | null = null) {
- this.__validateTypeName(type, name);
-
- if (type && name && value !== undefined && value !== null) {
- return this.ownedAttributes.filter((attr) => attr.name === name && attr.value === value && attr.type === type);
- } else if (type && name) {
- return this.ownedAttributes.filter((attr) => attr.name === name && attr.type === type);
- } else if (type) {
- return this.ownedAttributes.filter((attr) => attr.type === type);
- } else if (name) {
- return this.ownedAttributes.filter((attr) => attr.name === name);
- }
- return this.ownedAttributes;
- }
-
- /**
- * @returns attribute belonging to this specific note (excludes inherited attributes)
- *
- * This method can be significantly faster than the getAttribute()
- */
- getOwnedAttribute(type: string, name: string, value: string | null = null) {
- const attrs = this.getOwnedAttributes(type, name, value);
-
- return attrs.length > 0 ? attrs[0] : null;
- }
-
- get isArchived() {
- return this.hasAttribute("label", "archived");
- }
-
- areAllNotePathsArchived() {
- // there's a slight difference between note being itself archived and all its note paths being archived
- // - note is archived when it itself has an archived label or inherits it
- // - note does not have or inherit archived label, but each note path contains a note with (non-inheritable)
- // archived label
-
- const bestNotePathRecord = this.getSortedNotePathRecords()[0];
-
- if (!bestNotePathRecord) {
- throw new Error(`No note path available for note '${this.noteId}'`);
- }
-
- return bestNotePathRecord.isArchived;
- }
-
- hasInheritableArchivedLabel() {
- for (const attr of this.getAttributes()) {
- if (attr.name === "archived" && attr.type === LABEL && attr.isInheritable) {
- return true;
- }
- }
-
- return false;
- }
-
- // will sort the parents so that the non-archived are first and archived at the end
- // this is done so that the non-archived paths are always explored as first when looking for note path
- sortParents() {
- this.parentBranches.sort((a, b) => {
- if (a.parentNote?.isArchived) {
- return 1;
- } else if (a.parentNote?.isHiddenCompletely()) {
- return 1;
- }
- return 0;
- });
-
- this.parents = this.parentBranches.map((branch) => branch.parentNote).filter((note) => !!note) as BNote[];
- }
-
- sortChildren() {
- if (this.children.length === 0) {
- return;
- }
-
- const becca = this.becca;
-
- this.children.sort((a, b) => {
- const aBranch = becca.getBranchFromChildAndParent(a.noteId, this.noteId);
- const bBranch = becca.getBranchFromChildAndParent(b.noteId, this.noteId);
-
- return (aBranch?.notePosition || 0) - (bBranch?.notePosition || 0) || 0;
- });
- }
-
- /**
- * This is used for:
- * - fast searching
- * - note similarity evaluation
- *
- * @returns - returns flattened textual representation of note, prefixes and attributes
- */
- getFlatText() {
- if (!this.__flatTextCache) {
- this.__flatTextCache = `${this.noteId} ${this.type} ${this.mime} `;
-
- for (const branch of this.parentBranches) {
- if (branch.prefix) {
- this.__flatTextCache += `${branch.prefix} `;
- }
- }
-
- this.__flatTextCache += `${this.title} `;
-
- for (const attr of this.getAttributes()) {
- // it's best to use space as separator since spaces are filtered from the search string by the tokenization into words
- this.__flatTextCache += `${attr.type === "label" ? "#" : "~"}${attr.name}`;
-
- if (attr.value) {
- this.__flatTextCache += `=${attr.value}`;
- }
-
- this.__flatTextCache += " ";
- }
-
- this.__flatTextCache = utils.normalize(this.__flatTextCache);
- }
-
- return this.__flatTextCache;
- }
-
- invalidateThisCache() {
- this.__flatTextCache = null;
-
- this.__attributeCache = null;
- this.__inheritableAttributeCache = null;
- this.__ancestorCache = null;
- }
-
- invalidateSubTree(path: string[] = []) {
- if (path.includes(this.noteId)) {
- return;
- }
-
- this.invalidateThisCache();
-
- if (this.children.length || this.targetRelations.length) {
- path = [...path, this.noteId];
- }
-
- for (const childNote of this.children) {
- childNote.invalidateSubTree(path);
- }
-
- for (const targetRelation of this.targetRelations) {
- if (targetRelation.name === "template" || targetRelation.name === "inherit") {
- const note = targetRelation.note;
-
- if (note) {
- note.invalidateSubTree(path);
- }
- }
- }
- }
-
- getRelationDefinitions() {
- return this.getLabels().filter((l) => l.name.startsWith("relation:"));
- }
-
- getLabelDefinitions() {
- return this.getLabels().filter((l) => l.name.startsWith("relation:"));
- }
-
- isInherited() {
- return !!this.targetRelations.find((rel) => rel.name === "template" || rel.name === "inherit");
- }
-
- getSubtreeNotesIncludingTemplated(): BNote[] {
- const set = new Set();
-
- function inner(note: BNote) {
- // _hidden is not counted as subtree for the purpose of inheritance
- if (set.has(note) || note.noteId === "_hidden") {
- return;
- }
-
- set.add(note);
-
- for (const childNote of note.children) {
- inner(childNote);
- }
-
- for (const targetRelation of note.targetRelations) {
- if (targetRelation.name === "template" || targetRelation.name === "inherit") {
- const targetNote = targetRelation.note;
-
- if (targetNote) {
- inner(targetNote);
- }
- }
- }
- }
-
- inner(this);
-
- return Array.from(set);
- }
-
- getSearchResultNotes(): BNote[] {
- if (this.type !== "search") {
- return [];
- }
-
- try {
- const result = searchService.searchFromNote(this);
- 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}`);
- return [];
- }
- }
-
- getSubtree({ includeArchived = true, includeHidden = false, resolveSearch = false } = {}): {
- notes: BNote[];
- relationships: Relationship[];
- } {
- const noteSet = new Set();
- const relationships: Relationship[] = []; // list of tuples parentNoteId -> childNoteId
-
- function resolveSearchNote(searchNote: BNote) {
- try {
- for (const resultNote of searchNote.getSearchResultNotes()) {
- addSubtreeNotesInner(resultNote, searchNote);
- }
- } catch (e: any) {
- log.error(`Could not resolve search note ${searchNote?.noteId}: ${e.message}`);
- }
- }
-
- function addSubtreeNotesInner(note: BNote, parentNote: BNote | null = null) {
- if (note.noteId === "_hidden" && !includeHidden) {
- return;
- }
-
- if (parentNote) {
- // this needs to happen first before noteSet check to include all clone relationships
- relationships.push({
- parentNoteId: parentNote.noteId,
- childNoteId: note.noteId
- });
- }
-
- if (noteSet.has(note)) {
- return;
- }
-
- if (!includeArchived && note.isArchived) {
- return;
- }
-
- noteSet.add(note);
-
- if (note.type === "search") {
- if (resolveSearch) {
- resolveSearchNote(note);
- }
- } else {
- for (const childNote of note.children) {
- addSubtreeNotesInner(childNote, note);
- }
- }
- }
-
- addSubtreeNotesInner(this);
-
- return {
- notes: Array.from(noteSet),
- relationships
- };
- }
-
- /** @returns includes the subtree root note as well */
- getSubtreeNoteIds({ includeArchived = true, includeHidden = false, resolveSearch = false } = {}) {
- return this.getSubtree({ includeArchived, includeHidden, resolveSearch }).notes.map((note) => note.noteId);
- }
-
- /** @deprecated use getSubtreeNoteIds() instead */
- getDescendantNoteIds() {
- return this.getSubtreeNoteIds();
- }
-
- get parentCount() {
- return this.parents.length;
- }
-
- get childrenCount() {
- return this.children.length;
- }
-
- get labelCount() {
- return this.getAttributes().filter((attr) => attr.type === "label").length;
- }
-
- get ownedLabelCount() {
- return this.ownedAttributes.filter((attr) => attr.type === "label").length;
- }
-
- get relationCount() {
- return this.getAttributes().filter((attr) => attr.type === "relation" && !attr.isAutoLink()).length;
- }
-
- get relationCountIncludingLinks() {
- return this.getAttributes().filter((attr) => attr.type === "relation").length;
- }
-
- get ownedRelationCount() {
- return this.ownedAttributes.filter((attr) => attr.type === "relation" && !attr.isAutoLink()).length;
- }
-
- get ownedRelationCountIncludingLinks() {
- return this.ownedAttributes.filter((attr) => attr.type === "relation").length;
- }
-
- get targetRelationCount() {
- return this.targetRelations.filter((attr) => !attr.isAutoLink()).length;
- }
-
- get targetRelationCountIncludingLinks() {
- return this.targetRelations.length;
- }
-
- get attributeCount() {
- return this.getAttributes().length;
- }
-
- get ownedAttributeCount() {
- return this.getOwnedAttributes().length;
- }
-
- getAncestors() {
- if (!this.__ancestorCache) {
- const noteIds = new Set();
- this.__ancestorCache = [];
-
- for (const parent of this.parents) {
- if (noteIds.has(parent.noteId)) {
- continue;
- }
-
- this.__ancestorCache.push(parent);
- noteIds.add(parent.noteId);
-
- for (const ancestorNote of parent.getAncestors()) {
- if (!noteIds.has(ancestorNote.noteId)) {
- this.__ancestorCache.push(ancestorNote);
- noteIds.add(ancestorNote.noteId);
- }
- }
- }
- }
-
- return this.__ancestorCache;
- }
-
- getAncestorNoteIds(): string[] {
- return this.getAncestors().map((note) => note.noteId);
- }
-
- hasAncestor(ancestorNoteId: string): boolean {
- for (const ancestorNote of this.getAncestors()) {
- if (ancestorNote.noteId === ancestorNoteId) {
- return true;
- }
- }
-
- return false;
- }
-
- isInHiddenSubtree() {
- return this.noteId === "_hidden" || this.hasAncestor("_hidden");
- }
-
- getTargetRelations() {
- return this.targetRelations;
- }
-
- /** @returns returns only notes which are templated, does not include their subtrees
- * in effect returns notes which are influenced by note's non-inheritable attributes */
- getInheritingNotes(): BNote[] {
- const arr: BNote[] = [this];
-
- for (const targetRelation of this.targetRelations) {
- if (targetRelation.name === "template" || targetRelation.name === "inherit") {
- const note = targetRelation.note;
-
- if (note) {
- arr.push(note);
- }
- }
- }
-
- return arr;
- }
-
- getDistanceToAncestor(ancestorNoteId: string) {
- if (this.noteId === ancestorNoteId) {
- return 0;
- }
-
- let minDistance = 999999;
-
- for (const parent of this.parents) {
- minDistance = Math.min(minDistance, parent.getDistanceToAncestor(ancestorNoteId) + 1);
- }
-
- return minDistance;
- }
-
- getRevisions(): BRevision[] {
- return sql.getRows("SELECT * FROM revisions WHERE noteId = ? ORDER BY revisions.utcDateCreated ASC", [this.noteId]).map((row) => new BRevision(row));
- }
-
- getAttachments() {
- const query = /*sql*/`\
- SELECT attachments.*, LENGTH(blobs.content) AS contentLength
- FROM attachments
- JOIN blobs USING (blobId)
- WHERE ownerId = ? AND isDeleted = 0
- ORDER BY position`;
-
- return sql.getRows(query, [this.noteId]).map((row) => new BAttachment(row));
- }
-
- getAttachmentById(attachmentId: string) {
- const query = /*sql*/`\
- SELECT attachments.*, LENGTH(blobs.content) AS contentLength
- FROM attachments
- JOIN blobs USING (blobId)
- WHERE ownerId = ? AND attachmentId = ? AND isDeleted = 0`;
-
- return sql.getRows(query, [this.noteId, attachmentId]).map((row) => new BAttachment(row))[0];
- }
-
- getAttachmentsByRole(role: string): BAttachment[] {
- return sql
- .getRows(
- `
- SELECT attachments.*
- FROM attachments
- WHERE ownerId = ?
- AND role = ?
- AND isDeleted = 0
- ORDER BY position`,
- [this.noteId, role]
- )
- .map((row) => new BAttachment(row));
- }
-
- getAttachmentByTitle(title: string): BAttachment | undefined {
- // cannot use SQL to filter by title since it can be encrypted
- return this.getAttachments().filter((attachment) => attachment.title === title)[0];
- }
-
- /**
- * Gives all possible note paths leading to this note. Paths containing search note are ignored (could form cycles)
- *
- * @returns array of notePaths (each represented by array of noteIds constituting the particular note path)
- */
- getAllNotePaths(): string[][] {
- if (this.noteId === "root") {
- return [["root"]];
- }
-
- const parentNotes = this.getParentNotes();
-
- const notePaths =
- parentNotes.length === 1
- ? parentNotes[0].getAllNotePaths() // optimization for the most common case
- : parentNotes.flatMap((parentNote) => parentNote.getAllNotePaths());
-
- for (const notePath of notePaths) {
- notePath.push(this.noteId);
- }
-
- return notePaths;
- }
-
- getSortedNotePathRecords(hoistedNoteId: string = "root"): NotePathRecord[] {
- const isHoistedRoot = hoistedNoteId === "root";
-
- const notePaths = this.getAllNotePaths().map((path) => ({
- notePath: path,
- isInHoistedSubTree: isHoistedRoot || path.includes(hoistedNoteId),
- isArchived: path.some((noteId) => this.becca.notes[noteId].isArchived),
- isHidden: path.includes("_hidden")
- }));
-
- notePaths.sort((a, b) => {
- if (a.isInHoistedSubTree !== b.isInHoistedSubTree) {
- return a.isInHoistedSubTree ? -1 : 1;
- } else if (a.isArchived !== b.isArchived) {
- return a.isArchived ? 1 : -1;
- } else if (a.isHidden !== b.isHidden) {
- return a.isHidden ? 1 : -1;
- }
- return a.notePath.length - b.notePath.length;
- });
-
- return notePaths;
- }
-
- /**
- * Returns a note path considered to be the "best"
- *
- * @return array of noteIds constituting the particular note path
- */
- getBestNotePath(hoistedNoteId: string = "root"): string[] {
- return this.getSortedNotePathRecords(hoistedNoteId)[0]?.notePath;
- }
-
- /**
- * Returns a note path considered to be the "best"
- *
- * @return serialized note path (e.g. 'root/a1h315/js725h')
- */
- getBestNotePathString(hoistedNoteId: string = "root"): string {
- const notePath = this.getBestNotePath(hoistedNoteId);
-
- return notePath?.join("/");
- }
-
- /**
- * @return boolean - true if there's no non-hidden path, note is not cloned to the visible tree
- */
- isHiddenCompletely() {
- if (this.noteId === "root") {
- return false;
- }
-
- for (const parentNote of this.parents) {
- if (parentNote.noteId === "root") {
- return false;
- } else if (parentNote.noteId === "_hidden") {
- continue;
- } else if (!parentNote.isHiddenCompletely()) {
- return false;
- }
- }
-
- return true;
- }
-
- /**
- * @returns true if ancestorNoteId occurs in at least one of the note's paths
- */
- isDescendantOfNote(ancestorNoteId: string): boolean {
- const notePaths = this.getAllNotePaths();
-
- return notePaths.some((path) => path.includes(ancestorNoteId));
- }
-
- /**
- * Update's given attribute's value or creates it if it doesn't exist
- *
- * @param type - attribute type (label, relation, etc.)
- * @param name - attribute name
- * @param value - attribute value (optional)
- */
- setAttribute(type: AttributeType, name: string, value?: string) {
- const attributes = this.getOwnedAttributes();
- const attr = attributes.find((attr) => attr.type === type && attr.name === name);
-
- value = value?.toString() || "";
-
- if (attr) {
- if (attr.value !== value) {
- attr.value = value;
- attr.save();
- }
- } else {
- new BAttribute({
- noteId: this.noteId,
- type,
- name,
- value
- }).save();
- }
- }
-
- /**
- * Removes given attribute name-value pair if it exists.
- *
- * @param type - attribute type (label, relation, etc.)
- * @param name - attribute name
- * @param value - attribute value (optional)
- */
- removeAttribute(type: string, name: string, value?: string) {
- const attributes = this.getOwnedAttributes();
-
- for (const attribute of attributes) {
- if (attribute.type === type && attribute.name === name && (value === undefined || value === attribute.value)) {
- attribute.markAsDeleted();
- }
- }
- }
-
- /**
- * Adds a new attribute to this note. The attribute is saved and returned.
- * See addLabel, addRelation for more specific methods.
- *
- * @param type - attribute type (label / relation)
- * @param name - name of the attribute, not including the leading ~/#
- * @param value - value of the attribute - text for labels, target note ID for relations; optional.
- */
- addAttribute(type: AttributeType, name: string, value: string = "", isInheritable: boolean = false, position: number | null = null): BAttribute {
- return new BAttribute({
- noteId: this.noteId,
- type,
- name,
- value,
- isInheritable,
- position
- }).save();
- }
-
- /**
- * Adds a new label to this note. The label attribute is saved and returned.
- *
- * @param name - name of the label, not including the leading #
- * @param value - text value of the label; optional
- */
- addLabel(name: string, value: string = "", isInheritable: boolean = false): BAttribute {
- return this.addAttribute(LABEL, name, value, isInheritable);
- }
-
- /**
- * Adds a new relation to this note. The relation attribute is saved and
- * returned.
- *
- * @param name - name of the relation, not including the leading ~
- */
- addRelation(name: string, targetNoteId: string, isInheritable: boolean = false): BAttribute {
- return this.addAttribute(RELATION, name, targetNoteId, isInheritable);
- }
-
- /**
- * Based on enabled, the attribute is either set or removed.
- *
- * @param type - attribute type ('relation', 'label' etc.)
- * @param enabled - toggle On or Off
- * @param name - attribute name
- * @param value - attribute value (optional)
- */
- toggleAttribute(type: AttributeType, enabled: boolean, name: string, value?: string) {
- if (enabled) {
- this.setAttribute(type, name, value);
- } else {
- this.removeAttribute(type, name, value);
- }
- }
-
- /**
- * Based on enabled, label is either set or removed.
- *
- * @param enabled - toggle On or Off
- * @param name - label name
- * @param value - label value (optional)
- */
- toggleLabel(enabled: boolean, name: string, value?: string) {
- return this.toggleAttribute(LABEL, enabled, name, value);
- }
-
- /**
- * Based on enabled, relation is either set or removed.
- *
- * @param enabled - toggle On or Off
- * @param name - relation name
- * @param value - relation value (noteId)
- */
- toggleRelation(enabled: boolean, name: string, value?: string) {
- return this.toggleAttribute(RELATION, enabled, name, value);
- }
-
- /**
- * Update's given label's value or creates it if it doesn't exist
- *
- * @param name - label name
- * @param value label value
- */
- setLabel(name: string, value?: string) {
- return this.setAttribute(LABEL, name, value);
- }
-
- /**
- * Update's given relation's value or creates it if it doesn't exist
- *
- * @param name - relation name
- * @param value - relation value (noteId)
- */
- setRelation(name: string, value?: string) {
- return this.setAttribute(RELATION, name, value);
- }
-
- /**
- * Remove label name-value pair, if it exists.
- *
- * @param name - label name
- * @param value - label value
- */
- removeLabel(name: string, value?: string) {
- return this.removeAttribute(LABEL, name, value);
- }
-
- /**
- * Remove the relation name-value pair, if it exists.
- *
- * @param name - relation name
- * @param value - relation value (noteId)
- */
- removeRelation(name: string, value?: string) {
- return this.removeAttribute(RELATION, name, value);
- }
-
- searchNotesInSubtree(searchString: string) {
- return searchService.searchNotes(searchString) as BNote[];
- }
-
- searchNoteInSubtree(searchString: string) {
- return this.searchNotesInSubtree(searchString)[0];
- }
-
- cloneTo(parentNoteId: string): CloneResponse {
- const branch = this.becca.getNote(parentNoteId)?.getParentBranches()[0];
- if (!branch?.branchId) {
- return {
- success: false,
- message: "Unable to find the branch ID to clone."
- };
- }
-
- return cloningService.cloneNoteToBranch(this.noteId, branch.branchId);
- }
-
- isEligibleForConversionToAttachment(opts: ConvertOpts = { autoConversion: false }) {
- if (this.type !== "image" || !this.isContentAvailable() || this.hasChildren() || this.getParentBranches().length !== 1) {
- return false;
- }
-
- const targetRelations = this.getTargetRelations().filter((relation) => relation.name === "imageLink");
-
- if (opts.autoConversion && targetRelations.length === 0) {
- return false;
- } else if (targetRelations.length > 1) {
- return false;
- }
-
- const parentNote = this.getParentNotes()[0]; // at this point note can have only one parent
- const referencingNote = targetRelations[0]?.getNote();
-
- if (referencingNote && parentNote !== referencingNote) {
- return false;
- } else if (parentNote.type !== "text" || !parentNote.isContentAvailable()) {
- return false;
- }
-
- return true;
- }
-
- /**
- * Some notes are eligible for conversion into an attachment of its parent, note must have these properties:
- * - it has exactly one target relation
- * - it has a relation from its parent note
- * - it has no children
- * - it has no clones
- * - the parent is of type text
- * - both notes are either unprotected or user is in protected session
- *
- * Currently, works only for image notes.
- *
- * In the future, this functionality might get more generic and some of the requirements relaxed.
- *
- * @returns null if note is not eligible for conversion
- */
- convertToParentAttachment(opts: ConvertOpts = { autoConversion: false }): BAttachment | null {
- if (!this.isEligibleForConversionToAttachment(opts)) {
- return null;
- }
-
- const content = this.getContent();
-
- const parentNote = this.getParentNotes()[0];
- const attachment = parentNote.saveAttachment({
- role: "image",
- mime: this.mime,
- title: this.title,
- content
- });
-
- const parentContent = parentNote.getContent();
-
- const oldNoteUrl = `api/images/${this.noteId}/`;
- const newAttachmentUrl = `api/attachments/${attachment.attachmentId}/image/`;
-
- if (typeof parentContent !== "string") {
- 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);
-
- parentNote.setContent(fixedContent);
-
- noteService.asyncPostProcessContent(parentNote, fixedContent); // to mark an unused attachment for deletion
-
- this.deleteNote();
-
- return attachment;
- }
-
- /**
- * (Soft) delete a note and all its descendants.
- *
- * @param deleteId - optional delete identified
- */
- deleteNote(deleteId: string | null = null, taskContext: TaskContext<"deleteNotes"> | null = null) {
- if (this.isDeleted) {
- return;
- }
-
- if (!deleteId) {
- deleteId = utils.randomString(10);
- }
-
- if (!taskContext) {
- taskContext = new TaskContext("no-progress-reporting", "deleteNotes", null);
- }
-
- // needs to be run before branches and attributes are deleted and thus attached relations disappear
- handlers.runAttachedRelations(this, "runOnNoteDeletion", this);
- taskContext.noteDeletionHandlerTriggered = true;
-
- for (const branch of this.getParentBranches()) {
- branch.deleteBranch(deleteId, taskContext);
- }
- }
-
- decrypt() {
- if (this.isProtected && !this.isDecrypted && protectedSessionService.isProtectedSessionAvailable()) {
- try {
- this.title = protectedSessionService.decryptString(this.title) || "";
- this.__flatTextCache = null;
-
- this.isDecrypted = true;
- } catch (e: any) {
- log.error(`Could not decrypt note ${this.noteId}: ${e.message} ${e.stack}`);
- }
- }
- }
-
- isLaunchBarConfig() {
- return this.type === "launcher"
- || ["_lbRoot", "_lbAvailableLaunchers", "_lbVisibleLaunchers"].includes(this.noteId)
- || ["_lbMobileRoot", "_lbMobileAvailableLaunchers", "_lbMobileVisibleLaunchers"].includes(this.noteId);
- }
-
- isOptions() {
- return this.noteId.startsWith("_options");
- }
-
- override get isDeleted() {
- // isBeingDeleted is relevant only in the transition period when the deletion process has begun, but not yet
- // finished (note is still in becca)
- return !(this.noteId in this.becca.notes) || this.isBeingDeleted;
- }
-
- saveRevision(): BRevision {
- return sql.transactional(() => {
- let noteContent = this.getContent();
-
- const revision = new BRevision(
- {
- noteId: this.noteId,
- // title and text should be decrypted now
- title: this.title,
- type: this.type,
- mime: this.mime,
- isProtected: this.isProtected,
- utcDateLastEdited: this.utcDateModified,
- utcDateCreated: dateUtils.utcNowDateTime(),
- utcDateModified: dateUtils.utcNowDateTime(),
- dateLastEdited: this.dateModified,
- dateCreated: dateUtils.localNowDateTime()
- },
- true
- );
-
- revision.save(); // to generate revisionId, which is then used to save attachments
-
- for (const noteAttachment of this.getAttachments()) {
- const revisionAttachment = noteAttachment.copy();
-
- if (!revision.revisionId) {
- throw new Error("Revision ID is missing.");
- }
-
- revisionAttachment.ownerId = revision.revisionId;
- revisionAttachment.setContent(noteAttachment.getContent(), { forceSave: true });
-
- if (this.type === "text" && typeof noteContent === "string") {
- // content is rewritten to point to the revision attachments
- noteContent = noteContent.replaceAll(`attachments/${noteAttachment.attachmentId}`, `attachments/${revisionAttachment.attachmentId}`);
-
- noteContent = noteContent.replaceAll(
- new RegExp(`href="[^"]*attachmentId=${noteAttachment.attachmentId}[^"]*"`, "gi"),
- `href="api/attachments/${revisionAttachment.attachmentId}/download"`
- );
- }
- }
-
- revision.setContent(noteContent);
-
- this.eraseExcessRevisionSnapshots();
- return revision;
- });
- }
-
- // Limit the number of Snapshots to revisionSnapshotNumberLimit
- // Delete older Snapshots that exceed the limit
- eraseExcessRevisionSnapshots() {
- // lable has a higher priority
- let revisionSnapshotNumberLimit = parseInt(this.getLabelValue("versioningLimit") ?? "");
- if (!Number.isInteger(revisionSnapshotNumberLimit)) {
- revisionSnapshotNumberLimit = parseInt(optionService.getOption("revisionSnapshotNumberLimit"));
- }
- if (revisionSnapshotNumberLimit >= 0) {
- const revisions = this.getRevisions();
- if (revisions.length - revisionSnapshotNumberLimit > 0) {
- const revisionIds = revisions
- .slice(0, revisions.length - revisionSnapshotNumberLimit)
- .map((revision) => revision.revisionId)
- .filter((id): id is string => id !== undefined);
- eraseService.eraseRevisions(revisionIds);
- }
- }
- }
-
- /**
- * @param matchBy - choose by which property we detect if to update an existing attachment.
- * Supported values are either 'attachmentId' (default) or 'title'
- */
- saveAttachment({ attachmentId, role, mime, title, content, position }: Omit, matchBy: "attachmentId" | "title" | undefined = "attachmentId") {
- if (!["attachmentId", "title"].includes(matchBy)) {
- throw new Error(`Unsupported value '${matchBy}' for matchBy param, has to be either 'attachmentId' or 'title'.`);
- }
-
- let attachment;
-
- if (matchBy === "title" && title) {
- attachment = this.getAttachmentByTitle(title);
- } else if (matchBy === "attachmentId" && attachmentId) {
- attachment = this.becca.getAttachmentOrThrow(attachmentId);
- }
-
- attachment =
- attachment ||
- new BAttachment({
- ownerId: this.noteId,
- title,
- role,
- mime,
- isProtected: this.isProtected,
- position
- });
-
- content = content || "";
- attachment.setContent(content, { forceSave: true });
-
- return attachment;
- }
-
- getFileName() {
- return utils.formatDownloadTitle(this.title, this.type, this.mime);
- }
-
- override beforeSaving() {
- super.beforeSaving();
-
- this.becca.addNote(this.noteId, this);
-
- this.dateModified = dateUtils.localNowDateTime();
- this.utcDateModified = dateUtils.utcNowDateTime();
- }
-
- getPojo(): NotePojo {
- return {
- noteId: this.noteId,
- title: this.title,
- isProtected: this.isProtected,
- type: this.type,
- mime: this.mime,
- blobId: this.blobId,
- isDeleted: false,
- dateCreated: this.dateCreated,
- dateModified: this.dateModified,
- utcDateCreated: this.utcDateCreated,
- utcDateModified: this.utcDateModified
- };
- }
-
- override getPojoToSave() {
- const pojo = this.getPojo();
-
- if (pojo.isProtected) {
- if (this.isDecrypted && pojo.title) {
- pojo.title = protectedSessionService.encrypt(pojo.title) || undefined;
- } else {
- // updating protected note outside of protected session means we will keep original ciphertexts
- delete pojo.title;
- }
- }
-
- return pojo;
- }
-
- getIcon() {
- return `tn-icon ${this.#getIconInternal()}`;
- }
-
- // TODO: Deduplicate with fnote
- #getIconInternal() {
- const iconClassLabels = this.getLabels("iconClass");
-
- if (iconClassLabels && iconClassLabels.length > 0) {
- return iconClassLabels[0].value;
- } else if (this.noteId === "root") {
- return "bx bx-home-alt-2";
- }
- if (this.noteId === "_share") {
- return "bx bx-share-alt";
- } else if (this.type === "text") {
- if (this.isFolder()) {
- return "bx bx-folder";
- }
- return "bx bx-note";
-
- } else if (this.type === "code" && this.mime.startsWith("text/x-sql")) {
- return "bx bx-data";
- }
- return NOTE_TYPE_ICONS[this.type];
- }
-
- // TODO: Deduplicate with fnote
- isFolder() {
- return this.type === "search" || this.getFilteredChildBranches().length > 0;
- }
-
- // TODO: Deduplicate with fnote
- getFilteredChildBranches() {
- const childBranches = this.getChildBranches();
-
- if (!childBranches) {
- console.error(`No children for '${this.noteId}'. This shouldn't happen.`);
- return [];
- }
-
- // we're not checking hideArchivedNotes since that would mean we need to lazy load the child notes
- // which would seriously slow down everything.
- // we check this flag only once user chooses to expand the parent. This has the negative consequence that
- // note may appear as a folder but not contain any children when all of them are archived
-
- return childBranches;
- }
-
- get encodedTitle() {
- return encodeURIComponent(this.title);
- }
-
- getVisibleChildBranches() {
- return this.getChildBranches().filter((branch) => !branch.getNote().isLabelTruthy("shareHiddenFromTree"));
- }
-
- getVisibleChildNotes() {
- return this.getVisibleChildBranches().map((branch) => branch.getNote());
- }
-
- hasVisibleChildren() {
- return this.getVisibleChildNotes().length > 0;
- }
-
- get shareId() {
- return this.noteId;
- }
-
- /**
- * Return an attribute by it's attributeId. Requires the attribute cache to be available.
- * @param attributeId - the id of the attribute owned by this note
- * @returns - the BAttribute with the given id or undefined if not found.
- */
- getAttributeById(attributeId : string): BAttribute | undefined {
- this.__ensureAttributeCacheIsAvailable();
-
- if (!this.__attributeCache) {
- throw new Error("Attribute cache not available.");
- }
-
- return this.__attributeCache.find((attr) => attr.attributeId === attributeId);
- }
-
- /**
- * Sets an attribute's value by it's attributeId.
- * @param attributeId - the id of the attribute owned by this note
- * @param value - the new value to replace
- */
- setAttributeValueById(attributeId : string, value? : string) {
- const attributes = this.getOwnedAttributes();
- const attr = attributes.find((attr) => attr.attributeId === attributeId);
-
- value = value?.toString() || "";
-
- if (attr) {
- if (attr.value !== value) {
- attr.value = value;
- attr.save();
- }
- } else {
- throw new Error(`Attribute with id ${attributeId} not found.`);
- }
- }
-}
-
+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
index 7c841931b9..e17b896ff1 100644
--- a/apps/server/src/becca/entities/boption.ts
+++ b/apps/server/src/becca/entities/boption.ts
@@ -1,56 +1,2 @@
-"use strict";
-
-import dateUtils from "../../services/date_utils.js";
-import AbstractBeccaEntity from "./abstract_becca_entity.js";
-import type { OptionRow } from "@triliumnext/commons";
-
-/**
- * Option represents a name-value pair, either directly configurable by the user or some system property.
- */
-class BOption extends AbstractBeccaEntity {
- static get entityName() {
- return "options";
- }
- static get primaryKeyName() {
- return "name";
- }
- static get hashedProperties() {
- return ["name", "value"];
- }
-
- name!: string;
- value!: string;
-
- constructor(row?: OptionRow) {
- super();
-
- if (row) {
- this.updateFromRow(row);
- }
- this.becca.options[this.name] = this;
- }
-
- updateFromRow(row: OptionRow) {
- this.name = row.name;
- this.value = row.value;
- this.isSynced = !!row.isSynced;
- this.utcDateModified = row.utcDateModified;
- }
-
- override beforeSaving() {
- super.beforeSaving();
-
- this.utcDateModified = dateUtils.utcNowDateTime();
- }
-
- getPojo() {
- return {
- name: this.name,
- value: this.value,
- isSynced: this.isSynced,
- utcDateModified: this.utcDateModified
- };
- }
-}
-
+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
index bfaa465447..e8cee478da 100644
--- a/apps/server/src/becca/entities/brecent_note.ts
+++ b/apps/server/src/becca/entities/brecent_note.ts
@@ -1,46 +1,2 @@
-"use strict";
-
-import type { RecentNoteRow } from "@triliumnext/commons";
-
-import dateUtils from "../../services/date_utils.js";
-import AbstractBeccaEntity from "./abstract_becca_entity.js";
-
-/**
- * RecentNote represents recently visited note.
- */
-class BRecentNote extends AbstractBeccaEntity {
- static get entityName() {
- return "recent_notes";
- }
- static get primaryKeyName() {
- return "noteId";
- }
- static get hashedProperties() {
- return ["noteId", "notePath"];
- }
-
- noteId!: string;
- notePath!: string;
-
- constructor(row: RecentNoteRow) {
- super();
-
- this.updateFromRow(row);
- }
-
- updateFromRow(row: RecentNoteRow): void {
- this.noteId = row.noteId;
- this.notePath = row.notePath;
- this.utcDateCreated = row.utcDateCreated || dateUtils.utcNowDateTime();
- }
-
- getPojo() {
- return {
- noteId: this.noteId,
- notePath: this.notePath,
- utcDateCreated: this.utcDateCreated
- };
- }
-}
-
+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
index 88f647db29..c11de52a48 100644
--- a/apps/server/src/becca/entities/brevision.ts
+++ b/apps/server/src/becca/entities/brevision.ts
@@ -1,225 +1,2 @@
-"use strict";
-
-import protectedSessionService from "../../services/protected_session.js";
-import utils from "../../services/utils.js";
-import dateUtils from "../../services/date_utils.js";
-import becca from "../becca.js";
-import AbstractBeccaEntity from "./abstract_becca_entity.js";
-import sql from "../../services/sql.js";
-import BAttachment from "./battachment.js";
-import type { AttachmentRow, NoteType, RevisionPojo, RevisionRow } from "@triliumnext/commons";
-import eraseService from "../../services/erase.js";
-
-interface ContentOpts {
- /** will also save this BRevision entity */
- forceSave?: boolean;
-}
-
-interface GetByIdOpts {
- includeContentLength?: boolean;
-}
-
-/**
- * Revision represents a snapshot of note's title and content at some point in the past.
- * It's used for seamless note versioning.
- */
-class BRevision extends AbstractBeccaEntity {
- static get entityName() {
- return "revisions";
- }
- static get primaryKeyName() {
- return "revisionId";
- }
- static get hashedProperties() {
- return ["revisionId", "noteId", "title", "isProtected", "dateLastEdited", "dateCreated", "utcDateLastEdited", "utcDateCreated", "utcDateModified", "blobId"];
- }
-
- revisionId?: string;
- noteId!: string;
- type!: NoteType;
- mime!: string;
- title!: string;
- dateLastEdited?: string;
- utcDateLastEdited?: string;
- contentLength?: number;
- content?: string | Buffer;
-
- constructor(row: RevisionRow, titleDecrypted = false) {
- super();
-
- this.updateFromRow(row);
- if (this.isProtected && !titleDecrypted) {
- const decryptedTitle = protectedSessionService.isProtectedSessionAvailable() ? protectedSessionService.decryptString(this.title) : null;
- this.title = decryptedTitle || "[protected]";
- }
- }
-
- updateFromRow(row: RevisionRow) {
- this.revisionId = row.revisionId;
- this.noteId = row.noteId;
- this.type = row.type;
- this.mime = row.mime;
- this.isProtected = !!row.isProtected;
- this.title = row.title;
- this.blobId = row.blobId;
- this.dateLastEdited = row.dateLastEdited;
- this.dateCreated = row.dateCreated;
- this.utcDateLastEdited = row.utcDateLastEdited;
- this.utcDateCreated = row.utcDateCreated;
- this.utcDateModified = row.utcDateModified;
- this.contentLength = row.contentLength;
- }
-
- getNote() {
- return becca.notes[this.noteId];
- }
-
- /** @returns true if the note has string content (not binary) */
- override hasStringContent(): boolean {
- return utils.isStringNote(this.type, this.mime);
- }
-
- isContentAvailable() {
- return (
- !this.revisionId || // new note which was not encrypted yet
- !this.isProtected ||
- protectedSessionService.isProtectedSessionAvailable()
- );
- }
-
- /*
- * Note revision content has quite special handling - it's not a separate entity, but a lazily loaded
- * part of Revision entity with its own sync. The reason behind this hybrid design is that
- * content can be quite large, and it's not necessary to load it / fill memory for any note access even
- * if we don't need a content, especially for bulk operations like search.
- *
- * This is the same approach as is used for Note's content.
- */
- getContent(): string | Buffer {
- return this._getContent();
- }
-
- /**
- * @throws Error in case of invalid JSON */
- getJsonContent(): {} | null {
- const content = this.getContent();
-
- if (!content || typeof content !== "string" || !content.trim()) {
- return null;
- }
-
- return JSON.parse(content);
- }
-
- /** @returns valid object or null if the content cannot be parsed as JSON */
- getJsonContentSafely(): {} | null {
- try {
- return this.getJsonContent();
- } catch (e) {
- return null;
- }
- }
-
- setContent(content: string | Buffer, opts: ContentOpts = {}) {
- this._setContent(content, opts);
- }
-
- getAttachments(): BAttachment[] {
- return sql
- .getRows(
- `
- SELECT attachments.*
- FROM attachments
- WHERE ownerId = ?
- AND isDeleted = 0`,
- [this.revisionId]
- )
- .map((row) => new BAttachment(row));
- }
-
- getAttachmentById(attachmentId: String, opts: GetByIdOpts = {}): BAttachment | null {
- opts.includeContentLength = !!opts.includeContentLength;
-
- const query = opts.includeContentLength
- ? /*sql*/`SELECT attachments.*, LENGTH(blobs.content) AS contentLength
- FROM attachments
- JOIN blobs USING (blobId)
- WHERE ownerId = ? AND attachmentId = ? AND isDeleted = 0`
- : /*sql*/`SELECT * FROM attachments WHERE ownerId = ? AND attachmentId = ? AND isDeleted = 0`;
-
- return sql.getRows(query, [this.revisionId, attachmentId]).map((row) => new BAttachment(row))[0];
- }
-
- getAttachmentsByRole(role: string): BAttachment[] {
- return sql
- .getRows(
- `
- SELECT attachments.*
- FROM attachments
- WHERE ownerId = ?
- AND role = ?
- AND isDeleted = 0
- ORDER BY position`,
- [this.revisionId, role]
- )
- .map((row) => new BAttachment(row));
- }
-
- getAttachmentByTitle(title: string): BAttachment {
- // cannot use SQL to filter by title since it can be encrypted
- return this.getAttachments().filter((attachment) => attachment.title === title)[0];
- }
-
- /**
- * Revisions are not soft-deletable, they are immediately hard-deleted (erased).
- */
- eraseRevision() {
- if (this.revisionId) {
- eraseService.eraseRevisions([this.revisionId]);
- }
- }
-
- override beforeSaving() {
- super.beforeSaving();
-
- this.utcDateModified = dateUtils.utcNowDateTime();
- }
-
- getPojo() {
- return {
- revisionId: this.revisionId,
- noteId: this.noteId,
- type: this.type,
- mime: this.mime,
- isProtected: this.isProtected,
- title: this.title,
- blobId: this.blobId,
- dateLastEdited: this.dateLastEdited,
- dateCreated: this.dateCreated,
- utcDateLastEdited: this.utcDateLastEdited,
- utcDateCreated: this.utcDateCreated,
- utcDateModified: this.utcDateModified,
- content: this.content, // used when retrieving full note revision to frontend
- contentLength: this.contentLength
- } satisfies RevisionPojo;
- }
-
- override getPojoToSave() {
- const pojo = this.getPojo();
- delete pojo.content; // not getting persisted
- delete pojo.contentLength; // not getting persisted
-
- if (pojo.isProtected) {
- if (protectedSessionService.isProtectedSessionAvailable()) {
- pojo.title = protectedSessionService.encrypt(this.title) ?? "";
- } else {
- // updating protected note outside of protected session means we will keep original ciphertexts
- pojo.title = "";
- }
- }
-
- return pojo;
- }
-}
-
+import { BRevision } from "@triliumnext/core";
export default BRevision;
diff --git a/apps/server/src/cls_provider.ts b/apps/server/src/cls_provider.ts
new file mode 100644
index 0000000000..a11f616129
--- /dev/null
+++ b/apps/server/src/cls_provider.ts
@@ -0,0 +1,24 @@
+import { ExecutionContext } from "@triliumnext/core";
+import clsHooked from "cls-hooked";
+
+export const namespace = clsHooked.createNamespace("trilium");
+
+export default class ClsHookedExecutionContext implements ExecutionContext {
+
+ get(key: string): T | undefined {
+ return namespace.get(key);
+ }
+
+ set(key: string, value: any): void {
+ namespace.set(key, value);
+ }
+
+ reset(): void {
+ clsHooked.reset();
+ }
+
+ init(callback: () => T): T {
+ return namespace.runAndReturn(callback);
+ }
+
+}
diff --git a/apps/server/src/crypto_provider.ts b/apps/server/src/crypto_provider.ts
new file mode 100644
index 0000000000..6ddeecf9f3
--- /dev/null
+++ b/apps/server/src/crypto_provider.ts
@@ -0,0 +1,29 @@
+import { CryptoProvider } from "@triliumnext/core";
+import crypto from "crypto";
+import { generator } from "rand-token";
+
+const randtoken = generator({ source: "crypto" });
+
+export default class NodejsCryptoProvider implements CryptoProvider {
+
+ createHash(algorithm: "sha1", content: string | Uint8Array): Uint8Array {
+ return crypto.createHash(algorithm).update(content).digest();
+ }
+
+ createCipheriv(algorithm: "aes-128-cbc", key: Uint8Array, iv: Uint8Array): { update(data: Uint8Array): Uint8Array; final(): Uint8Array; } {
+ return crypto.createCipheriv(algorithm, key, iv);
+ }
+
+ createDecipheriv(algorithm: "aes-128-cbc", key: Uint8Array, iv: Uint8Array) {
+ return crypto.createDecipheriv(algorithm, key, iv);
+ }
+
+ randomBytes(size: number): Uint8Array {
+ return crypto.randomBytes(size);
+ }
+
+ randomString(length: number): string {
+ return randtoken.generate(length);
+ }
+
+}
diff --git a/apps/server/src/errors/forbidden_error.ts b/apps/server/src/errors/forbidden_error.ts
deleted file mode 100644
index 3e62665b07..0000000000
--- a/apps/server/src/errors/forbidden_error.ts
+++ /dev/null
@@ -1,12 +0,0 @@
-import HttpError from "./http_error.js";
-
-class ForbiddenError extends HttpError {
-
- constructor(message: string) {
- super(message, 403);
- this.name = "ForbiddenError";
- }
-
-}
-
-export default ForbiddenError;
\ No newline at end of file
diff --git a/apps/server/src/errors/http_error.ts b/apps/server/src/errors/http_error.ts
deleted file mode 100644
index 2ab806d8bd..0000000000
--- a/apps/server/src/errors/http_error.ts
+++ /dev/null
@@ -1,13 +0,0 @@
-class HttpError extends Error {
-
- statusCode: number;
-
- constructor(message: string, statusCode: number) {
- super(message);
- this.name = "HttpError";
- this.statusCode = statusCode;
- }
-
-}
-
-export default HttpError;
\ No newline at end of file
diff --git a/apps/server/src/errors/not_found_error.ts b/apps/server/src/errors/not_found_error.ts
deleted file mode 100644
index 44f718a2c8..0000000000
--- a/apps/server/src/errors/not_found_error.ts
+++ /dev/null
@@ -1,12 +0,0 @@
-import HttpError from "./http_error.js";
-
-class NotFoundError extends HttpError {
-
- constructor(message: string) {
- super(message, 404);
- this.name = "NotFoundError";
- }
-
-}
-
-export default NotFoundError;
diff --git a/apps/server/src/errors/open_id_error.ts b/apps/server/src/errors/open_id_error.ts
deleted file mode 100644
index 0206a17f34..0000000000
--- a/apps/server/src/errors/open_id_error.ts
+++ /dev/null
@@ -1,9 +0,0 @@
-class OpenIdError {
- message: string;
-
- constructor(message: string) {
- this.message = message;
- }
-}
-
-export default OpenIdError;
\ No newline at end of file
diff --git a/apps/server/src/errors/validation_error.ts b/apps/server/src/errors/validation_error.ts
deleted file mode 100644
index 25cdd509ef..0000000000
--- a/apps/server/src/errors/validation_error.ts
+++ /dev/null
@@ -1,12 +0,0 @@
-import HttpError from "./http_error.js";
-
-class ValidationError extends HttpError {
-
- constructor(message: string) {
- super(message, 400)
- this.name = "ValidationError";
- }
-
-}
-
-export default ValidationError;
diff --git a/apps/server/src/etapi/etapi_utils.ts b/apps/server/src/etapi/etapi_utils.ts
index 1319162575..f018975ecb 100644
--- a/apps/server/src/etapi/etapi_utils.ts
+++ b/apps/server/src/etapi/etapi_utils.ts
@@ -1,12 +1,14 @@
-import cls from "../services/cls.js";
-import sql from "../services/sql.js";
-import log from "../services/log.js";
-import becca from "../becca/becca.js";
-import etapiTokenService from "../services/etapi_tokens.js";
-import config from "../services/config.js";
import type { NextFunction, Request, RequestHandler, Response, Router } from "express";
-import type { ValidatorMap } from "./etapi-interface.js";
+
+import becca from "../becca/becca.js";
+import { namespace } from "../cls_provider.js";
import type { ApiRequestHandler, SyncRouteRequestHandler } from "../routes/route_api.js";
+import cls from "../services/cls.js";
+import config from "../services/config.js";
+import etapiTokenService from "../services/etapi_tokens.js";
+import log from "../services/log.js";
+import sql from "../services/sql.js";
+import type { ValidatorMap } from "./etapi-interface.js";
const GENERIC_CODE = "GENERIC";
type HttpMethod = "all" | "get" | "post" | "put" | "delete" | "patch" | "options" | "head";
@@ -35,8 +37,8 @@ function sendError(res: Response, statusCode: number, code: string, message: str
.send(
JSON.stringify({
status: statusCode,
- code: code,
- message: message
+ code,
+ message
})
);
}
@@ -51,8 +53,8 @@ function checkEtapiAuth(req: Request, res: Response, next: NextFunction) {
function processRequest(req: Request, res: Response, routeHandler: ApiRequestHandler, next: NextFunction, method: string, path: string) {
try {
- cls.namespace.bindEmitter(req);
- cls.namespace.bindEmitter(res);
+ namespace.bindEmitter(req);
+ namespace.bindEmitter(res);
cls.init(() => {
cls.set("componentId", "etapi");
@@ -86,9 +88,9 @@ function getAndCheckNote(noteId: string) {
if (note) {
return note;
- } else {
- throw new EtapiError(404, "NOTE_NOT_FOUND", `Note '${noteId}' not found.`);
- }
+ }
+ throw new EtapiError(404, "NOTE_NOT_FOUND", `Note '${noteId}' not found.`);
+
}
function getAndCheckAttachment(attachmentId: string) {
@@ -96,9 +98,9 @@ function getAndCheckAttachment(attachmentId: string) {
if (attachment) {
return attachment;
- } else {
- throw new EtapiError(404, "ATTACHMENT_NOT_FOUND", `Attachment '${attachmentId}' not found.`);
- }
+ }
+ throw new EtapiError(404, "ATTACHMENT_NOT_FOUND", `Attachment '${attachmentId}' not found.`);
+
}
function getAndCheckBranch(branchId: string) {
@@ -106,9 +108,9 @@ function getAndCheckBranch(branchId: string) {
if (branch) {
return branch;
- } else {
- throw new EtapiError(404, "BRANCH_NOT_FOUND", `Branch '${branchId}' not found.`);
- }
+ }
+ throw new EtapiError(404, "BRANCH_NOT_FOUND", `Branch '${branchId}' not found.`);
+
}
function getAndCheckAttribute(attributeId: string) {
@@ -116,9 +118,9 @@ function getAndCheckAttribute(attributeId: string) {
if (attribute) {
return attribute;
- } else {
- throw new EtapiError(404, "ATTRIBUTE_NOT_FOUND", `Attribute '${attributeId}' not found.`);
- }
+ }
+ throw new EtapiError(404, "ATTRIBUTE_NOT_FOUND", `Attribute '${attributeId}' not found.`);
+
}
function validateAndPatch(target: any, source: any, allowedProperties: ValidatorMap) {
diff --git a/apps/server/src/etapi/notes.ts b/apps/server/src/etapi/notes.ts
index 9fae830704..735291c861 100644
--- a/apps/server/src/etapi/notes.ts
+++ b/apps/server/src/etapi/notes.ts
@@ -1,20 +1,21 @@
-import becca from "../becca/becca.js";
-import utils from "../services/utils.js";
-import eu from "./etapi_utils.js";
-import mappers from "./mappers.js";
-import noteService from "../services/notes.js";
-import TaskContext from "../services/task_context.js";
-import v from "./validators.js";
-import searchService from "../services/search/services/search.js";
-import SearchContext from "../services/search/search_context.js";
-import zipExportService from "../services/export/zip.js";
-import zipImportService from "../services/import/zip.js";
+import { NoteParams } from "@triliumnext/core";
import type { Request, Router } from "express";
import type { ParsedQs } from "qs";
-import type { NoteParams } from "../services/note-interface.js";
-import type { SearchParams } from "../services/search/services/types.js";
-import type { ValidatorMap } from "./etapi-interface.js";
+
+import becca from "../becca/becca.js";
+import zipExportService from "../services/export/zip.js";
import type { ExportFormat } from "../services/export/zip/abstract_provider.js";
+import zipImportService from "../services/import/zip.js";
+import noteService from "../services/notes.js";
+import SearchContext from "../services/search/search_context.js";
+import searchService from "../services/search/services/search.js";
+import type { SearchParams } from "../services/search/services/types.js";
+import TaskContext from "../services/task_context.js";
+import utils from "../services/utils.js";
+import eu from "./etapi_utils.js";
+import type { ValidatorMap } from "./etapi-interface.js";
+import mappers from "./mappers.js";
+import v from "./validators.js";
function register(router: Router) {
eu.route(router, "get", "/etapi/notes", (req, res, next) => {
diff --git a/apps/server/src/main.ts b/apps/server/src/main.ts
index 072ff3229e..7dd11fa64b 100644
--- a/apps/server/src/main.ts
+++ b/apps/server/src/main.ts
@@ -3,9 +3,49 @@
* are loaded later and will result in an empty string.
*/
-import { initializeTranslations } from "./services/i18n.js";
+import { initializeCore } from "@triliumnext/core";
+
+import ClsHookedExecutionContext from "./cls_provider.js";
+import NodejsCryptoProvider from "./crypto_provider.js";
+import BetterSqlite3Provider from "./sql_provider.js";
async function startApplication() {
+ const config = (await import("./services/config.js")).default;
+ const { DOCUMENT_PATH } = (await import("./services/data_dir.js")).default;
+
+ const dbProvider = new BetterSqlite3Provider();
+ dbProvider.loadFromFile(DOCUMENT_PATH, config.General.readOnly);
+
+ initializeCore({
+ dbConfig: {
+ provider: dbProvider,
+ isReadOnly: config.General.readOnly,
+ async onTransactionCommit() {
+ const ws = (await import("./services/ws.js")).default;
+ ws.sendTransactionEntityChangesToAllClients();
+ },
+ async onTransactionRollback() {
+ const cls = (await import("./services/cls.js")).default;
+ const becca_loader = (await import("@triliumnext/core")).becca_loader;
+ const entity_changes = (await import("./services/entity_changes.js")).default;
+ const log = (await import("./services/log")).default;
+
+ const entityChangeIds = cls.getAndClearEntityChangeIds();
+
+ if (entityChangeIds.length > 0) {
+ log.info("Transaction rollback dirtied the becca, forcing reload.");
+
+ becca_loader.load();
+ }
+
+ // the maxEntityChangeId has been incremented during failed transaction, need to recalculate
+ entity_changes.recalculateMaxEntityChangeId();
+ }
+ },
+ crypto: new NodejsCryptoProvider(),
+ executionContext: new ClsHookedExecutionContext()
+ });
+ const { initializeTranslations } = (await import("./services/i18n.js"));
await initializeTranslations();
const startTriliumServer = (await import("./www.js")).default;
await startTriliumServer();
diff --git a/apps/server/src/migrations/0220__migrate_images_to_attachments.ts b/apps/server/src/migrations/0220__migrate_images_to_attachments.ts
index 9e06644c3b..f20e21cb8a 100644
--- a/apps/server/src/migrations/0220__migrate_images_to_attachments.ts
+++ b/apps/server/src/migrations/0220__migrate_images_to_attachments.ts
@@ -1,5 +1,6 @@
+import { becca_loader } from "@triliumnext/core";
+
import becca from "../becca/becca.js";
-import becca_loader from "../becca/becca_loader.js";
import cls from "../services/cls.js";
import log from "../services/log.js";
import sql from "../services/sql.js";
diff --git a/apps/server/src/migrations/0233__migrate_geo_map_to_collection.ts b/apps/server/src/migrations/0233__migrate_geo_map_to_collection.ts
index 7bcf55ebe3..ccd32b1b0d 100644
--- a/apps/server/src/migrations/0233__migrate_geo_map_to_collection.ts
+++ b/apps/server/src/migrations/0233__migrate_geo_map_to_collection.ts
@@ -1,5 +1,6 @@
+import { becca_loader } from "@triliumnext/core";
+
import becca from "../becca/becca";
-import becca_loader from "../becca/becca_loader";
import cls from "../services/cls.js";
import hidden_subtree from "../services/hidden_subtree";
diff --git a/apps/server/src/routes/api/attachments.ts b/apps/server/src/routes/api/attachments.ts
index b2c877fcb3..9e409e6f9f 100644
--- a/apps/server/src/routes/api/attachments.ts
+++ b/apps/server/src/routes/api/attachments.ts
@@ -1,9 +1,9 @@
-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";
+import { blob as blobService, ValidationError } from "@triliumnext/core";
+import type { Request } from "express";
+
+import becca from "../../becca/becca.js";
+import imageService from "../../services/image.js";
function getAttachmentBlob(req: Request) {
const preview = req.query.preview === "true";
@@ -34,7 +34,7 @@ function getAllAttachments(req: Request) {
function saveAttachment(req: Request) {
const { noteId } = req.params;
const { attachmentId, role, mime, title, content } = req.body;
- const matchByQuery = req.query.matchBy
+ const matchByQuery = req.query.matchBy;
const isValidMatchBy = (typeof matchByQuery === "string") && (matchByQuery === "attachmentId" || matchByQuery === "title");
const matchBy = isValidMatchBy ? matchByQuery : undefined;
diff --git a/apps/server/src/routes/api/attributes.ts b/apps/server/src/routes/api/attributes.ts
index 55c3e3e298..3b0ac83269 100644
--- a/apps/server/src/routes/api/attributes.ts
+++ b/apps/server/src/routes/api/attributes.ts
@@ -1,13 +1,12 @@
-"use strict";
-
-import sql from "../../services/sql.js";
-import log from "../../services/log.js";
-import attributeService from "../../services/attributes.js";
-import BAttribute from "../../becca/entities/battribute.js";
-import becca from "../../becca/becca.js";
-import ValidationError from "../../errors/validation_error.js";
-import type { Request } from "express";
import { UpdateAttributeResponse } from "@triliumnext/commons";
+import { ValidationError } from "@triliumnext/core";
+import type { Request } from "express";
+
+import becca from "../../becca/becca.js";
+import BAttribute from "../../becca/entities/battribute.js";
+import attributeService from "../../services/attributes.js";
+import log from "../../services/log.js";
+import sql from "../../services/sql.js";
function getEffectiveNoteAttributes(req: Request) {
const note = becca.getNote(req.params.noteId);
@@ -47,7 +46,7 @@ function updateNoteAttribute(req: Request) {
}
attribute = new BAttribute({
- noteId: noteId,
+ noteId,
name: body.name,
type: body.type,
isInheritable: body.isInheritable
@@ -208,7 +207,7 @@ function createRelation(req: Request) {
if (!attribute) {
attribute = new BAttribute({
noteId: sourceNoteId,
- name: name,
+ name,
type: "relation",
value: targetNoteId
}).save();
diff --git a/apps/server/src/routes/api/autocomplete.ts b/apps/server/src/routes/api/autocomplete.ts
index 9915f58cb1..8b75dd7a6a 100644
--- a/apps/server/src/routes/api/autocomplete.ts
+++ b/apps/server/src/routes/api/autocomplete.ts
@@ -1,8 +1,7 @@
+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 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";
@@ -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/branches.ts b/apps/server/src/routes/api/branches.ts
index 73ce03a7a8..26f030e471 100644
--- a/apps/server/src/routes/api/branches.ts
+++ b/apps/server/src/routes/api/branches.ts
@@ -1,18 +1,15 @@
-"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 { 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 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 +253,7 @@ function deleteBranch(req: Request) {
}
return {
- noteDeleted: noteDeleted
+ noteDeleted
};
}
@@ -272,7 +269,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/clipper.ts b/apps/server/src/routes/api/clipper.ts
index 133c35a88e..fe2486059b 100644
--- a/apps/server/src/routes/api/clipper.ts
+++ b/apps/server/src/routes/api/clipper.ts
@@ -1,16 +1,15 @@
+import { sanitize, 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";
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,13 +31,13 @@ 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) {
clippingNote = noteService.createNewNote({
parentNoteId: clipperInbox.noteId,
- title: title,
+ title,
content: "",
type: "text"
}).note;
@@ -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"
};
- } else {
- return {
- result: "open-in-browser"
- };
}
+ return {
+ result: "open-in-browser"
+ };
+
}
function handshake() {
diff --git a/apps/server/src/routes/api/database.ts b/apps/server/src/routes/api/database.ts
index c29f6e9aaa..2fe001a0b5 100644
--- a/apps/server/src/routes/api/database.ts
+++ b/apps/server/src/routes/api/database.ts
@@ -1,15 +1,13 @@
-"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 { becca_loader, ValidationError } from "@triliumnext/core";
+import type { Request } from "express";
+
+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/export.ts b/apps/server/src/routes/api/export.ts
index 944eee841c..fc328b444b 100644
--- a/apps/server/src/routes/api/export.ts
+++ b/apps/server/src/routes/api/export.ts
@@ -1,14 +1,12 @@
-"use strict";
-
-import zipExportService from "../../services/export/zip.js";
-import singleExportService from "../../services/export/single.js";
-import opmlExportService from "../../services/export/opml.js";
-import becca from "../../becca/becca.js";
-import TaskContext from "../../services/task_context.js";
-import log from "../../services/log.js";
-import NotFoundError from "../../errors/not_found_error.js";
+import { NotFoundError, ValidationError } from "@triliumnext/core";
import type { Request, Response } from "express";
-import ValidationError from "../../errors/validation_error.js";
+
+import becca from "../../becca/becca.js";
+import opmlExportService from "../../services/export/opml.js";
+import singleExportService from "../../services/export/single.js";
+import zipExportService from "../../services/export/zip.js";
+import log from "../../services/log.js";
+import TaskContext from "../../services/task_context.js";
import { safeExtractMessageAndStackFromError } from "../../services/utils.js";
function exportBranch(req: Request, res: Response) {
diff --git a/apps/server/src/routes/api/files.ts b/apps/server/src/routes/api/files.ts
index 4a6e17382b..fbfa63a0d0 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";
@@ -123,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");
}
@@ -170,7 +168,7 @@ function saveAttachmentToTmpDir(req: Request) {
const createdTemporaryFiles = new Set();
-function saveToTmpDir(fileName: string, content: string | Buffer, entityType: string, entityId: string) {
+function saveToTmpDir(fileName: string, content: string | Uint8Array, entityType: string, entityId: string) {
const tmpObj = tmp.fileSync({
postfix: fileName,
tmpdir: dataDirs.TMP_DIR
diff --git a/apps/server/src/routes/api/image.ts b/apps/server/src/routes/api/image.ts
index aa24e7aae9..ec7228d7b9 100644
--- a/apps/server/src/routes/api/image.ts
+++ b/apps/server/src/routes/api/image.ts
@@ -1,11 +1,12 @@
-"use strict";
-import imageService from "../../services/image.js";
-import becca from "../../becca/becca.js";
-import fs from "fs";
+
import type { Request, Response } from "express";
+import fs from "fs";
+
+import becca from "../../becca/becca.js";
import type BNote from "../../becca/entities/bnote.js";
import type BRevision from "../../becca/entities/brevision.js";
+import imageService from "../../services/image.js";
import { RESOURCE_DIR } from "../../services/resource_dir.js";
function returnImageFromNote(req: Request, res: Response) {
@@ -42,7 +43,7 @@ function returnImageInt(image: BNote | BRevision | null, res: Response) {
}
export function renderSvgAttachment(image: BNote | BRevision, res: Response, attachmentName: string) {
- let svg: string | Buffer = ``;
+ let svg: string | Uint8Array = ``;
const attachment = image.getAttachmentByTitle(attachmentName);
if (attachment) {
diff --git a/apps/server/src/routes/api/import.ts b/apps/server/src/routes/api/import.ts
index 273dc1e1da..a37124bbbb 100644
--- a/apps/server/src/routes/api/import.ts
+++ b/apps/server/src/routes/api/import.ts
@@ -1,18 +1,16 @@
-"use strict";
+import { becca_loader,ValidationError } from "@triliumnext/core";
+import type { Request } from "express";
+import path from "path";
+import becca from "../../becca/becca.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 zipImportService from "../../services/import/zip.js";
import singleImportService from "../../services/import/single.js";
-import cls from "../../services/cls.js";
-import path from "path";
-import becca from "../../becca/becca.js";
-import beccaLoader from "../../becca/becca_loader.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 +86,7 @@ async function importNotesToBranch(req: Request) {
setTimeout(
() =>
taskContext.taskSucceeded({
- parentNoteId: parentNoteId,
+ parentNoteId,
importedNoteId: note?.noteId
}),
1000
@@ -96,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();
}
@@ -138,7 +136,7 @@ function importAttachmentsToNote(req: Request) {
setTimeout(
() =>
taskContext.taskSucceeded({
- parentNoteId: parentNoteId
+ parentNoteId
}),
1000
);
diff --git a/apps/server/src/routes/api/login.ts b/apps/server/src/routes/api/login.ts
index 22c0e6ab0f..6106fc52d2 100644
--- a/apps/server/src/routes/api/login.ts
+++ b/apps/server/src/routes/api/login.ts
@@ -1,20 +1,18 @@
-"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, getInstanceId } 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 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 +113,7 @@ function loginSync(req: Request) {
req.session.loggedIn = true;
return {
- instanceId: instanceId,
+ instanceId: getInstanceId(),
maxEntityChangeId: sql.getValue("SELECT COALESCE(MAX(id), 0) FROM entity_changes WHERE isSynced = 1")
};
}
diff --git a/apps/server/src/routes/api/notes.ts b/apps/server/src/routes/api/notes.ts
index 3c6db40549..cb5de84780 100644
--- a/apps/server/src/routes/api/notes.ts
+++ b/apps/server/src/routes/api/notes.ts
@@ -1,18 +1,15 @@
-"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 { 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 log from "../../services/log.js";
+import noteService from "../../services/notes.js";
+import sql from "../../services/sql.js";
+import TaskContext from "../../services/task_context.js";
+import treeService from "../../services/tree.js";
+import utils from "../../services/utils.js";
/**
* @swagger
diff --git a/apps/server/src/routes/api/options.ts b/apps/server/src/routes/api/options.ts
index e7377bdfdb..123c048043 100644
--- a/apps/server/src/routes/api/options.ts
+++ b/apps/server/src/routes/api/options.ts
@@ -1,9 +1,9 @@
import type { OptionNames } from "@triliumnext/commons";
+import { ValidationError } from "@triliumnext/core";
import type { Request } from "express";
-import ValidationError from "../../errors/validation_error.js";
import config from "../../services/config.js";
import { changeLanguage, getLocales } from "../../services/i18n.js";
import log from "../../services/log.js";
diff --git a/apps/server/src/routes/api/password.ts b/apps/server/src/routes/api/password.ts
index 8a9c93940f..959357893a 100644
--- a/apps/server/src/routes/api/password.ts
+++ b/apps/server/src/routes/api/password.ts
@@ -1,16 +1,15 @@
-"use strict";
+import { ChangePasswordResponse } from "@triliumnext/commons";
+import { ValidationError } from "@triliumnext/core";
+import type { Request } from "express";
import passwordService from "../../services/encryption/password.js";
-import ValidationError from "../../errors/validation_error.js";
-import type { Request } from "express";
-import { ChangePasswordResponse } from "@triliumnext/commons";
function changePassword(req: Request): ChangePasswordResponse {
if (passwordService.isPasswordSet()) {
return passwordService.changePassword(req.body.current_password, req.body.new_password);
- } else {
- return passwordService.setPassword(req.body.new_password);
}
+ return passwordService.setPassword(req.body.new_password);
+
}
function resetPassword(req: Request) {
diff --git a/apps/server/src/routes/api/revisions.ts b/apps/server/src/routes/api/revisions.ts
index 9700e7f782..afa5a3c39b 100644
--- a/apps/server/src/routes/api/revisions.ts
+++ b/apps/server/src/routes/api/revisions.ts
@@ -1,18 +1,14 @@
-"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 { EditedNotesResponse, RevisionItem, RevisionPojo } from "@triliumnext/commons";
+import { becca_service, binary_utils, blob as blobService, erase as eraseService, NotePojo } from "@triliumnext/core";
import type { Request, Response } from "express";
-import type BRevision from "../../becca/entities/brevision.js";
+import path from "path";
+
+import becca from "../../becca/becca.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 type BRevision from "../../becca/entities/brevision.js";
+import cls from "../../services/cls.js";
+import sql from "../../services/sql.js";
+import utils from "../../services/utils.js";
interface NotePath {
noteId: string;
@@ -56,7 +52,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);
}
}
@@ -166,7 +162,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 +187,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 +200,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 cbd5845299..d406524ed5 100644
--- a/apps/server/src/routes/api/search.ts
+++ b/apps/server/src/routes/api/search.ts
@@ -1,17 +1,14 @@
-"use strict";
-
+import { becca_service,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 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);
@@ -72,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,
@@ -82,7 +79,7 @@ function quickSearch(req: Request) {
highlightedContentSnippet: result.highlightedContentSnippet,
attributeSnippet: result.attributeSnippet,
highlightedAttributeSnippet: result.highlightedAttributeSnippet,
- icon: icon
+ icon
};
});
@@ -90,7 +87,7 @@ function quickSearch(req: Request) {
return {
searchResultNoteIds: resultNoteIds,
- searchResults: searchResults,
+ searchResults,
error: searchContext.getError()
};
}
diff --git a/apps/server/src/routes/api/sender.ts b/apps/server/src/routes/api/sender.ts
index efdf6817a5..a4c8348464 100644
--- a/apps/server/src/routes/api/sender.ts
+++ b/apps/server/src/routes/api/sender.ts
@@ -1,9 +1,9 @@
+import { utils } from "@triliumnext/core";
import type { Request } from "express";
import imageType from "image-type";
import imageService from "../../services/image.js";
import noteService from "../../services/notes.js";
-import sanitizeAttributeName from "../../services/sanitize_attribute_name.js";
import specialNotesService from "../../services/special_notes.js";
async function uploadImage(req: Request) {
@@ -43,14 +43,14 @@ async function uploadImage(req: Request) {
const labels = JSON.parse(labelsStr);
for (const { name, value } of labels) {
- note.setLabel(sanitizeAttributeName(name), value);
+ note.setLabel(utils.sanitizeAttributeName(name), value);
}
}
note.setLabel("sentFromSender");
return {
- noteId: noteId
+ noteId
};
}
@@ -72,7 +72,7 @@ async function saveNote(req: Request) {
if (req.body.labels) {
for (const { name, value } of req.body.labels) {
- note.setLabel(sanitizeAttributeName(name), value);
+ note.setLabel(utils.sanitizeAttributeName(name), value);
}
}
diff --git a/apps/server/src/routes/api/similar_notes.ts b/apps/server/src/routes/api/similar_notes.ts
index 6b9cbb9261..afa845b102 100644
--- a/apps/server/src/routes/api/similar_notes.ts
+++ b/apps/server/src/routes/api/similar_notes.ts
@@ -1,17 +1,15 @@
-"use strict";
-
+import { SimilarNoteResponse } from "@triliumnext/commons";
+import { similarity } from "@triliumnext/core";
import type { Request } from "express";
-import similarityService from "../../becca/similarity.js";
import becca from "../../becca/becca.js";
-import { SimilarNoteResponse } from "@triliumnext/commons";
async function getSimilarNotes(req: Request) {
const noteId = req.params.noteId;
const _note = becca.getNoteOrThrow(noteId);
- return (await similarityService.findSimilarNotes(noteId) satisfies SimilarNoteResponse);
+ return (await similarity.findSimilarNotes(noteId) satisfies SimilarNoteResponse);
}
export default {
diff --git a/apps/server/src/routes/api/sql.ts b/apps/server/src/routes/api/sql.ts
index 33cd61b5e7..71c404a9d0 100644
--- a/apps/server/src/routes/api/sql.ts
+++ b/apps/server/src/routes/api/sql.ts
@@ -1,9 +1,8 @@
-"use strict";
-
-import sql from "../../services/sql.js";
-import becca from "../../becca/becca.js";
+import { ValidationError } from "@triliumnext/core";
import type { Request } from "express";
-import ValidationError from "../../errors/validation_error.js";
+
+import becca from "../../becca/becca.js";
+import sql from "../../services/sql.js";
import { safeExtractMessageAndStackFromError } from "../../services/utils.js";
interface Table {
diff --git a/apps/server/src/routes/api/sync.ts b/apps/server/src/routes/api/sync.ts
index 5e1c53041c..da55e4ba4b 100644
--- a/apps/server/src/routes/api/sync.ts
+++ b/apps/server/src/routes/api/sync.ts
@@ -1,21 +1,20 @@
-"use strict";
+import { type EntityChange,SyncTestResponse } from "@triliumnext/commons";
+import { ValidationError } from "@triliumnext/core";
+import type { Request } from "express";
+import { t } from "i18next";
-import syncService from "../../services/sync.js";
-import syncUpdateService from "../../services/sync_update.js";
+import consistencyChecksService from "../../services/consistency_checks.js";
+import contentHashService from "../../services/content_hash.js";
import entityChangesService from "../../services/entity_changes.js";
+import log from "../../services/log.js";
+import optionService from "../../services/options.js";
import sql from "../../services/sql.js";
import sqlInit from "../../services/sql_init.js";
-import optionService from "../../services/options.js";
-import contentHashService from "../../services/content_hash.js";
-import log from "../../services/log.js";
+import syncService from "../../services/sync.js";
import syncOptions from "../../services/sync_options.js";
+import syncUpdateService from "../../services/sync_update.js";
import utils, { safeExtractMessageAndStackFromError } from "../../services/utils.js";
import ws from "../../services/ws.js";
-import type { Request } from "express";
-import ValidationError from "../../errors/validation_error.js";
-import consistencyChecksService from "../../services/consistency_checks.js";
-import { t } from "i18next";
-import { SyncTestResponse, type EntityChange } from "@triliumnext/commons";
async function testSync(): Promise {
try {
@@ -287,10 +286,10 @@ function update(req: Request) {
if (pageIndex !== pageCount - 1) {
return;
- } else {
- body = JSON.parse(partialRequests[requestId].payload);
- delete partialRequests[requestId];
- }
+ }
+ body = JSON.parse(partialRequests[requestId].payload);
+ delete partialRequests[requestId];
+
}
const { entities, instanceId } = body;
diff --git a/apps/server/src/routes/api/tree.ts b/apps/server/src/routes/api/tree.ts
index b9621d5d02..6c1c3c6844 100644
--- a/apps/server/src/routes/api/tree.ts
+++ b/apps/server/src/routes/api/tree.ts
@@ -1,11 +1,10 @@
-"use strict";
+import type { AttributeRow, BranchRow, NoteRow } from "@triliumnext/commons";
+import { NotFoundError } from "@triliumnext/core";
+import type { Request } from "express";
import becca from "../../becca/becca.js";
-import log from "../../services/log.js";
-import NotFoundError from "../../errors/not_found_error.js";
-import type { Request } from "express";
import type BNote from "../../becca/entities/bnote.js";
-import type { AttributeRow, BranchRow, NoteRow } from "@triliumnext/commons";
+import log from "../../services/log.js";
function getNotesAndBranchesAndAttributes(_noteIds: string[] | Set) {
const noteIds = new Set(_noteIds);
diff --git a/apps/server/src/routes/custom.ts b/apps/server/src/routes/custom.ts
index b27a710f2e..5498ef6d06 100644
--- a/apps/server/src/routes/custom.ts
+++ b/apps/server/src/routes/custom.ts
@@ -1,11 +1,13 @@
-import log from "../services/log.js";
-import fileService from "./api/files.js";
-import scriptService from "../services/script.js";
-import cls from "../services/cls.js";
-import sql from "../services/sql.js";
-import becca from "../becca/becca.js";
import type { Request, Response, Router } from "express";
-import { safeExtractMessageAndStackFromError, normalizeCustomHandlerPattern } from "../services/utils.js";
+
+import becca from "../becca/becca.js";
+import { namespace } from "../cls_provider.js";
+import cls from "../services/cls.js";
+import log from "../services/log.js";
+import scriptService from "../services/script.js";
+import sql from "../services/sql.js";
+import { normalizeCustomHandlerPattern,safeExtractMessageAndStackFromError } from "../services/utils.js";
+import fileService from "./api/files.js";
function handleRequest(req: Request, res: Response) {
@@ -27,7 +29,7 @@ function handleRequest(req: Request, res: Response) {
// splitPath.map(segment => encodeURIComponent(segment)).join("/")
// might be safer
- const path = splitPath.join("/")
+ const path = splitPath.join("/");
const attributeIds = sql.getColumn("SELECT attributeId FROM attributes WHERE isDeleted = 0 AND type = 'label' AND name IN ('customRequestHandler', 'customResourceProvider')");
@@ -96,8 +98,8 @@ function register(router: Router) {
// explicitly no CSRF middleware since it's meant to allow integration from external services
router.all("/custom/*path", (req: Request, res: Response, _next) => {
- cls.namespace.bindEmitter(req);
- cls.namespace.bindEmitter(res);
+ namespace.bindEmitter(req);
+ namespace.bindEmitter(res);
cls.init(() => handleRequest(req, res));
});
diff --git a/apps/server/src/routes/error_handlers.ts b/apps/server/src/routes/error_handlers.ts
index 05b05f6a4a..146d28fbee 100644
--- a/apps/server/src/routes/error_handlers.ts
+++ b/apps/server/src/routes/error_handlers.ts
@@ -1,8 +1,7 @@
+import { ForbiddenError, HttpError, NotFoundError } from "@triliumnext/core";
import type { Application, NextFunction, Request, Response } from "express";
+
import log from "../services/log.js";
-import NotFoundError from "../errors/not_found_error.js";
-import ForbiddenError from "../errors/forbidden_error.js";
-import HttpError from "../errors/http_error.js";
function register(app: Application) {
diff --git a/apps/server/src/routes/login.ts b/apps/server/src/routes/login.ts
index 2a505a9938..bcb81ad85e 100644
--- a/apps/server/src/routes/login.ts
+++ b/apps/server/src/routes/login.ts
@@ -1,18 +1,19 @@
+import { ValidationError } from "@triliumnext/core";
import crypto from "crypto";
-import utils from "../services/utils.js";
-import optionService from "../services/options.js";
-import myScryptService from "../services/encryption/my_scrypt.js";
-import log from "../services/log.js";
-import passwordService from "../services/encryption/password.js";
-import assetPath, { assetUrlFragment } from "../services/asset_path.js";
-import appPath from "../services/app_path.js";
-import ValidationError from "../errors/validation_error.js";
import type { Request, Response } from 'express';
-import totp from '../services/totp.js';
-import recoveryCodeService from '../services/encryption/recovery_codes.js';
-import openID from '../services/open_id.js';
+
+import appPath from "../services/app_path.js";
+import assetPath, { assetUrlFragment } from "../services/asset_path.js";
+import myScryptService from "../services/encryption/my_scrypt.js";
import openIDEncryption from '../services/encryption/open_id_encryption.js';
+import passwordService from "../services/encryption/password.js";
+import recoveryCodeService from '../services/encryption/recovery_codes.js';
import { getCurrentLocale } from "../services/i18n.js";
+import log from "../services/log.js";
+import openID from '../services/open_id.js';
+import optionService from "../services/options.js";
+import totp from '../services/totp.js';
+import utils from "../services/utils.js";
function loginPage(req: Request, res: Response) {
// Login page is triggered twice. Once here, and another time (see sendLoginError) if the password is failed.
@@ -23,9 +24,9 @@ function loginPage(req: Request, res: Response) {
ssoEnabled: openID.isOpenIDEnabled(),
ssoIssuerName: openID.getSSOIssuerName(),
ssoIssuerIcon: openID.getSSOIssuerIcon(),
- assetPath: assetPath,
+ assetPath,
assetPathFragment: assetUrlFragment,
- appPath: appPath,
+ appPath,
currentLocale: getCurrentLocale()
});
}
@@ -181,9 +182,9 @@ function sendLoginError(req: Request, res: Response, errorType: 'password' | 'to
wrongTotp: errorType === 'totp',
totpEnabled: totp.isTotpEnabled(),
ssoEnabled: openID.isOpenIDEnabled(),
- assetPath: assetPath,
+ assetPath,
assetPathFragment: assetUrlFragment,
- appPath: appPath,
+ appPath,
currentLocale: getCurrentLocale()
});
}
diff --git a/apps/server/src/routes/route_api.ts b/apps/server/src/routes/route_api.ts
index 1b4ea48f24..dc11dfb417 100644
--- a/apps/server/src/routes/route_api.ts
+++ b/apps/server/src/routes/route_api.ts
@@ -1,15 +1,15 @@
+import { AbstractBeccaEntity,NotFoundError, ValidationError } from "@triliumnext/core";
import express, { type RequestHandler } from "express";
import multer from "multer";
-import log from "../services/log.js";
-import cls from "../services/cls.js";
-import sql from "../services/sql.js";
-import entityChangesService from "../services/entity_changes.js";
-import AbstractBeccaEntity from "../becca/entities/abstract_becca_entity.js";
-import NotFoundError from "../errors/not_found_error.js";
-import ValidationError from "../errors/validation_error.js";
+
+import { namespace } from "../cls_provider.js";
import auth from "../services/auth.js";
-import { doubleCsrfProtection as csrfMiddleware } from "./csrf_protection.js";
+import cls from "../services/cls.js";
+import entityChangesService from "../services/entity_changes.js";
+import log from "../services/log.js";
+import sql from "../services/sql.js";
import { safeExtractMessageAndStackFromError } from "../services/utils.js";
+import { doubleCsrfProtection as csrfMiddleware } from "./csrf_protection.js";
const MAX_ALLOWED_FILE_SIZE_MB = 250;
export const router = express.Router();
@@ -67,9 +67,9 @@ export function apiResultHandler(req: express.Request, res: express.Response, re
return send(res, statusCode, response);
} else if (result === undefined) {
return send(res, 204, "");
- } else {
- return send(res, 200, result);
}
+ return send(res, 200, result);
+
}
function send(res: express.Response, statusCode: number, response: unknown) {
@@ -81,14 +81,14 @@ function send(res: express.Response, statusCode: number, response: unknown) {
res.status(statusCode).send(response);
return response.length;
- } else {
- const json = JSON.stringify(response);
-
- res.setHeader("Content-Type", "application/json");
- res.status(statusCode).send(json);
-
- return json.length;
}
+ const json = JSON.stringify(response);
+
+ res.setHeader("Content-Type", "application/json");
+ res.status(statusCode).send(json);
+
+ return json.length;
+
}
export function apiRoute(method: HttpMethod, path: string, routeHandler: SyncRouteRequestHandler) {
@@ -112,8 +112,8 @@ function internalRoute(method: HttpMethod, path: string, middleware: express.Han
const start = Date.now();
try {
- cls.namespace.bindEmitter(req);
- cls.namespace.bindEmitter(res);
+ namespace.bindEmitter(req);
+ namespace.bindEmitter(res);
const result = cls.init(() => {
cls.set("componentId", req.headers["trilium-component-id"]);
@@ -193,7 +193,7 @@ export function createUploadMiddleware(): RequestHandler {
const uploadMiddleware = createUploadMiddleware();
export const uploadMiddlewareWithErrorHandling = function (req: express.Request, res: express.Response, next: express.NextFunction) {
- uploadMiddleware(req, res, function (err) {
+ uploadMiddleware(req, res, (err) => {
if (err?.code === "LIMIT_FILE_SIZE") {
res.setHeader("Content-Type", "text/plain").status(400).send(`Cannot upload file because it excceeded max allowed file size of ${MAX_ALLOWED_FILE_SIZE_MB} MiB`);
} else {
diff --git a/apps/server/src/services/app_info.ts b/apps/server/src/services/app_info.ts
index 2837e8de79..497bbb102c 100644
--- a/apps/server/src/services/app_info.ts
+++ b/apps/server/src/services/app_info.ts
@@ -1,21 +1,11 @@
-import path from "path";
-import build from "./build.js";
-import packageJson from "../../package.json" with { type: "json" };
-import dataDir from "./data_dir.js";
import { AppInfo } from "@triliumnext/commons";
+import { app_info as coreAppInfo } from "@triliumnext/core";
+import path from "path";
-const APP_DB_VERSION = 233;
-const SYNC_VERSION = 36;
-const CLIPPER_PROTOCOL_VERSION = "1.0";
+import dataDir from "./data_dir.js";
export default {
- appVersion: packageJson.version,
- dbVersion: APP_DB_VERSION,
+ ...coreAppInfo,
nodeVersion: process.version,
- syncVersion: SYNC_VERSION,
- buildDate: build.buildDate,
- buildRevision: build.buildRevision,
dataDirectory: path.resolve(dataDir.TRILIUM_DATA_DIR),
- clipperProtocolVersion: CLIPPER_PROTOCOL_VERSION,
- utcDateTime: new Date().toISOString()
} satisfies AppInfo;
diff --git a/apps/server/src/services/backend_script_api.ts b/apps/server/src/services/backend_script_api.ts
index b322926efc..5d1d86331f 100644
--- a/apps/server/src/services/backend_script_api.ts
+++ b/apps/server/src/services/backend_script_api.ts
@@ -1,41 +1,38 @@
+import { type AttributeRow, dayjs, formatLogMessage } from "@triliumnext/commons";
+import { type AbstractBeccaEntity, Becca, NoteParams } from "@triliumnext/core";
+import axios from "axios";
+import * as cheerio from "cheerio";
+import xml2js from "xml2js";
+
+import becca from "../becca/becca.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 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 { dayjs } from "@triliumnext/commons";
-import xml2js from "xml2js";
-import * as cheerio from "cheerio";
-import cloningService from "./cloning.js";
-import appInfo from "./app_info.js";
-import searchService from "./search/services/search.js";
+import optionsService from "./options.js";
import SearchContext from "./search/search_context.js";
-import becca from "../becca/becca.js";
-import ws from "./ws.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 +503,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 +617,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 +677,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 +693,9 @@ function BackendScriptApi(this: Api, currentNote: BNote, apiParams: ApiParams) {
return params.map((p) => {
if (typeof p === "function") {
return `!@#Function: ${p.toString()}`;
- } else {
- return p;
}
+ return p;
+
});
}
};
diff --git a/apps/server/src/services/backend_script_api_interface.ts b/apps/server/src/services/backend_script_api_interface.ts
index 4dce6e6a43..c7729ba669 100644
--- a/apps/server/src/services/backend_script_api_interface.ts
+++ b/apps/server/src/services/backend_script_api_interface.ts
@@ -1,5 +1,6 @@
+import type { AbstractBeccaEntity } from "@triliumnext/core";
import type { Request, Response } from "express";
-import type AbstractBeccaEntity from "../becca/entities/abstract_becca_entity.js";
+
import type BNote from "../becca/entities/bnote.js";
export interface ApiParams {
diff --git a/apps/server/src/services/blob-interface.ts b/apps/server/src/services/blob-interface.ts
deleted file mode 100644
index a0e6052785..0000000000
--- a/apps/server/src/services/blob-interface.ts
+++ /dev/null
@@ -1,5 +0,0 @@
-export interface Blob {
- blobId: string;
- content: string | Buffer;
- utcDateModified: string;
-}
diff --git a/apps/server/src/services/bulk_actions.ts b/apps/server/src/services/bulk_actions.ts
index 531bd9976e..2c662fba2d 100644
--- a/apps/server/src/services/bulk_actions.ts
+++ b/apps/server/src/services/bulk_actions.ts
@@ -1,11 +1,12 @@
-import log from "./log.js";
-import becca from "../becca/becca.js";
-import cloningService from "./cloning.js";
-import branchService from "./branches.js";
-import { randomString } from "./utils.js";
-import eraseService from "./erase.js";
-import type BNote from "../becca/entities/bnote.js";
import { ActionHandlers, BulkAction, BulkActionData } from "@triliumnext/commons";
+import { erase as eraseService } from "@triliumnext/core";
+
+import becca from "../becca/becca.js";
+import type BNote from "../becca/entities/bnote.js";
+import branchService from "./branches.js";
+import cloningService from "./cloning.js";
+import log from "./log.js";
+import { randomString } from "./utils.js";
type ActionHandler = (action: T, note: BNote) => void;
diff --git a/apps/server/src/services/cloning.ts b/apps/server/src/services/cloning.ts
index fc5552b42c..475ee81bc9 100644
--- a/apps/server/src/services/cloning.ts
+++ b/apps/server/src/services/cloning.ts
@@ -1,190 +1,2 @@
-"use strict";
-
-import sql from "./sql.js";
-import eventChangesService from "./entity_changes.js";
-import treeService from "./tree.js";
-import BBranch from "../becca/entities/bbranch.js";
-import becca from "../becca/becca.js";
-import log from "./log.js";
-import { CloneResponse } from "@triliumnext/commons";
-
-function cloneNoteToParentNote(noteId: string, parentNoteId: string, prefix: string | null = null): CloneResponse {
- if (!(noteId in becca.notes) || !(parentNoteId in becca.notes)) {
- return { success: false, message: "Note cannot be cloned because either the cloned note or the intended parent is deleted." };
- }
-
- const parentNote = becca.getNote(parentNoteId);
- if (!parentNote) {
- return { success: false, message: "Note cannot be cloned because the parent note could not be found." };
- }
-
- if (parentNote.type === "search") {
- return {
- success: false,
- message: "Can't clone into a search note"
- };
- }
-
- const validationResult = treeService.validateParentChild(parentNoteId, noteId);
-
- if (!validationResult.success) {
- return validationResult;
- }
-
- const branch = new BBranch({
- noteId: noteId,
- parentNoteId: parentNoteId,
- prefix: prefix,
- isExpanded: false
- }).save();
-
- log.info(`Cloned note '${noteId}' to a new parent note '${parentNoteId}' with prefix '${prefix}'`);
-
- return {
- success: true,
- branchId: branch.branchId,
- notePath: `${parentNote.getBestNotePathString()}/${noteId}`
- };
-}
-
-function cloneNoteToBranch(noteId: string, parentBranchId: string, prefix?: string) {
- const parentBranch = becca.getBranch(parentBranchId);
-
- if (!parentBranch) {
- return { success: false, message: `Parent branch '${parentBranchId}' does not exist.` };
- }
-
- const ret = cloneNoteToParentNote(noteId, parentBranch.noteId, prefix);
-
- parentBranch.isExpanded = true; // the new target should be expanded, so it immediately shows up to the user
- parentBranch.save();
-
- return ret;
-}
-
-function ensureNoteIsPresentInParent(noteId: string, parentNoteId: string, prefix?: string) {
- if (!(noteId in becca.notes)) {
- return { branch: null, success: false, message: `Note '${noteId}' is deleted.` };
- } else if (!(parentNoteId in becca.notes)) {
- return { branch: null, success: false, message: `Note '${parentNoteId}' is deleted.` };
- }
-
- const parentNote = becca.getNote(parentNoteId);
-
- if (!parentNote) {
- return { branch: null, success: false, message: "Can't find parent note." };
- }
- if (parentNote.type === "search") {
- return { branch: null, success: false, message: "Can't clone into a search note" };
- }
-
- const validationResult = treeService.validateParentChild(parentNoteId, noteId);
-
- if (!validationResult.success) {
- return validationResult;
- }
-
- const branch = new BBranch({
- noteId: noteId,
- parentNoteId: parentNoteId,
- prefix: prefix,
- isExpanded: false
- }).save();
-
- log.info(`Ensured note '${noteId}' is in parent note '${parentNoteId}' with prefix '${branch.prefix}'`);
-
- return { branch: branch, success: true };
-}
-
-function ensureNoteIsAbsentFromParent(noteId: string, parentNoteId: string) {
- const branchId = sql.getValue(/*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/apps/server/src/services/cls.ts b/apps/server/src/services/cls.ts
index 7636be7dd0..a9c91ede94 100644
--- a/apps/server/src/services/cls.ts
+++ b/apps/server/src/services/cls.ts
@@ -1,109 +1,79 @@
-import clsHooked from "cls-hooked";
import type { EntityChange } from "@triliumnext/commons";
-const namespace = clsHooked.createNamespace("trilium");
+import { cls } from "@triliumnext/core";
-type Callback = (...args: any[]) => any;
-
-function init(callback: Callback) {
- return namespace.runAndReturn(callback);
-}
-
-function wrap(callback: Callback) {
- return () => {
- try {
- init(callback);
- } catch (e: any) {
- console.log(`Error occurred: ${e.message}: ${e.stack}`);
- }
- };
-}
-
-function get(key: string) {
- return namespace.get(key);
-}
-
-function set(key: string, value: any) {
- namespace.set(key, value);
+function init(callback: () => T) {
+ return cls.getContext().init(callback);
}
function getHoistedNoteId() {
- return namespace.get("hoistedNoteId") || "root";
+ return cls.getHoistedNoteId();
}
function getComponentId() {
- return namespace.get("componentId");
-}
-
-function getLocalNowDateTime() {
- return namespace.get("localNowDateTime");
+ return cls.getComponentId();
}
+/** @deprecated */
function disableEntityEvents() {
- namespace.set("disableEntityEvents", true);
+ cls.disableEntityEvents();
}
+/** @deprecated */
function enableEntityEvents() {
- namespace.set("disableEntityEvents", false);
+ cls.enableEntityEvents();
}
function isEntityEventsDisabled() {
- return !!namespace.get("disableEntityEvents");
+ return cls.isEntityEventsDisabled();
}
+/** @deprecated */
function setMigrationRunning(running: boolean) {
- namespace.set("migrationRunning", !!running);
+ cls.setMigrationRunning(running);
}
+/** @deprecated */
function isMigrationRunning() {
- return !!namespace.get("migrationRunning");
-}
-
-function disableSlowQueryLogging(disable: boolean) {
- namespace.set("disableSlowQueryLogging", disable);
-}
-
-function isSlowQueryLoggingDisabled() {
- return !!namespace.get("disableSlowQueryLogging");
+ return cls.isMigrationRunning();
}
function getAndClearEntityChangeIds() {
- const entityChangeIds = namespace.get("entityChangeIds") || [];
+ const entityChangeIds = cls.getContext().get("entityChangeIds") || [];
- namespace.set("entityChangeIds", []);
+ cls.getContext().set("entityChangeIds", []);
return entityChangeIds;
}
function putEntityChange(entityChange: EntityChange) {
- if (namespace.get("ignoreEntityChangeIds")) {
- return;
- }
-
- const entityChangeIds = namespace.get("entityChangeIds") || [];
-
- // store only ID since the record can be modified (e.g., in erase)
- entityChangeIds.push(entityChange.id);
-
- namespace.set("entityChangeIds", entityChangeIds);
-}
-
-function reset() {
- clsHooked.reset();
+ cls.putEntityChange(entityChange);
}
function ignoreEntityChangeIds() {
- namespace.set("ignoreEntityChangeIds", true);
+ cls.getContext().set("ignoreEntityChangeIds", true);
}
+function get(key: string) {
+ return cls.getContext().get(key);
+}
+
+function set(key: string, value: unknown) {
+ cls.getContext().set(key, value);
+}
+
+function reset() {
+ cls.getContext().reset();
+}
+
+export const wrap = cls.wrap;
+
export default {
init,
wrap,
get,
set,
- namespace,
getHoistedNoteId,
getComponentId,
- getLocalNowDateTime,
disableEntityEvents,
enableEntityEvents,
isEntityEventsDisabled,
@@ -111,8 +81,6 @@ export default {
getAndClearEntityChangeIds,
putEntityChange,
ignoreEntityChangeIds,
- disableSlowQueryLogging,
- isSlowQueryLoggingDisabled,
setMigrationRunning,
isMigrationRunning
};
diff --git a/apps/server/src/services/consistency_checks.ts b/apps/server/src/services/consistency_checks.ts
index 7b4ba72adf..b144d41bfb 100644
--- a/apps/server/src/services/consistency_checks.ts
+++ b/apps/server/src/services/consistency_checks.ts
@@ -1,22 +1,19 @@
-"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, erase as eraseService, utils } from "@triliumnext/core";
+
+import becca from "../becca/becca.js";
+import BBranch from "../becca/entities/bbranch.js";
+import noteTypesService from "../services/note_types.js";
+import { 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 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 +81,11 @@ class ConsistencyChecks {
}
return true;
- } else {
- logError(`Tree cycle detected at parent-child relationship: '${parentNoteId}' - '${noteId}', whole path: '${path}'`);
-
- this.unrecoveredConsistencyErrors = 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);
@@ -186,7 +183,7 @@ class ConsistencyChecks {
if (note.getParentBranches().length === 0) {
const newBranch = new BBranch({
parentNoteId: "root",
- noteId: noteId,
+ noteId,
prefix: "recovered"
}).save();
@@ -349,7 +346,7 @@ class ConsistencyChecks {
if (this.autoFix) {
const branch = new BBranch({
parentNoteId: "root",
- noteId: noteId,
+ noteId,
prefix: "recovered"
}).save();
@@ -485,18 +482,18 @@ 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
});
- const hash = getHash(randomString(10));
+ const hash = utils.hash(randomString(10));
entityChangesService.putEntityChange({
entityName: "blobs",
entityId: blobId,
- hash: hash,
+ hash,
isErased: false,
utcDateChanged: fakeDate,
isSynced: true
@@ -805,7 +802,7 @@ class ConsistencyChecks {
const attrNames = sql.getColumn(/*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) {
@@ -911,7 +908,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/content_hash.ts b/apps/server/src/services/content_hash.ts
index 6c8365e2b6..6b189bbe61 100644
--- a/apps/server/src/services/content_hash.ts
+++ b/apps/server/src/services/content_hash.ts
@@ -1,9 +1,7 @@
-"use strict";
+import { erase as eraseService,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 sql from "./sql.js";
type SectorHash = Record;
@@ -48,7 +46,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/date_utils.ts b/apps/server/src/services/date_utils.ts
index 0cbe69cfaa..0e38fb1eae 100644
--- a/apps/server/src/services/date_utils.ts
+++ b/apps/server/src/services/date_utils.ts
@@ -1,107 +1,2 @@
-import { dayjs } from "@triliumnext/commons";
-import cls from "./cls.js";
-
-const LOCAL_DATETIME_FORMAT = "YYYY-MM-DD HH:mm:ss.SSSZZ";
-const UTC_DATETIME_FORMAT = "YYYY-MM-DD HH:mm:ssZ";
-
-function utcNowDateTime() {
- return utcDateTimeStr(new Date());
-}
-
-// CLS date time is important in web deployments - server often runs in different time zone than user is located in,
-// so we'd prefer client timezone to be used to record local dates. For this reason, requests from clients contain
-// "trilium-local-now-datetime" header which is then stored in CLS
-function localNowDateTime() {
- return cls.getLocalNowDateTime() || dayjs().format(LOCAL_DATETIME_FORMAT);
-}
-
-function localNowDate() {
- const clsDateTime = cls.getLocalNowDateTime();
-
- if (clsDateTime) {
- return clsDateTime.substr(0, 10);
- } else {
- const date = new Date();
-
- return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`;
- }
-}
-
-function pad(num: number) {
- return num <= 9 ? `0${num}` : `${num}`;
-}
-
-function utcDateStr(date: Date) {
- return date.toISOString().split("T")[0];
-}
-
-function utcDateTimeStr(date: Date) {
- return date.toISOString().replace("T", " ");
-}
-
-/**
- * @param str - needs to be in the ISO 8601 format "YYYY-MM-DDTHH:MM:SS.sssZ" format as outputted by dateStr().
- * also is assumed to be GMT time (as indicated by the "Z" at the end), *not* local time
- */
-function parseDateTime(str: string) {
- try {
- return new Date(Date.parse(str));
- } catch (e: any) {
- throw new Error(`Can't parse date from '${str}': ${e.stack}`);
- }
-}
-
-function parseLocalDate(str: string) {
- const datePart = str.substr(0, 10);
-
- // not specifying the timezone and specifying the time means Date.parse() will use the local timezone
- return parseDateTime(`${datePart} 12:00:00.000`);
-}
-
-function getDateTimeForFile() {
- return new Date().toISOString().substr(0, 19).replace(/:/g, "");
-}
-
-function validateLocalDateTime(str: string | null | undefined) {
- if (!str) {
- return;
- }
-
- if (!/[0-9]{4}-[0-9]{2}-[0-9]{2} [0-9]{2}:[0-9]{2}:[0-9]{2}\.[0-9]{3}[+-][0-9]{4}/.test(str)) {
- return `Invalid local date time format in '${str}'. Correct format shoud follow example: '2023-08-21 23:38:51.110+0200'`;
- }
-
- if (!dayjs(str, LOCAL_DATETIME_FORMAT)) {
- return `Date '${str}' appears to be in the correct format, but cannot be parsed. It likely represents an invalid date.`;
- }
-}
-
-function validateUtcDateTime(str: string | undefined) {
- if (!str) {
- return;
- }
-
- if (!/[0-9]{4}-[0-9]{2}-[0-9]{2} [0-9]{2}:[0-9]{2}:[0-9]{2}\.[0-9]{3}Z/.test(str)) {
- return `Invalid UTC date time format in '${str}'. Correct format shoud follow example: '2023-08-21 23:38:51.110Z'`;
- }
-
- if (!dayjs(str, UTC_DATETIME_FORMAT)) {
- return `Date '${str}' appears to be in the correct format, but cannot be parsed. It likely represents an invalid date.`;
- }
-}
-
-export default {
- LOCAL_DATETIME_FORMAT,
- UTC_DATETIME_FORMAT,
- utcNowDateTime,
- localNowDateTime,
- localNowDate,
-
- utcDateStr,
- utcDateTimeStr,
- parseDateTime,
- parseLocalDate,
- getDateTimeForFile,
- validateLocalDateTime,
- validateUtcDateTime
-};
+import { date_utils } from "@triliumnext/core";
+export default date_utils;
diff --git a/apps/server/src/services/encryption/data_encryption.ts b/apps/server/src/services/encryption/data_encryption.ts
deleted file mode 100644
index 250db687b2..0000000000
--- a/apps/server/src/services/encryption/data_encryption.ts
+++ /dev/null
@@ -1,114 +0,0 @@
-import crypto from "crypto";
-import log from "../log.js";
-
-function arraysIdentical(a: any[] | Buffer, b: any[] | Buffer) {
- let i = a.length;
- if (i !== b.length) return false;
- while (i--) {
- if (a[i] !== b[i]) return false;
- }
- return true;
-}
-
-function shaArray(content: crypto.BinaryLike) {
- // we use this as a simple checksum and don't rely on its security, so SHA-1 is good enough
- return crypto.createHash("sha1").update(content).digest();
-}
-
-function pad(data: Buffer): Buffer {
- if (data.length > 16) {
- data = data.slice(0, 16);
- } else if (data.length < 16) {
- const zeros = Array(16 - data.length).fill(0);
-
- data = Buffer.concat([data, Buffer.from(zeros)]);
- }
-
- return Buffer.from(data);
-}
-
-function encrypt(key: Buffer, plainText: Buffer | string) {
- if (!key) {
- throw new Error("No data key!");
- }
-
- const plainTextBuffer = Buffer.isBuffer(plainText) ? plainText : Buffer.from(plainText);
-
- const iv = crypto.randomBytes(16);
- const cipher = crypto.createCipheriv("aes-128-cbc", pad(key), pad(iv));
-
- const digest = shaArray(plainTextBuffer).slice(0, 4);
-
- const digestWithPayload = Buffer.concat([digest, plainTextBuffer]);
-
- const encryptedData = Buffer.concat([cipher.update(digestWithPayload), cipher.final()]);
-
- const encryptedDataWithIv = Buffer.concat([iv, encryptedData]);
-
- return encryptedDataWithIv.toString("base64");
-}
-
-function decrypt(key: Buffer, cipherText: string | Buffer): Buffer | false | null {
- if (cipherText === null) {
- return null;
- }
-
- if (!key) {
- return Buffer.from("[protected]");
- }
-
- try {
- const cipherTextBufferWithIv = Buffer.from(cipherText.toString(), "base64");
-
- // old encrypted data can have IV of length 13, see some details here: https://github.com/zadam/trilium/issues/3017
- const ivLength = cipherTextBufferWithIv.length % 16 === 0 ? 16 : 13;
-
- const iv = cipherTextBufferWithIv.slice(0, ivLength);
-
- const cipherTextBuffer = cipherTextBufferWithIv.slice(ivLength);
-
- const decipher = crypto.createDecipheriv("aes-128-cbc", pad(key), pad(iv));
-
- const decryptedBytes = Buffer.concat([decipher.update(cipherTextBuffer), decipher.final()]);
-
- const digest = decryptedBytes.slice(0, 4);
- const payload = decryptedBytes.slice(4);
-
- const computedDigest = shaArray(payload).slice(0, 4);
-
- if (!arraysIdentical(digest, computedDigest)) {
- return false;
- }
-
- return payload;
- } catch (e: any) {
- // recovery from https://github.com/zadam/trilium/issues/510
- if (e.message?.includes("WRONG_FINAL_BLOCK_LENGTH") || e.message?.includes("wrong final block length")) {
- log.info("Caught WRONG_FINAL_BLOCK_LENGTH, returning cipherText instead");
-
- return (Buffer.isBuffer(cipherText) ? cipherText : Buffer.from(cipherText));
- } else {
- throw e;
- }
- }
-}
-
-function decryptString(dataKey: Buffer, cipherText: string) {
- const buffer = decrypt(dataKey, cipherText);
-
- if (buffer === null) {
- return null;
- } else if (buffer === false) {
- log.error(`Could not decrypt string. Buffer: ${buffer}`);
-
- throw new Error("Could not decrypt string.");
- }
-
- return buffer.toString("utf-8");
-}
-
-export default {
- encrypt,
- decrypt,
- decryptString
-};
diff --git a/apps/server/src/services/encryption/open_id_encryption.ts b/apps/server/src/services/encryption/open_id_encryption.ts
index 5ba14ca829..5126a0cf5c 100644
--- a/apps/server/src/services/encryption/open_id_encryption.ts
+++ b/apps/server/src/services/encryption/open_id_encryption.ts
@@ -1,9 +1,9 @@
-import myScryptService from "./my_scrypt.js";
-import utils, { constantTimeCompare } from "../utils.js";
-import dataEncryptionService from "./data_encryption.js";
+import { data_encryption, OpenIdError } from "@triliumnext/core";
+
import sql from "../sql.js";
import sqlInit from "../sql_init.js";
-import OpenIdError from "../../errors/open_id_error.js";
+import utils, { constantTimeCompare } from "../utils.js";
+import myScryptService from "./my_scrypt.js";
function saveUser(subjectIdentifier: string, name: string, email: string) {
if (isUserSaved()) return false;
@@ -16,7 +16,7 @@ function saveUser(subjectIdentifier: string, name: string, email: string) {
verificationSalt
);
if (!verificationHash) {
- throw new OpenIdError("Verification hash undefined!")
+ throw new OpenIdError("Verification hash undefined!");
}
const userIDEncryptedDataKey = setDataKey(
@@ -35,10 +35,10 @@ function saveUser(subjectIdentifier: string, name: string, email: string) {
userIDVerificationHash: utils.toBase64(verificationHash),
salt: verificationSalt,
derivedKey: derivedKeySalt,
- userIDEncryptedDataKey: userIDEncryptedDataKey,
+ userIDEncryptedDataKey,
isSetup: "true",
username: name,
- email: email
+ email
};
sql.upsert("user_data", "tmpID", data);
@@ -53,7 +53,7 @@ function isSubjectIdentifierSaved() {
function isUserSaved() {
const isSaved = sql.getValue("SELECT isSetup FROM user_data;");
- return isSaved === "true" ? true : false;
+ return isSaved === "true";
}
function verifyOpenIDSubjectIdentifier(subjectIdentifier: string) {
@@ -102,7 +102,7 @@ function setDataKey(
console.error("SOMETHING WENT WRONG SAVING USER ID DERIVED KEY");
return undefined;
}
- const newEncryptedDataKey = dataEncryptionService.encrypt(
+ const newEncryptedDataKey = data_encryption.encrypt(
subjectIdentifierDerivedKey,
plainTextDataKey
);
@@ -127,7 +127,7 @@ function getDataKey(subjectIdentifier: string) {
console.error("SOMETHING WENT WRONG SAVING USER ID DERIVED KEY");
return undefined;
}
- const decryptedDataKey = dataEncryptionService.decrypt(
+ const decryptedDataKey = data_encryption.decrypt(
subjectIdentifierDerivedKey,
encryptedDataKey.toString()
);
diff --git a/apps/server/src/services/encryption/password_encryption.ts b/apps/server/src/services/encryption/password_encryption.ts
index 61e73495ce..7c126f3260 100644
--- a/apps/server/src/services/encryption/password_encryption.ts
+++ b/apps/server/src/services/encryption/password_encryption.ts
@@ -1,7 +1,8 @@
+import { data_encryption } from "@triliumnext/core";
+
import optionService from "../options.js";
+import { constantTimeCompare,toBase64 } from "../utils.js";
import myScryptService from "./my_scrypt.js";
-import { toBase64, constantTimeCompare } from "../utils.js";
-import dataEncryptionService from "./data_encryption.js";
function verifyPassword(password: string) {
const givenPasswordHash = toBase64(myScryptService.getVerificationHash(password));
@@ -15,10 +16,10 @@ function verifyPassword(password: string) {
return constantTimeCompare(givenPasswordHash, dbPasswordHash);
}
-function setDataKey(password: string, plainTextDataKey: string | Buffer) {
+function setDataKey(password: string, plainTextDataKey: string | Buffer | Uint8Array) {
const passwordDerivedKey = myScryptService.getPasswordDerivedKey(password);
- const newEncryptedDataKey = dataEncryptionService.encrypt(passwordDerivedKey, plainTextDataKey);
+ const newEncryptedDataKey = data_encryption.encrypt(passwordDerivedKey, plainTextDataKey);
optionService.setOption("encryptedDataKey", newEncryptedDataKey);
}
@@ -28,7 +29,7 @@ function getDataKey(password: string) {
const encryptedDataKey = optionService.getOption("encryptedDataKey");
- const decryptedDataKey = dataEncryptionService.decrypt(passwordDerivedKey, encryptedDataKey);
+ const decryptedDataKey = data_encryption.decrypt(passwordDerivedKey, encryptedDataKey);
return decryptedDataKey;
}
diff --git a/apps/server/src/services/encryption/totp_encryption.ts b/apps/server/src/services/encryption/totp_encryption.ts
index 87f4cfef18..1d1297dc52 100644
--- a/apps/server/src/services/encryption/totp_encryption.ts
+++ b/apps/server/src/services/encryption/totp_encryption.ts
@@ -1,8 +1,9 @@
-import optionService from "../options.js";
-import myScryptService from "./my_scrypt.js";
-import { randomSecureToken, toBase64, constantTimeCompare } from "../utils.js";
-import dataEncryptionService from "./data_encryption.js";
import type { OptionNames } from "@triliumnext/commons";
+import { data_encryption } from "@triliumnext/core";
+
+import optionService from "../options.js";
+import { constantTimeCompare,randomSecureToken, toBase64 } from "../utils.js";
+import myScryptService from "./my_scrypt.js";
const TOTP_OPTIONS: Record = {
SALT: "totpEncryptionSalt",
@@ -32,7 +33,7 @@ function setTotpSecret(secret: string) {
const verificationHash = toBase64(myScryptService.getVerificationHash(secret));
optionService.setOption(TOTP_OPTIONS.VERIFICATION_HASH, verificationHash);
- const encryptedSecret = dataEncryptionService.encrypt(
+ const encryptedSecret = data_encryption.encrypt(
Buffer.from(encryptionSalt),
secret
);
@@ -48,7 +49,7 @@ function getTotpSecret(): string | null {
}
try {
- const decryptedSecret = dataEncryptionService.decrypt(
+ const decryptedSecret = data_encryption.decrypt(
Buffer.from(encryptionSalt),
encryptedSecret
);
diff --git a/apps/server/src/services/entity_changes.ts b/apps/server/src/services/entity_changes.ts
index c0a97c7d6b..24b210d95c 100644
--- a/apps/server/src/services/entity_changes.ts
+++ b/apps/server/src/services/entity_changes.ts
@@ -1,208 +1,2 @@
-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 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";
-
-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(/*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(
- `
- 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(/*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 = {
- entityName,
- entityId,
- isErased: false
- };
-
- if (entityName === "blobs") {
- const blob = sql.getRow("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("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/export/single.ts b/apps/server/src/services/export/single.ts
index 16d36807e8..e115e0e54b 100644
--- a/apps/server/src/services/export/single.ts
+++ b/apps/server/src/services/export/single.ts
@@ -1,14 +1,15 @@
-"use strict";
-import mimeTypes from "mime-types";
-import html from "html";
-import { getContentDisposition, escapeHtml } from "../utils.js";
-import mdService from "./markdown.js";
-import becca from "../../becca/becca.js";
-import type TaskContext from "../task_context.js";
-import type BBranch from "../../becca/entities/bbranch.js";
+
import type { Response } from "express";
+import html from "html";
+import mimeTypes from "mime-types";
+
+import becca from "../../becca/becca.js";
+import type BBranch from "../../becca/entities/bbranch.js";
import type BNote from "../../becca/entities/bnote.js";
+import type TaskContext from "../task_context.js";
+import { escapeHtml,getContentDisposition } from "../utils.js";
+import mdService from "./markdown.js";
import type { ExportFormat } from "./zip/abstract_provider.js";
function exportSingleNote(taskContext: TaskContext<"export">, branch: BBranch, format: ExportFormat, res: Response) {
@@ -34,7 +35,7 @@ function exportSingleNote(taskContext: TaskContext<"export">, branch: BBranch, f
taskContext.taskSucceeded(null);
}
-export function mapByNoteType(note: BNote, content: string | Buffer, 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 4a9f140411..e9eec69bf5 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);
@@ -319,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();
@@ -340,11 +339,13 @@ async function exportToZip(taskContext: TaskContext<"export">, branch: BBranch,
if (noteMeta.isClone) {
const targetUrl = getNoteTargetUrl(noteMeta.noteId, noteMeta);
- let content: string | Buffer = `This is a clone of a note. Go to its primary location.
`;
+ let content: string | Uint8Array = `This is a clone of a note. Go to its primary location.
`;
content = prepareContent(noteMeta.title, content, noteMeta, undefined);
- archive.append(content, { name: filePathPrefix + noteMeta.dataFileName });
+ archive.append(typeof content === "string" ? content : Buffer.from(content), {
+ name: filePathPrefix + noteMeta.dataFileName
+ });
return;
}
@@ -360,7 +361,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)
});
@@ -376,7 +377,7 @@ async function exportToZip(taskContext: TaskContext<"export">, branch: BBranch,
const attachment = note.getAttachmentById(attachmentMeta.attachmentId);
const content = attachment.getContent();
- archive.append(content, {
+ archive.append(typeof content === "string" ? content : Buffer.from(content), {
name: filePathPrefix + attachmentMeta.dataFileName,
date: dateUtils.parseDateTime(note.utcDateModified)
});
@@ -421,9 +422,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/export/zip/abstract_provider.ts b/apps/server/src/services/export/zip/abstract_provider.ts
index 5eda4b0767..d3c5ea2220 100644
--- a/apps/server/src/services/export/zip/abstract_provider.ts
+++ b/apps/server/src/services/export/zip/abstract_provider.ts
@@ -52,7 +52,7 @@ export abstract class ZipExportProvider {
}
abstract prepareMeta(metaFile: NoteMetaFile): void;
- abstract prepareContent(title: string, content: string | Buffer, noteMeta: NoteMeta, note: BNote | undefined, branch: BBranch): string | Buffer;
+ abstract prepareContent(title: string, content: string | Uint8Array, noteMeta: NoteMeta, note: BNote | undefined, branch: BBranch): string | Uint8Array;
abstract afterDone(rootMeta: NoteMeta): void;
/**
diff --git a/apps/server/src/services/export/zip/html.ts b/apps/server/src/services/export/zip/html.ts
index 14fb44acc3..0ba9ed2136 100644
--- a/apps/server/src/services/export/zip/html.ts
+++ b/apps/server/src/services/export/zip/html.ts
@@ -34,7 +34,7 @@ export default class HtmlExportProvider extends ZipExportProvider {
metaFile.files.push(this.cssMeta);
}
- prepareContent(title: string, content: string | Buffer, noteMeta: NoteMeta): string | Buffer {
+ prepareContent(title: string, content: string | Uint8Array, noteMeta: NoteMeta): string | Uint8Array {
if (noteMeta.format === "html" && typeof content === "string") {
if (!content.substr(0, 100).toLowerCase().includes(" void;
-
-function runAttachedRelations(note: BNote, relationName: string, originEntity: AbstractBeccaEntity) {
- 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/apps/server/src/services/hidden_subtree.ts b/apps/server/src/services/hidden_subtree.ts
index a95955f466..4c3d52aa0d 100644
--- a/apps/server/src/services/hidden_subtree.ts
+++ b/apps/server/src/services/hidden_subtree.ts
@@ -1,498 +1,2 @@
-import BAttribute from "../becca/entities/battribute.js";
-import BBranch from "../becca/entities/bbranch.js";
-import type { HiddenSubtreeItem } from "@triliumnext/commons";
-
-import becca from "../becca/becca.js";
-import noteService from "./notes.js";
-import log from "./log.js";
-import migrationService from "./migration.js";
-import { t } from "i18next";
-import { cleanUpHelp, getHelpHiddenSubtreeData } from "./in_app_help.js";
-import buildLaunchBarConfig from "./hidden_subtree_launcherbar.js";
-import buildHiddenSubtreeTemplates from "./hidden_subtree_templates.js";
-
-export const LBTPL_ROOT = "_lbTplRoot";
-export const LBTPL_BASE = "_lbTplBase";
-export const LBTPL_HEADER = "_lbTplHeader";
-export const LBTPL_NOTE_LAUNCHER = "_lbTplLauncherNote";
-export const LBTPL_WIDGET = "_lbTplLauncherWidget";
-export const LBTPL_COMMAND = "_lbTplLauncherCommand";
-export const LBTPL_SCRIPT = "_lbTplLauncherScript";
-export const LBTPL_SPACER = "_lbTplSpacer";
-export const LBTPL_CUSTOM_WIDGET = "_lbTplCustomWidget";
-
-/*
- * Hidden subtree is generated as a "predictable structure" which means that it avoids generating random IDs to always
- * produce the same structure. This is needed because it is run on multiple instances in the sync cluster which might produce
- * duplicate subtrees. This way, all instances will generate the same structure with the same IDs.
- */
-
-let hiddenSubtreeDefinition: HiddenSubtreeItem;
-
-function buildHiddenSubtreeDefinition(helpSubtree: HiddenSubtreeItem[]): HiddenSubtreeItem {
- const launchbarConfig = buildLaunchBarConfig();
-
- return {
- id: "_hidden",
- title: t("hidden-subtree.root-title"),
- type: "doc",
- icon: "bx bx-hide",
- // we want to keep the hidden subtree always last, otherwise there will be problems with e.g., keyboard navigation
- // over tree when it's in the middle
- notePosition: 999_999_999,
- enforceAttributes: true,
- attributes: [
- { type: "label", name: "docName", value: "hidden" }
- ],
- children: [
- {
- id: "_search",
- title: t("hidden-subtree.search-history-title"),
- type: "doc"
- },
- {
- id: "_globalNoteMap",
- title: t("hidden-subtree.note-map-title"),
- type: "noteMap",
- attributes: [
- { type: "label", name: "mapRootNoteId", value: "hoisted" },
- { type: "label", name: "keepCurrentHoisting" }
- ]
- },
- {
- id: "_sqlConsole",
- title: t("hidden-subtree.sql-console-history-title"),
- type: "doc",
- icon: "bx-data"
- },
- {
- id: "_share",
- title: t("hidden-subtree.shared-notes-title"),
- type: "doc",
- attributes: [{ type: "label", name: "docName", value: "share" }]
- },
- {
- id: "_bulkAction",
- title: t("hidden-subtree.bulk-action-title"),
- type: "doc"
- },
- {
- id: "_backendLog",
- title: t("hidden-subtree.backend-log-title"),
- type: "contentWidget",
- icon: "bx-terminal",
- attributes: [
- { type: "label", name: "keepCurrentHoisting" },
- { type: "label", name: "fullContentWidth" }
- ]
- },
- {
- // place for user scripts hidden stuff (scripts should not create notes directly under hidden root)
- id: "_userHidden",
- title: t("hidden-subtree.user-hidden-title"),
- type: "doc",
- attributes: [{ type: "label", name: "docName", value: "user_hidden" }]
- },
- {
- id: LBTPL_ROOT,
- title: t("hidden-subtree.launch-bar-templates-title"),
- type: "doc",
- children: [
- {
- id: LBTPL_BASE,
- title: t("hidden-subtree.base-abstract-launcher-title"),
- type: "doc"
- },
- {
- id: LBTPL_COMMAND,
- title: t("hidden-subtree.command-launcher-title"),
- type: "doc",
- attributes: [
- { type: "relation", name: "template", value: LBTPL_BASE },
- { type: "label", name: "launcherType", value: "command" },
- { type: "label", name: "docName", value: "launchbar_command_launcher" }
- ]
- },
- {
- id: LBTPL_NOTE_LAUNCHER,
- title: t("hidden-subtree.note-launcher-title"),
- type: "doc",
- attributes: [
- { type: "relation", name: "template", value: LBTPL_BASE },
- { type: "label", name: "launcherType", value: "note" },
- { type: "label", name: "relation:target", value: "promoted" },
- { type: "label", name: "relation:hoistedNote", value: "promoted" },
- { type: "label", name: "label:keyboardShortcut", value: "promoted,text" },
- { type: "label", name: "docName", value: "launchbar_note_launcher" }
- ]
- },
- {
- id: LBTPL_SCRIPT,
- title: t("hidden-subtree.script-launcher-title"),
- type: "doc",
- attributes: [
- { type: "relation", name: "template", value: LBTPL_BASE },
- { type: "label", name: "launcherType", value: "script" },
- { type: "label", name: "relation:script", value: "promoted" },
- { type: "label", name: "label:keyboardShortcut", value: "promoted,text" },
- { type: "label", name: "docName", value: "launchbar_script_launcher" }
- ]
- },
- {
- id: LBTPL_WIDGET,
- title: t("hidden-subtree.built-in-widget-title"),
- type: "doc",
- attributes: [
- { type: "relation", name: "template", value: LBTPL_BASE },
- { type: "label", name: "launcherType", value: "builtinWidget" }
- ]
- },
- {
- id: LBTPL_SPACER,
- title: t("hidden-subtree.spacer-title"),
- type: "doc",
- icon: "bx-move-vertical",
- attributes: [
- { type: "relation", name: "template", value: LBTPL_WIDGET },
- { type: "label", name: "builtinWidget", value: "spacer" },
- { type: "label", name: "label:baseSize", value: "promoted,number" },
- { type: "label", name: "label:growthFactor", value: "promoted,number" },
- { type: "label", name: "docName", value: "launchbar_spacer" }
- ]
- },
- {
- id: LBTPL_CUSTOM_WIDGET,
- title: t("hidden-subtree.custom-widget-title"),
- type: "doc",
- attributes: [
- { type: "relation", name: "template", value: LBTPL_BASE },
- { type: "label", name: "launcherType", value: "customWidget" },
- { type: "label", name: "relation:widget", value: "promoted" },
- { type: "label", name: "docName", value: "launchbar_widget_launcher" }
- ]
- }
- ]
- },
- {
- id: "_lbRoot",
- title: t("hidden-subtree.launch-bar-title"),
- type: "doc",
- icon: "bx-sidebar",
- isExpanded: true,
- attributes: [{ type: "label", name: "docName", value: "launchbar_intro" }],
- children: [
- {
- id: "_lbAvailableLaunchers",
- title: t("hidden-subtree.available-launchers-title"),
- type: "doc",
- icon: "bx-hide",
- isExpanded: true,
- attributes: [{ type: "label", name: "docName", value: "launchbar_intro" }],
- children: launchbarConfig.desktopAvailableLaunchers
- },
- {
- id: "_lbVisibleLaunchers",
- title: t("hidden-subtree.visible-launchers-title"),
- type: "doc",
- icon: "bx-show",
- isExpanded: true,
- attributes: [{ type: "label", name: "docName", value: "launchbar_intro" }],
- children: launchbarConfig.desktopVisibleLaunchers
- }
- ]
- },
- {
- id: "_lbMobileRoot",
- title: "Mobile Launch Bar",
- type: "doc",
- icon: "bx-mobile",
- isExpanded: true,
- attributes: [{ type: "label", name: "docName", value: "launchbar_intro" }],
- children: [
- {
- id: "_lbMobileAvailableLaunchers",
- title: t("hidden-subtree.available-launchers-title"),
- type: "doc",
- icon: "bx-hide",
- isExpanded: true,
- attributes: [{ type: "label", name: "docName", value: "launchbar_intro" }],
- children: launchbarConfig.mobileAvailableLaunchers
- },
- {
- id: "_lbMobileVisibleLaunchers",
- title: t("hidden-subtree.visible-launchers-title"),
- type: "doc",
- icon: "bx-show",
- isExpanded: true,
- attributes: [{ type: "label", name: "docName", value: "launchbar_intro" }],
- children: launchbarConfig.mobileVisibleLaunchers
- }
- ]
- },
- {
- id: "_options",
- title: t("hidden-subtree.options-title"),
- type: "book",
- icon: "bx-cog",
- children: [
- { id: "_optionsAppearance", title: t("hidden-subtree.appearance-title"), type: "contentWidget", icon: "bx-layout" },
- { id: "_optionsShortcuts", title: t("hidden-subtree.shortcuts-title"), type: "contentWidget", icon: "bxs-keyboard" },
- { id: "_optionsTextNotes", title: t("hidden-subtree.text-notes"), type: "contentWidget", icon: "bx-text" },
- { id: "_optionsCodeNotes", title: t("hidden-subtree.code-notes-title"), type: "contentWidget", icon: "bx-code" },
- { id: "_optionsImages", title: t("hidden-subtree.images-title"), type: "contentWidget", icon: "bx-image" },
- { id: "_optionsSpellcheck", title: t("hidden-subtree.spellcheck-title"), type: "contentWidget", icon: "bx-check-double" },
- { id: "_optionsPassword", title: t("hidden-subtree.password-title"), type: "contentWidget", icon: "bx-lock" },
- { id: '_optionsMFA', title: t('hidden-subtree.multi-factor-authentication-title'), type: 'contentWidget', icon: 'bx-lock ' },
- { id: "_optionsEtapi", title: t("hidden-subtree.etapi-title"), type: "contentWidget", icon: "bx-extension" },
- { id: "_optionsBackup", title: t("hidden-subtree.backup-title"), type: "contentWidget", icon: "bx-data" },
- { id: "_optionsSync", title: t("hidden-subtree.sync-title"), type: "contentWidget", icon: "bx-wifi" },
- { id: "_optionsAi", title: t("hidden-subtree.ai-llm-title"), type: "contentWidget", icon: "bx-bot" },
- { id: "_optionsOther", title: t("hidden-subtree.other"), type: "contentWidget", icon: "bx-dots-horizontal" },
- { id: "_optionsLocalization", title: t("hidden-subtree.localization"), type: "contentWidget", icon: "bx-world" },
- { id: "_optionsAdvanced", title: t("hidden-subtree.advanced-title"), type: "contentWidget" }
- ]
- },
- {
- id: "_help",
- title: t("hidden-subtree.user-guide"),
- type: "book",
- icon: "bx-help-circle",
- children: helpSubtree,
- isExpanded: true
- },
- buildHiddenSubtreeTemplates()
- ]
- };
-}
-
-interface CheckHiddenExtraOpts {
- restoreNames?: boolean;
-}
-
-function checkHiddenSubtree(force = false, extraOpts: CheckHiddenExtraOpts = {}) {
- if (!force && !migrationService.isDbUpToDate()) {
- // on-delete hook might get triggered during some future migration and cause havoc
- log.info("Will not check hidden subtree until migration is finished.");
- return;
- }
-
- const helpSubtree = getHelpHiddenSubtreeData();
- if (!hiddenSubtreeDefinition || force) {
- hiddenSubtreeDefinition = buildHiddenSubtreeDefinition(helpSubtree);
- }
-
- checkHiddenSubtreeRecursively("root", hiddenSubtreeDefinition, extraOpts);
-
- try {
- cleanUpHelp(helpSubtree);
- } catch (e) {
- // Non-critical operation should something go wrong.
- console.error(e);
- }
-}
-
-/**
- * Get all expected parent IDs for a given note ID from the hidden subtree definition
- */
-function getExpectedParentIds(noteId: string, subtree: HiddenSubtreeItem): string[] {
- const expectedParents: string[] = [];
-
- function traverse(item: HiddenSubtreeItem, parentId: string) {
- if (item.id === noteId) {
- expectedParents.push(parentId);
- }
-
- if (item.children) {
- for (const child of item.children) {
- traverse(child, item.id);
- }
- }
- }
-
- // Start traversal from root
- if (subtree.id === noteId) {
- expectedParents.push("root");
- }
-
- if (subtree.children) {
- for (const child of subtree.children) {
- traverse(child, subtree.id);
- }
- }
-
- return expectedParents;
-}
-
-/**
- * Check if a note ID is within the hidden subtree structure
- */
-function isWithinHiddenSubtree(noteId: string): boolean {
- // Consider a note to be within hidden subtree if it starts with underscore
- // This is the convention used for hidden subtree notes
- return noteId.startsWith("_") || noteId === "root";
-}
-
-function checkHiddenSubtreeRecursively(parentNoteId: string, item: HiddenSubtreeItem, extraOpts: CheckHiddenExtraOpts = {}) {
- if (!item.id || !item.type || !item.title) {
- throw new Error(`Item does not contain mandatory properties: ${JSON.stringify(item)}`);
- }
-
- if (item.id.charAt(0) !== "_") {
- throw new Error(`ID has to start with underscore, given '${item.id}'`);
- }
-
- let note = becca.notes[item.id];
- let branch;
-
- if (!note) {
- // Missing item, add it.
- ({ note, branch } = noteService.createNewNote({
- noteId: item.id,
- title: item.title,
- type: item.type,
- parentNoteId: parentNoteId,
- content: item.content ?? "",
- ignoreForbiddenParents: true
- }));
- } else {
- // Existing item, check if it's in the right state.
- branch = note.getParentBranches().find((branch) => branch.parentNoteId === parentNoteId);
-
- if (item.content && note.getContent() !== item.content) {
- log.info(`Updating content of ${item.id}.`);
- note.setContent(item.content);
- }
-
- // Clean up any branches that shouldn't exist according to the meta definition
- // For hidden subtree notes, we want to ensure they only exist in their designated locations
- if (item.enforceBranches || item.id.startsWith("_help")) {
- // If the note exists but doesn't have a branch in the expected parent,
- // create the missing branch to ensure it's in the correct location
- if (!branch) {
- log.info(`Creating missing branch for note ${item.id} under parent ${parentNoteId}.`);
- branch = new BBranch({
- noteId: item.id,
- parentNoteId: parentNoteId,
- notePosition: item.notePosition !== undefined ? item.notePosition : undefined,
- isExpanded: item.isExpanded !== undefined ? item.isExpanded : false
- }).save();
- }
-
- // Remove any branches that are not in the expected parent.
- const expectedParents = getExpectedParentIds(item.id, hiddenSubtreeDefinition);
- const currentBranches = note.getParentBranches();
-
- for (const currentBranch of currentBranches) {
- // Only delete branches that are not in the expected locations
- // and are within the hidden subtree structure (avoid touching user-created clones)
- if (!expectedParents.includes(currentBranch.parentNoteId) &&
- isWithinHiddenSubtree(currentBranch.parentNoteId)) {
- log.info(`Removing unexpected branch for note '${item.id}' from parent '${currentBranch.parentNoteId}'`);
- currentBranch.markAsDeleted();
- }
- }
- }
- }
-
- const attrs = [...(item.attributes || [])];
-
- if (item.icon) {
- attrs.push({ type: "label", name: "iconClass", value: `bx ${item.icon}` });
- }
-
- if (item.type === "launcher") {
- if (item.command) {
- attrs.push({ type: "relation", name: "template", value: LBTPL_COMMAND });
- attrs.push({ type: "label", name: "command", value: item.command });
- } else if (item.builtinWidget) {
- if (item.builtinWidget === "spacer") {
- attrs.push({ type: "relation", name: "template", value: LBTPL_SPACER });
- attrs.push({ type: "label", name: "baseSize", value: item.baseSize });
- attrs.push({ type: "label", name: "growthFactor", value: item.growthFactor });
- } else {
- attrs.push({ type: "relation", name: "template", value: LBTPL_WIDGET });
- }
-
- attrs.push({ type: "label", name: "builtinWidget", value: item.builtinWidget });
- } else if (item.targetNoteId) {
- attrs.push({ type: "relation", name: "template", value: LBTPL_NOTE_LAUNCHER });
- attrs.push({ type: "relation", name: "target", value: item.targetNoteId });
- } else {
- throw new Error(`No action defined for launcher ${JSON.stringify(item)}`);
- }
- }
-
- const shouldRestoreNames = extraOpts.restoreNames || note.noteId.startsWith("_help") || item.id.startsWith("_lb") || item.id.startsWith("_template");
- if (shouldRestoreNames && note.title !== item.title) {
- note.title = item.title;
- note.save();
- }
-
- if (note.type !== item.type) {
- // enforce a correct note type
- note.type = item.type;
- note.save();
- }
-
- if (branch) {
- // in case of launchers the branch ID is not preserved and should not be relied upon - launchers which move between
- // visible and available will change branch since the branch's parent-child relationship is immutable
- if (item.notePosition !== undefined && branch.notePosition !== item.notePosition) {
- branch.notePosition = item.notePosition;
- branch.save();
- }
-
- if (item.isExpanded !== undefined && branch.isExpanded !== item.isExpanded) {
- branch.isExpanded = item.isExpanded;
- branch.save();
- }
- }
-
- // Enforce attribute structure if needed.
- if (item.enforceAttributes) {
- for (const attribute of note.getAttributes()) {
- // Remove unwanted attributes.
- const attrDef = attrs.find(a => a.name === attribute.name);
- if (!attrDef) {
- attribute.markAsDeleted();
- continue;
- }
-
- // Ensure value is consistent.
- if (attribute.value !== attrDef.value) {
- note.setAttributeValueById(attribute.attributeId, attrDef.value);
- }
- }
- }
-
- for (const attr of attrs) {
- const attrId = note.noteId + "_" + attr.type.charAt(0) + attr.name;
-
- const existingAttribute = note.getAttributes().find((attr) => attr.attributeId === attrId);
-
- if (!existingAttribute) {
- new BAttribute({
- attributeId: attrId,
- noteId: note.noteId,
- type: attr.type,
- name: attr.name,
- value: attr.value,
- isInheritable: attr.isInheritable
- }).save();
- } else if (attr.name === "docName" || (existingAttribute.noteId.startsWith("_help") && attr.name === "iconClass")) {
- if (existingAttribute.value !== attr.value) {
- log.info(`Updating attribute ${attrId} from "${existingAttribute.value}" to "${attr.value}"`);
- existingAttribute.value = attr.value ?? "";
- existingAttribute.save();
- }
- }
- }
-
- for (const child of item.children || []) {
- checkHiddenSubtreeRecursively(item.id, child, extraOpts);
- }
-}
-
-export default {
- checkHiddenSubtree
-};
+import { hidden_subtree } from "@triliumnext/core";
+export default hidden_subtree;
diff --git a/apps/server/src/services/image.ts b/apps/server/src/services/image.ts
index 387fcaec54..e57811f748 100644
--- a/apps/server/src/services/image.ts
+++ b/apps/server/src/services/image.ts
@@ -1,17 +1,18 @@
-"use strict";
+
+
+import { sanitize } from "@triliumnext/core";
+import imageType from "image-type";
+import isAnimated from "is-animated";
+import isSvg from "is-svg";
+import { Jimp } from "jimp";
+import sanitizeFilename from "sanitize-filename";
import becca from "../becca/becca.js";
import log from "./log.js";
-import protectedSessionService from "./protected_session.js";
import noteService from "./notes.js";
import optionService from "./options.js";
+import protectedSessionService from "./protected_session.js";
import sql from "./sql.js";
-import { Jimp } from "jimp";
-import imageType from "image-type";
-import sanitizeFilename from "sanitize-filename";
-import isSvg from "is-svg";
-import isAnimated from "is-animated";
-import htmlSanitizer from "./html_sanitizer.js";
async function processImage(uploadBuffer: Buffer, originalName: string, shrinkImageSwitch: boolean) {
const compressImages = optionService.getOptionBool("compressImages");
@@ -46,9 +47,9 @@ async function processImage(uploadBuffer: Buffer, originalName: string, shrinkIm
async function getImageType(buffer: Buffer) {
if (isSvg(buffer.toString())) {
return { ext: "svg" };
- } else {
- return (await imageType(buffer)) || { ext: "jpg" }; // optimistic JPG default
- }
+ }
+ return (await imageType(buffer)) || { ext: "jpg" }; // optimistic JPG default
+
}
function getImageMimeFromExtension(ext: string) {
@@ -60,7 +61,7 @@ function getImageMimeFromExtension(ext: string) {
function updateImage(noteId: string, uploadBuffer: Buffer, originalName: string) {
log.info(`Updating image ${noteId}: ${originalName}`);
- originalName = htmlSanitizer.sanitize(originalName);
+ originalName = sanitize.sanitizeHtml(originalName);
const note = becca.getNote(noteId);
if (!note) {
diff --git a/apps/server/src/services/import/enex.ts b/apps/server/src/services/import/enex.ts
index 5f9166fce3..d50f6e4174 100644
--- a/apps/server/src/services/import/enex.ts
+++ b/apps/server/src/services/import/enex.ts
@@ -1,20 +1,20 @@
+import type { AttributeType } from "@triliumnext/commons";
import { dayjs } from "@triliumnext/commons";
+import { sanitize, 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 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 +25,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;
}
@@ -117,7 +117,7 @@ function importEnex(taskContext: TaskContext<"importNotes">, file: File, parentN
"\u2611 "
);
- content = htmlSanitizer.sanitize(content);
+ content = sanitize.sanitizeHtml(content);
return content;
}
@@ -155,7 +155,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 +201,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: ""
});
}
@@ -367,7 +367,7 @@ function importEnex(taskContext: TaskContext<"importNotes">, file: File, parentN
}
}
- content = htmlSanitizer.sanitize(content);
+ content = sanitize.sanitizeHtml(content);
// save updated content with links to files/images
noteEntity.setContent(content);
diff --git a/apps/server/src/services/import/markdown.ts b/apps/server/src/services/import/markdown.ts
index e91670df66..dec4a68e7f 100644
--- a/apps/server/src/services/import/markdown.ts
+++ b/apps/server/src/services/import/markdown.ts
@@ -2,10 +2,10 @@
import { getMimeTypeFromMarkdownName, MIME_TYPE_AUTO } from "@triliumnext/commons";
import { normalizeMimeTypeForCKEditor } from "@triliumnext/commons";
+import { sanitize } from "@triliumnext/core";
import { parse, Renderer, type Tokens,use } from "marked";
import { ADMONITION_TYPE_MAPPINGS } from "../export/markdown.js";
-import htmlSanitizer from "../html_sanitizer.js";
import utils from "../utils.js";
import wikiLinkInternalLink from "./markdown/wikilink_internal_link.js";
import wikiLinkTransclusion from "./markdown/wikilink_transclusion.js";
@@ -151,7 +151,7 @@ function renderToHtml(content: string, title: string) {
// h1 handling needs to come before sanitization
html = importUtils.handleH1(html, title);
- html = htmlSanitizer.sanitize(html);
+ html = sanitize.sanitizeHtml(html);
// Add a trailing semicolon to CSS styles.
html = html.replaceAll(/(<(img|figure|col).*?style=".*?)"/g, "$1;\"");
diff --git a/apps/server/src/services/import/opml.ts b/apps/server/src/services/import/opml.ts
index 130eb81974..d9a2ed8fbe 100644
--- a/apps/server/src/services/import/opml.ts
+++ b/apps/server/src/services/import/opml.ts
@@ -1,11 +1,12 @@
-"use strict";
-import noteService from "../../services/notes.js";
+
+import { sanitize } from "@triliumnext/core";
import xml2js from "xml2js";
-import protectedSessionService from "../protected_session.js";
-import htmlSanitizer from "../html_sanitizer.js";
-import type TaskContext from "../task_context.js";
+
import type BNote from "../../becca/entities/bnote.js";
+import noteService from "../../services/notes.js";
+import protectedSessionService from "../protected_session.js";
+import type TaskContext from "../task_context.js";
const parseString = xml2js.parseString;
interface OpmlXml {
@@ -29,8 +30,8 @@ interface OpmlOutline {
}
async function importOpml(taskContext: TaskContext<"importNotes">, fileBuffer: string | Buffer, parentNote: BNote) {
- const xml = await new Promise(function (resolve, reject) {
- parseString(fileBuffer, function (err: any, result: OpmlXml) {
+ const xml = await new Promise((resolve, reject) => {
+ parseString(fileBuffer, (err: any, result: OpmlXml) => {
if (err) {
reject(err);
} else {
@@ -64,7 +65,7 @@ async function importOpml(taskContext: TaskContext<"importNotes">, fileBuffer: s
throw new Error(`Unrecognized OPML version ${opmlVersion}`);
}
- content = htmlSanitizer.sanitize(content || "");
+ content = sanitize.sanitizeHtml(content || "");
const { note } = noteService.createNewNote({
parentNoteId,
diff --git a/apps/server/src/services/import/single.ts b/apps/server/src/services/import/single.ts
index ac52a43f49..942914027b 100644
--- a/apps/server/src/services/import/single.ts
+++ b/apps/server/src/services/import/single.ts
@@ -1,18 +1,16 @@
-"use strict";
+import type { NoteType } from "@triliumnext/commons";
+import { sanitize } from "@triliumnext/core";
import type BNote from "../../becca/entities/bnote.js";
-import type TaskContext from "../task_context.js";
-
-import noteService from "../../services/notes.js";
import imageService from "../../services/image.js";
+import noteService from "../../services/notes.js";
+import { getNoteTitle, processStringOrBuffer } from "../../services/utils.js";
import protectedSessionService from "../protected_session.js";
+import type TaskContext from "../task_context.js";
+import type { File } from "./common.js";
import markdownService from "./markdown.js";
import mimeService from "./mime.js";
-import { getNoteTitle, processStringOrBuffer } from "../../services/utils.js";
import importUtils from "./utils.js";
-import htmlSanitizer from "../html_sanitizer.js";
-import type { File } from "./common.js";
-import type { NoteType } from "@triliumnext/commons";
function importSingleFile(taskContext: TaskContext<"importNotes">, file: File, parentNote: BNote) {
const mime = mimeService.getMime(file.originalname) || file.mimetype;
@@ -88,7 +86,7 @@ function importCodeNote(taskContext: TaskContext<"importNotes">, file: File, par
title,
content,
type,
- mime: mime,
+ mime,
isProtected: parentNote.isProtected && protectedSessionService.isProtectedSessionAvailable()
});
@@ -106,7 +104,7 @@ function importCustomType(taskContext: TaskContext<"importNotes">, file: File, p
title,
content,
type,
- mime: mime,
+ mime,
isProtected: parentNote.isProtected && protectedSessionService.isProtectedSessionAvailable()
});
@@ -157,7 +155,7 @@ function importMarkdown(taskContext: TaskContext<"importNotes">, file: File, par
let htmlContent = markdownService.renderToHtml(markdownContent, title);
if (taskContext.data?.safeImport) {
- htmlContent = htmlSanitizer.sanitize(htmlContent);
+ htmlContent = sanitize.sanitizeHtml(htmlContent);
}
const { note } = noteService.createNewNote({
@@ -185,7 +183,7 @@ function importHtml(taskContext: TaskContext<"importNotes">, file: File, parentN
content = importUtils.handleH1(content, title);
if (taskContext?.data?.safeImport) {
- content = htmlSanitizer.sanitize(content);
+ content = sanitize.sanitizeHtml(content);
}
const { note } = noteService.createNewNote({
@@ -214,7 +212,7 @@ function importAttachment(taskContext: TaskContext<"importNotes">, file: File, p
title: file.originalname,
content: file.buffer,
role: "file",
- mime: mime
+ mime
});
taskContext.increaseProgressCount();
diff --git a/apps/server/src/services/import/zip.ts b/apps/server/src/services/import/zip.ts
index c1ac90b913..da27376d9c 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 { sanitize } from "@triliumnext/core";
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 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) {
@@ -216,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);
@@ -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,13 +313,13 @@ 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) {
- content = htmlSanitizer.sanitize(content);
+ content = sanitize.sanitizeHtml(content);
}
content = content.replace(/]*>/gis, "");
@@ -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/apps/server/src/services/instance_id.ts b/apps/server/src/services/instance_id.ts
deleted file mode 100644
index 31e620a8fd..0000000000
--- a/apps/server/src/services/instance_id.ts
+++ /dev/null
@@ -1,5 +0,0 @@
-import { randomString } from "./utils.js";
-
-const instanceId = randomString(12);
-
-export default instanceId;
diff --git a/apps/server/src/services/keyboard_actions.ts b/apps/server/src/services/keyboard_actions.ts
index 326672eeb2..d6d70b6bcf 100644
--- a/apps/server/src/services/keyboard_actions.ts
+++ b/apps/server/src/services/keyboard_actions.ts
@@ -1,882 +1,2 @@
-"use strict";
-
-import optionService from "./options.js";
-import log from "./log.js";
-import { isElectron, isMac } from "./utils.js";
-import type { ActionKeyboardShortcut, KeyboardShortcut } from "@triliumnext/commons";
-import { t } from "i18next";
-
-function getDefaultKeyboardActions() {
- if (!t("keyboard_actions.note-navigation")) {
- throw new Error("Keyboard actions loaded before translations.");
- }
-
- const DEFAULT_KEYBOARD_ACTIONS: KeyboardShortcut[] = [
- {
- separator: t("keyboard_actions.note-navigation")
- },
- {
- actionName: "backInNoteHistory",
- friendlyName: t("keyboard_action_names.back-in-note-history"),
- iconClass: "bx bxs-chevron-left",
- // Mac has a different history navigation shortcuts - https://github.com/zadam/trilium/issues/376
- defaultShortcuts: isMac ? ["CommandOrControl+["] : ["Alt+Left"],
- description: t("keyboard_actions.back-in-note-history"),
- scope: "window"
- },
- {
- actionName: "forwardInNoteHistory",
- friendlyName: t("keyboard_action_names.forward-in-note-history"),
- iconClass: "bx bxs-chevron-right",
- // Mac has a different history navigation shortcuts - https://github.com/zadam/trilium/issues/376
- defaultShortcuts: isMac ? ["CommandOrControl+]"] : ["Alt+Right"],
- description: t("keyboard_actions.forward-in-note-history"),
- scope: "window"
- },
- {
- actionName: "jumpToNote",
- friendlyName: t("keyboard_action_names.jump-to-note"),
- defaultShortcuts: ["CommandOrControl+J"],
- description: t("keyboard_actions.open-jump-to-note-dialog"),
- scope: "window",
- ignoreFromCommandPalette: true
- },
- {
- actionName: "openTodayNote",
- friendlyName: t("hidden-subtree.open-today-journal-note-title"),
- iconClass: "bx bx-calendar",
- defaultShortcuts: [],
- description: t("hidden-subtree.open-today-journal-note-title"),
- scope: "window"
- },
- {
- actionName: "commandPalette",
- friendlyName: t("keyboard_action_names.command-palette"),
- defaultShortcuts: ["CommandOrControl+Shift+J"],
- description: t("keyboard_actions.open-command-palette"),
- scope: "window",
- ignoreFromCommandPalette: true
- },
- {
- actionName: "scrollToActiveNote",
- friendlyName: t("keyboard_action_names.scroll-to-active-note"),
- defaultShortcuts: ["CommandOrControl+."],
- iconClass: "bx bx-current-location",
- description: t("keyboard_actions.scroll-to-active-note"),
- scope: "window"
- },
- {
- actionName: "quickSearch",
- friendlyName: t("keyboard_action_names.quick-search"),
- iconClass: "bx bx-search",
- defaultShortcuts: ["CommandOrControl+S"],
- description: t("keyboard_actions.quick-search"),
- scope: "window"
- },
- {
- actionName: "searchInSubtree",
- friendlyName: t("keyboard_action_names.search-in-subtree"),
- defaultShortcuts: ["CommandOrControl+Shift+S"],
- iconClass: "bx bx-search-alt",
- description: t("keyboard_actions.search-in-subtree"),
- scope: "note-tree"
- },
- {
- actionName: "expandSubtree",
- friendlyName: t("keyboard_action_names.expand-subtree"),
- defaultShortcuts: [],
- iconClass: "bx bx-layer-plus",
- description: t("keyboard_actions.expand-subtree"),
- scope: "note-tree"
- },
- {
- actionName: "collapseTree",
- friendlyName: t("keyboard_action_names.collapse-tree"),
- defaultShortcuts: ["Alt+C"],
- iconClass: "bx bx-layer-minus",
- description: t("keyboard_actions.collapse-tree"),
- scope: "window"
- },
- {
- actionName: "collapseSubtree",
- friendlyName: t("keyboard_action_names.collapse-subtree"),
- iconClass: "bx bxs-layer-minus",
- defaultShortcuts: ["Alt+-"],
- description: t("keyboard_actions.collapse-subtree"),
- scope: "note-tree"
- },
- {
- actionName: "sortChildNotes",
- friendlyName: t("keyboard_action_names.sort-child-notes"),
- iconClass: "bx bx-sort-down",
- defaultShortcuts: ["Alt+S"],
- description: t("keyboard_actions.sort-child-notes"),
- scope: "note-tree"
- },
-
- {
- separator: t("keyboard_actions.creating-and-moving-notes")
- },
- {
- actionName: "createNoteAfter",
- friendlyName: t("keyboard_action_names.create-note-after"),
- iconClass: "bx bx-plus",
- defaultShortcuts: ["CommandOrControl+O"],
- description: t("keyboard_actions.create-note-after"),
- scope: "window"
- },
- {
- actionName: "createNoteInto",
- friendlyName: t("keyboard_action_names.create-note-into"),
- iconClass: "bx bx-plus",
- defaultShortcuts: ["CommandOrControl+P"],
- description: t("keyboard_actions.create-note-into"),
- scope: "window"
- },
- {
- actionName: "createNoteIntoInbox",
- friendlyName: t("keyboard_action_names.create-note-into-inbox"),
- iconClass: "bx bxs-inbox",
- defaultShortcuts: ["global:CommandOrControl+Alt+P"],
- description: t("keyboard_actions.create-note-into-inbox"),
- scope: "window"
- },
- {
- actionName: "deleteNotes",
- friendlyName: t("keyboard_action_names.delete-notes"),
- iconClass: "bx bx-trash",
- defaultShortcuts: ["Delete"],
- description: t("keyboard_actions.delete-note"),
- scope: "note-tree"
- },
- {
- actionName: "moveNoteUp",
- friendlyName: t("keyboard_action_names.move-note-up"),
- iconClass: "bx bx-up-arrow-alt",
- defaultShortcuts: isMac ? ["Alt+Up"] : ["CommandOrControl+Up"],
- description: t("keyboard_actions.move-note-up"),
- scope: "note-tree"
- },
- {
- actionName: "moveNoteDown",
- friendlyName: t("keyboard_action_names.move-note-down"),
- iconClass: "bx bx-down-arrow-alt",
- defaultShortcuts: isMac ? ["Alt+Down"] : ["CommandOrControl+Down"],
- description: t("keyboard_actions.move-note-down"),
- scope: "note-tree"
- },
- {
- actionName: "moveNoteUpInHierarchy",
- friendlyName: t("keyboard_action_names.move-note-up-in-hierarchy"),
- iconClass: "bx bx-arrow-from-bottom",
- defaultShortcuts: isMac ? ["Alt+Left"] : ["CommandOrControl+Left"],
- description: t("keyboard_actions.move-note-up-in-hierarchy"),
- scope: "note-tree"
- },
- {
- actionName: "moveNoteDownInHierarchy",
- friendlyName: t("keyboard_action_names.move-note-down-in-hierarchy"),
- iconClass: "bx bx-arrow-from-top",
- defaultShortcuts: isMac ? ["Alt+Right"] : ["CommandOrControl+Right"],
- description: t("keyboard_actions.move-note-down-in-hierarchy"),
- scope: "note-tree"
- },
- {
- actionName: "editNoteTitle",
- friendlyName: t("keyboard_action_names.edit-note-title"),
- iconClass: "bx bx-rename",
- defaultShortcuts: ["Enter"],
- description: t("keyboard_actions.edit-note-title"),
- scope: "note-tree"
- },
- {
- actionName: "editBranchPrefix",
- friendlyName: t("keyboard_action_names.edit-branch-prefix"),
- iconClass: "bx bx-rename",
- defaultShortcuts: ["F2"],
- description: t("keyboard_actions.edit-branch-prefix"),
- scope: "note-tree"
- },
- {
- actionName: "cloneNotesTo",
- friendlyName: t("keyboard_action_names.clone-notes-to"),
- iconClass: "bx bx-duplicate",
- defaultShortcuts: ["CommandOrControl+Shift+C"],
- description: t("keyboard_actions.clone-notes-to"),
- scope: "window"
- },
- {
- actionName: "moveNotesTo",
- friendlyName: t("keyboard_action_names.move-notes-to"),
- iconClass: "bx bx-transfer",
- defaultShortcuts: ["CommandOrControl+Shift+X"],
- description: t("keyboard_actions.move-notes-to"),
- scope: "window"
- },
-
- {
- separator: t("keyboard_actions.note-clipboard")
- },
-
- {
- actionName: "copyNotesToClipboard",
- friendlyName: t("keyboard_action_names.copy-notes-to-clipboard"),
- iconClass: "bx bx-copy",
- defaultShortcuts: ["CommandOrControl+C"],
- description: t("keyboard_actions.copy-notes-to-clipboard"),
- scope: "note-tree"
- },
- {
- actionName: "pasteNotesFromClipboard",
- friendlyName: t("keyboard_action_names.paste-notes-from-clipboard"),
- iconClass: "bx bx-paste",
- defaultShortcuts: ["CommandOrControl+V"],
- description: t("keyboard_actions.paste-notes-from-clipboard"),
- scope: "note-tree"
- },
- {
- actionName: "cutNotesToClipboard",
- friendlyName: t("keyboard_action_names.cut-notes-to-clipboard"),
- iconClass: "bx bx-cut",
- defaultShortcuts: ["CommandOrControl+X"],
- description: t("keyboard_actions.cut-notes-to-clipboard"),
- scope: "note-tree"
- },
- {
- actionName: "selectAllNotesInParent",
- friendlyName: t("keyboard_action_names.select-all-notes-in-parent"),
- iconClass: "bx bx-select-multiple",
- defaultShortcuts: ["CommandOrControl+A"],
- description: t("keyboard_actions.select-all-notes-in-parent"),
- scope: "note-tree"
- },
- {
- actionName: "addNoteAboveToSelection",
- friendlyName: t("keyboard_action_names.add-note-above-to-selection"),
- defaultShortcuts: ["Shift+Up"],
- description: t("keyboard_actions.add-note-above-to-the-selection"),
- scope: "note-tree",
- ignoreFromCommandPalette: true
- },
- {
- actionName: "addNoteBelowToSelection",
- friendlyName: t("keyboard_action_names.add-note-below-to-selection"),
- defaultShortcuts: ["Shift+Down"],
- description: t("keyboard_actions.add-note-below-to-selection"),
- scope: "note-tree",
- ignoreFromCommandPalette: true
- },
- {
- actionName: "duplicateSubtree",
- friendlyName: t("keyboard_action_names.duplicate-subtree"),
- iconClass: "bx bx-outline",
- defaultShortcuts: [],
- description: t("keyboard_actions.duplicate-subtree"),
- scope: "note-tree"
- },
-
- {
- separator: t("keyboard_actions.tabs-and-windows")
- },
- {
- actionName: "openNewTab",
- friendlyName: t("keyboard_action_names.open-new-tab"),
- iconClass: "bx bx-plus",
- defaultShortcuts: isElectron ? ["CommandOrControl+T"] : [],
- description: t("keyboard_actions.open-new-tab"),
- scope: "window"
- },
- {
- actionName: "closeActiveTab",
- friendlyName: t("keyboard_action_names.close-active-tab"),
- iconClass: "bx bx-minus",
- defaultShortcuts: isElectron ? ["CommandOrControl+W"] : [],
- description: t("keyboard_actions.close-active-tab"),
- scope: "window"
- },
- {
- actionName: "reopenLastTab",
- friendlyName: t("keyboard_action_names.reopen-last-tab"),
- iconClass: "bx bx-undo",
- defaultShortcuts: isElectron ? ["CommandOrControl+Shift+T"] : [],
- isElectronOnly: true,
- description: t("keyboard_actions.reopen-last-tab"),
- scope: "window"
- },
- {
- actionName: "activateNextTab",
- friendlyName: t("keyboard_action_names.activate-next-tab"),
- iconClass: "bx bx-skip-next",
- defaultShortcuts: isElectron ? ["CommandOrControl+Tab", "CommandOrControl+PageDown"] : [],
- description: t("keyboard_actions.activate-next-tab"),
- scope: "window"
- },
- {
- actionName: "activatePreviousTab",
- friendlyName: t("keyboard_action_names.activate-previous-tab"),
- iconClass: "bx bx-skip-previous",
- defaultShortcuts: isElectron ? ["CommandOrControl+Shift+Tab", "CommandOrControl+PageUp"] : [],
- description: t("keyboard_actions.activate-previous-tab"),
- scope: "window"
- },
- {
- actionName: "openNewWindow",
- friendlyName: t("keyboard_action_names.open-new-window"),
- iconClass: "bx bx-window-open",
- defaultShortcuts: [],
- description: t("keyboard_actions.open-new-window"),
- scope: "window"
- },
- {
- actionName: "toggleTray",
- friendlyName: t("keyboard_action_names.toggle-system-tray-icon"),
- iconClass: "bx bx-show",
- defaultShortcuts: [],
- isElectronOnly: true,
- description: t("keyboard_actions.toggle-tray"),
- scope: "window"
- },
- {
- actionName: "toggleZenMode",
- friendlyName: t("keyboard_action_names.toggle-zen-mode"),
- iconClass: "bx bxs-yin-yang",
- defaultShortcuts: ["F9"],
- description: t("keyboard_actions.toggle-zen-mode"),
- scope: "window"
- },
- {
- actionName: "firstTab",
- friendlyName: t("keyboard_action_names.switch-to-first-tab"),
- iconClass: "bx bx-rectangle",
- defaultShortcuts: ["CommandOrControl+1"],
- description: t("keyboard_actions.first-tab"),
- scope: "window"
- },
- {
- actionName: "secondTab",
- friendlyName: t("keyboard_action_names.switch-to-second-tab"),
- iconClass: "bx bx-rectangle",
- defaultShortcuts: ["CommandOrControl+2"],
- description: t("keyboard_actions.second-tab"),
- scope: "window"
- },
- {
- actionName: "thirdTab",
- friendlyName: t("keyboard_action_names.switch-to-third-tab"),
- iconClass: "bx bx-rectangle",
- defaultShortcuts: ["CommandOrControl+3"],
- description: t("keyboard_actions.third-tab"),
- scope: "window"
- },
- {
- actionName: "fourthTab",
- friendlyName: t("keyboard_action_names.switch-to-fourth-tab"),
- iconClass: "bx bx-rectangle",
- defaultShortcuts: ["CommandOrControl+4"],
- description: t("keyboard_actions.fourth-tab"),
- scope: "window"
- },
- {
- actionName: "fifthTab",
- friendlyName: t("keyboard_action_names.switch-to-fifth-tab"),
- iconClass: "bx bx-rectangle",
- defaultShortcuts: ["CommandOrControl+5"],
- description: t("keyboard_actions.fifth-tab"),
- scope: "window"
- },
- {
- actionName: "sixthTab",
- friendlyName: t("keyboard_action_names.switch-to-sixth-tab"),
- iconClass: "bx bx-rectangle",
- defaultShortcuts: ["CommandOrControl+6"],
- description: t("keyboard_actions.sixth-tab"),
- scope: "window"
- },
- {
- actionName: "seventhTab",
- friendlyName: t("keyboard_action_names.switch-to-seventh-tab"),
- iconClass: "bx bx-rectangle",
- defaultShortcuts: ["CommandOrControl+7"],
- description: t("keyboard_actions.seventh-tab"),
- scope: "window"
- },
- {
- actionName: "eigthTab",
- friendlyName: t("keyboard_action_names.switch-to-eighth-tab"),
- iconClass: "bx bx-rectangle",
- defaultShortcuts: ["CommandOrControl+8"],
- description: t("keyboard_actions.eight-tab"),
- scope: "window"
- },
- {
- actionName: "ninthTab",
- friendlyName: t("keyboard_action_names.switch-to-ninth-tab"),
- iconClass: "bx bx-rectangle",
- defaultShortcuts: ["CommandOrControl+9"],
- description: t("keyboard_actions.ninth-tab"),
- scope: "window"
- },
- {
- actionName: "lastTab",
- friendlyName: t("keyboard_action_names.switch-to-last-tab"),
- iconClass: "bx bx-rectangle",
- defaultShortcuts: ["CommandOrControl+0"],
- description: t("keyboard_actions.last-tab"),
- scope: "window"
- },
-
- {
- separator: t("keyboard_actions.dialogs")
- },
- {
- friendlyName: t("keyboard_action_names.show-note-source"),
- actionName: "showNoteSource",
- iconClass: "bx bx-code",
- defaultShortcuts: [],
- description: t("keyboard_actions.show-note-source"),
- scope: "window"
- },
- {
- friendlyName: t("keyboard_action_names.show-options"),
- actionName: "showOptions",
- iconClass: "bx bx-cog",
- defaultShortcuts: [],
- description: t("keyboard_actions.show-options"),
- scope: "window"
- },
- {
- friendlyName: t("keyboard_action_names.show-revisions"),
- actionName: "showRevisions",
- iconClass: "bx bx-history",
- defaultShortcuts: [],
- description: t("keyboard_actions.show-revisions"),
- scope: "window"
- },
- {
- friendlyName: t("keyboard_action_names.show-recent-changes"),
- actionName: "showRecentChanges",
- iconClass: "bx bx-history",
- defaultShortcuts: [],
- description: t("keyboard_actions.show-recent-changes"),
- scope: "window"
- },
- {
- friendlyName: t("keyboard_action_names.show-sql-console"),
- actionName: "showSQLConsole",
- iconClass: "bx bx-data",
- defaultShortcuts: ["Alt+O"],
- description: t("keyboard_actions.show-sql-console"),
- scope: "window"
- },
- {
- friendlyName: t("keyboard_action_names.show-backend-log"),
- actionName: "showBackendLog",
- iconClass: "bx bx-detail",
- defaultShortcuts: [],
- description: t("keyboard_actions.show-backend-log"),
- scope: "window"
- },
- {
- friendlyName: t("keyboard_action_names.show-help"),
- actionName: "showHelp",
- iconClass: "bx bx-help-circle",
- defaultShortcuts: ["F1"],
- description: t("keyboard_actions.show-help"),
- scope: "window"
- },
- {
- friendlyName: t("keyboard_action_names.show-cheatsheet"),
- actionName: "showCheatsheet",
- iconClass: "bx bxs-keyboard",
- defaultShortcuts: ["Shift+F1"],
- description: t("keyboard_actions.show-cheatsheet"),
- scope: "window"
- },
-
- {
- separator: t("keyboard_actions.text-note-operations")
- },
-
- {
- friendlyName: t("keyboard_action_names.add-link-to-text"),
- actionName: "addLinkToText",
- iconClass: "bx bx-link",
- defaultShortcuts: ["CommandOrControl+L"],
- description: t("keyboard_actions.add-link-to-text"),
- scope: "text-detail"
- },
- {
- friendlyName: t("keyboard_action_names.follow-link-under-cursor"),
- actionName: "followLinkUnderCursor",
- iconClass: "bx bx-link-external",
- defaultShortcuts: ["CommandOrControl+Enter"],
- description: t("keyboard_actions.follow-link-under-cursor"),
- scope: "text-detail"
- },
- {
- friendlyName: t("keyboard_action_names.insert-date-and-time-to-text"),
- actionName: "insertDateTimeToText",
- iconClass: "bx bx-calendar-event",
- defaultShortcuts: ["Alt+T"],
- description: t("keyboard_actions.insert-date-and-time-to-text"),
- scope: "text-detail"
- },
- {
- friendlyName: t("keyboard_action_names.paste-markdown-into-text"),
- actionName: "pasteMarkdownIntoText",
- iconClass: "bx bxl-markdown",
- defaultShortcuts: [],
- description: t("keyboard_actions.paste-markdown-into-text"),
- scope: "text-detail"
- },
- {
- friendlyName: t("keyboard_action_names.cut-into-note"),
- actionName: "cutIntoNote",
- iconClass: "bx bx-cut",
- defaultShortcuts: [],
- description: t("keyboard_actions.cut-into-note"),
- scope: "text-detail"
- },
- {
- friendlyName: t("keyboard_action_names.add-include-note-to-text"),
- actionName: "addIncludeNoteToText",
- iconClass: "bx bx-note",
- defaultShortcuts: [],
- description: t("keyboard_actions.add-include-note-to-text"),
- scope: "text-detail"
- },
- {
- friendlyName: t("keyboard_action_names.edit-read-only-note"),
- actionName: "editReadOnlyNote",
- iconClass: "bx bx-edit-alt",
- defaultShortcuts: [],
- description: t("keyboard_actions.edit-readonly-note"),
- scope: "window"
- },
-
- {
- separator: t("keyboard_actions.attributes-labels-and-relations")
- },
-
- {
- friendlyName: t("keyboard_action_names.add-new-label"),
- actionName: "addNewLabel",
- iconClass: "bx bx-hash",
- defaultShortcuts: ["Alt+L"],
- description: t("keyboard_actions.add-new-label"),
- scope: "window"
- },
- {
- friendlyName: t("keyboard_action_names.add-new-relation"),
- actionName: "addNewRelation",
- iconClass: "bx bx-transfer",
- defaultShortcuts: ["Alt+R"],
- description: t("keyboard_actions.create-new-relation"),
- scope: "window"
- },
-
- {
- separator: t("keyboard_actions.ribbon-tabs")
- },
-
- {
- friendlyName: t("keyboard_action_names.toggle-ribbon-tab-classic-editor"),
- actionName: "toggleRibbonTabClassicEditor",
- iconClass: "bx bx-text",
- defaultShortcuts: [],
- description: t("keyboard_actions.toggle-classic-editor-toolbar"),
- scope: "window"
- },
- {
- actionName: "toggleRibbonTabBasicProperties",
- friendlyName: t("keyboard_action_names.toggle-ribbon-tab-basic-properties"),
- iconClass: "bx bx-slider",
- defaultShortcuts: [],
- description: t("keyboard_actions.toggle-basic-properties"),
- scope: "window"
- },
- {
- actionName: "toggleRibbonTabBookProperties",
- friendlyName: t("keyboard_action_names.toggle-ribbon-tab-book-properties"),
- iconClass: "bx bx-book",
- defaultShortcuts: [],
- description: t("keyboard_actions.toggle-book-properties"),
- scope: "window"
- },
- {
- actionName: "toggleRibbonTabFileProperties",
- friendlyName: t("keyboard_action_names.toggle-ribbon-tab-file-properties"),
- iconClass: "bx bx-file",
- defaultShortcuts: [],
- description: t("keyboard_actions.toggle-file-properties"),
- scope: "window"
- },
- {
- actionName: "toggleRibbonTabImageProperties",
- friendlyName: t("keyboard_action_names.toggle-ribbon-tab-image-properties"),
- iconClass: "bx bx-image",
- defaultShortcuts: [],
- description: t("keyboard_actions.toggle-image-properties"),
- scope: "window"
- },
- {
- actionName: "toggleRibbonTabOwnedAttributes",
- friendlyName: t("keyboard_action_names.toggle-ribbon-tab-owned-attributes"),
- iconClass: "bx bx-list-check",
- defaultShortcuts: ["Alt+A"],
- description: t("keyboard_actions.toggle-owned-attributes"),
- scope: "window"
- },
- {
- actionName: "toggleRibbonTabInheritedAttributes",
- friendlyName: t("keyboard_action_names.toggle-ribbon-tab-inherited-attributes"),
- iconClass: "bx bx-list-plus",
- defaultShortcuts: [],
- description: t("keyboard_actions.toggle-inherited-attributes"),
- scope: "window"
- },
- // TODO: Remove or change since promoted attributes have been changed.
- {
- actionName: "toggleRibbonTabPromotedAttributes",
- friendlyName: t("keyboard_action_names.toggle-ribbon-tab-promoted-attributes"),
- iconClass: "bx bx-star",
- defaultShortcuts: [],
- description: t("keyboard_actions.toggle-promoted-attributes"),
- scope: "window"
- },
- {
- actionName: "toggleRibbonTabNoteMap",
- friendlyName: t("keyboard_action_names.toggle-ribbon-tab-note-map"),
- iconClass: "bx bxs-network-chart",
- defaultShortcuts: [],
- description: t("keyboard_actions.toggle-link-map"),
- scope: "window"
- },
- {
- actionName: "toggleRibbonTabNoteInfo",
- friendlyName: t("keyboard_action_names.toggle-ribbon-tab-note-info"),
- iconClass: "bx bx-info-circle",
- defaultShortcuts: [],
- description: t("keyboard_actions.toggle-note-info"),
- scope: "window"
- },
- {
- actionName: "toggleRibbonTabNotePaths",
- friendlyName: t("keyboard_action_names.toggle-ribbon-tab-note-paths"),
- iconClass: "bx bx-collection",
- defaultShortcuts: [],
- description: t("keyboard_actions.toggle-note-paths"),
- scope: "window"
- },
- {
- actionName: "toggleRibbonTabSimilarNotes",
- friendlyName: t("keyboard_action_names.toggle-ribbon-tab-similar-notes"),
- iconClass: "bx bx-bar-chart",
- defaultShortcuts: [],
- description: t("keyboard_actions.toggle-similar-notes"),
- scope: "window"
- },
-
- {
- separator: t("keyboard_actions.other")
- },
-
- {
- actionName: "toggleRightPane",
- friendlyName: t("keyboard_action_names.toggle-right-pane"),
- iconClass: "bx bx-dock-right",
- defaultShortcuts: [],
- description: t("keyboard_actions.toggle-right-pane"),
- scope: "window"
- },
- {
- actionName: "printActiveNote",
- friendlyName: t("keyboard_action_names.print-active-note"),
- iconClass: "bx bx-printer",
- defaultShortcuts: [],
- description: t("keyboard_actions.print-active-note"),
- scope: "window"
- },
- {
- actionName: "exportAsPdf",
- friendlyName: t("keyboard_action_names.export-active-note-as-pdf"),
- iconClass: "bx bxs-file-pdf",
- defaultShortcuts: [],
- description: t("keyboard_actions.export-as-pdf"),
- scope: "window"
- },
- {
- actionName: "openNoteExternally",
- friendlyName: t("keyboard_action_names.open-note-externally"),
- iconClass: "bx bx-file-find",
- defaultShortcuts: [],
- description: t("keyboard_actions.open-note-externally"),
- scope: "window"
- },
- {
- actionName: "renderActiveNote",
- friendlyName: t("keyboard_action_names.render-active-note"),
- iconClass: "bx bx-refresh",
- defaultShortcuts: [],
- description: t("keyboard_actions.render-active-note"),
- scope: "window"
- },
- {
- actionName: "runActiveNote",
- friendlyName: t("keyboard_action_names.run-active-note"),
- iconClass: "bx bx-play",
- defaultShortcuts: ["CommandOrControl+Enter"],
- description: t("keyboard_actions.run-active-note"),
- scope: "code-detail"
- },
- {
- actionName: "toggleNoteHoisting",
- friendlyName: t("keyboard_action_names.toggle-note-hoisting"),
- iconClass: "bx bx-chevrons-up",
- defaultShortcuts: ["Alt+H"],
- description: t("keyboard_actions.toggle-note-hoisting"),
- scope: "window"
- },
- {
- actionName: "unhoist",
- friendlyName: t("keyboard_action_names.unhoist-note"),
- iconClass: "bx bx-door-open",
- defaultShortcuts: ["Alt+U"],
- description: t("keyboard_actions.unhoist"),
- scope: "window"
- },
- {
- actionName: "reloadFrontendApp",
- friendlyName: t("keyboard_action_names.reload-frontend-app"),
- iconClass: "bx bx-refresh",
- defaultShortcuts: ["F5", "CommandOrControl+R"],
- description: t("keyboard_actions.reload-frontend-app"),
- scope: "window"
- },
- {
- actionName: "openDevTools",
- friendlyName: t("keyboard_action_names.open-developer-tools"),
- iconClass: "bx bx-bug-alt",
- defaultShortcuts: isElectron ? ["CommandOrControl+Shift+I"] : [],
- isElectronOnly: true,
- description: t("keyboard_actions.open-dev-tools"),
- scope: "window"
- },
- {
- actionName: "findInText",
- friendlyName: t("keyboard_action_names.find-in-text"),
- iconClass: "bx bx-search",
- defaultShortcuts: isElectron ? ["CommandOrControl+F"] : [],
- description: t("keyboard_actions.find-in-text"),
- scope: "window"
- },
- {
- actionName: "toggleLeftPane",
- friendlyName: t("keyboard_action_names.toggle-left-pane"),
- iconClass: "bx bx-sidebar",
- defaultShortcuts: [],
- description: t("keyboard_actions.toggle-left-note-tree-panel"),
- scope: "window"
- },
- {
- actionName: "toggleFullscreen",
- friendlyName: t("keyboard_action_names.toggle-full-screen"),
- iconClass: "bx bx-fullscreen",
- defaultShortcuts: ["F11"],
- description: t("keyboard_actions.toggle-full-screen"),
- scope: "window"
- },
- {
- actionName: "zoomOut",
- friendlyName: t("keyboard_action_names.zoom-out"),
- iconClass: "bx bx-zoom-out",
- defaultShortcuts: isElectron ? ["CommandOrControl+-"] : [],
- isElectronOnly: true,
- description: t("keyboard_actions.zoom-out"),
- scope: "window"
- },
- {
- actionName: "zoomIn",
- friendlyName: t("keyboard_action_names.zoom-in"),
- iconClass: "bx bx-zoom-in",
- description: t("keyboard_actions.zoom-in"),
- defaultShortcuts: isElectron ? ["CommandOrControl+="] : [],
- isElectronOnly: true,
- scope: "window"
- },
- {
- actionName: "zoomReset",
- friendlyName: t("keyboard_action_names.reset-zoom-level"),
- iconClass: "bx bx-search-alt",
- description: t("keyboard_actions.reset-zoom-level"),
- defaultShortcuts: isElectron ? ["CommandOrControl+0"] : [],
- isElectronOnly: true,
- scope: "window"
- },
- {
- actionName: "copyWithoutFormatting",
- friendlyName: t("keyboard_action_names.copy-without-formatting"),
- iconClass: "bx bx-copy-alt",
- defaultShortcuts: ["CommandOrControl+Alt+C"],
- description: t("keyboard_actions.copy-without-formatting"),
- scope: "text-detail"
- },
- {
- actionName: "forceSaveRevision",
- friendlyName: t("keyboard_action_names.force-save-revision"),
- iconClass: "bx bx-save",
- defaultShortcuts: [],
- description: t("keyboard_actions.force-save-revision"),
- scope: "window"
- }
- ];
-
- /*
- * Apply macOS-specific tweaks.
- */
- const platformModifier = isMac ? "Meta" : "Ctrl";
-
- for (const action of DEFAULT_KEYBOARD_ACTIONS) {
- if ("defaultShortcuts" in action && action.defaultShortcuts) {
- action.defaultShortcuts = action.defaultShortcuts.map((shortcut) => shortcut.replace("CommandOrControl", platformModifier));
- }
- }
-
- return DEFAULT_KEYBOARD_ACTIONS;
-}
-
-function getKeyboardActions() {
- const actions: KeyboardShortcut[] = JSON.parse(JSON.stringify(getDefaultKeyboardActions()));
-
- for (const action of actions) {
- if ("effectiveShortcuts" in action && action.effectiveShortcuts) {
- action.effectiveShortcuts = action.defaultShortcuts ? action.defaultShortcuts.slice() : [];
- }
- }
-
- for (const option of optionService.getOptions()) {
- if (option.name.startsWith("keyboardShortcuts")) {
- let actionName = option.name.substring(17);
- actionName = actionName.charAt(0).toLowerCase() + actionName.slice(1);
-
- const action = actions.find((ea) => "actionName" in ea && ea.actionName === actionName) as ActionKeyboardShortcut;
-
- if (action) {
- try {
- action.effectiveShortcuts = JSON.parse(option.value);
- } catch (e) {
- log.error(`Could not parse shortcuts for action ${actionName}`);
- }
- } else {
- log.info(`Keyboard action ${actionName} found in database, but not in action definition.`);
- }
- }
- }
-
- return actions;
-}
-
-export default {
- getDefaultKeyboardActions,
- getKeyboardActions
-};
+import { keyboard_actions } from "@triliumnext/core";
+export default keyboard_actions;
diff --git a/apps/server/src/services/llm/ai_service_manager.ts b/apps/server/src/services/llm/ai_service_manager.ts
index bd47b43274..5a16146476 100644
--- a/apps/server/src/services/llm/ai_service_manager.ts
+++ b/apps/server/src/services/llm/ai_service_manager.ts
@@ -1,32 +1,28 @@
-import options from '../options.js';
-import eventService from '../events.js';
-import type { AIService, ChatCompletionOptions, ChatResponse, Message } from './ai_interface.js';
-import { AnthropicService } from './providers/anthropic_service.js';
-import { ContextExtractor } from './context/index.js';
-import agentTools from './context_extractors/index.js';
-import contextService from './context/services/context_service.js';
import log from '../log.js';
-import { OllamaService } from './providers/ollama_service.js';
-import { OpenAIService } from './providers/openai_service.js';
-
-// Import interfaces
-import type {
- ServiceProviders,
- IAIServiceManager,
- ProviderMetadata
-} from './interfaces/ai_service_interfaces.js';
-import type { NoteSearchResult } from './interfaces/context_interfaces.js';
-
+import options from '../options.js';
+import type { AIService, ChatCompletionOptions, ChatResponse, Message } from './ai_interface.js';
// Import new configuration system
import {
- getSelectedProvider,
- parseModelIdentifier,
- isAIEnabled,
- getDefaultModelForProvider,
clearConfigurationCache,
+ getDefaultModelForProvider,
+ getSelectedProvider,
+ isAIEnabled,
+ parseModelIdentifier,
validateConfiguration
} from './config/configuration_helpers.js';
+import { ContextExtractor } from './context/index.js';
+import contextService from './context/services/context_service.js';
+import agentTools from './context_extractors/index.js';
+// Import interfaces
+import type {
+ IAIServiceManager,
+ ProviderMetadata,
+ ServiceProviders} from './interfaces/ai_service_interfaces.js';
import type { ProviderType } from './interfaces/configuration_interfaces.js';
+import type { NoteSearchResult } from './interfaces/context_interfaces.js';
+import { AnthropicService } from './providers/anthropic_service.js';
+import { OllamaService } from './providers/ollama_service.js';
+import { OpenAIService } from './providers/openai_service.js';
/**
* Interface representing relevant note context
@@ -173,7 +169,7 @@ export class AIServiceManager implements IAIServiceManager {
/**
* Get list of available providers
*/
- getAvailableProviders(): ServiceProviders[] {
+ getAvailableProviders(): ServiceProviders[] {
this.ensureInitialized();
const allProviders: ServiceProviders[] = ['openai', 'anthropic', 'ollama'];
diff --git a/apps/server/src/services/llm/context/modules/context_formatter.ts b/apps/server/src/services/llm/context/modules/context_formatter.ts
index 58198c9049..090df06c6f 100644
--- a/apps/server/src/services/llm/context/modules/context_formatter.ts
+++ b/apps/server/src/services/llm/context/modules/context_formatter.ts
@@ -1,11 +1,12 @@
-import sanitizeHtml from 'sanitize-html';
+import { sanitize } from '@triliumnext/core';
+
import log from '../../../log.js';
+import type { Message } from '../../ai_interface.js';
import { CONTEXT_PROMPTS, FORMATTING_PROMPTS } from '../../constants/llm_prompt_constants.js';
import { LLM_CONSTANTS } from '../../constants/provider_constants.js';
import type { IContextFormatter, NoteSearchResult } from '../../interfaces/context_interfaces.js';
-import modelCapabilitiesService from '../../model_capabilities_service.js';
import { calculateAvailableContextSize } from '../../interfaces/model_capabilities.js';
-import type { Message } from '../../ai_interface.js';
+import modelCapabilitiesService from '../../model_capabilities_service.js';
// Use constants from the centralized file
// const CONTEXT_WINDOW = {
@@ -44,7 +45,7 @@ export class ContextFormatter implements IContextFormatter {
try {
// Get model name from provider
- let modelName = providerId;
+ const modelName = providerId;
// Look up model capabilities
const modelCapabilities = await modelCapabilitiesService.getChatModelCapabilities(modelName);
@@ -59,9 +60,9 @@ export class ContextFormatter implements IContextFormatter {
// Use the calculated size or fall back to constants
const maxTotalLength = availableContextSize || (
providerId === 'openai' ? LLM_CONSTANTS.CONTEXT_WINDOW.OPENAI :
- providerId === 'anthropic' ? LLM_CONSTANTS.CONTEXT_WINDOW.ANTHROPIC :
- providerId === 'ollama' ? LLM_CONSTANTS.CONTEXT_WINDOW.OLLAMA :
- LLM_CONSTANTS.CONTEXT_WINDOW.DEFAULT
+ providerId === 'anthropic' ? LLM_CONSTANTS.CONTEXT_WINDOW.ANTHROPIC :
+ providerId === 'ollama' ? LLM_CONSTANTS.CONTEXT_WINDOW.OLLAMA :
+ LLM_CONSTANTS.CONTEXT_WINDOW.DEFAULT
);
// DEBUG: Log context window size
@@ -239,11 +240,11 @@ export class ContextFormatter implements IContextFormatter {
// Handle line breaks
.replace(/
/gi, '\n');
- // Then use sanitize-html to remove remaining HTML
- const sanitized = sanitizeHtml(contentWithMarkdown, {
+ // Then sanitize to remove remaining HTML
+ const sanitized = sanitize.sanitizeHtmlCustom(contentWithMarkdown, {
allowedTags: [], // No tags allowed (strip all HTML)
allowedAttributes: {}, // No attributes allowed
- textFilter: function(text) {
+ textFilter(text) {
return text
.replace(/ /g, ' ')
.replace(/</g, '<')
@@ -264,7 +265,7 @@ export class ContextFormatter implements IContextFormatter {
if (type === 'code' || mime?.includes('application/')) {
// For code, limit to a reasonable size
if (content.length > 2000) {
- return content.substring(0, 2000) + '...\n\n[Content truncated for brevity]';
+ return `${content.substring(0, 2000) }...\n\n[Content truncated for brevity]`;
}
return content;
}
@@ -288,7 +289,7 @@ export class ContextFormatter implements IContextFormatter {
try {
// First remove any HTML
- let plaintext = sanitizeHtml(content, {
+ let plaintext = sanitize.sanitizeHtmlCustom(content, {
allowedTags: [],
allowedAttributes: {},
textFilter: (text) => text
diff --git a/apps/server/src/services/llm/context/note_content.ts b/apps/server/src/services/llm/context/note_content.ts
index 575aaadd24..2de9aec8ef 100644
--- a/apps/server/src/services/llm/context/note_content.ts
+++ b/apps/server/src/services/llm/context/note_content.ts
@@ -1,4 +1,5 @@
-import sanitizeHtml from 'sanitize-html';
+import { sanitize } from '@triliumnext/core';
+
import becca from '../../../becca/becca.js';
// Define interfaces for JSON structures
@@ -98,13 +99,13 @@ export function formatNoteContent(content: string, type: string, mime: string, t
switch (type) {
case 'text':
// Remove HTML formatting for text notes
- formattedContent += sanitizeHtml(content);
+ formattedContent += sanitize.sanitizeHtml(content);
break;
case 'code':
// For code, we'll handle this in code_handlers.ts
// Just use basic formatting here
- formattedContent += '```\n' + content + '\n```';
+ formattedContent += `\`\`\`\n${ content }\n\`\`\``;
break;
case 'canvas':
@@ -119,7 +120,7 @@ export function formatNoteContent(content: string, type: string, mime: string, t
.filter((element) => element.type === 'text' && element.text)
.map((element) => element.text as string);
- formattedContent += 'Canvas content:\n' + texts.join('\n');
+ formattedContent += `Canvas content:\n${ texts.join('\n')}`;
} else {
formattedContent += '[Empty canvas]';
}
@@ -154,7 +155,7 @@ export function formatNoteContent(content: string, type: string, mime: string, t
};
if (jsonContent.root) {
- formattedContent += 'Mind map content:\n' + extractMindMapNodes(jsonContent.root).join('\n');
+ formattedContent += `Mind map content:\n${ extractMindMapNodes(jsonContent.root).join('\n')}`;
} else {
formattedContent += '[Empty mind map]';
}
@@ -178,14 +179,14 @@ export function formatNoteContent(content: string, type: string, mime: string, t
let result = 'Relation map content:\n';
if (jsonContent.notes && Array.isArray(jsonContent.notes)) {
- result += 'Notes: ' + jsonContent.notes
+ result += `Notes: ${ jsonContent.notes
.map((note) => note.title || note.name)
.filter(Boolean)
- .join(', ') + '\n';
+ .join(', ') }\n`;
}
if (jsonContent.relations && Array.isArray(jsonContent.relations)) {
- result += 'Relations: ' + jsonContent.relations
+ result += `Relations: ${ jsonContent.relations
.map((rel) => {
const sourceNote = jsonContent.notes?.find((n) => n.noteId === rel.sourceNoteId);
const targetNote = jsonContent.notes?.find((n) => n.noteId === rel.targetNoteId);
@@ -193,7 +194,7 @@ export function formatNoteContent(content: string, type: string, mime: string, t
const target = targetNote ? (targetNote.title || targetNote.name) : 'unknown';
return `${source} → ${rel.name || ''} → ${target}`;
})
- .join('; ');
+ .join('; ')}`;
}
formattedContent += result;
@@ -219,7 +220,7 @@ export function formatNoteContent(content: string, type: string, mime: string, t
if (jsonContent.markers.length > 0) {
result += jsonContent.markers
.map((marker) => {
- return `Location: ${marker.title || ''} (${marker.lat}, ${marker.lng})${marker.description ? ' - ' + marker.description : ''}`;
+ return `Location: ${marker.title || ''} (${marker.lat}, ${marker.lng})${marker.description ? ` - ${ marker.description}` : ''}`;
})
.join('\n');
} else {
@@ -242,7 +243,7 @@ export function formatNoteContent(content: string, type: string, mime: string, t
case 'mermaid':
// Format mermaid diagrams as code blocks
- formattedContent += '```mermaid\n' + content + '\n```';
+ formattedContent += `\`\`\`mermaid\n${ content }\n\`\`\``;
break;
case 'image':
@@ -252,7 +253,7 @@ export function formatNoteContent(content: string, type: string, mime: string, t
default:
// For other notes, just use the content as is
- formattedContent += sanitizeHtml(content);
+ formattedContent += sanitize.sanitizeHtml(content);
}
return formattedContent;
@@ -265,7 +266,7 @@ export function sanitizeHtmlContent(html: string): string {
if (!html) return '';
// Use sanitizeHtml to remove all HTML tags
- let content = sanitizeHtml(html, {
+ let content = sanitize.sanitizeHtmlCustom(html, {
allowedTags: [],
allowedAttributes: {},
textFilter: (text) => {
diff --git a/apps/server/src/services/llm/formatters/base_formatter.ts b/apps/server/src/services/llm/formatters/base_formatter.ts
index fe4c97f42c..f45c459636 100644
--- a/apps/server/src/services/llm/formatters/base_formatter.ts
+++ b/apps/server/src/services/llm/formatters/base_formatter.ts
@@ -1,16 +1,16 @@
-import sanitizeHtml from 'sanitize-html';
+import { sanitize } from '@triliumnext/core';
+
import type { Message } from '../ai_interface.js';
-import type { MessageFormatter } from '../interfaces/message_formatter.js';
-import { DEFAULT_SYSTEM_PROMPT, PROVIDER_PROMPTS } from '../constants/llm_prompt_constants.js';
import {
- HTML_ALLOWED_TAGS,
- HTML_ALLOWED_ATTRIBUTES,
- HTML_TRANSFORMS,
- HTML_TO_MARKDOWN_PATTERNS,
- HTML_ENTITY_REPLACEMENTS,
ENCODING_FIXES,
- FORMATTER_LOGS
-} from '../constants/formatter_constants.js';
+ FORMATTER_LOGS,
+ HTML_ALLOWED_ATTRIBUTES,
+ HTML_ALLOWED_TAGS,
+ HTML_ENTITY_REPLACEMENTS,
+ HTML_TO_MARKDOWN_PATTERNS,
+ HTML_TRANSFORMS} from '../constants/formatter_constants.js';
+import { DEFAULT_SYSTEM_PROMPT, PROVIDER_PROMPTS } from '../constants/llm_prompt_constants.js';
+import type { MessageFormatter } from '../interfaces/message_formatter.js';
/**
* Base formatter with common functionality for all providers
@@ -49,7 +49,7 @@ export abstract class BaseMessageFormatter implements MessageFormatter {
const fixedContent = this.fixEncodingIssues(content);
// Convert HTML to markdown for better readability
- const cleaned = sanitizeHtml(fixedContent, {
+ const cleaned = sanitize.sanitizeHtmlCustom(fixedContent, {
allowedTags: HTML_ALLOWED_TAGS.STANDARD,
allowedAttributes: HTML_ALLOWED_ATTRIBUTES.STANDARD,
transformTags: HTML_TRANSFORMS.STANDARD
diff --git a/apps/server/src/services/llm/formatters/ollama_formatter.ts b/apps/server/src/services/llm/formatters/ollama_formatter.ts
index eb780f7602..a85ec718ea 100644
--- a/apps/server/src/services/llm/formatters/ollama_formatter.ts
+++ b/apps/server/src/services/llm/formatters/ollama_formatter.ts
@@ -1,15 +1,15 @@
+import { sanitize } from '@triliumnext/core';
+
+import log from '../../log.js';
import type { Message } from '../ai_interface.js';
-import { BaseMessageFormatter } from './base_formatter.js';
-import sanitizeHtml from 'sanitize-html';
+import {
+ FORMATTER_LOGS,
+ HTML_ALLOWED_ATTRIBUTES,
+ HTML_ALLOWED_TAGS,
+ OLLAMA_CLEANING} from '../constants/formatter_constants.js';
import { PROVIDER_PROMPTS } from '../constants/llm_prompt_constants.js';
import { LLM_CONSTANTS } from '../constants/provider_constants.js';
-import {
- HTML_ALLOWED_TAGS,
- HTML_ALLOWED_ATTRIBUTES,
- OLLAMA_CLEANING,
- FORMATTER_LOGS
-} from '../constants/formatter_constants.js';
-import log from '../../log.js';
+import { BaseMessageFormatter } from './base_formatter.js';
/**
* Ollama-specific message formatter
@@ -196,7 +196,7 @@ export class OllamaMessageFormatter extends BaseMessageFormatter {
// Then apply Ollama-specific aggressive cleaning
// Remove any remaining HTML using sanitizeHtml while keeping our markers
- let plaintext = sanitizeHtml(sanitized, {
+ let plaintext = sanitize.sanitizeHtmlCustom(sanitized, {
allowedTags: HTML_ALLOWED_TAGS.NONE,
allowedAttributes: HTML_ALLOWED_ATTRIBUTES.NONE,
textFilter: (text) => text
diff --git a/apps/server/src/services/llm/formatters/openai_formatter.ts b/apps/server/src/services/llm/formatters/openai_formatter.ts
index d09a3675a1..9d551e6183 100644
--- a/apps/server/src/services/llm/formatters/openai_formatter.ts
+++ b/apps/server/src/services/llm/formatters/openai_formatter.ts
@@ -1,16 +1,16 @@
-import sanitizeHtml from 'sanitize-html';
+import { sanitize } from '@triliumnext/core';
+
+import log from '../../log.js';
import type { Message } from '../ai_interface.js';
-import { BaseMessageFormatter } from './base_formatter.js';
+import {
+ FORMATTER_LOGS,
+ HTML_ALLOWED_ATTRIBUTES,
+ HTML_ALLOWED_TAGS,
+ HTML_ENTITY_REPLACEMENTS,
+ HTML_TO_MARKDOWN_PATTERNS} from '../constants/formatter_constants.js';
import { PROVIDER_PROMPTS } from '../constants/llm_prompt_constants.js';
import { LLM_CONSTANTS } from '../constants/provider_constants.js';
-import {
- HTML_ALLOWED_TAGS,
- HTML_ALLOWED_ATTRIBUTES,
- HTML_TO_MARKDOWN_PATTERNS,
- HTML_ENTITY_REPLACEMENTS,
- FORMATTER_LOGS
-} from '../constants/formatter_constants.js';
-import log from '../../log.js';
+import { BaseMessageFormatter } from './base_formatter.js';
/**
* OpenAI-specific message formatter
@@ -54,18 +54,18 @@ export class OpenAIMessageFormatter extends BaseMessageFormatter {
// If we don't have explicit context but have a system prompt
else if (!hasSystemMessage && systemPrompt) {
let baseSystemPrompt = systemPrompt || PROVIDER_PROMPTS.COMMON.DEFAULT_ASSISTANT_INTRO;
-
+
// Check if this is a tool-using conversation
const hasPreviousToolCalls = messages.some(msg => msg.tool_calls && msg.tool_calls.length > 0);
const hasToolResults = messages.some(msg => msg.role === 'tool');
const isToolUsingConversation = useTools || hasPreviousToolCalls || hasToolResults;
-
+
// Add tool instructions for OpenAI when tools are being used
if (isToolUsingConversation && PROVIDER_PROMPTS.OPENAI.TOOL_INSTRUCTIONS) {
log.info('Adding tool instructions to system prompt for OpenAI');
baseSystemPrompt = `${baseSystemPrompt}\n\n${PROVIDER_PROMPTS.OPENAI.TOOL_INSTRUCTIONS}`;
}
-
+
formattedMessages.push({
role: 'system',
content: baseSystemPrompt
@@ -111,7 +111,7 @@ export class OpenAIMessageFormatter extends BaseMessageFormatter {
try {
// Convert HTML to Markdown for better readability
- const cleaned = sanitizeHtml(content, {
+ const cleaned = sanitize.sanitizeHtmlCustom(content, {
allowedTags: HTML_ALLOWED_TAGS.STANDARD,
allowedAttributes: HTML_ALLOWED_ATTRIBUTES.STANDARD
});
diff --git a/apps/server/src/services/llm/tools/read_note_tool.ts b/apps/server/src/services/llm/tools/read_note_tool.ts
index ddcad559f1..0230e59dad 100644
--- a/apps/server/src/services/llm/tools/read_note_tool.ts
+++ b/apps/server/src/services/llm/tools/read_note_tool.ts
@@ -4,16 +4,16 @@
* This tool allows the LLM to read the content of a specific note.
*/
-import type { Tool, ToolHandler } from './tool_interfaces.js';
-import log from '../../log.js';
import becca from '../../../becca/becca.js';
+import log from '../../log.js';
+import type { Tool, ToolHandler } from './tool_interfaces.js';
// Define type for note response
interface NoteResponse {
noteId: string;
title: string;
type: string;
- content: string | Buffer;
+ content: string | Uint8Array;
attributes?: Array<{
name: string;
value: string;
diff --git a/apps/server/src/services/log.ts b/apps/server/src/services/log.ts
index 3d8cdd6b76..247b5de639 100644
--- a/apps/server/src/services/log.ts
+++ b/apps/server/src/services/log.ts
@@ -1,12 +1,12 @@
-"use strict";
-
+import { getLog } from "@triliumnext/core/src/services/log.js";
import type { Request, Response } from "express";
import fs from "fs";
-import path from "path";
import { EOL } from "os";
-import dataDir from "./data_dir.js";
+import path from "path";
+
import cls from "./cls.js";
import config, { LOGGING_DEFAULT_RETENTION_DAYS } from "./config.js";
+import dataDir from "./data_dir.js";
if (!fs.existsSync(dataDir.LOG_DIR)) {
fs.mkdirSync(dataDir.LOG_DIR, 0o700);
@@ -40,7 +40,7 @@ async function cleanupOldLogFiles() {
retentionDays = customRetentionDays;
} else if (customRetentionDays <= -1){
info(`Log cleanup: keeping all log files, as specified by configuration.`);
- return
+ return;
}
const cutoffDate = new Date();
@@ -150,11 +150,11 @@ function log(str: string | Error) {
}
function info(message: string | Error) {
- log(message);
+ getLog().info(message);
}
function error(message: string | Error | unknown) {
- log(`ERROR: ${message}`);
+ getLog().error(message);
}
const requestBlacklist = ["/app", "/images", "/stylesheets", "/api/recent-notes"];
@@ -170,7 +170,7 @@ function request(req: Request, res: Response, timeMs: number, responseLength: nu
return;
}
- info((timeMs >= 10 ? "Slow " : "") + `${res.statusCode} ${req.method} ${req.url} with ${responseLength} bytes took ${timeMs}ms`);
+ info(`${timeMs >= 10 ? "Slow " : "" }${res.statusCode} ${req.method} ${req.url} with ${responseLength} bytes took ${timeMs}ms`);
}
function pad(num: number) {
@@ -184,9 +184,9 @@ function padMilli(num: number) {
return `00${num}`;
} else if (num < 100) {
return `0${num}`;
- } else {
- return num.toString();
}
+ return num.toString();
+
}
function formatTime(millisSinceMidnight: number) {
diff --git a/apps/server/src/services/note-interface.ts b/apps/server/src/services/note-interface.ts
deleted file mode 100644
index 5ebaa6dfa7..0000000000
--- a/apps/server/src/services/note-interface.ts
+++ /dev/null
@@ -1,27 +0,0 @@
-import type { NoteType } from "@triliumnext/commons";
-
-export interface NoteParams {
- /** optionally can force specific noteId */
- noteId?: string;
- branchId?: string;
- parentNoteId: string;
- templateNoteId?: string;
- title: string;
- content: string | Buffer;
- /** text, code, file, image, search, book, relationMap, canvas, webView */
- type: NoteType;
- /** default value is derived from default mimes for type */
- mime?: string;
- /** default is false */
- isProtected?: boolean;
- /** default is false */
- isExpanded?: boolean;
- /** default is empty string */
- prefix?: string;
- /** default is the last existing notePosition in a parent + 10 */
- notePosition?: number;
- dateCreated?: string;
- utcDateCreated?: string;
- ignoreForbiddenParents?: boolean;
- target?: "into";
-}
diff --git a/apps/server/src/services/note_types.ts b/apps/server/src/services/note_types.ts
index 2aa86d0b64..ce31085123 100644
--- a/apps/server/src/services/note_types.ts
+++ b/apps/server/src/services/note_types.ts
@@ -1,34 +1,2 @@
-const noteTypes = [
- { type: "text", defaultMime: "text/html" },
- { type: "code", defaultMime: "text/plain" },
- { type: "render", defaultMime: "" },
- { type: "file", defaultMime: "application/octet-stream" },
- { type: "image", defaultMime: "" },
- { type: "search", defaultMime: "" },
- { type: "relationMap", defaultMime: "application/json" },
- { type: "book", defaultMime: "" },
- { type: "noteMap", defaultMime: "" },
- { type: "mermaid", defaultMime: "text/vnd.mermaid" },
- { type: "canvas", defaultMime: "application/json" },
- { type: "webView", defaultMime: "" },
- { type: "launcher", defaultMime: "" },
- { type: "doc", defaultMime: "" },
- { type: "contentWidget", defaultMime: "" },
- { type: "mindMap", defaultMime: "application/json" },
- { type: "aiChat", defaultMime: "application/json" }
-];
-
-function getDefaultMimeForNoteType(typeName: string) {
- const typeRec = noteTypes.find((nt) => nt.type === typeName);
-
- if (!typeRec) {
- throw new Error(`Cannot find note type '${typeName}'`);
- }
-
- return typeRec.defaultMime;
-}
-
-export default {
- getNoteTypeNames: () => noteTypes.map((nt) => nt.type),
- getDefaultMimeForNoteType
-};
+import { note_types } from "@triliumnext/core";
+export default note_types;
diff --git a/apps/server/src/services/notes.ts b/apps/server/src/services/notes.ts
index 4964a57976..97756a3830 100644
--- a/apps/server/src/services/notes.ts
+++ b/apps/server/src/services/notes.ts
@@ -1,1086 +1,2 @@
-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 path from "path";
-import url from "url";
-import becca from "../becca/becca.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 imageService from "./image.js";
-import { t } from "i18next";
-
-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;
- } 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;
- }
-}
-
-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: 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(cls.getLocalNowDateTime() || 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 = dateUtils.validateLocalDateTime(params.dateCreated))) {
- throw new Error(error);
- }
-
- if ((error = dateUtils.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: 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;
- } else {
- 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();
- 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 = dateUtils.utcNowDateTime();
- attachment.save();
- }
- }
-
- const existingAttachmentIds = new Set(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 = /]*>/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 = {};
-
-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> = {};
-
-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 = /
]*?\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)}
{
- 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 = /]*?\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: mime,
- title: title,
- content: buffer
- });
-
- content = `${content.substring(0, attachmentMatch.index)}${title}${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 | Buffer) {
- 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 = dateUtils.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();
-
- 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("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("SELECT * FROM branches WHERE branchId = ?", [branchId]);
-
- if (!branchRow.isDeleted) {
- return;
- }
-
- const noteRow = sql.getRow("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(
- `
- 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(
- `
- 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(
- `
- 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(
- `
- 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 | Buffer) {
- 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 | Buffer) {
- 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) {
- 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: 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) {
- 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: dateUtils.localNowDateTime(),
- utcDateCreated: dateUtils.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()
- };
- } else {
- 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 = {};
-
- // 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/apps/server/src/services/options.ts b/apps/server/src/services/options.ts
index 1cc67df5a5..68c69c5ab4 100644
--- a/apps/server/src/services/options.ts
+++ b/apps/server/src/services/options.ts
@@ -1,145 +1,2 @@
-/**
- * @module
- *
- * Options are key-value pairs that are used to store information such as user preferences (for example
- * the current theme, sync server information), but also information about the state of the application.
- *
- * Although options internally are represented as strings, their value can be interpreted as a number or
- * boolean by calling the appropriate methods from this service (e.g. {@link #getOptionInt}).\
- *
- * Generally options are shared across multiple instances of the application via the sync mechanism,
- * however it is possible to have options that are local to an instance. For example, the user can select
- * a theme on a device and it will not affect other devices.
- */
-
-import becca from "../becca/becca.js";
-import BOption from "../becca/entities/boption.js";
-import type { OptionRow } from "@triliumnext/commons";
-import type { FilterOptionsByType, OptionDefinitions, OptionMap, OptionNames } from "@triliumnext/commons";
-import sql from "./sql.js";
-
-function getOptionOrNull(name: OptionNames): string | null {
- let option;
-
- if (becca.loaded) {
- option = becca.getOption(name);
- } else {
- // e.g. in initial sync becca is not loaded because DB is not initialized
- try {
- option = sql.getRow("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, 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 {
- 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(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(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 = {};
-
- for (const option of Object.values(becca.options)) {
- map[option.name] = option.value;
- }
-
- return map as OptionMap;
-}
-
-export default {
- getOption,
- getOptionInt,
- getOptionBool,
- setOption,
- createOption,
- getOptions,
- getOptionMap,
- getOptionOrNull
-};
+import { options } from "@triliumnext/core";
+export default options;
diff --git a/apps/server/src/services/options_init.ts b/apps/server/src/services/options_init.ts
index b23e532a86..ce14b77411 100644
--- a/apps/server/src/services/options_init.ts
+++ b/apps/server/src/services/options_init.ts
@@ -1,280 +1,2 @@
-import { type KeyboardShortcutWithRequiredActionName, type OptionMap, type OptionNames, SANITIZER_DEFAULT_ALLOWED_TAGS } from "@triliumnext/commons";
-
-import appInfo from "./app_info.js";
-import dateUtils from "./date_utils.js";
-import keyboardActions from "./keyboard_actions.js";
-import log from "./log.js";
-import optionService from "./options.js";
-import { isWindows, randomSecureToken } from "./utils.js";
-
-function initDocumentOptions() {
- optionService.createOption("documentId", randomSecureToken(16), false);
- optionService.createOption("documentSecret", randomSecureToken(16), false);
-}
-
-/**
- * Contains additional options to be initialized for a new database, containing the information entered by the user.
- */
-interface NotSyncedOpts {
- syncServerHost?: string;
- syncProxy?: string;
-}
-
-/**
- * Represents a correspondence between an option and its default value, to be initialized when the database is missing that particular option (after a migration from an older version, or when creating a new database).
- */
-interface DefaultOption {
- name: OptionNames;
- /**
- * The value to initialize the option with, if the option is not already present in the database.
- *
- * If a function is passed Gin instead, the function is called if the option does not exist (with access to the current options) and the return value is used instead. Useful to migrate a new option with a value depending on some other option that might be initialized.
- */
- value: string | ((options: OptionMap) => string);
- isSynced: boolean;
-}
-
-/**
- * Initializes the default options for new databases only.
- *
- * @param initialized `true` if the database has been fully initialized (i.e. a new database was created), or `false` if the database is created for sync.
- * @param opts additional options to be initialized, for example the sync configuration.
- */
-async function initNotSyncedOptions(initialized: boolean, opts: NotSyncedOpts = {}) {
- optionService.createOption(
- "openNoteContexts",
- JSON.stringify([
- {
- notePath: "root",
- active: true
- }
- ]),
- false
- );
-
- optionService.createOption("lastDailyBackupDate", dateUtils.utcNowDateTime(), false);
- optionService.createOption("lastWeeklyBackupDate", dateUtils.utcNowDateTime(), false);
- optionService.createOption("lastMonthlyBackupDate", dateUtils.utcNowDateTime(), false);
- optionService.createOption("dbVersion", appInfo.dbVersion.toString(), false);
-
- optionService.createOption("initialized", initialized ? "true" : "false", false);
-
- optionService.createOption("lastSyncedPull", "0", false);
- optionService.createOption("lastSyncedPush", "0", false);
-
- optionService.createOption("theme", "next", false);
- optionService.createOption("textNoteEditorType", "ckeditor-classic", true);
-
- optionService.createOption("syncServerHost", opts.syncServerHost || "", false);
- optionService.createOption("syncServerTimeout", "120000", false);
- optionService.createOption("syncProxy", opts.syncProxy || "", false);
-}
-
-/**
- * Contains all the default options that must be initialized on new and existing databases (at startup). The value can also be determined based on other options, provided they have already been initialized.
- */
-const defaultOptions: DefaultOption[] = [
- { name: "revisionSnapshotTimeInterval", value: "600", isSynced: true },
- { name: "revisionSnapshotTimeIntervalTimeScale", value: "60", isSynced: true }, // default to Minutes
- { name: "revisionSnapshotNumberLimit", value: "-1", isSynced: true },
- { name: "protectedSessionTimeout", value: "600", isSynced: true },
- { name: "protectedSessionTimeoutTimeScale", value: "60", isSynced: true },
- { name: "zoomFactor", value: isWindows ? "0.9" : "1.0", isSynced: false },
- { name: "overrideThemeFonts", value: "false", isSynced: false },
- { name: "mainFontFamily", value: "theme", isSynced: false },
- { name: "mainFontSize", value: "100", isSynced: false },
- { name: "treeFontFamily", value: "theme", isSynced: false },
- { name: "treeFontSize", value: "100", isSynced: false },
- { name: "detailFontFamily", value: "theme", isSynced: false },
- { name: "detailFontSize", value: "110", isSynced: false },
- { name: "monospaceFontFamily", value: "theme", isSynced: false },
- { name: "monospaceFontSize", value: "110", isSynced: false },
- { name: "spellCheckEnabled", value: "true", isSynced: false },
- { name: "spellCheckLanguageCode", value: "en-US", isSynced: false },
- { name: "imageMaxWidthHeight", value: "2000", isSynced: true },
- { name: "imageJpegQuality", value: "75", isSynced: true },
- { name: "autoFixConsistencyIssues", value: "true", isSynced: false },
- { name: "vimKeymapEnabled", value: "false", isSynced: false },
- { name: "codeLineWrapEnabled", value: "true", isSynced: false },
- {
- name: "codeNotesMimeTypes",
- value: '["text/x-csrc","text/x-c++src","text/x-csharp","text/css","text/x-elixir","text/x-go","text/x-groovy","text/x-haskell","text/html","message/http","text/x-java","application/javascript;env=frontend","application/javascript;env=backend","application/json","text/x-kotlin","text/x-markdown","text/x-perl","text/x-php","text/x-python","text/x-ruby",null,"text/x-sql","text/x-sqlite;schema=trilium","text/x-swift","text/xml","text/x-yaml","text/x-sh","application/typescript"]',
- isSynced: true
- },
- { name: "leftPaneWidth", value: "25", isSynced: false },
- { name: "leftPaneVisible", value: "true", isSynced: false },
- { name: "rightPaneWidth", value: "25", isSynced: false },
- { name: "rightPaneVisible", value: "true", isSynced: false },
- { name: "rightPaneCollapsedItems", value: "[]", isSynced: false },
- { name: "nativeTitleBarVisible", value: "false", isSynced: false },
- { name: "eraseEntitiesAfterTimeInSeconds", value: "604800", isSynced: true }, // default is 7 days
- { name: "eraseEntitiesAfterTimeScale", value: "86400", isSynced: true }, // default 86400 seconds = Day
- { name: "hideArchivedNotes_main", value: "false", isSynced: false },
- { name: "debugModeEnabled", value: "false", isSynced: false },
- { name: "headingStyle", value: "underline", isSynced: true },
- { name: "autoCollapseNoteTree", value: "true", isSynced: true },
- { name: "autoReadonlySizeText", value: "32000", isSynced: false },
- { name: "autoReadonlySizeCode", value: "64000", isSynced: false },
- { name: "dailyBackupEnabled", value: "true", isSynced: false },
- { name: "weeklyBackupEnabled", value: "true", isSynced: false },
- { name: "monthlyBackupEnabled", value: "true", isSynced: false },
- { name: "maxContentWidth", value: "1200", isSynced: false },
- { name: "centerContent", value: "false", isSynced: false },
- { name: "compressImages", value: "true", isSynced: true },
- { name: "downloadImagesAutomatically", value: "true", isSynced: true },
- { name: "minTocHeadings", value: "5", isSynced: true },
- { name: "highlightsList", value: '["underline","color","bgColor"]', isSynced: true },
- { name: "checkForUpdates", value: "true", isSynced: true },
- { name: "disableTray", value: "false", isSynced: false },
- { name: "eraseUnusedAttachmentsAfterSeconds", value: "2592000", isSynced: true }, // default 30 days
- { name: "eraseUnusedAttachmentsAfterTimeScale", value: "86400", isSynced: true }, // default 86400 seconds = Day
- { name: "logRetentionDays", value: "90", isSynced: false }, // default 90 days
- { name: "customSearchEngineName", value: "DuckDuckGo", isSynced: true },
- { name: "customSearchEngineUrl", value: "https://duckduckgo.com/?q={keyword}", isSynced: true },
- { name: "editedNotesOpenInRibbon", value: "true", isSynced: true },
- { name: "mfaEnabled", value: "false", isSynced: false },
- { name: "mfaMethod", value: "totp", isSynced: false },
- { name: "encryptedRecoveryCodes", value: "false", isSynced: false },
- { name: "userSubjectIdentifierSaved", value: "false", isSynced: false },
-
- // Appearance
- { name: "splitEditorOrientation", value: "horizontal", isSynced: true },
- {
- name: "codeNoteTheme",
- value: (optionsMap) => {
- switch (optionsMap.theme) {
- case "light":
- case "next-light":
- return "default:vs-code-light";
- case "dark":
- case "next-dark":
- default:
- return "default:vs-code-dark";
- }
- },
- isSynced: false
- },
- { name: "motionEnabled", value: "true", isSynced: false },
- { name: "shadowsEnabled", value: "true", isSynced: false },
- { name: "backdropEffectsEnabled", value: "true", isSynced: false },
- { name: "smoothScrollEnabled", value: "true", isSynced: false },
- { name: "newLayout", value: "true", isSynced: true },
-
- // Internationalization
- { name: "locale", value: "en", isSynced: true },
- { name: "formattingLocale", value: "", isSynced: true }, // no value means auto-detect
- { name: "firstDayOfWeek", value: "1", isSynced: true },
- { name: "firstWeekOfYear", value: "0", isSynced: true },
- { name: "minDaysInFirstWeek", value: "4", isSynced: true },
- { name: "languages", value: "[]", isSynced: true },
-
- // Code block configuration
- {
- name: "codeBlockTheme",
- value: (optionsMap) => {
- if (optionsMap.theme === "light") {
- return "default:stackoverflow-light";
- }
- return "default:stackoverflow-dark";
-
- },
- isSynced: false
- },
- { name: "codeBlockWordWrap", value: "false", isSynced: true },
-
- // Text note configuration
- { name: "textNoteEditorType", value: "ckeditor-balloon", isSynced: true },
- { name: "textNoteEditorMultilineToolbar", value: "false", isSynced: true },
- { name: "textNoteEmojiCompletionEnabled", value: "true", isSynced: true },
- { name: "textNoteCompletionEnabled", value: "true", isSynced: true },
- { name: "textNoteSlashCommandsEnabled", value: "true", isSynced: true },
-
- // HTML import configuration
- { name: "layoutOrientation", value: "vertical", isSynced: false },
- { name: "backgroundEffects", value: "true", isSynced: false },
- {
- name: "allowedHtmlTags",
- value: JSON.stringify(SANITIZER_DEFAULT_ALLOWED_TAGS),
- isSynced: true
- },
-
- // Share settings
- { name: "redirectBareDomain", value: "false", isSynced: true },
- { name: "showLoginInShareTheme", value: "false", isSynced: true },
-
- // AI Options
- { name: "aiEnabled", value: "false", isSynced: true },
- { name: "openaiApiKey", value: "", isSynced: false },
- { name: "openaiDefaultModel", value: "", isSynced: true },
- { name: "openaiBaseUrl", value: "https://api.openai.com/v1", isSynced: true },
- { name: "anthropicApiKey", value: "", isSynced: false },
- { name: "anthropicDefaultModel", value: "", isSynced: true },
- { name: "voyageApiKey", value: "", isSynced: false },
- { name: "anthropicBaseUrl", value: "https://api.anthropic.com/v1", isSynced: true },
- { name: "ollamaEnabled", value: "false", isSynced: true },
- { name: "ollamaDefaultModel", value: "", isSynced: true },
- { name: "ollamaBaseUrl", value: "http://localhost:11434", isSynced: true },
- { name: "aiTemperature", value: "0.7", isSynced: true },
- { name: "aiSystemPrompt", value: "", isSynced: true },
- { name: "aiSelectedProvider", value: "openai", isSynced: true },
-
- {
- name: "seenCallToActions",
- value: JSON.stringify([
- "new_layout", "background_effects", "next_theme"
- ]),
- isSynced: true
- },
- { name: "experimentalFeatures", value: "[]", isSynced: true }
-];
-
-/**
- * Initializes the options, by checking which options from {@link #allDefaultOptions()} are missing and registering them. It will also check some environment variables such as safe mode, to make any necessary adjustments.
- *
- * This method is called regardless of whether a new database is created, or an existing database is used.
- */
-function initStartupOptions() {
- const optionsMap = optionService.getOptionMap();
-
- const allDefaultOptions = defaultOptions.concat(getKeyboardDefaultOptions());
-
- for (const { name, value, isSynced } of allDefaultOptions) {
- if (!(name in optionsMap)) {
- let resolvedValue;
- if (typeof value === "function") {
- resolvedValue = value(optionsMap);
- } else {
- resolvedValue = value;
- }
-
- optionService.createOption(name, resolvedValue, isSynced);
- log.info(`Created option "${name}" with default value "${resolvedValue}"`);
- }
- }
-
- if (process.env.TRILIUM_START_NOTE_ID || process.env.TRILIUM_SAFE_MODE) {
- optionService.setOption(
- "openNoteContexts",
- JSON.stringify([
- {
- notePath: process.env.TRILIUM_START_NOTE_ID || "root",
- active: true
- }
- ])
- );
- }
-}
-
-function getKeyboardDefaultOptions() {
- return (keyboardActions.getDefaultKeyboardActions().filter((ka) => "actionName" in ka) as KeyboardShortcutWithRequiredActionName[]).map((ka) => ({
- name: `keyboardShortcuts${ka.actionName.charAt(0).toUpperCase()}${ka.actionName.slice(1)}`,
- value: JSON.stringify(ka.defaultShortcuts),
- isSynced: false
- })) as DefaultOption[];
-}
-
-export default {
- initDocumentOptions,
- initNotSyncedOptions,
- initStartupOptions
-};
+import { options_init } from "@triliumnext/core";
+export default options_init;
diff --git a/apps/server/src/services/promoted_attribute_definition_interface.ts b/apps/server/src/services/promoted_attribute_definition_interface.ts
deleted file mode 100644
index 2f68aa7ac1..0000000000
--- a/apps/server/src/services/promoted_attribute_definition_interface.ts
+++ /dev/null
@@ -1,8 +0,0 @@
-export interface DefinitionObject {
- isPromoted?: boolean;
- labelType?: string;
- multiplicity?: string;
- numberPrecision?: number;
- promotedAlias?: string;
- inverseRelation?: string;
-}
diff --git a/apps/server/src/services/protected_session.ts b/apps/server/src/services/protected_session.ts
index d69d318d1b..780b584b27 100644
--- a/apps/server/src/services/protected_session.ts
+++ b/apps/server/src/services/protected_session.ts
@@ -1,69 +1,3 @@
-"use strict";
+import { protected_session } from "@triliumnext/core";
-import dataEncryptionService from "./encryption/data_encryption.js";
-
-let dataKey: Buffer | null = null;
-
-function setDataKey(decryptedDataKey: Buffer) {
- dataKey = Buffer.from(decryptedDataKey);
-}
-
-function getDataKey() {
- return dataKey;
-}
-
-export function resetDataKey() {
- dataKey = null;
-}
-
-export function isProtectedSessionAvailable() {
- return !!dataKey;
-}
-
-function encrypt(plainText: string | Buffer) {
- const dataKey = getDataKey();
- if (plainText === null || dataKey === null) {
- return null;
- }
-
- return dataEncryptionService.encrypt(dataKey, plainText);
-}
-
-function decrypt(cipherText: string | Buffer): Buffer | null {
- const dataKey = getDataKey();
- if (cipherText === null || dataKey === null) {
- return null;
- }
-
- return dataEncryptionService.decrypt(dataKey, cipherText) || null;
-}
-
-function decryptString(cipherText: string): string | null {
- const dataKey = getDataKey();
- if (dataKey === null) {
- return null;
- }
- return dataEncryptionService.decryptString(dataKey, cipherText);
-}
-
-let lastProtectedSessionOperationDate: number | null = null;
-
-function touchProtectedSession() {
- if (isProtectedSessionAvailable()) {
- lastProtectedSessionOperationDate = Date.now();
- }
-}
-
-export function getLastProtectedSessionOperationDate() {
- return lastProtectedSessionOperationDate;
-}
-
-export default {
- setDataKey,
- resetDataKey,
- isProtectedSessionAvailable,
- encrypt,
- decrypt,
- decryptString,
- touchProtectedSession
-};
+export default protected_session.default;
diff --git a/apps/server/src/services/revisions.ts b/apps/server/src/services/revisions.ts
index 1265d49272..ae8f826b0c 100644
--- a/apps/server/src/services/revisions.ts
+++ b/apps/server/src/services/revisions.ts
@@ -1,49 +1,2 @@
-"use strict";
-
-import log from "./log.js";
-import sql from "./sql.js";
-import protectedSessionService from "./protected_session.js";
-import dateUtils from "./date_utils.js";
-import type BNote from "../becca/entities/bnote.js";
-
-function protectRevisions(note: BNote) {
- if (!protectedSessionService.isProtectedSessionAvailable()) {
- throw new Error(`Cannot (un)protect revisions of note '${note.noteId}' without active protected session`);
- }
-
- for (const revision of note.getRevisions()) {
- if (note.isProtected !== revision.isProtected) {
- try {
- const content = revision.getContent();
-
- revision.isProtected = !!note.isProtected;
-
- // this will force de/encryption
- revision.setContent(content, { forceSave: true });
- } catch (e) {
- log.error(`Could not un/protect note revision '${revision.revisionId}'`);
-
- throw e;
- }
- }
-
- for (const attachment of revision.getAttachments()) {
- if (note.isProtected !== attachment.isProtected) {
- try {
- const content = attachment.getContent();
-
- attachment.isProtected = note.isProtected;
- attachment.setContent(content, { forceSave: true });
- } catch (e) {
- log.error(`Could not un/protect attachment '${attachment.attachmentId}'`);
-
- throw e;
- }
- }
- }
- }
-}
-
-export default {
- protectRevisions
-};
+import { revisions } from "@triliumnext/core";
+export default revisions;
diff --git a/apps/server/src/services/sanitize_attribute_name.ts b/apps/server/src/services/sanitize_attribute_name.ts
deleted file mode 100644
index 3e597e94a8..0000000000
--- a/apps/server/src/services/sanitize_attribute_name.ts
+++ /dev/null
@@ -1,6 +0,0 @@
-export default function sanitizeAttributeName(origName: string) {
- const fixedName = origName === "" ? "unnamed" : origName.replace(/[^\p{L}\p{N}_:]/gu, "_");
- // any not allowed character should be replaced with underscore
-
- return fixedName;
-}
diff --git a/apps/server/src/services/scheduler.ts b/apps/server/src/services/scheduler.ts
index f44e3aa430..0ed4236513 100644
--- a/apps/server/src/services/scheduler.ts
+++ b/apps/server/src/services/scheduler.ts
@@ -1,13 +1,14 @@
-import scriptService from "./script.js";
-import cls from "./cls.js";
-import sqlInit from "./sql_init.js";
-import config from "./config.js";
-import log from "./log.js";
-import attributeService from "../services/attributes.js";
-import hiddenSubtreeService from "./hidden_subtree.js";
+import { protected_session } from "@triliumnext/core";
+
import type BNote from "../becca/entities/bnote.js";
+import attributeService from "../services/attributes.js";
+import cls from "./cls.js";
+import config from "./config.js";
+import hiddenSubtreeService from "./hidden_subtree.js";
+import log from "./log.js";
import options from "./options.js";
-import { getLastProtectedSessionOperationDate, isProtectedSessionAvailable, resetDataKey } from "./protected_session.js";
+import scriptService from "./script.js";
+import sqlInit from "./sql_init.js";
import ws from "./ws.js";
function getRunAtHours(note: BNote): number[] {
@@ -71,9 +72,9 @@ sqlInit.dbReady.then(() => {
function checkProtectedSessionExpiration() {
const protectedSessionTimeout = options.getOptionInt("protectedSessionTimeout");
- const lastProtectedSessionOperationDate = getLastProtectedSessionOperationDate();
- if (isProtectedSessionAvailable() && lastProtectedSessionOperationDate && Date.now() - lastProtectedSessionOperationDate > protectedSessionTimeout * 1000) {
- resetDataKey();
+ const lastProtectedSessionOperationDate = protected_session.getLastProtectedSessionOperationDate();
+ if (protected_session.isProtectedSessionAvailable() && lastProtectedSessionOperationDate && Date.now() - lastProtectedSessionOperationDate > protectedSessionTimeout * 1000) {
+ protected_session.resetDataKey();
log.info("Expiring protected session");
ws.reloadFrontend("leaving protected session");
}
diff --git a/apps/server/src/services/script.ts b/apps/server/src/services/script.ts
index 97dd99898a..4b335f11d2 100644
--- a/apps/server/src/services/script.ts
+++ b/apps/server/src/services/script.ts
@@ -1,3 +1,4 @@
+import { binary_utils } from "@triliumnext/core";
import { transform } from "sucrase";
import becca from "../becca/becca.js";
@@ -217,8 +218,8 @@ return module.exports;
return bundle;
}
-export function buildJsx(contentRaw: string | Buffer) {
- const content = Buffer.isBuffer(contentRaw) ? contentRaw.toString("utf-8") : contentRaw;
+export function buildJsx(contentRaw: string | Uint8Array) {
+ const content = binary_utils.unwrapStringOrBuffer(contentRaw);
const output = transform(content, {
transforms: ["jsx", "imports"],
jsxPragma: "api.preact.h",
diff --git a/apps/server/src/services/search/expressions/note_content_fulltext.ts b/apps/server/src/services/search/expressions/note_content_fulltext.ts
index 89ba1bc984..4bd3408f9e 100644
--- a/apps/server/src/services/search/expressions/note_content_fulltext.ts
+++ b/apps/server/src/services/search/expressions/note_content_fulltext.ts
@@ -1,24 +1,23 @@
-"use strict";
+
import type { NoteRow } from "@triliumnext/commons";
-import type SearchContext from "../search_context.js";
-
-import Expression from "./expression.js";
-import NoteSet from "../note_set.js";
-import log from "../../log.js";
-import becca from "../../../becca/becca.js";
-import protectedSessionService from "../../protected_session.js";
import striptags from "striptags";
-import { normalize } from "../../utils.js";
+
+import becca from "../../../becca/becca.js";
+import log from "../../log.js";
+import protectedSessionService from "../../protected_session.js";
import sql from "../../sql.js";
-import {
- normalizeSearchText,
- calculateOptimizedEditDistance,
- validateFuzzySearchTokens,
- validateAndPreprocessContent,
+import { normalize } from "../../utils.js";
+import NoteSet from "../note_set.js";
+import type SearchContext from "../search_context.js";
+import {
+ calculateOptimizedEditDistance,
+ FUZZY_SEARCH_CONFIG,
fuzzyMatchWord,
- FUZZY_SEARCH_CONFIG
-} from "../utils/text_utils.js";
+ normalizeSearchText,
+ validateAndPreprocessContent,
+ validateFuzzySearchTokens} from "../utils/text_utils.js";
+import Expression from "./expression.js";
const ALLOWED_OPERATORS = new Set(["=", "!=", "*=*", "*=", "=*", "%=", "~=", "~*"]);
@@ -295,7 +294,7 @@ class NoteContentFulltextExp extends Expression {
return content;
}
- preprocessContent(content: string | Buffer, type: string, mime: string) {
+ preprocessContent(content: string | Uint8Array, type: string, mime: string) {
content = normalize(content.toString());
if (type === "text" && mime === "text/html") {
@@ -338,16 +337,16 @@ class NoteContentFulltextExp extends Expression {
private tokenMatchesContent(token: string, content: string, noteId: string): boolean {
const normalizedToken = normalizeSearchText(token);
const normalizedContent = normalizeSearchText(content);
-
+
if (normalizedContent.includes(normalizedToken)) {
return true;
}
-
+
// Check flat text for default fulltext search
if (!this.flatText || !becca.notes[noteId].getFlatText().includes(token)) {
return false;
}
-
+
return true;
}
@@ -358,15 +357,15 @@ class NoteContentFulltextExp extends Expression {
try {
const normalizedContent = normalizeSearchText(content);
const flatText = this.flatText ? normalizeSearchText(becca.notes[noteId].getFlatText()) : "";
-
+
// For phrase matching, check if tokens appear within reasonable proximity
if (this.tokens.length > 1) {
return this.matchesPhrase(normalizedContent, flatText);
}
-
+
// Single token fuzzy matching
const token = normalizeSearchText(this.tokens[0]);
- return this.fuzzyMatchToken(token, normalizedContent) ||
+ return this.fuzzyMatchToken(token, normalizedContent) ||
(this.flatText && this.fuzzyMatchToken(token, flatText));
} catch (error) {
log.error(`Error in fuzzy matching for note ${noteId}: ${error}`);
@@ -379,45 +378,45 @@ class NoteContentFulltextExp extends Expression {
*/
private matchesPhrase(content: string, flatText: string): boolean {
const searchText = this.flatText ? `${content} ${flatText}` : content;
-
+
// Apply content size limits for phrase matching
const limitedText = validateAndPreprocessContent(searchText);
if (!limitedText) {
return false;
}
-
+
const words = limitedText.toLowerCase().split(/\s+/);
-
+
// Only skip phrase matching for truly extreme word counts that could crash the system
if (words.length > FUZZY_SEARCH_CONFIG.ABSOLUTE_MAX_WORD_COUNT) {
console.error(`Phrase matching skipped due to extreme word count that could cause system instability: ${words.length} words`);
return false;
}
-
+
// Warn about large word counts but still attempt matching
if (words.length > FUZZY_SEARCH_CONFIG.PERFORMANCE_WARNING_WORDS) {
console.info(`Large word count for phrase matching: ${words.length} words - may take longer but will attempt full matching`);
}
-
+
// Find positions of each token
const tokenPositions: number[][] = this.tokens.map(token => {
const normalizedToken = normalizeSearchText(token);
const positions: number[] = [];
-
+
words.forEach((word, index) => {
if (this.fuzzyMatchSingle(normalizedToken, word)) {
positions.push(index);
}
});
-
+
return positions;
});
-
+
// Check if we found all tokens
if (tokenPositions.some(positions => positions.length === 0)) {
return false;
}
-
+
// Check for phrase proximity using configurable distance
return this.hasProximityMatch(tokenPositions, FUZZY_SEARCH_CONFIG.MAX_PHRASE_PROXIMITY);
}
@@ -431,18 +430,18 @@ class NoteContentFulltextExp extends Expression {
const [pos1, pos2] = tokenPositions;
return pos1.some(p1 => pos2.some(p2 => Math.abs(p1 - p2) <= maxDistance));
}
-
+
// For more tokens, check if we can find a sequence where all tokens are within range
const findSequence = (remaining: number[][], currentPos: number): boolean => {
if (remaining.length === 0) return true;
-
+
const [nextPositions, ...rest] = remaining;
- return nextPositions.some(pos =>
- Math.abs(pos - currentPos) <= maxDistance &&
+ return nextPositions.some(pos =>
+ Math.abs(pos - currentPos) <= maxDistance &&
findSequence(rest, pos)
);
};
-
+
const [firstPositions, ...rest] = tokenPositions;
return firstPositions.some(startPos => findSequence(rest, startPos));
}
@@ -455,12 +454,12 @@ class NoteContentFulltextExp extends Expression {
// For short tokens, require exact match to avoid too many false positives
return content.includes(token);
}
-
+
const words = content.split(/\s+/);
-
+
// Only limit word processing for truly extreme cases to prevent system instability
const limitedWords = words.slice(0, FUZZY_SEARCH_CONFIG.ABSOLUTE_MAX_WORD_COUNT);
-
+
return limitedWords.some(word => this.fuzzyMatchSingle(token, word));
}
diff --git a/apps/server/src/services/search/expressions/note_flat_text.ts b/apps/server/src/services/search/expressions/note_flat_text.ts
index b9ad19c36c..18bb0944d8 100644
--- a/apps/server/src/services/search/expressions/note_flat_text.ts
+++ b/apps/server/src/services/search/expressions/note_flat_text.ts
@@ -1,14 +1,12 @@
-"use strict";
+import { becca_service } from "@triliumnext/core";
-import type BNote from "../../../becca/entities/bnote.js";
-import type SearchContext from "../search_context.js";
-
-import Expression from "./expression.js";
-import NoteSet from "../note_set.js";
import becca from "../../../becca/becca.js";
+import type BNote from "../../../becca/entities/bnote.js";
import { normalize } from "../../utils.js";
-import { normalizeSearchText, fuzzyMatchWord, fuzzyMatchWordWithResult } from "../utils/text_utils.js";
-import beccaService from "../../../becca/becca_service.js";
+import NoteSet from "../note_set.js";
+import type SearchContext from "../search_context.js";
+import { fuzzyMatchWord, fuzzyMatchWordWithResult,normalizeSearchText } from "../utils/text_utils.js";
+import Expression from "./expression.js";
class NoteFlatTextExp extends Expression {
tokens: string[];
@@ -60,7 +58,7 @@ class NoteFlatTextExp extends Expression {
// Add defensive checks for undefined properties
const typeMatches = note.type && note.type.includes(token);
const mimeMatches = note.mime && note.mime.includes(token);
-
+
if (typeMatches || mimeMatches) {
foundAttrTokens.push(token);
}
@@ -78,7 +76,7 @@ class NoteFlatTextExp extends Expression {
}
for (const parentNote of note.parents) {
- const title = normalizeSearchText(beccaService.getNoteTitle(note.noteId, parentNote.noteId));
+ const title = normalizeSearchText(becca_service.getNoteTitle(note.noteId, parentNote.noteId));
const foundTokens: string[] = foundAttrTokens.slice();
for (const token of remainingTokens) {
@@ -112,7 +110,7 @@ class NoteFlatTextExp extends Expression {
// Add defensive checks for undefined properties
const typeMatches = note.type && note.type.includes(token);
const mimeMatches = note.mime && note.mime.includes(token);
-
+
if (typeMatches || mimeMatches) {
foundAttrTokens.push(token);
}
@@ -125,7 +123,7 @@ class NoteFlatTextExp extends Expression {
}
for (const parentNote of note.parents) {
- const title = normalizeSearchText(beccaService.getNoteTitle(note.noteId, parentNote.noteId));
+ const title = normalizeSearchText(becca_service.getNoteTitle(note.noteId, parentNote.noteId));
const foundTokens = foundAttrTokens.slice();
for (const token of this.tokens) {
@@ -190,7 +188,7 @@ class NoteFlatTextExp extends Expression {
if (text.includes(token)) {
return true;
}
-
+
// Fuzzy fallback only if enabled and for tokens >= 4 characters
if (searchContext?.enableFuzzyMatching && token.length >= 4) {
const matchedWord = fuzzyMatchWordWithResult(token, text);
@@ -202,7 +200,7 @@ class NoteFlatTextExp extends Expression {
return true;
}
}
-
+
return false;
}
}
diff --git a/apps/server/src/services/search/note_set.ts b/apps/server/src/services/search/note_set.ts
index bab76afa5e..10ecb41346 100644
--- a/apps/server/src/services/search/note_set.ts
+++ b/apps/server/src/services/search/note_set.ts
@@ -1,67 +1,2 @@
-"use strict";
-
-import type BNote from "../../becca/entities/bnote.js";
-
-class NoteSet {
- private noteIdSet: Set;
-
- 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/apps/server/src/services/search/search_result.ts b/apps/server/src/services/search/search_result.ts
index bf8a33524b..984a6d97a8 100644
--- a/apps/server/src/services/search/search_result.ts
+++ b/apps/server/src/services/search/search_result.ts
@@ -1,12 +1,10 @@
-"use strict";
+import { becca_service } from "@triliumnext/core";
-import beccaService from "../../becca/becca_service.js";
import becca from "../../becca/becca.js";
-import {
- normalizeSearchText,
- calculateOptimizedEditDistance,
- FUZZY_SEARCH_CONFIG
-} from "./utils/text_utils.js";
+import {
+ calculateOptimizedEditDistance,
+ FUZZY_SEARCH_CONFIG,
+ normalizeSearchText} from "./utils/text_utils.js";
// Scoring constants for better maintainability
const SCORE_WEIGHTS = {
@@ -41,7 +39,7 @@ class SearchResult {
constructor(notePathArray: string[]) {
this.notePathArray = notePathArray;
- this.notePathTitle = beccaService.getNoteTitleForPath(notePathArray);
+ this.notePathTitle = becca_service.getNoteTitleForPath(notePathArray);
this.score = 0;
this.fuzzyScore = 0;
}
@@ -98,7 +96,7 @@ class SearchResult {
for (const chunk of chunks) {
for (const token of tokens) {
const normalizedToken = normalizeSearchText(token.toLowerCase());
-
+
if (chunk === normalizedToken) {
tokenScore += SCORE_WEIGHTS.TOKEN_EXACT_MATCH * token.length * factor;
} else if (chunk.startsWith(normalizedToken)) {
@@ -108,10 +106,10 @@ class SearchResult {
} else {
// Try fuzzy matching for individual tokens with caps applied
const editDistance = calculateOptimizedEditDistance(chunk, normalizedToken, FUZZY_SEARCH_CONFIG.MAX_EDIT_DISTANCE);
- if (editDistance <= FUZZY_SEARCH_CONFIG.MAX_EDIT_DISTANCE &&
+ if (editDistance <= FUZZY_SEARCH_CONFIG.MAX_EDIT_DISTANCE &&
normalizedToken.length >= FUZZY_SEARCH_CONFIG.MIN_FUZZY_TOKEN_LENGTH &&
this.fuzzyScore < SCORE_WEIGHTS.MAX_TOTAL_FUZZY_SCORE) {
-
+
const fuzzyWeight = SCORE_WEIGHTS.TOKEN_FUZZY_MATCH * (1 - editDistance / FUZZY_SEARCH_CONFIG.MAX_EDIT_DISTANCE);
// Apply caps: limit token length multiplier and per-token contribution
const cappedTokenLength = Math.min(token.length, SCORE_WEIGHTS.MAX_FUZZY_TOKEN_LENGTH_MULTIPLIER);
@@ -119,7 +117,7 @@ class SearchResult {
fuzzyWeight * cappedTokenLength * factor,
SCORE_WEIGHTS.MAX_FUZZY_SCORE_PER_TOKEN
);
-
+
tokenScore += fuzzyTokenScore;
this.fuzzyScore += fuzzyTokenScore;
}
@@ -134,8 +132,8 @@ class SearchResult {
* Checks if the query matches as a complete word in the text
*/
private isWordMatch(text: string, query: string): boolean {
- return text.includes(` ${query} `) ||
- text.startsWith(`${query} `) ||
+ return text.includes(` ${query} `) ||
+ text.startsWith(`${query} `) ||
text.endsWith(` ${query}`);
}
@@ -147,21 +145,21 @@ class SearchResult {
if (this.fuzzyScore >= SCORE_WEIGHTS.MAX_TOTAL_FUZZY_SCORE) {
return 0;
}
-
+
const editDistance = calculateOptimizedEditDistance(title, query, FUZZY_SEARCH_CONFIG.MAX_EDIT_DISTANCE);
const maxLen = Math.max(title.length, query.length);
-
+
// Only apply fuzzy matching if the query is reasonably long and edit distance is small
- if (query.length >= FUZZY_SEARCH_CONFIG.MIN_FUZZY_TOKEN_LENGTH &&
- editDistance <= FUZZY_SEARCH_CONFIG.MAX_EDIT_DISTANCE &&
+ if (query.length >= FUZZY_SEARCH_CONFIG.MIN_FUZZY_TOKEN_LENGTH &&
+ editDistance <= FUZZY_SEARCH_CONFIG.MAX_EDIT_DISTANCE &&
editDistance / maxLen <= 0.3) {
const similarity = 1 - (editDistance / maxLen);
const baseFuzzyScore = SCORE_WEIGHTS.TITLE_WORD_MATCH * similarity * 0.7; // Reduced weight for fuzzy matches
-
+
// Apply cap to ensure fuzzy title matches don't exceed reasonable bounds
return Math.min(baseFuzzyScore, SCORE_WEIGHTS.MAX_TOTAL_FUZZY_SCORE * 0.3);
}
-
+
return 0;
}
diff --git a/apps/server/src/services/search/services/search.ts b/apps/server/src/services/search/services/search.ts
index 5ca4bda4a1..16eeb70ccd 100644
--- a/apps/server/src/services/search/services/search.ts
+++ b/apps/server/src/services/search/services/search.ts
@@ -1,24 +1,22 @@
-"use strict";
-
+import { becca_service } from "@triliumnext/core";
import normalizeString from "normalize-strings";
-import lex from "./lex.js";
-import handleParens from "./handle_parens.js";
-import parse from "./parse.js";
-import SearchResult from "../search_result.js";
-import SearchContext from "../search_context.js";
-import becca from "../../../becca/becca.js";
-import beccaService from "../../../becca/becca_service.js";
-import { normalize, escapeHtml, escapeRegExp } from "../../utils.js";
-import log from "../../log.js";
-import hoistedNoteService from "../../hoisted_note.js";
-import type BNote from "../../../becca/entities/bnote.js";
-import type BAttribute from "../../../becca/entities/battribute.js";
-import type { SearchParams, TokenStructure } from "./types.js";
-import type Expression from "../expressions/expression.js";
-import sql from "../../sql.js";
-import scriptService from "../../script.js";
import striptags from "striptags";
+
+import becca from "../../../becca/becca.js";
+import type BNote from "../../../becca/entities/bnote.js";
+import hoistedNoteService from "../../hoisted_note.js";
+import log from "../../log.js";
import protectedSessionService from "../../protected_session.js";
+import scriptService from "../../script.js";
+import sql from "../../sql.js";
+import { escapeHtml, escapeRegExp } from "../../utils.js";
+import type Expression from "../expressions/expression.js";
+import SearchContext from "../search_context.js";
+import SearchResult from "../search_result.js";
+import handleParens from "./handle_parens.js";
+import lex from "./lex.js";
+import parse from "./parse.js";
+import type { SearchParams, TokenStructure } from "./types.js";
export interface SearchNoteResult {
searchResultNoteIds: string[];
@@ -67,7 +65,7 @@ function searchFromNote(note: BNote): SearchNoteResult {
return {
searchResultNoteIds: searchResultNoteIds.filter((resultNoteId) => !["root", note.noteId].includes(resultNoteId)),
highlightedTokens,
- error: error
+ error
};
}
@@ -252,21 +250,21 @@ function findResultsWithExpression(expression: Expression, searchContext: Search
// Phase 1: Try exact matches first (without fuzzy matching)
const exactResults = performSearch(expression, searchContext, false);
-
+
// Check if we have sufficient high-quality results
const minResultThreshold = 5;
const minScoreForQuality = 10; // Minimum score to consider a result "high quality"
-
+
const highQualityResults = exactResults.filter(result => result.score >= minScoreForQuality);
-
+
// If we have enough high-quality exact matches, return them
if (highQualityResults.length >= minResultThreshold) {
return exactResults;
}
-
+
// Phase 2: Add fuzzy matching as fallback when exact matches are insufficient
const fuzzyResults = performSearch(expression, searchContext, true);
-
+
// Merge results, ensuring exact matches always rank higher than fuzzy matches
return mergeExactAndFuzzyResults(exactResults, fuzzyResults);
}
@@ -326,10 +324,10 @@ function performSearch(expression: Expression, searchContext: SearchContext, ena
function mergeExactAndFuzzyResults(exactResults: SearchResult[], fuzzyResults: SearchResult[]): SearchResult[] {
// Create a map of exact result note IDs for deduplication
const exactNoteIds = new Set(exactResults.map(result => result.noteId));
-
+
// Add fuzzy results that aren't already in exact results
const additionalFuzzyResults = fuzzyResults.filter(result => !exactNoteIds.has(result.noteId));
-
+
// Sort exact results by score (best exact matches first)
exactResults.sort((a, b) => {
if (a.score > b.score) {
@@ -345,7 +343,7 @@ function mergeExactAndFuzzyResults(exactResults: SearchResult[], fuzzyResults: S
return a.notePathArray.length < b.notePathArray.length ? -1 : 1;
});
-
+
// Sort fuzzy results by score (best fuzzy matches first)
additionalFuzzyResults.sort((a, b) => {
if (a.score > b.score) {
@@ -361,7 +359,7 @@ function mergeExactAndFuzzyResults(exactResults: SearchResult[], fuzzyResults: S
return a.notePathArray.length < b.notePathArray.length ? -1 : 1;
});
-
+
// CRITICAL: Always put exact matches before fuzzy matches, regardless of scores
return [...exactResults, ...additionalFuzzyResults];
}
@@ -417,10 +415,10 @@ function findResultsWithQuery(query: string, searchContext: SearchContext): Sear
}
// If the query starts with '#', it's a pure expression query.
- // Don't use progressive search for these as they may have complex
+ // Don't use progressive search for these as they may have complex
// ordering or other logic that shouldn't be interfered with.
const isPureExpressionQuery = query.trim().startsWith('#');
-
+
if (isPureExpressionQuery) {
// For pure expression queries, use standard search without progressive phases
return performSearch(expression, searchContext, searchContext.enableFuzzyMatching);
@@ -448,7 +446,7 @@ function extractContentSnippet(noteId: string, searchTokens: string[], maxLength
try {
let content = note.getContent();
-
+
if (!content || typeof content !== "string") {
return "";
}
@@ -489,7 +487,7 @@ function extractContentSnippet(noteId: string, searchTokens: string[], maxLength
for (const token of searchTokens) {
const normalizedToken = normalizeString(token.toLowerCase());
const matchIndex = normalizedContent.indexOf(normalizedToken);
-
+
if (matchIndex !== -1) {
// Center the snippet around the match
snippetStart = Math.max(0, matchIndex - maxLength / 2);
@@ -528,7 +526,7 @@ function extractContentSnippet(noteId: string, searchTokens: string[], maxLength
snippet = lines.slice(0, 4).join('\n');
}
// Add ellipsis if we truncated lines
- snippet = snippet + "...";
+ snippet = `${snippet }...`;
} else if (lines.length > 1) {
// For multi-line snippets that are 4 or fewer lines, keep them as-is
// No need to truncate
@@ -540,15 +538,15 @@ function extractContentSnippet(noteId: string, searchTokens: string[], maxLength
if (firstSpace > 0 && firstSpace < 20) {
snippet = snippet.substring(firstSpace + 1);
}
- snippet = "..." + snippet;
+ snippet = `...${ snippet}`;
}
-
+
if (snippetStart + maxLength < content.length) {
const lastSpace = snippet.search(/\s[^\s]*$/);
if (lastSpace > snippet.length - 20 && lastSpace > 0) {
snippet = snippet.substring(0, lastSpace);
}
- snippet = snippet + "...";
+ snippet = `${snippet }...`;
}
}
@@ -572,20 +570,20 @@ function extractAttributeSnippet(noteId: string, searchTokens: string[], maxLeng
return "";
}
- let matchingAttributes: Array<{name: string, value: string, type: string}> = [];
-
+ const matchingAttributes: Array<{name: string, value: string, type: string}> = [];
+
// Look for attributes that match the search tokens
for (const attr of attributes) {
const attrName = attr.name?.toLowerCase() || "";
const attrValue = attr.value?.toLowerCase() || "";
const attrType = attr.type || "";
-
+
// Check if any search token matches the attribute name or value
const hasMatch = searchTokens.some(token => {
const normalizedToken = normalizeString(token.toLowerCase());
return attrName.includes(normalizedToken) || attrValue.includes(normalizedToken);
});
-
+
if (hasMatch) {
matchingAttributes.push({
name: attr.name || "",
@@ -611,20 +609,20 @@ function extractAttributeSnippet(noteId: string, searchTokens: string[], maxLeng
const targetTitle = targetNote ? targetNote.title : attr.value;
line = `~${attr.name}="${targetTitle}"`;
}
-
+
if (line) {
lines.push(line);
}
}
let snippet = lines.join('\n');
-
+
// Apply length limit while preserving line structure
if (snippet.length > maxLength) {
// Try to truncate at word boundaries but keep lines intact
const truncated = snippet.substring(0, maxLength);
const lastNewline = truncated.lastIndexOf('\n');
-
+
if (lastNewline > maxLength / 2) {
// If we can keep most content by truncating to last complete line
snippet = truncated.substring(0, lastNewline);
@@ -632,7 +630,7 @@ function extractAttributeSnippet(noteId: string, searchTokens: string[], maxLeng
// Otherwise just truncate and add ellipsis
const lastSpace = truncated.lastIndexOf(' ');
snippet = truncated.substring(0, lastSpace > maxLength / 2 ? lastSpace : maxLength - 3);
- snippet = snippet + "...";
+ snippet = `${snippet }...`;
}
}
@@ -645,7 +643,7 @@ function extractAttributeSnippet(noteId: string, searchTokens: string[], maxLeng
function searchNotesForAutocomplete(query: string, fastSearch: boolean = true) {
const searchContext = new SearchContext({
- fastSearch: fastSearch,
+ fastSearch,
includeArchivedNotes: false,
includeHiddenNotes: true,
fuzzyAttributeSearch: true,
@@ -666,7 +664,7 @@ function searchNotesForAutocomplete(query: string, fastSearch: boolean = true) {
highlightSearchResults(trimmed, searchContext.highlightedTokens, searchContext.ignoreInternalAttributes);
return trimmed.map((result) => {
- const { title, icon } = beccaService.getNoteTitleAndIcon(result.noteId);
+ const { title, icon } = becca_service.getNoteTitleAndIcon(result.noteId);
return {
notePath: result.notePath,
noteTitle: title,
@@ -698,7 +696,7 @@ function highlightSearchResults(searchResults: SearchResult[], highlightedTokens
for (const result of searchResults) {
result.highlightedNotePathTitle = result.notePathTitle.replace(/[<{}]/g, "");
-
+
// Initialize highlighted content snippet
if (result.contentSnippet) {
// Escape HTML but preserve newlines for later conversion to
@@ -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
@@ -767,14 +765,14 @@ function highlightSearchResults(searchResults: SearchResult[], highlightedTokens
if (result.highlightedNotePathTitle) {
result.highlightedNotePathTitle = result.highlightedNotePathTitle.replace(/{/g, "").replace(/}/g, "");
}
-
+
if (result.highlightedContentSnippet) {
// Replace highlighting markers with HTML tags
result.highlightedContentSnippet = result.highlightedContentSnippet.replace(/{/g, "").replace(/}/g, "");
// Convert newlines to
tags for HTML display
result.highlightedContentSnippet = result.highlightedContentSnippet.replace(/\n/g, "
");
}
-
+
if (result.highlightedAttributeSnippet) {
// Replace highlighting markers with HTML tags
result.highlightedAttributeSnippet = result.highlightedAttributeSnippet.replace(/{/g, "").replace(/}/g, "");
diff --git a/apps/server/src/services/sql.ts b/apps/server/src/services/sql.ts
index 206a828d66..ceff5a8917 100644
--- a/apps/server/src/services/sql.ts
+++ b/apps/server/src/services/sql.ts
@@ -1,438 +1,3 @@
-"use strict";
+import { getSql } from "@triliumnext/core";
-/**
- * @module sql
- */
-
-import log from "./log.js";
-import type { Statement, Database as DatabaseType, RunResult } from "better-sqlite3";
-import dataDir from "./data_dir.js";
-import cls from "./cls.js";
-import fs from "fs";
-import Database from "better-sqlite3";
-import ws from "./ws.js";
-import becca_loader from "../becca/becca_loader.js";
-import entity_changes from "./entity_changes.js";
-import config from "./config.js";
-
-const dbOpts: Database.Options = {
- nativeBinding: process.env.BETTERSQLITE3_NATIVE_PATH || undefined
-};
-
-let dbConnection: DatabaseType = buildDatabase();
-let statementCache: Record = {};
-
-function buildDatabase() {
- // for integration tests, ignore the config's readOnly setting
- if (process.env.TRILIUM_INTEGRATION_TEST === "memory") {
- return buildIntegrationTestDatabase();
- } else if (process.env.TRILIUM_INTEGRATION_TEST === "memory-no-store") {
- return new Database(":memory:", dbOpts);
- }
-
- return new Database(dataDir.DOCUMENT_PATH, {
- ...dbOpts,
- readonly: config.General.readOnly
- });
-}
-
-function buildIntegrationTestDatabase(dbPath?: string) {
- const dbBuffer = fs.readFileSync(dbPath ?? dataDir.DOCUMENT_PATH);
- return new Database(dbBuffer, dbOpts);
-}
-
-function rebuildIntegrationTestDatabase(dbPath?: string) {
- if (dbConnection) {
- dbConnection.close();
- }
-
- // This allows a database that is read normally but is kept in memory and discards all modifications.
- dbConnection = buildIntegrationTestDatabase(dbPath);
- statementCache = {};
-}
-
-if (!process.env.TRILIUM_INTEGRATION_TEST) {
- dbConnection.pragma("journal_mode = WAL");
-}
-
-const LOG_ALL_QUERIES = false;
-
-type Params = any;
-
-[`exit`, `SIGINT`, `SIGUSR1`, `SIGUSR2`, `SIGTERM`].forEach((eventType) => {
- process.on(eventType, () => {
- if (dbConnection) {
- // closing connection is especially important to fold -wal file into the main DB file
- // (see https://sqlite.org/tempfiles.html for details)
- dbConnection.close();
- }
- });
-});
-
-function insert(tableName: string, rec: T, replace = false) {
- const keys = Object.keys(rec || {});
- if (keys.length === 0) {
- log.error(`Can't insert empty object into table ${tableName}`);
- return;
- }
-
- const columns = keys.join(", ");
- const questionMarks = keys.map((p) => "?").join(", ");
-
- const query = `INSERT
- ${replace ? "OR REPLACE" : ""} INTO
- ${tableName}
- (
- ${columns}
- )
- VALUES (${questionMarks})`;
-
- const res = execute(query, Object.values(rec));
-
- return res ? res.lastInsertRowid : null;
-}
-
-function replace(tableName: string, rec: T): number | null {
- return insert(tableName, rec, true) as number | null;
-}
-
-function upsert(tableName: string, primaryKey: string, rec: T) {
- const keys = Object.keys(rec || {});
- if (keys.length === 0) {
- log.error(`Can't upsert empty object into table ${tableName}`);
- return;
- }
-
- const columns = keys.join(", ");
-
- const questionMarks = keys.map((colName) => `@${colName}`).join(", ");
-
- const updateMarks = keys.map((colName) => `${colName} = @${colName}`).join(", ");
-
- const query = `INSERT INTO ${tableName} (${columns}) VALUES (${questionMarks})
- ON CONFLICT (${primaryKey}) DO UPDATE SET ${updateMarks}`;
-
- for (const idx in rec) {
- if (rec[idx] === true || rec[idx] === false) {
- (rec as any)[idx] = rec[idx] ? 1 : 0;
- }
- }
-
- execute(query, rec);
-}
-
-/**
- * For the given SQL query, returns a prepared statement. For the same query (string comparison), the same statement is returned.
- *
- * @param sql the SQL query for which to return a prepared statement.
- * @param isRaw indicates whether `.raw()` is going to be called on the prepared statement in order to return the raw rows (e.g. via {@link getRawRows()}). The reason is that the raw state is preserved in the saved statement and would break non-raw calls for the same query.
- * @returns the corresponding {@link Statement}.
- */
-function stmt(sql: string, isRaw?: boolean) {
- const key = (isRaw ? "raw/" + sql : sql);
-
- if (!(key in statementCache)) {
- statementCache[key] = dbConnection.prepare(sql);
- }
-
- return statementCache[key];
-}
-
-function getRow(query: string, params: Params = []): T {
- return wrap(query, (s) => s.get(params)) as T;
-}
-
-function getRowOrNull(query: string, params: Params = []): T | null {
- const all = getRows(query, params);
- if (!all) {
- return null;
- }
-
- return (all.length > 0 ? all[0] : null) as T | null;
-}
-
-function getValue(query: string, params: Params = []): T {
- return wrap(query, (s) => s.pluck().get(params)) as T;
-}
-
-// smaller values can result in better performance due to better usage of statement cache
-const PARAM_LIMIT = 100;
-
-function getManyRows(query: string, params: Params): T[] {
- let results: unknown[] = [];
-
- while (params.length > 0) {
- const curParams = params.slice(0, Math.min(params.length, PARAM_LIMIT));
- params = params.slice(curParams.length);
-
- const curParamsObj: Record = {};
-
- let j = 1;
- for (const param of curParams) {
- curParamsObj["param" + j++] = param;
- }
-
- let i = 1;
- const questionMarks = curParams.map(() => ":param" + i++).join(",");
- const curQuery = query.replace(/\?\?\?/g, questionMarks);
-
- const statement = curParams.length === PARAM_LIMIT ? stmt(curQuery) : dbConnection.prepare(curQuery);
-
- const subResults = statement.all(curParamsObj);
- results = results.concat(subResults);
- }
-
- return (results as T[] | null) || [];
-}
-
-function getRows(query: string, params: Params = []): T[] {
- return wrap(query, (s) => s.all(params)) as T[];
-}
-
-function getRawRows(query: string, params: Params = []): T[] {
- return (wrap(query, (s) => s.raw().all(params), true) as T[]) || [];
-}
-
-function iterateRows(query: string, params: Params = []): IterableIterator {
- if (LOG_ALL_QUERIES) {
- console.log(query);
- }
-
- return stmt(query).iterate(params) as IterableIterator;
-}
-
-function getMap(query: string, params: Params = []) {
- const map: Record = {} as Record;
- const results = getRawRows<[K, V]>(query, params);
-
- for (const row of results || []) {
- map[row[0]] = row[1];
- }
-
- return map;
-}
-
-function getColumn(query: string, params: Params = []): T[] {
- return wrap(query, (s) => s.pluck().all(params)) as T[];
-}
-
-function execute(query: string, params: Params = []): RunResult {
- if (config.General.readOnly && (query.startsWith("UPDATE") || query.startsWith("INSERT") || query.startsWith("DELETE"))) {
- log.error(`read-only DB ignored: ${query} with parameters ${JSON.stringify(params)}`);
- return {
- changes: 0,
- lastInsertRowid: 0
- };
- }
- return wrap(query, (s) => s.run(params)) as RunResult;
-}
-
-function executeMany(query: string, params: Params) {
- if (LOG_ALL_QUERIES) {
- console.log(query);
- }
-
- while (params.length > 0) {
- const curParams = params.slice(0, Math.min(params.length, PARAM_LIMIT));
- params = params.slice(curParams.length);
-
- const curParamsObj: Record = {};
-
- let j = 1;
- for (const param of curParams) {
- curParamsObj["param" + j++] = param;
- }
-
- let i = 1;
- const questionMarks = curParams.map(() => ":param" + i++).join(",");
- const curQuery = query.replace(/\?\?\?/g, questionMarks);
-
- dbConnection.prepare(curQuery).run(curParamsObj);
- }
-}
-
-function executeScript(query: string): DatabaseType {
- if (LOG_ALL_QUERIES) {
- console.log(query);
- }
-
- return dbConnection.exec(query);
-}
-
-/**
- * @param isRaw indicates whether `.raw()` is going to be called on the prepared statement in order to return the raw rows (e.g. via {@link getRawRows()}). The reason is that the raw state is preserved in the saved statement and would break non-raw calls for the same query.
- */
-function wrap(query: string, func: (statement: Statement) => unknown, isRaw?: boolean): unknown {
- const startTimestamp = Date.now();
- let result;
-
- if (LOG_ALL_QUERIES) {
- console.log(query);
- }
-
- try {
- result = func(stmt(query, isRaw));
- } catch (e: any) {
- if (e.message.includes("The database connection is not open")) {
- // this often happens on killing the app which puts these alerts in front of user
- // in these cases error should be simply ignored.
- console.log(e.message);
-
- return null;
- }
-
- throw e;
- }
-
- const milliseconds = Date.now() - startTimestamp;
-
- if (milliseconds >= 20 && !cls.isSlowQueryLoggingDisabled()) {
- if (query.includes("WITH RECURSIVE")) {
- log.info(`Slow recursive query took ${milliseconds}ms.`);
- } else {
- log.info(`Slow query took ${milliseconds}ms: ${query.trim().replace(/\s+/g, " ")}`);
- }
- }
-
- return result;
-}
-
-function transactional(func: (statement: Statement) => T) {
- try {
- const ret = (dbConnection.transaction(func) as any).deferred();
-
- if (!dbConnection.inTransaction) {
- // i.e. transaction was really committed (and not just savepoint released)
- ws.sendTransactionEntityChangesToAllClients();
- }
-
- return ret as T;
- } catch (e) {
- console.warn("Got error ", e);
- const entityChangeIds = cls.getAndClearEntityChangeIds();
-
- if (entityChangeIds.length > 0) {
- log.info("Transaction rollback dirtied the becca, forcing reload.");
-
- becca_loader.load();
- }
-
- // the maxEntityChangeId has been incremented during failed transaction, need to recalculate
- entity_changes.recalculateMaxEntityChangeId();
-
- throw e;
- }
-}
-
-function fillParamList(paramIds: string[] | Set, truncate = true) {
- if ("length" in paramIds && paramIds.length === 0) {
- return;
- }
-
- if (truncate) {
- execute("DELETE FROM param_list");
- }
-
- paramIds = Array.from(new Set(paramIds));
-
- if (paramIds.length > 30000) {
- fillParamList(paramIds.slice(30000), false);
-
- paramIds = paramIds.slice(0, 30000);
- }
-
- // doing it manually to avoid this showing up on the slow query list
- const s = stmt(`INSERT INTO param_list VALUES ${paramIds.map((paramId) => `(?)`).join(",")}`);
-
- s.run(paramIds);
-}
-
-async function copyDatabase(targetFilePath: string) {
- try {
- fs.unlinkSync(targetFilePath);
- } catch (e) {} // unlink throws exception if the file did not exist
-
- await dbConnection.backup(targetFilePath);
-}
-
-function disableSlowQueryLogging(cb: () => T) {
- const orig = cls.isSlowQueryLoggingDisabled();
-
- try {
- cls.disableSlowQueryLogging(true);
-
- return cb();
- } finally {
- cls.disableSlowQueryLogging(orig);
- }
-}
-
-export default {
- insert,
- replace,
-
- /**
- * Get single value from the given query - first column from first returned row.
- *
- * @param query - SQL query with ? used as parameter placeholder
- * @param params - array of params if needed
- * @returns single value
- */
- getValue,
-
- /**
- * Get first returned row.
- *
- * @param query - SQL query with ? used as parameter placeholder
- * @param params - array of params if needed
- * @returns - map of column name to column value
- */
- getRow,
- getRowOrNull,
-
- /**
- * Get all returned rows.
- *
- * @param query - SQL query with ? used as parameter placeholder
- * @param params - array of params if needed
- * @returns - array of all rows, each row is a map of column name to column value
- */
- getRows,
- getRawRows,
- iterateRows,
- getManyRows,
-
- /**
- * Get a map of first column mapping to second column.
- *
- * @param query - SQL query with ? used as parameter placeholder
- * @param params - array of params if needed
- * @returns - map of first column to second column
- */
- getMap,
-
- /**
- * Get a first column in an array.
- *
- * @param query - SQL query with ? used as parameter placeholder
- * @param params - array of params if needed
- * @returns array of first column of all returned rows
- */
- getColumn,
-
- /**
- * Execute SQL
- *
- * @param query - SQL query with ? used as parameter placeholder
- * @param params - array of params if needed
- */
- execute,
- executeMany,
- executeScript,
- transactional,
- upsert,
- fillParamList,
- copyDatabase,
- disableSlowQueryLogging,
- rebuildIntegrationTestDatabase
-};
+export default getSql();
diff --git a/apps/server/src/services/sql_init.ts b/apps/server/src/services/sql_init.ts
index 93452669fc..c50b81acf3 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();
}
@@ -88,7 +89,7 @@ async function createInitialDatabase(skipDemoDb?: boolean) {
// We have to import async since options init requires keyboard actions which require translations.
const optionsInitService = (await import("./options_init.js")).default;
- const becca_loader = (await import("../becca/becca_loader.js")).default;
+ const becca_loader = (await import("@triliumnext/core")).becca_loader;
sql.transactional(() => {
log.info("Creating database schema ...");
diff --git a/apps/server/src/services/sync.ts b/apps/server/src/services/sync.ts
index ef3bd6cba9..7720e13f71 100644
--- a/apps/server/src/services/sync.ts
+++ b/apps/server/src/services/sync.ts
@@ -1,27 +1,24 @@
-"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, binary_utils, entity_constructor, getInstanceId } from "@triliumnext/core";
+
+import becca from "../becca/becca.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 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 +91,16 @@ async function sync() {
success: false,
message: "No connection to sync server."
};
- } else {
- log.info(`Sync failed: '${e.message}', stack: ${e.stack}`);
-
- ws.syncFailed();
-
- return {
- success: false,
- message: e.message
- };
}
+ log.info(`Sync failed: '${e.message}', stack: ${e.stack}`);
+
+ ws.syncFailed();
+
+ return {
+ success: false,
+ message: e.message
+ };
+
}
}
@@ -123,16 +120,16 @@ async function doLogin(): Promise {
const syncContext: SyncContext = { cookieJar: {} };
const resp = await syncRequest(syncContext, "POST", "/api/login/sync", {
- timestamp: timestamp,
+ timestamp,
syncVersion: appInfo.syncVersion,
- hash: hash
+ hash
});
if (!resp) {
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();
@@ -219,9 +216,9 @@ async function pushChanges(syncContext: SyncContext) {
lastSyncedPush = entityChange.id;
return false;
- } else {
- return true;
}
+ return true;
+
});
if (filteredEntityChanges.length === 0 && lastSyncedPush) {
@@ -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();
@@ -319,7 +316,7 @@ async function syncRequest(syncContext: SyncContext, method: strin
method,
url: syncOptions.getSyncServerHost() + requestPath,
cookieJar: syncContext.cookieJar,
- timeout: timeout,
+ timeout,
paging: {
pageIndex,
pageCount,
@@ -340,33 +337,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;
-
- if (!primaryKey) {
- throw new Error(`Unknown entity for entity change ${JSON.stringify(entityChange)}`);
- }
-
- const entityRow = sql.getRow(/*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;
}
+ const primaryKey = entity_constructor.getEntityFromEntityName(entityName).primaryKeyName;
+
+ if (!primaryKey) {
+ throw new Error(`Unknown entity for entity change ${JSON.stringify(entityChange)}`);
+ }
+
+ const entityRow = sql.getRow(/*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 = binary_utils.encodeBase64(entityRow.content);
+ }
+ }
+
+
+ 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 9d4ff5c4c0..0e28f8b014 100644
--- a/apps/server/src/services/sync_update.ts
+++ b/apps/server/src/services/sync_update.ts
@@ -1,10 +1,10 @@
-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 { entity_constructor, events as eventService } from "@triliumnext/core";
+
+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;
@@ -154,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/services/task_context.ts b/apps/server/src/services/task_context.ts
index 79122895bd..59bd04f058 100644
--- a/apps/server/src/services/task_context.ts
+++ b/apps/server/src/services/task_context.ts
@@ -1,79 +1,2 @@
-"use strict";
-
-import type { TaskData, TaskResult, TaskType, WebSocketMessage } from "@triliumnext/commons";
-import ws from "./ws.js";
-
-// taskId => TaskContext
-const taskContexts: Record> = {};
-
-class TaskContext {
- private taskId: string;
- private taskType: TaskType;
- private progressCount: number;
- private lastSentCountTs: number;
- data: TaskData;
- noteDeletionHandlerTriggered: boolean;
-
- constructor(taskId: string, taskType: T, data: TaskData) {
- 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(taskId: string, taskType: T, data: TaskData): TaskContext {
- 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) {
- 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/apps/server/src/services/tray.ts b/apps/server/src/services/tray.ts
index 504e81b21e..95d158fa3a 100644
--- a/apps/server/src/services/tray.ts
+++ b/apps/server/src/services/tray.ts
@@ -1,15 +1,15 @@
-import electron from "electron";
+import type { KeyboardActionNames } from "@triliumnext/commons";
+import { becca_service } from "@triliumnext/core";
import type { BrowserWindow, Tray } from "electron";
+import electron from "electron";
import { default as i18next, t } from "i18next";
import path from "path";
import becca from "../becca/becca.js";
-import becca_service from "../becca/becca_service.js";
import type BNote from "../becca/entities/bnote.js";
import type BRecentNote from "../becca/entities/brecent_note.js";
import cls from "./cls.js";
import date_notes from "./date_notes.js";
-import type { KeyboardActionNames } from "@triliumnext/commons";
import optionService from "./options.js";
import { getResourceDir, isDev, isMac } from "./utils.js";
import windowService from "./window.js";
@@ -31,9 +31,9 @@ function getTrayIconPath() {
if (process.env.NODE_ENV === "development") {
return path.join(__dirname, "../../../desktop/src/assets/images/tray", `${name}.png`);
- } else {
- return path.resolve(path.join(getResourceDir(), "assets", "images", "tray", `${name}.png`));
- }
+ }
+ return path.resolve(path.join(getResourceDir(), "assets", "images", "tray", `${name}.png`));
+
}
function getIconPath(name: string) {
@@ -41,9 +41,9 @@ function getIconPath(name: string) {
if (process.env.NODE_ENV === "development") {
return path.join(__dirname, "../../../desktop/src/assets/images/tray", `${name}Template${suffix}.png`);
- } else {
- return path.resolve(path.join(getResourceDir(), "assets", "images", "tray", `${name}Template${suffix}.png`));
- }
+ }
+ return path.resolve(path.join(getResourceDir(), "assets", "images", "tray", `${name}Template${suffix}.png`));
+
}
function registerVisibilityListener(window: BrowserWindow) {
@@ -74,7 +74,7 @@ function getWindowTitle(window: BrowserWindow | null) {
// Limit title maximum length to 17
if (titleWithoutAppName.length > 20) {
- return titleWithoutAppName.substring(0, 17) + '...';
+ return `${titleWithoutAppName.substring(0, 17) }...`;
}
return titleWithoutAppName;
diff --git a/apps/server/src/services/tree.ts b/apps/server/src/services/tree.ts
index 05c9ecdd9b..98b8892922 100644
--- a/apps/server/src/services/tree.ts
+++ b/apps/server/src/services/tree.ts
@@ -1,280 +1,2 @@
-"use strict";
-
-import sql from "./sql.js";
-import log from "./log.js";
-import BBranch from "../becca/entities/bbranch.js";
-import entityChangesService from "./entity_changes.js";
-import becca from "../becca/becca.js";
-import type BNote from "../becca/entities/bnote.js";
-
-export interface ValidationResponse {
- branch: BBranch | null;
- success: boolean;
- message?: string;
-}
-
-function validateParentChild(parentNoteId: string, childNoteId: string, branchId: string | null = null): ValidationResponse {
- if (["root", "_hidden", "_share", "_lbRoot", "_lbAvailableLaunchers", "_lbVisibleLaunchers"].includes(childNoteId)) {
- return { branch: null, success: false, message: `Cannot change this note's location.` };
- }
-
- if (parentNoteId === "none") {
- // this shouldn't happen
- return { branch: null, success: false, message: `Cannot move anything into 'none' parent.` };
- }
-
- const existingBranch = becca.getBranchFromChildAndParent(childNoteId, parentNoteId);
-
- if (existingBranch && existingBranch.branchId !== branchId) {
- const parentNote = becca.getNote(parentNoteId);
- const childNote = becca.getNote(childNoteId);
-
- return {
- branch: existingBranch,
- success: false,
- message: `Note "${childNote?.title}" note already exists in the "${parentNote?.title}".`
- };
- }
-
- if (wouldAddingBranchCreateCycle(parentNoteId, childNoteId)) {
- return {
- branch: null,
- success: false,
- message: "Moving/cloning note here would create cycle."
- };
- }
-
- if (parentNoteId !== "_lbBookmarks" && becca.getNote(parentNoteId)?.type === "launcher") {
- return {
- branch: null,
- success: false,
- message: "Launcher note cannot have any children."
- };
- }
-
- return { branch: null, success: true };
-}
-
-/**
- * Tree cycle can be created when cloning or when moving existing clone. This method should detect both cases.
- */
-function wouldAddingBranchCreateCycle(parentNoteId: string, childNoteId: string) {
- if (parentNoteId === childNoteId) {
- return true;
- }
-
- const childNote = becca.getNote(childNoteId);
- const parentNote = becca.getNote(parentNoteId);
-
- if (!childNote || !parentNote) {
- return false;
- }
-
- // we'll load the whole subtree - because the cycle can start in one of the notes in the subtree
- const childSubtreeNoteIds = new Set(childNote.getSubtreeNoteIds());
- const parentAncestorNoteIds = parentNote.getAncestorNoteIds();
-
- return parentAncestorNoteIds.some((parentAncestorNoteId) => childSubtreeNoteIds.has(parentAncestorNoteId));
-}
-
-function sortNotes(parentNoteId: string, customSortBy: string = "title", reverse = false, foldersFirst = false, sortNatural = false, _sortLocale?: string | null) {
- if (!customSortBy) {
- customSortBy = "title";
- }
-
- // sortLocale can not be empty string or null value, default value must be set to undefined.
- const sortLocale = _sortLocale || undefined;
-
- sql.transactional(() => {
- const note = becca.getNote(parentNoteId);
- if (!note) {
- throw new Error("Unable to find note");
- }
-
- const notes = note.getChildNotes();
-
- function normalize(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("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("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/apps/server/src/services/utils.ts b/apps/server/src/services/utils.ts
index 370f9297ff..5ad481211d 100644
--- a/apps/server/src/services/utils.ts
+++ b/apps/server/src/services/utils.ts
@@ -1,23 +1,18 @@
-"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 { release as osRelease } from "os";
+import path from "path";
+import stripBom from "strip-bom";
+
+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,35 +23,23 @@ 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);
-}
-
-export function randomSecureToken(bytes = 32) {
- return crypto.randomBytes(bytes).toString("base64");
+ return coreUtils.randomString(length);
}
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 +87,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;
@@ -119,10 +96,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(array: T[], fn: (item: T) => [K, V]): Record {
const obj: Record = {} as Record; // TODO: unsafe?
@@ -154,54 +127,29 @@ 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);
}
-// 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"]);
-
+/** @deprecated */
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);
}
+/** @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) {
@@ -259,28 +207,19 @@ export function timeLimit(promise: Promise, 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);
}
+/** @deprecated */
export function toMap>(list: T[], key: keyof T) {
- const map = new Map();
- 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
@@ -467,28 +406,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 +440,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) {
@@ -514,6 +453,10 @@ function slugify(text: string) {
.replace(/(^-|-$)+/g, ""); // trim dashes
}
+export const escapeHtml = coreUtils.escapeHtml;
+export const unescapeHtml = coreUtils.unescapeHtml;
+export const randomSecureToken = coreUtils.randomSecureToken;
+
export default {
compareVersions,
constantTimeCompare,
@@ -526,7 +469,6 @@ export default {
getContentDisposition,
getNoteTitle,
getResourceDir,
- hash,
hashedBlobId,
hmac,
isDev,
diff --git a/apps/server/src/services/ws.ts b/apps/server/src/services/ws.ts
index 9dfcbc0198..32e7f9c557 100644
--- a/apps/server/src/services/ws.ts
+++ b/apps/server/src/services/ws.ts
@@ -1,16 +1,16 @@
-import { WebSocketServer as WebSocketServer, WebSocket } from "ws";
-import { isElectron, randomString } from "./utils.js";
-import log from "./log.js";
-import sql from "./sql.js";
+import { type EntityChange,WebSocketMessage } from "@triliumnext/commons";
+import { AbstractBeccaEntity } from "@triliumnext/core";
+import type { IncomingMessage, Server as HttpServer } from "http";
+import { WebSocket,WebSocketServer } from "ws";
+
+import becca from "../becca/becca.js";
import cls from "./cls.js";
import config from "./config.js";
-import syncMutexService from "./sync_mutex.js";
+import log from "./log.js";
import protectedSessionService from "./protected_session.js";
-import becca from "../becca/becca.js";
-import AbstractBeccaEntity from "../becca/entities/abstract_becca_entity.js";
-
-import type { IncomingMessage, Server as HttpServer } from "http";
-import { WebSocketMessage, type EntityChange } from "@triliumnext/commons";
+import sql from "./sql.js";
+import syncMutexService from "./sync_mutex.js";
+import { isElectron, randomString } from "./utils.js";
let webSocketServer!: WebSocketServer;
let lastSyncedPush: number;
@@ -80,7 +80,7 @@ function sendMessageToAllClients(message: WebSocketMessage) {
}
let clientCount = 0;
- webSocketServer.clients.forEach(function each(client) {
+ webSocketServer.clients.forEach((client) => {
if (client.readyState === WebSocket.OPEN) {
client.send(jsonStr);
clientCount++;
diff --git a/apps/server/src/share/content_renderer.ts b/apps/server/src/share/content_renderer.ts
index e37ac8ab45..c777c5cbcb 100644
--- a/apps/server/src/share/content_renderer.ts
+++ b/apps/server/src/share/content_renderer.ts
@@ -1,4 +1,4 @@
-import { sanitizeUrl } from "@braintree/sanitize-url";
+import { sanitize } from "@triliumnext/core";
import { highlightAuto } from "@triliumnext/highlightjs";
import ejs from "ejs";
import escapeHtml from "escape-html";
@@ -30,7 +30,7 @@ const templateCache: Map = 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;
}
@@ -491,7 +491,7 @@ function renderWebView(note: SNote | BNote, result: Result) {
const url = note.getLabelValue("webViewSrc");
if (!url) return;
- result.content = ``;
+ result.content = ``;
}
export default {
diff --git a/apps/server/src/share/shaca/entities/sattachment.ts b/apps/server/src/share/shaca/entities/sattachment.ts
index 11d3af0969..090eaf734c 100644
--- a/apps/server/src/share/shaca/entities/sattachment.ts
+++ b/apps/server/src/share/shaca/entities/sattachment.ts
@@ -1,11 +1,13 @@
-"use strict";
-import sql from "../../sql.js";
+
+import { BlobRow } from "@triliumnext/commons";
+import { binary_utils } from "@triliumnext/core";
+
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 +39,23 @@ class SAttachment extends AbstractShacaEntity {
}
getContent(silentNotFoundError = false) {
- const row = sql.getRow>(/*sql*/`SELECT content FROM blobs WHERE blobId = ?`, [this.blobId]);
+ const row = sql.getRow>(/*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 === 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 da72cd419f..ab51a434fa 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 { binary_utils, 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";
import AbstractShacaEntity from "./abstract_shaca_entity.js";
@@ -95,7 +95,7 @@ class SNote extends AbstractShacaEntity {
}
getContent(silentNotFoundError = false) {
- const row = sql.getRow>(/*sql*/`SELECT content FROM blobs WHERE blobId = ?`, [this.blobId]);
+ const row = sql.getRow>(/*sql*/`SELECT content FROM blobs WHERE blobId = ?`, [this.blobId]);
if (!row) {
if (silentNotFoundError) {
@@ -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/apps/server/src/share/shaca/shaca_loader.ts b/apps/server/src/share/shaca/shaca_loader.ts
index c0834cb8b1..4e374a7842 100644
--- a/apps/server/src/share/shaca/shaca_loader.ts
+++ b/apps/server/src/share/shaca/shaca_loader.ts
@@ -1,15 +1,14 @@
-"use strict";
+import { events as eventService } from "@triliumnext/core";
-import sql from "../sql.js";
-import shaca from "./shaca.js";
import log from "../../services/log.js";
-import SNote from "./entities/snote.js";
-import SBranch from "./entities/sbranch.js";
-import SAttribute from "./entities/sattribute.js";
-import SAttachment from "./entities/sattachment.js";
import shareRoot from "../share_root.js";
-import eventService from "../../services/events.js";
+import sql from "../sql.js";
import type { SAttachmentRow, SAttributeRow, SBranchRow, SNoteRow } from "./entities/rows.js";
+import SAttachment from "./entities/sattachment.js";
+import SAttribute from "./entities/sattribute.js";
+import SBranch from "./entities/sbranch.js";
+import SNote from "./entities/snote.js";
+import shaca from "./shaca.js";
function load() {
const start = Date.now();
diff --git a/apps/server/src/sql_provider.ts b/apps/server/src/sql_provider.ts
new file mode 100644
index 0000000000..da1604d44e
--- /dev/null
+++ b/apps/server/src/sql_provider.ts
@@ -0,0 +1,68 @@
+import type { DatabaseProvider, Statement, Transaction } from "@triliumnext/core";
+import Database, { type Database as DatabaseType } from "better-sqlite3";
+import { unlinkSync } from "fs";
+
+const dbOpts: Database.Options = {
+ nativeBinding: process.env.BETTERSQLITE3_NATIVE_PATH || undefined
+};
+
+export default class BetterSqlite3Provider implements DatabaseProvider {
+
+ private dbConnection?: DatabaseType;
+
+ constructor() {
+ [`exit`, `SIGINT`, `SIGUSR1`, `SIGUSR2`, `SIGTERM`].forEach((eventType) => {
+ // closing connection is especially important to fold -wal file into the main DB file
+ // (see https://sqlite.org/tempfiles.html for details)
+ process.on(eventType, () => this.close());
+ });
+ }
+
+ loadFromFile(path: string, isReadOnly: boolean) {
+ this.dbConnection = new Database(path, {
+ ...dbOpts,
+ readonly: isReadOnly
+ });
+ this.dbConnection.pragma("journal_mode = WAL");
+ }
+
+ loadFromMemory() {
+ this.dbConnection = new Database(":memory:", dbOpts);
+ }
+
+ loadFromBuffer(buffer: NonSharedBuffer) {
+ this.dbConnection = new Database(buffer, dbOpts);
+ }
+
+ backup(destinationFile: string) {
+ try {
+ unlinkSync(destinationFile);
+ } catch (e) { } // unlink throws exception if the file did not exist
+
+ this.dbConnection?.backup(destinationFile);
+ }
+
+ prepare(query: string): Statement {
+ if (!this.dbConnection) throw new Error("DB not open.");
+ return this.dbConnection.prepare(query);
+ }
+
+ transaction(func: (statement: Statement) => T): Transaction {
+ if (!this.dbConnection) throw new Error("DB not open.");
+ return this.dbConnection.transaction(func) as any;
+ }
+
+ get inTransaction() {
+ if (!this.dbConnection) throw new Error("DB not open.");
+ return this.dbConnection.inTransaction;
+ }
+
+ exec(query: string): void {
+ this.dbConnection?.exec(query);
+ }
+
+ close() {
+ this.dbConnection?.close();
+ }
+
+}
diff --git a/apps/server/tsconfig.app.json b/apps/server/tsconfig.app.json
index 79dd0b7709..b416e1af91 100644
--- a/apps/server/tsconfig.app.json
+++ b/apps/server/tsconfig.app.json
@@ -46,6 +46,9 @@
},
{
"path": "../../packages/commons/tsconfig.lib.json"
+ },
+ {
+ "path": "../../packages/trilium-core/tsconfig.lib.json"
}
]
}
diff --git a/apps/server/tsconfig.json b/apps/server/tsconfig.json
index 6bc2242953..ae11c06bcd 100644
--- a/apps/server/tsconfig.json
+++ b/apps/server/tsconfig.json
@@ -15,6 +15,9 @@
{
"path": "../../packages/commons"
},
+ {
+ "path": "../../packages/trilium-core"
+ },
{
"path": "./tsconfig.app.json"
},
diff --git a/packages/commons/src/lib/rows.ts b/packages/commons/src/lib/rows.ts
index 809e36481d..03599a8b61 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 {
@@ -68,7 +68,7 @@ export interface EtapiTokenRow {
export interface BlobRow {
blobId: string;
- content: string | Buffer;
+ content: string | Uint8Array;
contentLength: number;
dateModified: string;
utcDateModified: string;
@@ -137,6 +137,6 @@ export interface NoteRow {
dateModified?: string;
utcDateCreated?: string;
utcDateModified?: string;
- content?: string | Buffer;
+ content?: string | Uint8Array;
}
diff --git a/packages/commons/src/lib/server_api.ts b/packages/commons/src/lib/server_api.ts
index a15192fd28..a26a8f2861 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;
@@ -50,7 +50,7 @@ export interface RevisionPojo {
utcDateLastEdited?: string;
utcDateCreated?: string;
utcDateModified?: string;
- content?: string | Buffer;
+ content?: string | Uint8Array;
contentLength?: number;
}
@@ -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/packages/commons/src/lib/test-utils.ts b/packages/commons/src/lib/test-utils.ts
index 86ebfb0d60..cf1797361d 100644
--- a/packages/commons/src/lib/test-utils.ts
+++ b/packages/commons/src/lib/test-utils.ts
@@ -55,10 +55,6 @@ export function trimIndentation(strings: TemplateStringsArray, ...values: any[])
return output.join("\n");
}
-export function flushPromises() {
- return new Promise(setImmediate);
-}
-
export function sleepFor(duration: number) {
return new Promise(resolve => setTimeout(resolve, duration));
}
diff --git a/packages/commons/src/lib/ws_api.ts b/packages/commons/src/lib/ws_api.ts
index 67beb0b42e..5b2b164a3f 100644
--- a/packages/commons/src/lib/ws_api.ts
+++ b/packages/commons/src/lib/ws_api.ts
@@ -18,7 +18,7 @@ export interface EntityChange {
export interface EntityRow {
isDeleted?: boolean;
- content?: Buffer | string;
+ content?: Uint8Array | string;
}
export interface EntityChangeRecord {
diff --git a/packages/commons/tsconfig.lib.json b/packages/commons/tsconfig.lib.json
index 31ab54c998..754cce2a31 100644
--- a/packages/commons/tsconfig.lib.json
+++ b/packages/commons/tsconfig.lib.json
@@ -6,10 +6,7 @@
"outDir": "dist",
"tsBuildInfoFile": "dist/tsconfig.lib.tsbuildinfo",
"emitDeclarationOnly": true,
- "forceConsistentCasingInFileNames": true,
- "types": [
- "node"
- ]
+ "forceConsistentCasingInFileNames": true
},
"include": [
"src/**/*.ts"
diff --git a/packages/commons/tsconfig.spec.json b/packages/commons/tsconfig.spec.json
index 699ed84389..e56c0502f4 100644
--- a/packages/commons/tsconfig.spec.json
+++ b/packages/commons/tsconfig.spec.json
@@ -3,8 +3,8 @@
"compilerOptions": {
"outDir": "./out-tsc/vitest",
"types": [
- "node",
- "vitest"
+ "vitest",
+ "node"
],
"forceConsistentCasingInFileNames": true
},
diff --git a/packages/trilium-core/package.json b/packages/trilium-core/package.json
new file mode 100644
index 0000000000..fd2f90a18c
--- /dev/null
+++ b/packages/trilium-core/package.json
@@ -0,0 +1,23 @@
+{
+ "name": "@triliumnext/core",
+ "version": "1.0.0",
+ "description": "",
+ "main": "src/index.ts",
+ "scripts": {
+ "test": "echo \"Error: no test specified\" && exit 1"
+ },
+ "dependencies": {
+ "@triliumnext/commons": "workspace:*",
+ "sanitize-html": "2.17.0",
+ "@braintree/sanitize-url": "7.1.1",
+ "sanitize-filename": "1.6.3",
+ "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/escape-html": "1.0.4"
+ }
+}
diff --git a/apps/server/src/becca/becca-interface.ts b/packages/trilium-core/src/becca/becca-interface.ts
similarity index 91%
rename from apps/server/src/becca/becca-interface.ts
rename to packages/trilium-core/src/becca/becca-interface.ts
index 1a8203f436..0c2c3690d7 100644
--- a/apps/server/src/becca/becca-interface.ts
+++ b/packages/trilium-core/src/becca/becca-interface.ts
@@ -1,6 +1,4 @@
-import sql from "../services/sql.js";
-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";
@@ -12,6 +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 "../services/sql/index.js";
+import NoteSet from "../services/search/note_set.js";
/**
* Becca is a backend cache of all notes, branches, and attributes.
@@ -151,7 +151,7 @@ export default class Becca {
}
getRevision(revisionId: string): BRevision | null {
- const row = 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/packages/trilium-core/src/becca/becca.ts b/packages/trilium-core/src/becca/becca.ts
new file mode 100644
index 0000000000..d4270fe09d
--- /dev/null
+++ b/packages/trilium-core/src/becca/becca.ts
@@ -0,0 +1,7 @@
+"use strict";
+
+import Becca from "./becca-interface.js";
+
+const becca = new Becca();
+
+export default becca;
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 f7faf13097..5ee7e252b2 100644
--- a/apps/server/src/becca/becca_loader.ts
+++ b/packages/trilium-core/src/becca/becca_loader.ts
@@ -1,27 +1,26 @@
-"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 eventService from "../services/events";
+
+import entityConstructor from "../becca/entity_constructor.js";
+import { getLog } from "../services/log.js";
import { dbReady } from "../services/sql_init.js";
+import ws from "../services/ws.js";
+import 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";
+import { getSql } from "../services/sql";
+import { getContext } from "../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();
@@ -36,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
@@ -72,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) {
@@ -284,7 +284,7 @@ eventService.subscribeBeccaLoader(eventService.ENTER_PROTECTED_SESSION, () => {
try {
becca.decryptProtectedNotes();
} catch (e: any) {
- log.error(`Could not decrypt protected notes: ${e.message} ${e.stack}`);
+ getLog().error(`Could not decrypt protected notes: ${e.message} ${e.stack}`);
}
});
diff --git a/apps/server/src/becca/becca_service.ts b/packages/trilium-core/src/becca/becca_service.ts
similarity index 91%
rename from apps/server/src/becca/becca_service.ts
rename to packages/trilium-core/src/becca/becca_service.ts
index 92967da342..c7b5731e21 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 { getHoistedNoteId } from "../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 = 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 83%
rename from apps/server/src/becca/entities/abstract_becca_entity.ts
rename to packages/trilium-core/src/becca/entities/abstract_becca_entity.ts
index 1f3bd0d863..dd84718e8f 100644
--- a/apps/server/src/becca/entities/abstract_becca_entity.ts
+++ b/packages/trilium-core/src/becca/entities/abstract_becca_entity.ts
@@ -1,16 +1,16 @@
-"use strict";
+import eventService from "../../services/events";
-import utils from "../../services/utils.js";
-import sql from "../../services/sql.js";
-import entityChangesService from "../../services/entity_changes.js";
-import eventService from "../../services/events.js";
-import dateUtils from "../../services/date_utils.js";
-import cls from "../../services/cls.js";
-import log from "../../services/log.js";
-import protectedSessionService from "../../services/protected_session.js";
import blobService from "../../services/blob.js";
-import type { default as Becca, ConstructorData } from "../becca-interface.js";
+import * as cls from "../../services/context";
+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 becca from "../becca.js";
+import type { ConstructorData,default as Becca } from "../becca-interface.js";
+import { getSql } from "../../services/sql";
+import { concat2, encodeUtf8, unwrapStringOrBuffer, wrapStringOrBuffer } from "../../services/utils/binary";
+import { hash, hashedBlobId, newEntityId, randomString } from "../../services/utils";
interface ContentOpts {
forceSave?: boolean;
@@ -36,7 +36,7 @@ abstract class AbstractBeccaEntity> {
protected beforeSaving(opts?: {}) {
const constructorData = this.constructor as unknown as ConstructorData;
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> {
contentToHash += "|deleted";
}
- return utils.hash(contentToHash).substr(0, 10);
+ return hash(contentToHash).substr(0, 10);
}
protected getPojoToSave() {
@@ -111,6 +111,7 @@ abstract class AbstractBeccaEntity> {
const pojo = this.getPojoToSave();
+ const sql = getSql();
sql.transactional(() => {
sql.upsert(entityName, primaryKeyName, pojo);
@@ -137,7 +138,7 @@ abstract class AbstractBeccaEntity> {
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> {
}
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);
@@ -167,7 +168,7 @@ abstract class AbstractBeccaEntity> {
}
}
- sql.transactional(() => {
+ getSql().transactional(() => {
const newBlobId = this.saveBlob(content, unencryptedContentForHashCalculation, opts);
const oldBlobId = this.blobId;
@@ -183,6 +184,7 @@ abstract class AbstractBeccaEntity> {
}
private deleteBlobIfNotUsed(oldBlobId: string) {
+ const sql = getSql();
if (sql.getValue("SELECT 1 FROM notes WHERE blobId = ? LIMIT 1", [oldBlobId])) {
return;
}
@@ -201,24 +203,29 @@ abstract class AbstractBeccaEntity> {
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}`;
- } else {
- return 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).
* 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]);
if (!blobNeedsInsert) {
@@ -227,7 +234,7 @@ abstract class AbstractBeccaEntity> {
const pojo = {
blobId: newBlobId,
- content: content,
+ content,
dateModified: dateUtils.localNowDateTime(),
utcDateModified: dateUtils.utcNowDateTime()
};
@@ -241,13 +248,13 @@ abstract class AbstractBeccaEntity> {
entityChangesService.putEntityChange({
entityName: "blobs",
entityId: newBlobId,
- hash: hash,
+ hash,
isErased: false,
utcDateChanged: pojo.utcDateModified,
isSynced: true,
// overriding componentId will cause the frontend to think the change is coming from a different component
// and thus reload
- componentId: opts.forceFrontendReload ? utils.randomString(10) : null
+ componentId: opts.forceFrontendReload ? randomString(10) : null
});
eventService.emit(eventService.ENTITY_CHANGED, {
@@ -258,15 +265,16 @@ abstract class AbstractBeccaEntity> {
return newBlobId;
}
- protected _getContent(): string | Buffer {
- const row = sql.getRow<{ content: string | Buffer }>(/*sql*/`SELECT content FROM blobs WHERE blobId = ?`, [this.blobId]);
+ protected _getContent(): string | Uint8Array {
+ const sql = getSql();
+ 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;
throw new Error(`Cannot find content for ${constructorData.primaryKeyName} '${(this as any)[constructorData.primaryKeyName]}', blobId '${this.blobId}'`);
}
- return blobService.processContent(row.content, this.isProtected || false, this.hasStringContent());
+ return blobService.processContent(row.content, this.isProtected || false, this.hasStringContent()) as string | Uint8Array;
}
/**
@@ -281,6 +289,7 @@ abstract class AbstractBeccaEntity> {
this.utcDateModified = dateUtils.utcNowDateTime();
+ const sql = getSql();
sql.execute(
/*sql*/`UPDATE ${entityName} SET isDeleted = 1, deleteId = ?, utcDateModified = ?
WHERE ${constructorData.primaryKeyName} = ?`,
@@ -293,7 +302,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);
@@ -307,13 +316,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/packages/trilium-core/src/becca/entities/battachment.ts b/packages/trilium-core/src/becca/entities/battachment.ts
new file mode 100644
index 0000000000..027413a65d
--- /dev/null
+++ b/packages/trilium-core/src/becca/entities/battachment.ts
@@ -0,0 +1,260 @@
+
+
+import type { AttachmentRow } from "@triliumnext/commons";
+
+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 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 { formatDownloadTitle, isStringNote, replaceAll } from "../../services/utils";
+
+const attachmentRoleToNoteTypeMapping = {
+ image: "image",
+ file: "file"
+};
+
+interface ContentOpts {
+ // TODO: Found in bnote.ts, to check if it's actually used and not a typo.
+ forceSave?: boolean;
+
+ /** will also save this BAttachment entity */
+ forceFullSave?: boolean;
+ /** override frontend heuristics on when to reload, instruct to reload */
+ forceFrontendReload?: boolean;
+}
+
+/**
+ * Attachment represent data related/attached to the note. Conceptually similar to attributes, but intended for
+ * larger amounts of data and generally not accessible to the user.
+ */
+class BAttachment extends AbstractBeccaEntity {
+ static get entityName() {
+ return "attachments";
+ }
+ static get primaryKeyName() {
+ return "attachmentId";
+ }
+ static get hashedProperties() {
+ return ["attachmentId", "ownerId", "role", "mime", "title", "blobId", "utcDateScheduledForErasureSince"];
+ }
+
+ noteId?: number;
+ attachmentId?: string;
+ /** either noteId or revisionId to which this attachment belongs */
+ ownerId!: string;
+ role!: string;
+ mime!: string;
+ title!: string;
+ type?: keyof typeof attachmentRoleToNoteTypeMapping;
+ position?: number;
+ utcDateScheduledForErasureSince?: string | null;
+ /** optionally added to the entity */
+ contentLength?: number;
+ isDecrypted?: boolean;
+
+ constructor(row: AttachmentRow) {
+ super();
+
+ this.updateFromRow(row);
+ this.decrypt();
+ }
+
+ updateFromRow(row: AttachmentRow): void {
+ if (!row.ownerId?.trim()) {
+ throw new Error("'ownerId' must be given to initialize a Attachment entity");
+ } else if (!row.role?.trim()) {
+ throw new Error("'role' must be given to initialize a Attachment entity");
+ } else if (!row.mime?.trim()) {
+ throw new Error("'mime' must be given to initialize a Attachment entity");
+ } else if (!row.title?.trim()) {
+ throw new Error("'title' must be given to initialize a Attachment entity");
+ }
+
+ this.attachmentId = row.attachmentId;
+ this.ownerId = row.ownerId;
+ this.role = row.role;
+ this.mime = row.mime;
+ this.title = row.title;
+ this.position = row.position;
+ this.blobId = row.blobId;
+ this.isProtected = !!row.isProtected;
+ this.dateModified = row.dateModified;
+ this.utcDateModified = row.utcDateModified;
+ this.utcDateScheduledForErasureSince = row.utcDateScheduledForErasureSince;
+ this.contentLength = row.contentLength;
+ }
+
+ copy(): BAttachment {
+ return new BAttachment({
+ ownerId: this.ownerId,
+ role: this.role,
+ mime: this.mime,
+ title: this.title,
+ blobId: this.blobId,
+ isProtected: this.isProtected
+ });
+ }
+
+ getNote(): BNote {
+ return this.becca.notes[this.ownerId];
+ }
+
+ /** @returns true if the note has string content (not binary) */
+ override hasStringContent(): boolean {
+ return isStringNote(this.type, this.mime); // here was !== undefined && utils.isStringNote(this.type, this.mime); I dont know why we need !=undefined. But it filters out canvas libary items
+ }
+
+ isContentAvailable() {
+ return (
+ !this.attachmentId || // new attachment which was not encrypted yet
+ !this.isProtected ||
+ protectedSessionService.isProtectedSessionAvailable()
+ );
+ }
+
+ getTitleOrProtected() {
+ return this.isContentAvailable() ? this.title : "[protected]";
+ }
+
+ decrypt() {
+ if (!this.isProtected || !this.attachmentId) {
+ this.isDecrypted = true;
+ return;
+ }
+
+ if (!this.isDecrypted && protectedSessionService.isProtectedSessionAvailable()) {
+ try {
+ this.title = protectedSessionService.decryptString(this.title) || "";
+ this.isDecrypted = true;
+ } catch (e: any) {
+ getLog().error(`Could not decrypt attachment ${this.attachmentId}: ${e.message} ${e.stack}`);
+ }
+ }
+ }
+
+ getContent(): Uint8Array {
+ return this._getContent() as Uint8Array;
+ }
+
+ setContent(content: string | Uint8Array, opts?: ContentOpts) {
+ this._setContent(content, opts);
+ }
+
+ convertToNote(): { note: BNote; branch: BBranch } {
+ // TODO: can this ever be "search"?
+ if ((this.type as string) === "search") {
+ throw new Error(`Note of type search cannot have child notes`);
+ }
+
+ if (!this.getNote()) {
+ throw new Error("Cannot find note of this attachment. It is possible that this is note revision's attachment. " + "Converting note revision's attachments to note is not (yet) supported.");
+ }
+
+ if (!(this.role in attachmentRoleToNoteTypeMapping)) {
+ throw new Error(`Mapping from attachment role '${this.role}' to note's type is not defined`);
+ }
+
+ if (!this.isContentAvailable()) {
+ // isProtected is the same for attachment
+ throw new Error(`Cannot convert protected attachment outside of protected session`);
+ }
+
+ const { note, branch } = noteService.createNewNote({
+ parentNoteId: this.ownerId,
+ title: this.title,
+ type: (attachmentRoleToNoteTypeMapping as any)[this.role],
+ mime: this.mime,
+ content: this.getContent(),
+ isProtected: this.isProtected
+ });
+
+ this.markAsDeleted();
+
+ const parentNote = this.getNote();
+
+ if (this.role === "image" && parentNote.type === "text") {
+ const origContent = parentNote.getContent();
+
+ if (typeof origContent !== "string") {
+ throw new Error(`Note with ID '${note.noteId} has a text type but non-string content.`);
+ }
+
+ const oldAttachmentUrl = `api/attachments/${this.attachmentId}/image/`;
+ const newNoteUrl = `api/images/${note.noteId}/`;
+
+ const fixedContent = replaceAll(origContent, oldAttachmentUrl, newNoteUrl);
+
+ if (fixedContent !== origContent) {
+ parentNote.setContent(fixedContent);
+ }
+
+ noteService.asyncPostProcessContent(note, fixedContent);
+ }
+
+ return { note, branch };
+ }
+
+ getFileName() {
+ const type = this.role === "image" ? "image" : "file";
+
+ return formatDownloadTitle(this.title, type, this.mime);
+ }
+
+ override beforeSaving() {
+ super.beforeSaving();
+
+ if (this.position === undefined || this.position === null) {
+ this.position =
+ 10 +
+ getSql().getValue(
+ /*sql*/`SELECT COALESCE(MAX(position), 0)
+ FROM attachments
+ WHERE ownerId = ?`,
+ [this.noteId]
+ );
+ }
+
+ this.dateModified = dateUtils.localNowDateTime();
+ this.utcDateModified = dateUtils.utcNowDateTime();
+ }
+
+ getPojo() {
+ return {
+ attachmentId: this.attachmentId,
+ ownerId: this.ownerId,
+ role: this.role,
+ mime: this.mime,
+ title: this.title || undefined,
+ position: this.position,
+ blobId: this.blobId,
+ isProtected: !!this.isProtected,
+ isDeleted: false,
+ dateModified: this.dateModified,
+ utcDateModified: this.utcDateModified,
+ utcDateScheduledForErasureSince: this.utcDateScheduledForErasureSince,
+ contentLength: this.contentLength
+ };
+ }
+
+ override getPojoToSave() {
+ const pojo = this.getPojo();
+ delete pojo.contentLength;
+
+ if (pojo.isProtected) {
+ if (this.isDecrypted) {
+ pojo.title = protectedSessionService.encrypt(pojo.title || "") || undefined;
+ } else {
+ // updating protected note outside of protected session means we will keep original ciphertexts
+ delete pojo.title;
+ }
+ }
+
+ return pojo;
+ }
+}
+
+export default BAttachment;
diff --git a/packages/trilium-core/src/becca/entities/battribute.ts b/packages/trilium-core/src/becca/entities/battribute.ts
new file mode 100644
index 0000000000..9156017793
--- /dev/null
+++ b/packages/trilium-core/src/becca/entities/battribute.ts
@@ -0,0 +1,227 @@
+"use strict";
+
+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 type { AttributeRow, AttributeType } from "@triliumnext/commons";
+import { sanitizeAttributeName } from "../../services/utils/index.js";
+
+interface SavingOpts {
+ skipValidation?: boolean;
+}
+
+/**
+ * Attribute is an abstract concept which has two real uses - label (key - value pair)
+ * and relation (representing named relationship between source and target note)
+ */
+class BAttribute extends AbstractBeccaEntity {
+ static get entityName() {
+ return "attributes";
+ }
+ static get primaryKeyName() {
+ return "attributeId";
+ }
+ static get hashedProperties() {
+ return ["attributeId", "noteId", "type", "name", "value", "isInheritable"];
+ }
+
+ attributeId!: string;
+ noteId!: string;
+ type!: AttributeType;
+ name!: string;
+ position!: number;
+ value!: string;
+ isInheritable!: boolean;
+
+ constructor(row?: AttributeRow) {
+ super();
+
+ if (!row) {
+ return;
+ }
+
+ this.updateFromRow(row);
+ this.init();
+ }
+
+ updateFromRow(row: AttributeRow) {
+ this.update([row.attributeId, row.noteId, row.type, row.name, row.value, row.isInheritable, row.position, row.utcDateModified]);
+ }
+
+ update([attributeId, noteId, type, name, value, isInheritable, position, utcDateModified]: any) {
+ this.attributeId = attributeId;
+ this.noteId = noteId;
+ this.type = type;
+ this.name = name;
+ this.position = position;
+ this.value = value || "";
+ this.isInheritable = !!isInheritable;
+ this.utcDateModified = utcDateModified;
+
+ return this;
+ }
+
+ override init() {
+ if (this.attributeId) {
+ this.becca.attributes[this.attributeId] = this;
+ }
+
+ if (!(this.noteId in this.becca.notes)) {
+ // entities can come out of order in sync, create skeleton which will be filled later
+ this.becca.addNote(this.noteId, new BNote({ noteId: this.noteId }));
+ }
+
+ this.becca.notes[this.noteId].ownedAttributes.push(this);
+
+ const key = `${this.type}-${this.name.toLowerCase()}`;
+ this.becca.attributeIndex[key] = this.becca.attributeIndex[key] || [];
+ this.becca.attributeIndex[key].push(this);
+
+ const targetNote = this.targetNote;
+
+ if (targetNote) {
+ targetNote.targetRelations.push(this);
+ }
+ }
+
+ validate() {
+ if (!["label", "relation"].includes(this.type)) {
+ throw new Error(`Invalid attribute type '${this.type}' in attribute '${this.attributeId}' of note '${this.noteId}'`);
+ }
+
+ if (!this.name?.trim()) {
+ throw new Error(`Invalid empty name in attribute '${this.attributeId}' of note '${this.noteId}'`);
+ }
+
+ if (this.type === "relation" && !(this.value in this.becca.notes)) {
+ throw new Error(`Cannot save relation '${this.name}' of note '${this.noteId}' since it targets not existing note '${this.value}'.`);
+ }
+ }
+
+ get isAffectingSubtree() {
+ return this.isInheritable || (this.type === "relation" && ["template", "inherit"].includes(this.name));
+ }
+
+ get targetNoteId() {
+ // alias
+ return this.type === "relation" ? this.value : undefined;
+ }
+
+ isAutoLink() {
+ return this.type === "relation" && ["internalLink", "imageLink", "relationMapLink", "includeNoteLink"].includes(this.name);
+ }
+
+ get note() {
+ return this.becca.notes[this.noteId];
+ }
+
+ get targetNote() {
+ if (this.type === "relation") {
+ return this.becca.notes[this.value];
+ }
+ }
+
+ getNote() {
+ const note = this.becca.getNote(this.noteId);
+
+ if (!note) {
+ throw new Error(`Note '${this.noteId}' of attribute '${this.attributeId}', type '${this.type}', name '${this.name}' does not exist.`);
+ }
+
+ return note;
+ }
+
+ getTargetNote() {
+ if (this.type !== "relation") {
+ throw new Error(`Attribute '${this.attributeId}' is not a relation.`);
+ }
+
+ if (!this.value) {
+ return null;
+ }
+
+ return this.becca.getNote(this.value);
+ }
+
+ isDefinition() {
+ return this.type === "label" && (this.name.startsWith("label:") || this.name.startsWith("relation:"));
+ }
+
+ getDefinition() {
+ return promotedAttributeDefinitionParser.parse(this.value);
+ }
+
+ getDefinedName() {
+ if (this.type === "label" && this.name.startsWith("label:")) {
+ return this.name.substr(6);
+ } else if (this.type === "label" && this.name.startsWith("relation:")) {
+ return this.name.substr(9);
+ } else {
+ return this.name;
+ }
+ }
+
+ override get isDeleted() {
+ return !(this.attributeId in this.becca.attributes);
+ }
+
+ override beforeSaving(opts: SavingOpts = {}) {
+ if (!opts.skipValidation) {
+ this.validate();
+ }
+
+ this.name = sanitizeAttributeName(this.name);
+
+ if (!this.value) {
+ // null value isn't allowed
+ this.value = "";
+ }
+
+ if (this.position === undefined || this.position === null) {
+ const maxExistingPosition = this.getNote()
+ .getAttributes()
+ .reduce((maxPosition, attr) => Math.max(maxPosition, attr.position || 0), 0);
+
+ this.position = maxExistingPosition + 10;
+ }
+
+ if (!this.isInheritable) {
+ this.isInheritable = false;
+ }
+
+ this.utcDateModified = dateUtils.utcNowDateTime();
+
+ super.beforeSaving();
+
+ this.becca.attributes[this.attributeId] = this;
+ }
+
+ getPojo() {
+ return {
+ attributeId: this.attributeId,
+ noteId: this.noteId,
+ type: this.type,
+ name: this.name,
+ position: this.position,
+ value: this.value,
+ isInheritable: this.isInheritable,
+ utcDateModified: this.utcDateModified,
+ isDeleted: false
+ };
+ }
+
+ createClone(type: AttributeType, name: string, value: string, isInheritable?: boolean) {
+ return new BAttribute({
+ noteId: this.noteId,
+ type: type,
+ name: name,
+ value: value,
+ position: this.position,
+ isInheritable: isInheritable,
+ utcDateModified: this.utcDateModified
+ });
+ }
+}
+
+export default BAttribute;
diff --git a/apps/server/src/becca/entities/bblob.ts b/packages/trilium-core/src/becca/entities/bblob.ts
similarity index 96%
rename from apps/server/src/becca/entities/bblob.ts
rename to packages/trilium-core/src/becca/entities/bblob.ts
index 2cff185d5c..a3ec261382 100644
--- a/apps/server/src/becca/entities/bblob.ts
+++ b/packages/trilium-core/src/becca/entities/bblob.ts
@@ -13,7 +13,7 @@ class BBlob extends AbstractBeccaEntity {
return ["blobId", "content"];
}
- content!: string | Buffer;
+ content!: string | Uint8Array;
contentLength!: number;
constructor(row: BlobRow) {
diff --git a/packages/trilium-core/src/becca/entities/bbranch.ts b/packages/trilium-core/src/becca/entities/bbranch.ts
new file mode 100644
index 0000000000..1468e04599
--- /dev/null
+++ b/packages/trilium-core/src/becca/entities/bbranch.ts
@@ -0,0 +1,289 @@
+
+
+import type { BranchRow } from "@triliumnext/commons";
+
+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 AbstractBeccaEntity from "./abstract_becca_entity.js";
+import BNote from "./bnote.js";
+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
+ * parents.
+ *
+ * Note that you should not rely on the branch's identity, since it can change easily with a note's move.
+ * Always check noteId instead.
+ */
+class BBranch extends AbstractBeccaEntity {
+ static get entityName() {
+ return "branches";
+ }
+ static get primaryKeyName() {
+ return "branchId";
+ }
+ // notePosition is not part of hash because it would produce a lot of updates in case of reordering
+ static get hashedProperties() {
+ return ["branchId", "noteId", "parentNoteId", "prefix"];
+ }
+
+ branchId?: string;
+ noteId!: string;
+ parentNoteId!: string;
+ prefix!: string | null;
+ notePosition!: number;
+ isExpanded!: boolean;
+
+ constructor(row?: BranchRow) {
+ super();
+
+ if (!row) {
+ return;
+ }
+
+ this.updateFromRow(row);
+ this.init();
+ }
+
+ updateFromRow(row: BranchRow) {
+ this.update([row.branchId, row.noteId, row.parentNoteId, row.prefix, row.notePosition, row.isExpanded, row.utcDateModified]);
+ }
+
+ update([branchId, noteId, parentNoteId, prefix, notePosition, isExpanded, utcDateModified]: any) {
+ this.branchId = branchId;
+ this.noteId = noteId;
+ this.parentNoteId = parentNoteId;
+ this.prefix = prefix;
+ this.notePosition = notePosition;
+ this.isExpanded = !!isExpanded;
+ this.utcDateModified = utcDateModified;
+
+ return this;
+ }
+
+ override init() {
+ if (this.branchId) {
+ this.becca.branches[this.branchId] = this;
+ }
+
+ this.becca.childParentToBranch[`${this.noteId}-${this.parentNoteId}`] = this;
+
+ const childNote = this.childNote;
+
+ if (!childNote.parentBranches.includes(this)) {
+ childNote.parentBranches.push(this);
+ }
+
+ if (this.noteId === "root") {
+ return;
+ }
+
+ const parentNote = this.parentNote;
+ if (parentNote) {
+ if (!childNote.parents.includes(parentNote)) {
+ childNote.parents.push(parentNote);
+ }
+
+ if (!parentNote.children.includes(childNote)) {
+ parentNote.children.push(childNote);
+ }
+ }
+ }
+
+ get childNote(): BNote {
+ if (!(this.noteId in this.becca.notes)) {
+ // entities can come out of order in sync/import, create skeleton which will be filled later
+ this.becca.addNote(this.noteId, new BNote({ noteId: this.noteId }));
+ }
+
+ return this.becca.notes[this.noteId];
+ }
+
+ getNote(): BNote {
+ return this.childNote;
+ }
+
+ /** @returns root branch will have undefined parent, all other branches have to have a parent note */
+ get parentNote(): BNote | undefined {
+ if (!(this.parentNoteId in this.becca.notes) && this.parentNoteId !== "none") {
+ // entities can come out of order in sync/import, create skeleton which will be filled later
+ this.becca.addNote(this.parentNoteId, new BNote({ noteId: this.parentNoteId }));
+ }
+
+ return this.becca.notes[this.parentNoteId];
+ }
+
+ override get isDeleted() {
+ return this.branchId == undefined || !(this.branchId in this.becca.branches);
+ }
+
+ /**
+ * Branch is weak when its existence should not hinder deletion of its note.
+ * As a result, note with only weak branches should be immediately deleted.
+ * An example is shared or bookmarked clones - they are created automatically and exist for technical reasons,
+ * not as user-intended actions. From user perspective, they don't count as real clones and for the purpose
+ * of deletion should not act as a clone.
+ */
+ get isWeak() {
+ return ["_share", "_lbBookmarks"].includes(this.parentNoteId);
+ }
+
+ /**
+ * Delete a branch. If this is a last note's branch, delete the note as well.
+ *
+ * @param deleteId - optional delete identified
+ *
+ * @returns true if note has been deleted, false otherwise
+ */
+ deleteBranch(deleteId?: string, taskContext?: TaskContext<"deleteNotes">): boolean {
+ if (!deleteId) {
+ deleteId = randomString(10);
+ }
+
+ if (!taskContext) {
+ taskContext = new TaskContext("no-progress-reporting", "deleteNotes", null);
+ }
+
+ taskContext.increaseProgressCount();
+
+ const note = this.getNote();
+
+ if (!taskContext.noteDeletionHandlerTriggered) {
+ const parentBranches = note.getParentBranches();
+
+ if (parentBranches.length === 1 && parentBranches[0] === this) {
+ // needs to be run before branches and attributes are deleted and thus attached relations disappear
+ handlers.runAttachedRelations(note, "runOnNoteDeletion", note);
+ }
+ }
+
+ if ((this.noteId === "root" || this.noteId === getHoistedNoteId()) && !this.isWeak) {
+ throw new Error("Can't delete root or hoisted branch/note");
+ }
+
+ this.markAsDeleted(deleteId);
+
+ const notDeletedBranches = note.getStrongParentBranches();
+
+ if (notDeletedBranches.length === 0) {
+ for (const weakBranch of note.getParentBranches()) {
+ weakBranch.markAsDeleted(deleteId);
+ }
+
+ for (const childBranch of note.getChildBranches()) {
+ if (childBranch) {
+ childBranch.deleteBranch(deleteId, taskContext);
+ }
+ }
+
+ // first delete children and then parent - this will show up better in recent changes
+
+ getLog().info(`Deleting note '${note.noteId}'`);
+
+ this.becca.notes[note.noteId].isBeingDeleted = true;
+
+ for (const attribute of note.getOwnedAttributes().slice()) {
+ attribute.markAsDeleted(deleteId);
+ }
+
+ for (const relation of note.getTargetRelations()) {
+ relation.markAsDeleted(deleteId);
+ }
+
+ for (const attachment of note.getAttachments()) {
+ attachment.markAsDeleted(deleteId);
+ }
+
+ note.markAsDeleted(deleteId);
+
+ return true;
+ }
+ return false;
+
+ }
+
+ override beforeSaving() {
+ if (!this.noteId || !this.parentNoteId) {
+ throw new Error(`noteId and parentNoteId are mandatory properties for Branch`);
+ }
+
+ this.branchId = `${this.parentNoteId}_${this.noteId}`;
+
+ if (this.notePosition === undefined || this.notePosition === null) {
+ let maxNotePos = 0;
+
+ if (this.parentNote) {
+ for (const childBranch of this.parentNote.getChildBranches()) {
+ if (!childBranch) {
+ continue;
+ }
+
+ if (
+ maxNotePos < childBranch.notePosition &&
+ childBranch.noteId !== "_hidden" // hidden has a very large notePosition to always stay last
+ ) {
+ maxNotePos = childBranch.notePosition;
+ }
+ }
+ }
+
+ this.notePosition = maxNotePos + 10;
+ }
+
+ if (!this.isExpanded) {
+ this.isExpanded = false;
+ }
+
+ if (!this.prefix?.trim()) {
+ this.prefix = null;
+ }
+
+ this.utcDateModified = dateUtils.utcNowDateTime();
+
+ super.beforeSaving();
+
+ this.becca.branches[this.branchId] = this;
+ }
+
+ getPojo() {
+ return {
+ branchId: this.branchId,
+ noteId: this.noteId,
+ parentNoteId: this.parentNoteId,
+ prefix: this.prefix,
+ notePosition: this.notePosition,
+ isExpanded: this.isExpanded,
+ isDeleted: false,
+ utcDateModified: this.utcDateModified
+ };
+ }
+
+ createClone(parentNoteId: string, notePosition?: number) {
+ const existingBranch = this.becca.getBranchFromChildAndParent(this.noteId, parentNoteId);
+
+ if (existingBranch) {
+ if (notePosition) {
+ existingBranch.notePosition = notePosition;
+ }
+ return existingBranch;
+ }
+ return new BBranch({
+ noteId: this.noteId,
+ parentNoteId,
+ notePosition: notePosition || null,
+ prefix: this.prefix,
+ isExpanded: this.isExpanded
+ });
+
+ }
+
+ getParentNote() {
+ return this.parentNote;
+ }
+
+}
+
+export default BBranch;
diff --git a/packages/trilium-core/src/becca/entities/betapi_token.ts b/packages/trilium-core/src/becca/entities/betapi_token.ts
new file mode 100644
index 0000000000..d76ede6bdc
--- /dev/null
+++ b/packages/trilium-core/src/becca/entities/betapi_token.ts
@@ -0,0 +1,89 @@
+"use strict";
+
+import type { EtapiTokenRow } from "@triliumnext/commons";
+
+import dateUtils from "../../services/utils/date";
+import AbstractBeccaEntity from "./abstract_becca_entity.js";
+
+/**
+ * EtapiToken is an entity representing token used to authenticate against Trilium REST API from client applications.
+ * Used by:
+ * - Trilium Sender
+ * - ETAPI clients
+ *
+ * The format user is presented with is "_". This is also called "authToken" to distinguish it
+ * from tokenHash and token.
+ */
+class BEtapiToken extends AbstractBeccaEntity {
+ static get entityName() {
+ return "etapi_tokens";
+ }
+ static get primaryKeyName() {
+ return "etapiTokenId";
+ }
+ static get hashedProperties() {
+ return ["etapiTokenId", "name", "tokenHash", "utcDateCreated", "utcDateModified", "isDeleted"];
+ }
+
+ etapiTokenId?: string;
+ name!: string;
+ tokenHash!: string;
+ private _isDeleted?: boolean;
+
+ constructor(row?: EtapiTokenRow) {
+ super();
+
+ if (!row) {
+ return;
+ }
+
+ this.updateFromRow(row);
+ this.init();
+ }
+
+ override get isDeleted() {
+ return !!this._isDeleted;
+ }
+
+ updateFromRow(row: EtapiTokenRow) {
+ this.etapiTokenId = row.etapiTokenId;
+ this.name = row.name;
+ this.tokenHash = row.tokenHash;
+ this.utcDateCreated = row.utcDateCreated || dateUtils.utcNowDateTime();
+ this.utcDateModified = row.utcDateModified || this.utcDateCreated;
+ this._isDeleted = !!row.isDeleted;
+
+ if (this.etapiTokenId) {
+ this.becca.etapiTokens[this.etapiTokenId] = this;
+ }
+ }
+
+ override init() {
+ if (this.etapiTokenId) {
+ this.becca.etapiTokens[this.etapiTokenId] = this;
+ }
+ }
+
+ getPojo() {
+ return {
+ etapiTokenId: this.etapiTokenId,
+ name: this.name,
+ tokenHash: this.tokenHash,
+ utcDateCreated: this.utcDateCreated,
+ utcDateModified: this.utcDateModified,
+ isDeleted: this.isDeleted
+ };
+ }
+
+ override beforeSaving() {
+ this.utcDateModified = dateUtils.utcNowDateTime();
+
+ super.beforeSaving();
+
+ if (this.etapiTokenId) {
+ this.becca.etapiTokens[this.etapiTokenId] = this;
+ }
+ }
+}
+
+export default BEtapiToken;
diff --git a/packages/trilium-core/src/becca/entities/bnote.ts b/packages/trilium-core/src/becca/entities/bnote.ts
new file mode 100644
index 0000000000..7be573166f
--- /dev/null
+++ b/packages/trilium-core/src/becca/entities/bnote.ts
@@ -0,0 +1,1806 @@
+import type { AttachmentRow, AttributeType, CloneResponse, NoteRow, NoteType, RevisionRow } from "@triliumnext/commons";
+import { dayjs } from "@triliumnext/commons";
+import eventService from "../../services/events";
+
+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 { 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 type { NotePojo } from "../becca-interface.js";
+import AbstractBeccaEntity from "./abstract_becca_entity.js";
+import BAttachment from "./battachment.js";
+import BAttribute from "./battribute.js";
+import type BBranch from "./bbranch.js";
+import BRevision from "./brevision.js";
+import { getSql } from "../../services/sql/index.js";
+import { formatDownloadTitle, isStringNote, normalize, randomString, replaceAll } from "../../services/utils";
+
+const LABEL = "label";
+const RELATION = "relation";
+
+// TODO: Deduplicate with fnote
+export const NOTE_TYPE_ICONS = {
+ file: "bx bx-file",
+ image: "bx bx-image",
+ code: "bx bx-code",
+ render: "bx bx-extension",
+ search: "bx bx-file-find",
+ relationMap: "bx bxs-network-chart",
+ book: "bx bx-book",
+ noteMap: "bx bxs-network-chart",
+ mermaid: "bx bx-selection",
+ canvas: "bx bx-pen",
+ webView: "bx bx-globe-alt",
+ launcher: "bx bx-link",
+ doc: "bx bxs-file-doc",
+ contentWidget: "bx bxs-widget",
+ mindMap: "bx bx-sitemap",
+ geoMap: "bx bx-map-alt"
+};
+
+interface NotePathRecord {
+ isArchived: boolean;
+ isInHoistedSubTree: boolean;
+ notePath: string[];
+ isHidden: boolean;
+}
+
+interface ContentOpts {
+ /** will also save this BNote entity */
+ forceSave?: boolean;
+ /** override frontend heuristics on when to reload, instruct to reload */
+ forceFrontendReload?: boolean;
+}
+
+interface Relationship {
+ parentNoteId: string;
+ childNoteId: string;
+}
+
+interface ConvertOpts {
+ /** if true, the action is not triggered by user, but e.g. by migration, and only perfect candidates will be migrated */
+ autoConversion?: boolean;
+}
+
+/**
+ * Trilium's main entity, which can represent text note, image, code note, file attachment etc.
+ */
+class BNote extends AbstractBeccaEntity {
+ static get entityName() {
+ return "notes";
+ }
+ static get primaryKeyName() {
+ return "noteId";
+ }
+ static get hashedProperties() {
+ return ["noteId", "title", "isProtected", "type", "mime", "blobId"];
+ }
+
+ noteId!: string;
+ title!: string;
+ type!: NoteType;
+ mime!: string;
+ /** set during the deletion operation, before it is completed (removed from becca completely). */
+ isBeingDeleted!: boolean;
+ isDecrypted!: boolean;
+
+ ownedAttributes!: BAttribute[];
+ parentBranches!: BBranch[];
+ parents!: BNote[];
+ children!: BNote[];
+ targetRelations!: BAttribute[];
+
+ __flatTextCache!: string | null;
+
+ private __attributeCache!: BAttribute[] | null;
+ private __inheritableAttributeCache!: BAttribute[] | null;
+ private __ancestorCache!: BNote[] | null;
+
+ // following attributes are filled during searching in the database
+ /** size of the content in bytes */
+ contentSize!: number | null;
+ /** size of the note content, attachment contents in bytes */
+ contentAndAttachmentsSize!: number | null;
+ /** size of the note content, attachment contents and revision contents in bytes */
+ contentAndAttachmentsAndRevisionsSize!: number | null;
+ /** number of note revisions for this note */
+ revisionCount!: number | null;
+
+ constructor(row?: Partial) {
+ super();
+
+ if (!row) {
+ return;
+ }
+
+ this.updateFromRow(row);
+ this.init();
+ }
+
+ updateFromRow(row: Partial) {
+ this.update([row.noteId, row.title, row.type, row.mime, row.isProtected, row.blobId, row.dateCreated, row.dateModified, row.utcDateCreated, row.utcDateModified]);
+ }
+
+ update([noteId, title, type, mime, isProtected, blobId, dateCreated, dateModified, utcDateCreated, utcDateModified]: any) {
+ // ------ Database persisted attributes ------
+
+ this.noteId = noteId;
+ this.title = title;
+ this.type = type;
+ this.mime = mime;
+ this.isProtected = !!isProtected;
+ this.blobId = blobId;
+ this.dateCreated = dateCreated || dateUtils.localNowDateTime();
+ this.dateModified = dateModified;
+ this.utcDateCreated = utcDateCreated || dateUtils.utcNowDateTime();
+ this.utcDateModified = utcDateModified;
+ this.isBeingDeleted = false;
+
+ // ------ Derived attributes ------
+
+ this.isDecrypted = !this.noteId || !this.isProtected;
+
+ this.decrypt();
+
+ this.__flatTextCache = null;
+
+ return this;
+ }
+
+ override init() {
+ this.parentBranches = [];
+ this.parents = [];
+ this.children = [];
+ this.ownedAttributes = [];
+ this.__attributeCache = null;
+ this.__inheritableAttributeCache = null;
+ this.targetRelations = [];
+
+ this.becca.addNote(this.noteId, this);
+ this.__ancestorCache = null;
+
+ this.contentSize = null;
+ this.contentAndAttachmentsSize = null;
+ this.contentAndAttachmentsAndRevisionsSize = null;
+ this.revisionCount = null;
+ }
+
+ isContentAvailable() {
+ return (
+ !this.noteId || // new note which was not encrypted yet
+ !this.isProtected ||
+ protectedSessionService.isProtectedSessionAvailable()
+ );
+ }
+
+ getTitleOrProtected() {
+ return this.isContentAvailable() ? this.title : "[protected]";
+ }
+
+ getParentBranches() {
+ return this.parentBranches;
+ }
+
+ /**
+ * Returns strong (as opposed to weak) parent branches. See isWeak for details.
+ */
+ getStrongParentBranches() {
+ return this.getParentBranches().filter((branch) => !branch.isWeak);
+ }
+
+ /**
+ * @deprecated use getParentBranches() instead
+ */
+ getBranches() {
+ return this.parentBranches;
+ }
+
+ getParentNotes() {
+ return this.parents;
+ }
+
+ getChildNotes() {
+ return this.children;
+ }
+
+ hasChildren() {
+ return this.children && this.children.length > 0;
+ }
+
+ getChildBranches(): BBranch[] {
+ return this.children.map((childNote) => this.becca.getBranchFromChildAndParent(childNote.noteId, this.noteId)) as BBranch[];
+ }
+
+ /**
+ * Note content has quite special handling - it's not a separate entity, but a lazily loaded
+ * part of Note entity with its own sync. Reasons behind this hybrid design has been:
+ *
+ * - content can be quite large, and it's not necessary to load it / fill memory for any note access even if we don't need a content, especially for bulk operations like search
+ * - changes in the note metadata or title should not trigger note content sync (so we keep separate utcDateModified and entity changes records)
+ * - but to the user note content and title changes are one and the same - single dateModified (so all changes must go through Note and content is not a separate entity)
+ */
+ getContent() {
+ return this._getContent();
+ }
+
+ /**
+ * @throws Error in case of invalid JSON
+ */
+ getJsonContent(): any | null {
+ const content = this.getContent();
+
+ if (typeof content !== "string" || !content || !content.trim()) {
+ return null;
+ }
+
+ return JSON.parse(content);
+ }
+
+ /** @returns valid object or null if the content cannot be parsed as JSON */
+ getJsonContentSafely() {
+ try {
+ return this.getJsonContent();
+ } catch (e) {
+ return null;
+ }
+ }
+
+ setContent(content: Uint8Array | string, opts: ContentOpts = {}) {
+ this._setContent(content, opts);
+
+ eventService.emit(eventService.NOTE_CONTENT_CHANGE, { entity: this });
+ }
+
+ setJsonContent(content: {}) {
+ this.setContent(JSON.stringify(content, null, "\t"));
+ }
+
+ get dateCreatedObj() {
+ return this.dateCreated === null ? null : dayjs(this.dateCreated);
+ }
+
+ get utcDateCreatedObj() {
+ return this.utcDateCreated === null ? null : dayjs.utc(this.utcDateCreated);
+ }
+
+ get dateModifiedObj() {
+ return this.dateModified === null ? null : dayjs(this.dateModified);
+ }
+
+ get utcDateModifiedObj() {
+ return this.utcDateModified === null ? null : dayjs.utc(this.utcDateModified);
+ }
+
+ /** @returns true if this note is the root of the note tree. Root note has "root" noteId */
+ isRoot() {
+ return this.noteId === "root";
+ }
+
+ /** @returns true if this note is of application/json content type */
+ isJson() {
+ return this.mime === "application/json";
+ }
+
+ /** @returns true if this note is JavaScript (code or attachment) */
+ isJavaScript() {
+ return (
+ (this.type === "code" || this.type === "file" || this.type === "launcher") &&
+ (this.mime.startsWith("application/javascript") || this.mime === "application/x-javascript" || this.mime === "text/javascript")
+ );
+ }
+
+ isJsx() {
+ return (this.type === "code" && this.mime === "text/jsx");
+ }
+
+ /** @returns true if this note is HTML */
+ isHtml() {
+ return ["code", "file", "render"].includes(this.type) && this.mime === "text/html";
+ }
+
+ /** @returns true if this note is an image */
+ isImage() {
+ return this.type === "image" || (this.type === "file" && this.mime?.startsWith("image/"));
+ }
+
+ /** @deprecated use hasStringContent() instead */
+ isStringNote() {
+ return this.hasStringContent();
+ }
+
+ /** @returns true if the note has string content (not binary) */
+ override hasStringContent() {
+ return isStringNote(this.type, this.mime);
+ }
+
+ /** @returns JS script environment - either "frontend" or "backend" */
+ getScriptEnv() {
+ if (this.isHtml() || (this.isJavaScript() && this.mime.endsWith("env=frontend")) || this.isJsx()) {
+ return "frontend";
+ }
+
+ if (this.type === "render") {
+ return "frontend";
+ }
+
+ if (this.isJavaScript() && this.mime.endsWith("env=backend")) {
+ return "backend";
+ }
+
+ return null;
+ }
+
+ /**
+ * Beware that the method must not create a copy of the array, but actually returns its internal array
+ * (for performance reasons)
+ *
+ * @param type - (optional) attribute type to filter
+ * @param name - (optional) attribute name to filter
+ * @returns all note's attributes, including inherited ones
+ */
+ getAttributes(type?: string, name?: string): BAttribute[] {
+ this.__validateTypeName(type, name);
+ this.__ensureAttributeCacheIsAvailable();
+
+ if (!this.__attributeCache) {
+ throw new Error("Attribute cache not available.");
+ }
+
+ if (type && name) {
+ return this.__attributeCache.filter((attr) => attr.name === name && attr.type === type);
+ } else if (type) {
+ return this.__attributeCache.filter((attr) => attr.type === type);
+ } else if (name) {
+ return this.__attributeCache.filter((attr) => attr.name === name);
+ }
+ return this.__attributeCache;
+ }
+
+ private __ensureAttributeCacheIsAvailable() {
+ if (!this.__attributeCache) {
+ this.__getAttributes([]);
+ }
+ }
+
+ private __getAttributes(path: string[]) {
+ if (path.includes(this.noteId)) {
+ return [];
+ }
+
+ if (!this.__attributeCache) {
+ const parentAttributes = this.ownedAttributes.slice();
+ const newPath = [...path, this.noteId];
+
+ // inheritable attrs on root are typically not intended to be applied to hidden subtree #3537
+ if (this.noteId !== "root" && this.noteId !== "_hidden") {
+ for (const parentNote of this.parents) {
+ parentAttributes.push(...parentNote.__getInheritableAttributes(newPath));
+ }
+ }
+
+ const templateAttributes: BAttribute[] = [];
+
+ for (const ownedAttr of parentAttributes) {
+ // parentAttributes so we process also inherited templates
+ if (ownedAttr.type === "relation" && ["template", "inherit"].includes(ownedAttr.name)) {
+ const templateNote = this.becca.notes[ownedAttr.value];
+
+ if (templateNote) {
+ templateAttributes.push(
+ ...templateNote
+ .__getAttributes(newPath)
+ // template attr is used as a marker for templates, but it's not meant to be inherited
+ .filter((attr) => !(attr.type === "label" && (attr.name === "template" || attr.name === "workspacetemplate")))
+ );
+ }
+ }
+ }
+
+ this.__attributeCache = [];
+
+ const addedAttributeIds = new Set();
+
+ for (const attr of parentAttributes.concat(templateAttributes)) {
+ if (!addedAttributeIds.has(attr.attributeId)) {
+ addedAttributeIds.add(attr.attributeId);
+
+ this.__attributeCache.push(attr);
+ }
+ }
+
+ this.__inheritableAttributeCache = [];
+
+ for (const attr of this.__attributeCache) {
+ if (attr.isInheritable) {
+ this.__inheritableAttributeCache.push(attr);
+ }
+ }
+ }
+
+ return this.__attributeCache;
+ }
+
+ private __getInheritableAttributes(path: string[]): BAttribute[] {
+ if (path.includes(this.noteId)) {
+ return [];
+ }
+
+ if (!this.__inheritableAttributeCache) {
+ this.__getAttributes(path); // will refresh also this.__inheritableAttributeCache
+ }
+
+ return this.__inheritableAttributeCache || [];
+ }
+
+ __validateTypeName(type?: string | null, name?: string | null) {
+ if (type && type !== "label" && type !== "relation") {
+ throw new Error(`Unrecognized attribute type '${type}'. Only 'label' and 'relation' are possible values.`);
+ }
+
+ if (name) {
+ const firstLetter = name.charAt(0);
+ if (firstLetter === "#" || firstLetter === "~") {
+ throw new Error(`Detect '#' or '~' in the attribute's name. In the API, attribute names should be set without these characters.`);
+ }
+ }
+ }
+
+ hasAttribute(type: string, name: string, value: string | null = null): boolean {
+ return !!this.getAttributes().find((attr) => attr.name === name && (value === undefined || value === null || attr.value === value) && attr.type === type);
+ }
+
+ getAttributeCaseInsensitive(type: string, name: string, value?: string | null) {
+ name = name.toLowerCase();
+ value = value ? value.toLowerCase() : null;
+
+ return this.getAttributes().find((attr) => attr.name.toLowerCase() === name && (!value || attr.value.toLowerCase() === value) && attr.type === type);
+ }
+
+ getRelationTarget(name: string) {
+ const relation = this.getAttributes().find((attr) => attr.name === name && attr.type === "relation");
+
+ return relation ? relation.targetNote : null;
+ }
+
+ /**
+ * @param name - label name
+ * @param value - label value
+ * @returns true if label exists (including inherited)
+ */
+ hasLabel(name: string, value?: string): boolean {
+ return this.hasAttribute(LABEL, name, value);
+ }
+
+ /**
+ * @param name - label name
+ * @returns true if label exists (including inherited) and does not have "false" value.
+ */
+ isLabelTruthy(name: string): boolean {
+ const label = this.getLabel(name);
+
+ if (!label) {
+ return false;
+ }
+
+ return label && label.value !== "false";
+ }
+
+ /**
+ * @param name - label name
+ * @param value - label value
+ * @returns true if label exists (excluding inherited)
+ */
+ hasOwnedLabel(name: string, value?: string): boolean {
+ return this.hasOwnedAttribute(LABEL, name, value);
+ }
+
+ /**
+ * @param name - relation name
+ * @param value - relation value
+ * @returns true if relation exists (including inherited)
+ */
+ hasRelation(name: string, value?: string): boolean {
+ return this.hasAttribute(RELATION, name, value);
+ }
+
+ /**
+ * @param name - relation name
+ * @param value - relation value
+ * @returns true if relation exists (excluding inherited)
+ */
+ hasOwnedRelation(name: string, value?: string): boolean {
+ return this.hasOwnedAttribute(RELATION, name, value);
+ }
+
+ /**
+ * @param name - label name
+ * @returns label if it exists, null otherwise
+ */
+ getLabel(name: string): BAttribute | null {
+ return this.getAttribute(LABEL, name);
+ }
+
+ /**
+ * @param name - label name
+ * @returns label if it exists, null otherwise
+ */
+ getOwnedLabel(name: string): BAttribute | null {
+ return this.getOwnedAttribute(LABEL, name);
+ }
+
+ /**
+ * @param name - relation name
+ * @returns relation if it exists, null otherwise
+ */
+ getRelation(name: string): BAttribute | null {
+ return this.getAttribute(RELATION, name);
+ }
+
+ /**
+ * @param name - relation name
+ * @returns relation if it exists, null otherwise
+ */
+ getOwnedRelation(name: string): BAttribute | null {
+ return this.getOwnedAttribute(RELATION, name);
+ }
+
+ /**
+ * @param name - label name
+ * @returns label value if label exists, null otherwise
+ */
+ getLabelValue(name: string): string | null {
+ return this.getAttributeValue(LABEL, name);
+ }
+
+ /**
+ * @param name - label name
+ * @returns label value if label exists, null otherwise
+ */
+ getOwnedLabelValue(name: string): string | null {
+ return this.getOwnedAttributeValue(LABEL, name);
+ }
+
+ /**
+ * @param name - relation name
+ * @returns relation value if relation exists, null otherwise
+ */
+ getRelationValue(name: string): string | null {
+ return this.getAttributeValue(RELATION, name);
+ }
+
+ /**
+ * @param name - relation name
+ * @returns relation value if relation exists, null otherwise
+ */
+ getOwnedRelationValue(name: string): string | null {
+ return this.getOwnedAttributeValue(RELATION, name);
+ }
+
+ /**
+ * @param attribute type (label, relation, etc.)
+ * @param name - attribute name
+ * @param value - attribute value
+ * @returns true if note has an attribute with given type and name (excluding inherited)
+ */
+ hasOwnedAttribute(type: string, name: string, value?: string): boolean {
+ return !!this.getOwnedAttribute(type, name, value);
+ }
+
+ /**
+ * @param type - attribute type (label, relation, etc.)
+ * @param name - attribute name
+ * @returns attribute of the given type and name. If there are more such attributes, first is returned.
+ * Returns null if there's no such attribute belonging to this note.
+ */
+ getAttribute(type: string, name: string): BAttribute | null {
+ const attributes = this.getAttributes();
+
+ return attributes.find((attr) => attr.name === name && attr.type === type) || null;
+ }
+
+ /**
+ * @param type - attribute type (label, relation, etc.)
+ * @param name - attribute name
+ * @returns attribute value of given type and name or null if no such attribute exists.
+ */
+ getAttributeValue(type: string, name: string): string | null {
+ const attr = this.getAttribute(type, name);
+
+ return attr ? attr.value : null;
+ }
+
+ /**
+ * @param type - attribute type (label, relation, etc.)
+ * @param name - attribute name
+ * @returns attribute value of given type and name or null if no such attribute exists.
+ */
+ getOwnedAttributeValue(type: string, name: string): string | null {
+ const attr = this.getOwnedAttribute(type, name);
+
+ return attr ? attr.value : null;
+ }
+
+ /**
+ * @param name - label name to filter
+ * @returns all note's labels (attributes with type label), including inherited ones
+ */
+ getLabels(name?: string): BAttribute[] {
+ return this.getAttributes(LABEL, name);
+ }
+
+ /**
+ * @param name - label name to filter
+ * @returns all note's label values, including inherited ones
+ */
+ getLabelValues(name: string): string[] {
+ return this.getLabels(name).map((l) => l.value);
+ }
+
+ /**
+ * @param name - label name to filter
+ * @returns all note's labels (attributes with type label), excluding inherited ones
+ */
+ getOwnedLabels(name: string): BAttribute[] {
+ return this.getOwnedAttributes(LABEL, name);
+ }
+
+ /**
+ * @param name - label name to filter
+ * @returns all note's label values, excluding inherited ones
+ */
+ getOwnedLabelValues(name: string): string[] {
+ return this.getOwnedAttributes(LABEL, name).map((l) => l.value);
+ }
+
+ /**
+ * @param name - relation name to filter
+ * @returns all note's relations (attributes with type relation), including inherited ones
+ */
+ getRelations(name?: string): BAttribute[] {
+ return this.getAttributes(RELATION, name);
+ }
+
+ /**
+ * @param name - relation name to filter
+ * @returns all note's relations (attributes with type relation), excluding inherited ones
+ */
+ getOwnedRelations(name?: string | null): BAttribute[] {
+ return this.getOwnedAttributes(RELATION, name);
+ }
+
+ /**
+ * Beware that the method must not create a copy of the array, but actually returns its internal array
+ * (for performance reasons)
+ *
+ * @param type - (optional) attribute type to filter
+ * @param name - (optional) attribute name to filter
+ * @param value - (optional) attribute value to filter
+ * @returns note's "owned" attributes - excluding inherited ones
+ */
+ getOwnedAttributes(type: string | null = null, name: string | null = null, value: string | null = null) {
+ this.__validateTypeName(type, name);
+
+ if (type && name && value !== undefined && value !== null) {
+ return this.ownedAttributes.filter((attr) => attr.name === name && attr.value === value && attr.type === type);
+ } else if (type && name) {
+ return this.ownedAttributes.filter((attr) => attr.name === name && attr.type === type);
+ } else if (type) {
+ return this.ownedAttributes.filter((attr) => attr.type === type);
+ } else if (name) {
+ return this.ownedAttributes.filter((attr) => attr.name === name);
+ }
+ return this.ownedAttributes;
+ }
+
+ /**
+ * @returns attribute belonging to this specific note (excludes inherited attributes)
+ *
+ * This method can be significantly faster than the getAttribute()
+ */
+ getOwnedAttribute(type: string, name: string, value: string | null = null) {
+ const attrs = this.getOwnedAttributes(type, name, value);
+
+ return attrs.length > 0 ? attrs[0] : null;
+ }
+
+ get isArchived() {
+ return this.hasAttribute("label", "archived");
+ }
+
+ areAllNotePathsArchived() {
+ // there's a slight difference between note being itself archived and all its note paths being archived
+ // - note is archived when it itself has an archived label or inherits it
+ // - note does not have or inherit archived label, but each note path contains a note with (non-inheritable)
+ // archived label
+
+ const bestNotePathRecord = this.getSortedNotePathRecords()[0];
+
+ if (!bestNotePathRecord) {
+ throw new Error(`No note path available for note '${this.noteId}'`);
+ }
+
+ return bestNotePathRecord.isArchived;
+ }
+
+ hasInheritableArchivedLabel() {
+ for (const attr of this.getAttributes()) {
+ if (attr.name === "archived" && attr.type === LABEL && attr.isInheritable) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ // will sort the parents so that the non-archived are first and archived at the end
+ // this is done so that the non-archived paths are always explored as first when looking for note path
+ sortParents() {
+ this.parentBranches.sort((a, b) => {
+ if (a.parentNote?.isArchived) {
+ return 1;
+ } else if (a.parentNote?.isHiddenCompletely()) {
+ return 1;
+ }
+ return 0;
+ });
+
+ this.parents = this.parentBranches.map((branch) => branch.parentNote).filter((note) => !!note) as BNote[];
+ }
+
+ sortChildren() {
+ if (this.children.length === 0) {
+ return;
+ }
+
+ const becca = this.becca;
+
+ this.children.sort((a, b) => {
+ const aBranch = becca.getBranchFromChildAndParent(a.noteId, this.noteId);
+ const bBranch = becca.getBranchFromChildAndParent(b.noteId, this.noteId);
+
+ return (aBranch?.notePosition || 0) - (bBranch?.notePosition || 0) || 0;
+ });
+ }
+
+ /**
+ * This is used for:
+ * - fast searching
+ * - note similarity evaluation
+ *
+ * @returns - returns flattened textual representation of note, prefixes and attributes
+ */
+ getFlatText(): string {
+ if (!this.__flatTextCache) {
+ this.__flatTextCache = `${this.noteId} ${this.type} ${this.mime} `;
+
+ for (const branch of this.parentBranches) {
+ if (branch.prefix) {
+ this.__flatTextCache += `${branch.prefix} `;
+ }
+ }
+
+ this.__flatTextCache += `${this.title} `;
+
+ for (const attr of this.getAttributes()) {
+ // it's best to use space as separator since spaces are filtered from the search string by the tokenization into words
+ this.__flatTextCache += `${attr.type === "label" ? "#" : "~"}${attr.name}`;
+
+ if (attr.value) {
+ this.__flatTextCache += `=${attr.value}`;
+ }
+
+ this.__flatTextCache += " ";
+ }
+
+ this.__flatTextCache = normalize(this.__flatTextCache);
+ }
+
+ return this.__flatTextCache as string;
+ }
+
+ invalidateThisCache() {
+ this.__flatTextCache = null;
+
+ this.__attributeCache = null;
+ this.__inheritableAttributeCache = null;
+ this.__ancestorCache = null;
+ }
+
+ invalidateSubTree(path: string[] = []) {
+ if (path.includes(this.noteId)) {
+ return;
+ }
+
+ this.invalidateThisCache();
+
+ if (this.children.length || this.targetRelations.length) {
+ path = [...path, this.noteId];
+ }
+
+ for (const childNote of this.children) {
+ childNote.invalidateSubTree(path);
+ }
+
+ for (const targetRelation of this.targetRelations) {
+ if (targetRelation.name === "template" || targetRelation.name === "inherit") {
+ const note = targetRelation.note;
+
+ if (note) {
+ note.invalidateSubTree(path);
+ }
+ }
+ }
+ }
+
+ getRelationDefinitions() {
+ return this.getLabels().filter((l) => l.name.startsWith("relation:"));
+ }
+
+ getLabelDefinitions() {
+ return this.getLabels().filter((l) => l.name.startsWith("relation:"));
+ }
+
+ isInherited() {
+ return !!this.targetRelations.find((rel) => rel.name === "template" || rel.name === "inherit");
+ }
+
+ getSubtreeNotesIncludingTemplated(): BNote[] {
+ const set = new Set();
+
+ function inner(note: BNote) {
+ // _hidden is not counted as subtree for the purpose of inheritance
+ if (set.has(note) || note.noteId === "_hidden") {
+ return;
+ }
+
+ set.add(note);
+
+ for (const childNote of note.children) {
+ inner(childNote);
+ }
+
+ for (const targetRelation of note.targetRelations) {
+ if (targetRelation.name === "template" || targetRelation.name === "inherit") {
+ const targetNote = targetRelation.note;
+
+ if (targetNote) {
+ inner(targetNote);
+ }
+ }
+ }
+ }
+
+ inner(this);
+
+ return Array.from(set);
+ }
+
+ getSearchResultNotes(): BNote[] {
+ if (this.type !== "search") {
+ return [];
+ }
+
+ try {
+ const result = searchService.searchFromNote(this);
+ const becca = this.becca;
+ return result.searchResultNoteIds.map((resultNoteId) => becca.notes[resultNoteId]).filter((note) => !!note);
+ } catch (e: any) {
+ getLog().error(`Could not resolve search note ${this.noteId}: ${e.message}`);
+ return [];
+ }
+ }
+
+ getSubtree({ includeArchived = true, includeHidden = false, resolveSearch = false } = {}): {
+ notes: BNote[];
+ relationships: Relationship[];
+ } {
+ const noteSet = new Set();
+ const relationships: Relationship[] = []; // list of tuples parentNoteId -> childNoteId
+
+ function resolveSearchNote(searchNote: BNote) {
+ try {
+ for (const resultNote of searchNote.getSearchResultNotes()) {
+ addSubtreeNotesInner(resultNote, searchNote);
+ }
+ } catch (e: any) {
+ getLog().error(`Could not resolve search note ${searchNote?.noteId}: ${e.message}`);
+ }
+ }
+
+ function addSubtreeNotesInner(note: BNote, parentNote: BNote | null = null) {
+ if (note.noteId === "_hidden" && !includeHidden) {
+ return;
+ }
+
+ if (parentNote) {
+ // this needs to happen first before noteSet check to include all clone relationships
+ relationships.push({
+ parentNoteId: parentNote.noteId,
+ childNoteId: note.noteId
+ });
+ }
+
+ if (noteSet.has(note)) {
+ return;
+ }
+
+ if (!includeArchived && note.isArchived) {
+ return;
+ }
+
+ noteSet.add(note);
+
+ if (note.type === "search") {
+ if (resolveSearch) {
+ resolveSearchNote(note);
+ }
+ } else {
+ for (const childNote of note.children) {
+ addSubtreeNotesInner(childNote, note);
+ }
+ }
+ }
+
+ addSubtreeNotesInner(this);
+
+ return {
+ notes: Array.from(noteSet),
+ relationships
+ };
+ }
+
+ /** @returns includes the subtree root note as well */
+ getSubtreeNoteIds({ includeArchived = true, includeHidden = false, resolveSearch = false } = {}) {
+ return this.getSubtree({ includeArchived, includeHidden, resolveSearch }).notes.map((note) => note.noteId);
+ }
+
+ /** @deprecated use getSubtreeNoteIds() instead */
+ getDescendantNoteIds() {
+ return this.getSubtreeNoteIds();
+ }
+
+ get parentCount() {
+ return this.parents.length;
+ }
+
+ get childrenCount() {
+ return this.children.length;
+ }
+
+ get labelCount() {
+ return this.getAttributes().filter((attr) => attr.type === "label").length;
+ }
+
+ get ownedLabelCount() {
+ return this.ownedAttributes.filter((attr) => attr.type === "label").length;
+ }
+
+ get relationCount() {
+ return this.getAttributes().filter((attr) => attr.type === "relation" && !attr.isAutoLink()).length;
+ }
+
+ get relationCountIncludingLinks() {
+ return this.getAttributes().filter((attr) => attr.type === "relation").length;
+ }
+
+ get ownedRelationCount() {
+ return this.ownedAttributes.filter((attr) => attr.type === "relation" && !attr.isAutoLink()).length;
+ }
+
+ get ownedRelationCountIncludingLinks() {
+ return this.ownedAttributes.filter((attr) => attr.type === "relation").length;
+ }
+
+ get targetRelationCount() {
+ return this.targetRelations.filter((attr) => !attr.isAutoLink()).length;
+ }
+
+ get targetRelationCountIncludingLinks() {
+ return this.targetRelations.length;
+ }
+
+ get attributeCount() {
+ return this.getAttributes().length;
+ }
+
+ get ownedAttributeCount() {
+ return this.getOwnedAttributes().length;
+ }
+
+ getAncestors() {
+ if (!this.__ancestorCache) {
+ const noteIds = new Set();
+ this.__ancestorCache = [];
+
+ for (const parent of this.parents) {
+ if (noteIds.has(parent.noteId)) {
+ continue;
+ }
+
+ this.__ancestorCache.push(parent);
+ noteIds.add(parent.noteId);
+
+ for (const ancestorNote of parent.getAncestors()) {
+ if (!noteIds.has(ancestorNote.noteId)) {
+ this.__ancestorCache.push(ancestorNote);
+ noteIds.add(ancestorNote.noteId);
+ }
+ }
+ }
+ }
+
+ return this.__ancestorCache;
+ }
+
+ getAncestorNoteIds(): string[] {
+ return this.getAncestors().map((note) => note.noteId);
+ }
+
+ hasAncestor(ancestorNoteId: string): boolean {
+ for (const ancestorNote of this.getAncestors()) {
+ if (ancestorNote.noteId === ancestorNoteId) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ isInHiddenSubtree() {
+ return this.noteId === "_hidden" || this.hasAncestor("_hidden");
+ }
+
+ getTargetRelations() {
+ return this.targetRelations;
+ }
+
+ /** @returns returns only notes which are templated, does not include their subtrees
+ * in effect returns notes which are influenced by note's non-inheritable attributes */
+ getInheritingNotes(): BNote[] {
+ const arr: BNote[] = [this];
+
+ for (const targetRelation of this.targetRelations) {
+ if (targetRelation.name === "template" || targetRelation.name === "inherit") {
+ const note = targetRelation.note;
+
+ if (note) {
+ arr.push(note);
+ }
+ }
+ }
+
+ return arr;
+ }
+
+ getDistanceToAncestor(ancestorNoteId: string) {
+ if (this.noteId === ancestorNoteId) {
+ return 0;
+ }
+
+ let minDistance = 999999;
+
+ for (const parent of this.parents) {
+ minDistance = Math.min(minDistance, parent.getDistanceToAncestor(ancestorNoteId) + 1);
+ }
+
+ return minDistance;
+ }
+
+ getRevisions(): BRevision[] {
+ return getSql().getRows("SELECT * FROM revisions WHERE noteId = ? ORDER BY revisions.utcDateCreated ASC", [this.noteId]).map((row) => new BRevision(row));
+ }
+
+ getAttachments() {
+ const query = /*sql*/`\
+ SELECT attachments.*, LENGTH(blobs.content) AS contentLength
+ FROM attachments
+ JOIN blobs USING (blobId)
+ WHERE ownerId = ? AND isDeleted = 0
+ ORDER BY position`;
+
+ return getSql().getRows(query, [this.noteId]).map((row) => new BAttachment(row));
+ }
+
+ getAttachmentById(attachmentId: string) {
+ const query = /*sql*/`\
+ SELECT attachments.*, LENGTH(blobs.content) AS contentLength
+ FROM attachments
+ JOIN blobs USING (blobId)
+ WHERE ownerId = ? AND attachmentId = ? AND isDeleted = 0`;
+
+ return getSql().getRows(query, [this.noteId, attachmentId]).map((row) => new BAttachment(row))[0];
+ }
+
+ getAttachmentsByRole(role: string): BAttachment[] {
+ return getSql()
+ .getRows(
+ `
+ SELECT attachments.*
+ FROM attachments
+ WHERE ownerId = ?
+ AND role = ?
+ AND isDeleted = 0
+ ORDER BY position`,
+ [this.noteId, role]
+ )
+ .map((row) => new BAttachment(row));
+ }
+
+ getAttachmentByTitle(title: string): BAttachment | undefined {
+ // cannot use SQL to filter by title since it can be encrypted
+ return this.getAttachments().filter((attachment) => attachment.title === title)[0];
+ }
+
+ /**
+ * Gives all possible note paths leading to this note. Paths containing search note are ignored (could form cycles)
+ *
+ * @returns array of notePaths (each represented by array of noteIds constituting the particular note path)
+ */
+ getAllNotePaths(): string[][] {
+ if (this.noteId === "root") {
+ return [["root"]];
+ }
+
+ const parentNotes = this.getParentNotes();
+
+ const notePaths =
+ parentNotes.length === 1
+ ? parentNotes[0].getAllNotePaths() // optimization for the most common case
+ : parentNotes.flatMap((parentNote) => parentNote.getAllNotePaths());
+
+ for (const notePath of notePaths) {
+ notePath.push(this.noteId);
+ }
+
+ return notePaths;
+ }
+
+ getSortedNotePathRecords(hoistedNoteId: string = "root"): NotePathRecord[] {
+ const isHoistedRoot = hoistedNoteId === "root";
+
+ const notePaths = this.getAllNotePaths().map((path) => ({
+ notePath: path,
+ isInHoistedSubTree: isHoistedRoot || path.includes(hoistedNoteId),
+ isArchived: path.some((noteId) => this.becca.notes[noteId].isArchived),
+ isHidden: path.includes("_hidden")
+ }));
+
+ notePaths.sort((a, b) => {
+ if (a.isInHoistedSubTree !== b.isInHoistedSubTree) {
+ return a.isInHoistedSubTree ? -1 : 1;
+ } else if (a.isArchived !== b.isArchived) {
+ return a.isArchived ? 1 : -1;
+ } else if (a.isHidden !== b.isHidden) {
+ return a.isHidden ? 1 : -1;
+ }
+ return a.notePath.length - b.notePath.length;
+ });
+
+ return notePaths;
+ }
+
+ /**
+ * Returns a note path considered to be the "best"
+ *
+ * @return array of noteIds constituting the particular note path
+ */
+ getBestNotePath(hoistedNoteId: string = "root"): string[] {
+ return this.getSortedNotePathRecords(hoistedNoteId)[0]?.notePath;
+ }
+
+ /**
+ * Returns a note path considered to be the "best"
+ *
+ * @return serialized note path (e.g. 'root/a1h315/js725h')
+ */
+ getBestNotePathString(hoistedNoteId: string = "root"): string {
+ const notePath = this.getBestNotePath(hoistedNoteId);
+
+ return notePath?.join("/");
+ }
+
+ /**
+ * @return boolean - true if there's no non-hidden path, note is not cloned to the visible tree
+ */
+ isHiddenCompletely() {
+ if (this.noteId === "root") {
+ return false;
+ }
+
+ for (const parentNote of this.parents) {
+ if (parentNote.noteId === "root") {
+ return false;
+ } else if (parentNote.noteId === "_hidden") {
+ continue;
+ } else if (!parentNote.isHiddenCompletely()) {
+ return false;
+ }
+ }
+
+ return true;
+ }
+
+ /**
+ * @returns true if ancestorNoteId occurs in at least one of the note's paths
+ */
+ isDescendantOfNote(ancestorNoteId: string): boolean {
+ const notePaths = this.getAllNotePaths();
+
+ return notePaths.some((path) => path.includes(ancestorNoteId));
+ }
+
+ /**
+ * Update's given attribute's value or creates it if it doesn't exist
+ *
+ * @param type - attribute type (label, relation, etc.)
+ * @param name - attribute name
+ * @param value - attribute value (optional)
+ */
+ setAttribute(type: AttributeType, name: string, value?: string) {
+ const attributes = this.getOwnedAttributes();
+ const attr = attributes.find((attr) => attr.type === type && attr.name === name);
+
+ value = value?.toString() || "";
+
+ if (attr) {
+ if (attr.value !== value) {
+ attr.value = value;
+ attr.save();
+ }
+ } else {
+ new BAttribute({
+ noteId: this.noteId,
+ type,
+ name,
+ value
+ }).save();
+ }
+ }
+
+ /**
+ * Removes given attribute name-value pair if it exists.
+ *
+ * @param type - attribute type (label, relation, etc.)
+ * @param name - attribute name
+ * @param value - attribute value (optional)
+ */
+ removeAttribute(type: string, name: string, value?: string) {
+ const attributes = this.getOwnedAttributes();
+
+ for (const attribute of attributes) {
+ if (attribute.type === type && attribute.name === name && (value === undefined || value === attribute.value)) {
+ attribute.markAsDeleted();
+ }
+ }
+ }
+
+ /**
+ * Adds a new attribute to this note. The attribute is saved and returned.
+ * See addLabel, addRelation for more specific methods.
+ *
+ * @param type - attribute type (label / relation)
+ * @param name - name of the attribute, not including the leading ~/#
+ * @param value - value of the attribute - text for labels, target note ID for relations; optional.
+ */
+ addAttribute(type: AttributeType, name: string, value: string = "", isInheritable: boolean = false, position: number | null = null): BAttribute {
+ return new BAttribute({
+ noteId: this.noteId,
+ type,
+ name,
+ value,
+ isInheritable,
+ position
+ }).save();
+ }
+
+ /**
+ * Adds a new label to this note. The label attribute is saved and returned.
+ *
+ * @param name - name of the label, not including the leading #
+ * @param value - text value of the label; optional
+ */
+ addLabel(name: string, value: string = "", isInheritable: boolean = false): BAttribute {
+ return this.addAttribute(LABEL, name, value, isInheritable);
+ }
+
+ /**
+ * Adds a new relation to this note. The relation attribute is saved and
+ * returned.
+ *
+ * @param name - name of the relation, not including the leading ~
+ */
+ addRelation(name: string, targetNoteId: string, isInheritable: boolean = false): BAttribute {
+ return this.addAttribute(RELATION, name, targetNoteId, isInheritable);
+ }
+
+ /**
+ * Based on enabled, the attribute is either set or removed.
+ *
+ * @param type - attribute type ('relation', 'label' etc.)
+ * @param enabled - toggle On or Off
+ * @param name - attribute name
+ * @param value - attribute value (optional)
+ */
+ toggleAttribute(type: AttributeType, enabled: boolean, name: string, value?: string) {
+ if (enabled) {
+ this.setAttribute(type, name, value);
+ } else {
+ this.removeAttribute(type, name, value);
+ }
+ }
+
+ /**
+ * Based on enabled, label is either set or removed.
+ *
+ * @param enabled - toggle On or Off
+ * @param name - label name
+ * @param value - label value (optional)
+ */
+ toggleLabel(enabled: boolean, name: string, value?: string) {
+ return this.toggleAttribute(LABEL, enabled, name, value);
+ }
+
+ /**
+ * Based on enabled, relation is either set or removed.
+ *
+ * @param enabled - toggle On or Off
+ * @param name - relation name
+ * @param value - relation value (noteId)
+ */
+ toggleRelation(enabled: boolean, name: string, value?: string) {
+ return this.toggleAttribute(RELATION, enabled, name, value);
+ }
+
+ /**
+ * Update's given label's value or creates it if it doesn't exist
+ *
+ * @param name - label name
+ * @param value label value
+ */
+ setLabel(name: string, value?: string) {
+ return this.setAttribute(LABEL, name, value);
+ }
+
+ /**
+ * Update's given relation's value or creates it if it doesn't exist
+ *
+ * @param name - relation name
+ * @param value - relation value (noteId)
+ */
+ setRelation(name: string, value?: string) {
+ return this.setAttribute(RELATION, name, value);
+ }
+
+ /**
+ * Remove label name-value pair, if it exists.
+ *
+ * @param name - label name
+ * @param value - label value
+ */
+ removeLabel(name: string, value?: string) {
+ return this.removeAttribute(LABEL, name, value);
+ }
+
+ /**
+ * Remove the relation name-value pair, if it exists.
+ *
+ * @param name - relation name
+ * @param value - relation value (noteId)
+ */
+ removeRelation(name: string, value?: string) {
+ return this.removeAttribute(RELATION, name, value);
+ }
+
+ searchNotesInSubtree(searchString: string) {
+ return searchService.searchNotes(searchString) as BNote[];
+ }
+
+ searchNoteInSubtree(searchString: string) {
+ return this.searchNotesInSubtree(searchString)[0];
+ }
+
+ cloneTo(parentNoteId: string): CloneResponse {
+ const branch = this.becca.getNote(parentNoteId)?.getParentBranches()[0];
+ if (!branch?.branchId) {
+ return {
+ success: false,
+ message: "Unable to find the branch ID to clone."
+ };
+ }
+
+ return cloningService.cloneNoteToBranch(this.noteId, branch.branchId);
+ }
+
+ isEligibleForConversionToAttachment(opts: ConvertOpts = { autoConversion: false }) {
+ if (this.type !== "image" || !this.isContentAvailable() || this.hasChildren() || this.getParentBranches().length !== 1) {
+ return false;
+ }
+
+ const targetRelations = this.getTargetRelations().filter((relation) => relation.name === "imageLink");
+
+ if (opts.autoConversion && targetRelations.length === 0) {
+ return false;
+ } else if (targetRelations.length > 1) {
+ return false;
+ }
+
+ const parentNote = this.getParentNotes()[0]; // at this point note can have only one parent
+ const referencingNote = targetRelations[0]?.getNote();
+
+ if (referencingNote && parentNote !== referencingNote) {
+ return false;
+ } else if (parentNote.type !== "text" || !parentNote.isContentAvailable()) {
+ return false;
+ }
+
+ return true;
+ }
+
+ /**
+ * Some notes are eligible for conversion into an attachment of its parent, note must have these properties:
+ * - it has exactly one target relation
+ * - it has a relation from its parent note
+ * - it has no children
+ * - it has no clones
+ * - the parent is of type text
+ * - both notes are either unprotected or user is in protected session
+ *
+ * Currently, works only for image notes.
+ *
+ * In the future, this functionality might get more generic and some of the requirements relaxed.
+ *
+ * @returns null if note is not eligible for conversion
+ */
+ convertToParentAttachment(opts: ConvertOpts = { autoConversion: false }): BAttachment | null {
+ if (!this.isEligibleForConversionToAttachment(opts)) {
+ return null;
+ }
+
+ const content = this.getContent();
+
+ const parentNote = this.getParentNotes()[0];
+ const attachment = parentNote.saveAttachment({
+ role: "image",
+ mime: this.mime,
+ title: this.title,
+ content
+ });
+
+ const parentContent = parentNote.getContent();
+
+ const oldNoteUrl = `api/images/${this.noteId}/`;
+ const newAttachmentUrl = `api/attachments/${attachment.attachmentId}/image/`;
+
+ if (typeof parentContent !== "string") {
+ throw new Error("Unable to convert image note into attachment because parent note does not have a string content.");
+ }
+
+ const fixedContent = replaceAll(parentContent, oldNoteUrl, newAttachmentUrl);
+
+ parentNote.setContent(fixedContent);
+
+ noteService.asyncPostProcessContent(parentNote, fixedContent); // to mark an unused attachment for deletion
+
+ this.deleteNote();
+
+ return attachment;
+ }
+
+ /**
+ * (Soft) delete a note and all its descendants.
+ *
+ * @param deleteId - optional delete identified
+ */
+ deleteNote(deleteId: string | null = null, taskContext: TaskContext<"deleteNotes"> | null = null) {
+ if (this.isDeleted) {
+ return;
+ }
+
+ if (!deleteId) {
+ deleteId = randomString(10);
+ }
+
+ if (!taskContext) {
+ taskContext = new TaskContext("no-progress-reporting", "deleteNotes", null);
+ }
+
+ // needs to be run before branches and attributes are deleted and thus attached relations disappear
+ handlers.runAttachedRelations(this, "runOnNoteDeletion", this);
+ taskContext.noteDeletionHandlerTriggered = true;
+
+ for (const branch of this.getParentBranches()) {
+ branch.deleteBranch(deleteId ?? undefined, taskContext);
+ }
+ }
+
+ decrypt() {
+ if (this.isProtected && !this.isDecrypted && protectedSessionService.isProtectedSessionAvailable()) {
+ try {
+ this.title = protectedSessionService.decryptString(this.title) || "";
+ this.__flatTextCache = null;
+
+ this.isDecrypted = true;
+ } catch (e: any) {
+ getLog().error(`Could not decrypt note ${this.noteId}: ${e.message} ${e.stack}`);
+ }
+ }
+ }
+
+ isLaunchBarConfig() {
+ return this.type === "launcher"
+ || ["_lbRoot", "_lbAvailableLaunchers", "_lbVisibleLaunchers"].includes(this.noteId)
+ || ["_lbMobileRoot", "_lbMobileAvailableLaunchers", "_lbMobileVisibleLaunchers"].includes(this.noteId);
+ }
+
+ isOptions() {
+ return this.noteId.startsWith("_options");
+ }
+
+ override get isDeleted() {
+ // isBeingDeleted is relevant only in the transition period when the deletion process has begun, but not yet
+ // finished (note is still in becca)
+ return !(this.noteId in this.becca.notes) || this.isBeingDeleted;
+ }
+
+ saveRevision(): BRevision {
+ return getSql().transactional(() => {
+ let noteContent = this.getContent();
+
+ const revision = new BRevision(
+ {
+ noteId: this.noteId,
+ // title and text should be decrypted now
+ title: this.title,
+ type: this.type,
+ mime: this.mime,
+ isProtected: this.isProtected,
+ utcDateLastEdited: this.utcDateModified,
+ utcDateCreated: dateUtils.utcNowDateTime(),
+ utcDateModified: dateUtils.utcNowDateTime(),
+ dateLastEdited: this.dateModified,
+ dateCreated: dateUtils.localNowDateTime()
+ },
+ true
+ );
+
+ revision.save(); // to generate revisionId, which is then used to save attachments
+
+ for (const noteAttachment of this.getAttachments()) {
+ const revisionAttachment = noteAttachment.copy();
+
+ if (!revision.revisionId) {
+ throw new Error("Revision ID is missing.");
+ }
+
+ revisionAttachment.ownerId = revision.revisionId;
+ revisionAttachment.setContent(noteAttachment.getContent(), { forceSave: true });
+
+ if (this.type === "text" && typeof noteContent === "string") {
+ // content is rewritten to point to the revision attachments
+ noteContent = noteContent.replaceAll(`attachments/${noteAttachment.attachmentId}`, `attachments/${revisionAttachment.attachmentId}`);
+
+ noteContent = noteContent.replaceAll(
+ new RegExp(`href="[^"]*attachmentId=${noteAttachment.attachmentId}[^"]*"`, "gi"),
+ `href="api/attachments/${revisionAttachment.attachmentId}/download"`
+ );
+ }
+ }
+
+ revision.setContent(noteContent);
+
+ this.eraseExcessRevisionSnapshots();
+ return revision;
+ });
+ }
+
+ // Limit the number of Snapshots to revisionSnapshotNumberLimit
+ // Delete older Snapshots that exceed the limit
+ eraseExcessRevisionSnapshots() {
+ // label has a higher priority
+ let revisionSnapshotNumberLimit = parseInt(this.getLabelValue("versioningLimit") ?? "");
+ if (!Number.isInteger(revisionSnapshotNumberLimit)) {
+ revisionSnapshotNumberLimit = parseInt(optionService.getOption("revisionSnapshotNumberLimit"));
+ }
+ if (revisionSnapshotNumberLimit >= 0) {
+ const revisions = this.getRevisions();
+ if (revisions.length - revisionSnapshotNumberLimit > 0) {
+ const revisionIds = revisions
+ .slice(0, revisions.length - revisionSnapshotNumberLimit)
+ .map((revision) => revision.revisionId)
+ .filter((id): id is string => id !== undefined);
+ eraseService.eraseRevisions(revisionIds);
+ }
+ }
+ }
+
+ /**
+ * @param matchBy - choose by which property we detect if to update an existing attachment.
+ * Supported values are either 'attachmentId' (default) or 'title'
+ */
+ saveAttachment({ attachmentId, role, mime, title, content, position }: Omit, matchBy: "attachmentId" | "title" | undefined = "attachmentId") {
+ if (!["attachmentId", "title"].includes(matchBy)) {
+ throw new Error(`Unsupported value '${matchBy}' for matchBy param, has to be either 'attachmentId' or 'title'.`);
+ }
+
+ let attachment;
+
+ if (matchBy === "title" && title) {
+ attachment = this.getAttachmentByTitle(title);
+ } else if (matchBy === "attachmentId" && attachmentId) {
+ attachment = this.becca.getAttachmentOrThrow(attachmentId);
+ }
+
+ attachment =
+ attachment ||
+ new BAttachment({
+ ownerId: this.noteId,
+ title,
+ role,
+ mime,
+ isProtected: this.isProtected,
+ position
+ });
+
+ content = content || "";
+ attachment.setContent(content, { forceSave: true });
+
+ return attachment;
+ }
+
+ getFileName() {
+ return formatDownloadTitle(this.title, this.type, this.mime);
+ }
+
+ override beforeSaving() {
+ super.beforeSaving();
+
+ this.becca.addNote(this.noteId, this);
+
+ this.dateModified = dateUtils.localNowDateTime();
+ this.utcDateModified = dateUtils.utcNowDateTime();
+ }
+
+ getPojo(): NotePojo {
+ return {
+ noteId: this.noteId,
+ title: this.title,
+ isProtected: this.isProtected,
+ type: this.type,
+ mime: this.mime,
+ blobId: this.blobId,
+ isDeleted: false,
+ dateCreated: this.dateCreated,
+ dateModified: this.dateModified,
+ utcDateCreated: this.utcDateCreated,
+ utcDateModified: this.utcDateModified
+ };
+ }
+
+ override getPojoToSave() {
+ const pojo = this.getPojo();
+
+ if (pojo.isProtected) {
+ if (this.isDecrypted && pojo.title) {
+ pojo.title = protectedSessionService.encrypt(pojo.title) || undefined;
+ } else {
+ // updating protected note outside of protected session means we will keep original ciphertexts
+ delete pojo.title;
+ }
+ }
+
+ return pojo;
+ }
+
+ getIcon() {
+ return `tn-icon ${this.#getIconInternal()}`;
+ }
+
+ // TODO: Deduplicate with fnote
+ #getIconInternal() {
+ const iconClassLabels = this.getLabels("iconClass");
+
+ if (iconClassLabels && iconClassLabels.length > 0) {
+ return iconClassLabels[0].value;
+ } else if (this.noteId === "root") {
+ return "bx bx-home-alt-2";
+ }
+ if (this.noteId === "_share") {
+ return "bx bx-share-alt";
+ } else if (this.type === "text") {
+ if (this.isFolder()) {
+ return "bx bx-folder";
+ }
+ return "bx bx-note";
+
+ } else if (this.type === "code" && this.mime.startsWith("text/x-sql")) {
+ return "bx bx-data";
+ }
+ return NOTE_TYPE_ICONS[this.type];
+ }
+
+ // TODO: Deduplicate with fnote
+ isFolder() {
+ return this.type === "search" || this.getFilteredChildBranches().length > 0;
+ }
+
+ // TODO: Deduplicate with fnote
+ getFilteredChildBranches() {
+ const childBranches = this.getChildBranches();
+
+ if (!childBranches) {
+ console.error(`No children for '${this.noteId}'. This shouldn't happen.`);
+ return [];
+ }
+
+ // we're not checking hideArchivedNotes since that would mean we need to lazy load the child notes
+ // which would seriously slow down everything.
+ // we check this flag only once user chooses to expand the parent. This has the negative consequence that
+ // note may appear as a folder but not contain any children when all of them are archived
+
+ return childBranches;
+ }
+
+ get encodedTitle() {
+ return encodeURIComponent(this.title);
+ }
+
+ getVisibleChildBranches() {
+ return this.getChildBranches().filter((branch) => !branch.getNote().isLabelTruthy("shareHiddenFromTree"));
+ }
+
+ getVisibleChildNotes() {
+ return this.getVisibleChildBranches().map((branch) => branch.getNote());
+ }
+
+ hasVisibleChildren() {
+ return this.getVisibleChildNotes().length > 0;
+ }
+
+ get shareId() {
+ return this.noteId;
+ }
+
+ /**
+ * Return an attribute by it's attributeId. Requires the attribute cache to be available.
+ * @param attributeId - the id of the attribute owned by this note
+ * @returns - the BAttribute with the given id or undefined if not found.
+ */
+ getAttributeById(attributeId : string): BAttribute | undefined {
+ this.__ensureAttributeCacheIsAvailable();
+
+ if (!this.__attributeCache) {
+ throw new Error("Attribute cache not available.");
+ }
+
+ return this.__attributeCache.find((attr) => attr.attributeId === attributeId);
+ }
+
+ /**
+ * Sets an attribute's value by it's attributeId.
+ * @param attributeId - the id of the attribute owned by this note
+ * @param value - the new value to replace
+ */
+ setAttributeValueById(attributeId : string, value? : string) {
+ const attributes = this.getOwnedAttributes();
+ const attr = attributes.find((attr) => attr.attributeId === attributeId);
+
+ value = value?.toString() || "";
+
+ if (attr) {
+ if (attr.value !== value) {
+ attr.value = value;
+ attr.save();
+ }
+ } else {
+ throw new Error(`Attribute with id ${attributeId} not found.`);
+ }
+ }
+}
+
+export default BNote;
diff --git a/packages/trilium-core/src/becca/entities/boption.ts b/packages/trilium-core/src/becca/entities/boption.ts
new file mode 100644
index 0000000000..f0cd473e9b
--- /dev/null
+++ b/packages/trilium-core/src/becca/entities/boption.ts
@@ -0,0 +1,56 @@
+"use strict";
+
+import dateUtils from "../../services/utils/date";
+import AbstractBeccaEntity from "./abstract_becca_entity.js";
+import type { OptionRow } from "@triliumnext/commons";
+
+/**
+ * Option represents a name-value pair, either directly configurable by the user or some system property.
+ */
+class BOption extends AbstractBeccaEntity {
+ static get entityName() {
+ return "options";
+ }
+ static get primaryKeyName() {
+ return "name";
+ }
+ static get hashedProperties() {
+ return ["name", "value"];
+ }
+
+ name!: string;
+ value!: string;
+
+ constructor(row?: OptionRow) {
+ super();
+
+ if (row) {
+ this.updateFromRow(row);
+ }
+ this.becca.options[this.name] = this;
+ }
+
+ updateFromRow(row: OptionRow) {
+ this.name = row.name;
+ this.value = row.value;
+ this.isSynced = !!row.isSynced;
+ this.utcDateModified = row.utcDateModified;
+ }
+
+ override beforeSaving() {
+ super.beforeSaving();
+
+ this.utcDateModified = dateUtils.utcNowDateTime();
+ }
+
+ getPojo() {
+ return {
+ name: this.name,
+ value: this.value,
+ isSynced: this.isSynced,
+ utcDateModified: this.utcDateModified
+ };
+ }
+}
+
+export default BOption;
diff --git a/packages/trilium-core/src/becca/entities/brecent_note.ts b/packages/trilium-core/src/becca/entities/brecent_note.ts
new file mode 100644
index 0000000000..d950df369e
--- /dev/null
+++ b/packages/trilium-core/src/becca/entities/brecent_note.ts
@@ -0,0 +1,46 @@
+"use strict";
+
+import type { RecentNoteRow } from "@triliumnext/commons";
+
+import dateUtils from "../../services/utils/date";
+import AbstractBeccaEntity from "./abstract_becca_entity.js";
+
+/**
+ * RecentNote represents recently visited note.
+ */
+class BRecentNote extends AbstractBeccaEntity {
+ static get entityName() {
+ return "recent_notes";
+ }
+ static get primaryKeyName() {
+ return "noteId";
+ }
+ static get hashedProperties() {
+ return ["noteId", "notePath"];
+ }
+
+ noteId!: string;
+ notePath!: string;
+
+ constructor(row: RecentNoteRow) {
+ super();
+
+ this.updateFromRow(row);
+ }
+
+ updateFromRow(row: RecentNoteRow): void {
+ this.noteId = row.noteId;
+ this.notePath = row.notePath;
+ this.utcDateCreated = row.utcDateCreated || dateUtils.utcNowDateTime();
+ }
+
+ getPojo() {
+ return {
+ noteId: this.noteId,
+ notePath: this.notePath,
+ utcDateCreated: this.utcDateCreated
+ };
+ }
+}
+
+export default BRecentNote;
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/packages/trilium-core/src/becca/entities/brevision.ts b/packages/trilium-core/src/becca/entities/brevision.ts
new file mode 100644
index 0000000000..a3a9b4ce08
--- /dev/null
+++ b/packages/trilium-core/src/becca/entities/brevision.ts
@@ -0,0 +1,225 @@
+"use strict";
+
+import protectedSessionService from "../../services/protected_session.js";
+import dateUtils from "../../services/utils/date";
+import becca from "../becca.js";
+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 "../../services/sql/index.js";
+import { isStringNote } from "../../services/utils/index.js";
+
+interface ContentOpts {
+ /** will also save this BRevision entity */
+ forceSave?: boolean;
+}
+
+interface GetByIdOpts {
+ includeContentLength?: boolean;
+}
+
+/**
+ * Revision represents a snapshot of note's title and content at some point in the past.
+ * It's used for seamless note versioning.
+ */
+class BRevision extends AbstractBeccaEntity {
+ static get entityName() {
+ return "revisions";
+ }
+ static get primaryKeyName() {
+ return "revisionId";
+ }
+ static get hashedProperties() {
+ return ["revisionId", "noteId", "title", "isProtected", "dateLastEdited", "dateCreated", "utcDateLastEdited", "utcDateCreated", "utcDateModified", "blobId"];
+ }
+
+ revisionId?: string;
+ noteId!: string;
+ type!: NoteType;
+ mime!: string;
+ title!: string;
+ dateLastEdited?: string;
+ utcDateLastEdited?: string;
+ contentLength?: number;
+ content?: string | Uint8Array;
+
+ constructor(row: RevisionRow, titleDecrypted = false) {
+ super();
+
+ this.updateFromRow(row);
+ if (this.isProtected && !titleDecrypted) {
+ const decryptedTitle = protectedSessionService.isProtectedSessionAvailable() ? protectedSessionService.decryptString(this.title) : null;
+ this.title = decryptedTitle || "[protected]";
+ }
+ }
+
+ updateFromRow(row: RevisionRow) {
+ this.revisionId = row.revisionId;
+ this.noteId = row.noteId;
+ this.type = row.type;
+ this.mime = row.mime;
+ this.isProtected = !!row.isProtected;
+ this.title = row.title;
+ this.blobId = row.blobId;
+ this.dateLastEdited = row.dateLastEdited;
+ this.dateCreated = row.dateCreated;
+ this.utcDateLastEdited = row.utcDateLastEdited;
+ this.utcDateCreated = row.utcDateCreated;
+ this.utcDateModified = row.utcDateModified;
+ this.contentLength = row.contentLength;
+ }
+
+ getNote() {
+ return becca.notes[this.noteId];
+ }
+
+ /** @returns true if the note has string content (not binary) */
+ override hasStringContent(): boolean {
+ return isStringNote(this.type, this.mime);
+ }
+
+ isContentAvailable() {
+ return (
+ !this.revisionId || // new note which was not encrypted yet
+ !this.isProtected ||
+ protectedSessionService.isProtectedSessionAvailable()
+ );
+ }
+
+ /*
+ * Note revision content has quite special handling - it's not a separate entity, but a lazily loaded
+ * part of Revision entity with its own sync. The reason behind this hybrid design is that
+ * content can be quite large, and it's not necessary to load it / fill memory for any note access even
+ * if we don't need a content, especially for bulk operations like search.
+ *
+ * This is the same approach as is used for Note's content.
+ */
+ getContent(): string | Uint8Array {
+ return this._getContent();
+ }
+
+ /**
+ * @throws Error in case of invalid JSON */
+ getJsonContent(): {} | null {
+ const content = this.getContent();
+
+ if (!content || typeof content !== "string" || !content.trim()) {
+ return null;
+ }
+
+ return JSON.parse(content);
+ }
+
+ /** @returns valid object or null if the content cannot be parsed as JSON */
+ getJsonContentSafely(): {} | null {
+ try {
+ return this.getJsonContent();
+ } catch (e) {
+ return null;
+ }
+ }
+
+ setContent(content: string | Uint8Array, opts: ContentOpts = {}) {
+ this._setContent(content, opts);
+ }
+
+ getAttachments(): BAttachment[] {
+ return getSql()
+ .getRows(
+ `
+ SELECT attachments.*
+ FROM attachments
+ WHERE ownerId = ?
+ AND isDeleted = 0`,
+ [this.revisionId]
+ )
+ .map((row) => new BAttachment(row));
+ }
+
+ getAttachmentById(attachmentId: String, opts: GetByIdOpts = {}): BAttachment | null {
+ opts.includeContentLength = !!opts.includeContentLength;
+
+ const query = opts.includeContentLength
+ ? /*sql*/`SELECT attachments.*, LENGTH(blobs.content) AS contentLength
+ FROM attachments
+ JOIN blobs USING (blobId)
+ WHERE ownerId = ? AND attachmentId = ? AND isDeleted = 0`
+ : /*sql*/`SELECT * FROM attachments WHERE ownerId = ? AND attachmentId = ? AND isDeleted = 0`;
+
+ return getSql().getRows(query, [this.revisionId, attachmentId]).map((row) => new BAttachment(row))[0];
+ }
+
+ getAttachmentsByRole(role: string): BAttachment[] {
+ return getSql()
+ .getRows(
+ `
+ SELECT attachments.*
+ FROM attachments
+ WHERE ownerId = ?
+ AND role = ?
+ AND isDeleted = 0
+ ORDER BY position`,
+ [this.revisionId, role]
+ )
+ .map((row) => new BAttachment(row));
+ }
+
+ getAttachmentByTitle(title: string): BAttachment {
+ // cannot use SQL to filter by title since it can be encrypted
+ return this.getAttachments().filter((attachment) => attachment.title === title)[0];
+ }
+
+ /**
+ * Revisions are not soft-deletable, they are immediately hard-deleted (erased).
+ */
+ eraseRevision() {
+ if (this.revisionId) {
+ eraseService.eraseRevisions([this.revisionId]);
+ }
+ }
+
+ override beforeSaving() {
+ super.beforeSaving();
+
+ this.utcDateModified = dateUtils.utcNowDateTime();
+ }
+
+ getPojo() {
+ return {
+ revisionId: this.revisionId,
+ noteId: this.noteId,
+ type: this.type,
+ mime: this.mime,
+ isProtected: this.isProtected,
+ title: this.title,
+ blobId: this.blobId,
+ dateLastEdited: this.dateLastEdited,
+ dateCreated: this.dateCreated,
+ utcDateLastEdited: this.utcDateLastEdited,
+ utcDateCreated: this.utcDateCreated,
+ utcDateModified: this.utcDateModified,
+ content: this.content, // used when retrieving full note revision to frontend
+ contentLength: this.contentLength
+ } satisfies RevisionPojo;
+ }
+
+ override getPojoToSave() {
+ const pojo = this.getPojo();
+ delete pojo.content; // not getting persisted
+ delete pojo.contentLength; // not getting persisted
+
+ if (pojo.isProtected) {
+ if (protectedSessionService.isProtectedSessionAvailable()) {
+ pojo.title = protectedSessionService.encrypt(this.title) ?? "";
+ } else {
+ // updating protected note outside of protected session means we will keep original ciphertexts
+ pojo.title = "";
+ }
+ }
+
+ return pojo;
+ }
+}
+
+export default BRevision;
diff --git a/apps/server/src/becca/entity_constructor.ts b/packages/trilium-core/src/becca/entity_constructor.ts
similarity index 100%
rename from apps/server/src/becca/entity_constructor.ts
rename to packages/trilium-core/src/becca/entity_constructor.ts
diff --git a/apps/server/src/becca/similarity.spec.ts b/packages/trilium-core/src/becca/similarity.spec.ts
similarity index 100%
rename from apps/server/src/becca/similarity.spec.ts
rename to packages/trilium-core/src/becca/similarity.spec.ts
diff --git a/apps/server/src/becca/similarity.ts b/packages/trilium-core/src/becca/similarity.ts
similarity index 98%
rename from apps/server/src/becca/similarity.ts
rename to packages/trilium-core/src/becca/similarity.ts
index 10a0e706dc..2696c9c51b 100644
--- a/apps/server/src/becca/similarity.ts
+++ b/packages/trilium-core/src/becca/similarity.ts
@@ -1,7 +1,7 @@
import becca from "./becca.js";
-import log from "../services/log.js";
+import { getLog } from "../services/log.js";
import beccaService from "./becca_service.js";
-import dateUtils from "../services/date_utils.js";
+import dateUtils from "../services/utils/date";
import { parse } from "node-html-parser";
import type BNote from "./entities/bnote.js";
import { SimilarNote } from "@triliumnext/commons";
@@ -359,7 +359,7 @@ async function findSimilarNotes(noteId: string): Promise) {
return hash(`${blobId}|${content.toString()}`);
}
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/cloning.ts b/packages/trilium-core/src/services/cloning.ts
new file mode 100644
index 0000000000..741723bfa7
--- /dev/null
+++ b/packages/trilium-core/src/services/cloning.ts
@@ -0,0 +1,190 @@
+"use strict";
+
+import eventChangesService from "./entity_changes.js";
+import treeService from "./tree.js";
+import BBranch from "../becca/entities/bbranch.js";
+import becca from "../becca/becca.js";
+import { getLog } from "./log.js";
+import { CloneResponse } from "@triliumnext/commons";
+import { getSql } from "./sql/index.js";
+
+function cloneNoteToParentNote(noteId: string, parentNoteId: string, prefix: string | null = null): CloneResponse {
+ if (!(noteId in becca.notes) || !(parentNoteId in becca.notes)) {
+ return { success: false, message: "Note cannot be cloned because either the cloned note or the intended parent is deleted." };
+ }
+
+ const parentNote = becca.getNote(parentNoteId);
+ if (!parentNote) {
+ return { success: false, message: "Note cannot be cloned because the parent note could not be found." };
+ }
+
+ if (parentNote.type === "search") {
+ return {
+ success: false,
+ message: "Can't clone into a search note"
+ };
+ }
+
+ const validationResult = treeService.validateParentChild(parentNoteId, noteId);
+
+ if (!validationResult.success) {
+ return validationResult;
+ }
+
+ const branch = new BBranch({
+ noteId: noteId,
+ parentNoteId: parentNoteId,
+ prefix: prefix,
+ isExpanded: false
+ }).save();
+
+ getLog().info(`Cloned note '${noteId}' to a new parent note '${parentNoteId}' with prefix '${prefix}'`);
+
+ return {
+ success: true,
+ branchId: branch.branchId,
+ notePath: `${parentNote.getBestNotePathString()}/${noteId}`
+ };
+}
+
+function cloneNoteToBranch(noteId: string, parentBranchId: string, prefix?: string) {
+ const parentBranch = becca.getBranch(parentBranchId);
+
+ if (!parentBranch) {
+ return { success: false, message: `Parent branch '${parentBranchId}' does not exist.` };
+ }
+
+ const ret = cloneNoteToParentNote(noteId, parentBranch.noteId, prefix);
+
+ parentBranch.isExpanded = true; // the new target should be expanded, so it immediately shows up to the user
+ parentBranch.save();
+
+ return ret;
+}
+
+function ensureNoteIsPresentInParent(noteId: string, parentNoteId: string, prefix?: string) {
+ if (!(noteId in becca.notes)) {
+ return { branch: null, success: false, message: `Note '${noteId}' is deleted.` };
+ } else if (!(parentNoteId in becca.notes)) {
+ return { branch: null, success: false, message: `Note '${parentNoteId}' is deleted.` };
+ }
+
+ const parentNote = becca.getNote(parentNoteId);
+
+ if (!parentNote) {
+ return { branch: null, success: false, message: "Can't find parent note." };
+ }
+ if (parentNote.type === "search") {
+ return { branch: null, success: false, message: "Can't clone into a search note" };
+ }
+
+ const validationResult = treeService.validateParentChild(parentNoteId, noteId);
+
+ if (!validationResult.success) {
+ return validationResult;
+ }
+
+ const branch = new BBranch({
+ noteId: noteId,
+ parentNoteId: parentNoteId,
+ prefix: prefix,
+ isExpanded: false
+ }).save();
+
+ getLog().info(`Ensured note '${noteId}' is in parent note '${parentNoteId}' with prefix '${branch.prefix}'`);
+
+ return { branch: branch, success: true };
+}
+
+function ensureNoteIsAbsentFromParent(noteId: string, parentNoteId: string) {
+ const branchId = getSql().getValue(/*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
+};
diff --git a/packages/trilium-core/src/services/context.ts b/packages/trilium-core/src/services/context.ts
new file mode 100644
index 0000000000..91db0e8365
--- /dev/null
+++ b/packages/trilium-core/src/services/context.ts
@@ -0,0 +1,71 @@
+import { EntityChange } from "@triliumnext/commons";
+
+export interface ExecutionContext {
+ init(fn: () => T): T;
+ get(key: string): T | undefined;
+ set(key: string, value: any): void;
+ reset(): void;
+}
+
+let ctx: ExecutionContext | null = null;
+
+export function initContext(context: ExecutionContext) {
+ if (ctx) throw new Error("Context already initialized");
+ ctx = context;
+}
+
+export function getContext(): ExecutionContext {
+ if (!ctx) throw new Error("Context not initialized");
+ return ctx;
+}
+
+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");
+}
+
+export function isEntityEventsDisabled() {
+ return !!getContext().get("disableEntityEvents");
+}
+
+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) {
+ 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/encryption/crypto.ts b/packages/trilium-core/src/services/encryption/crypto.ts
new file mode 100644
index 0000000000..87cd8fb6c0
--- /dev/null
+++ b/packages/trilium-core/src/services/encryption/crypto.ts
@@ -0,0 +1,24 @@
+interface Cipher {
+ update(data: Uint8Array): Uint8Array;
+ final(): Uint8Array;
+}
+
+export interface CryptoProvider {
+
+ 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;
+}
+
+let crypto: CryptoProvider | null = null;
+
+export function initCrypto(cryptoProvider: CryptoProvider) {
+ crypto = cryptoProvider;
+}
+
+export function getCrypto() {
+ if (!crypto) throw new Error("Crypto not initialized.");
+ return crypto;
+}
diff --git a/packages/trilium-core/src/services/encryption/data_encryption.ts b/packages/trilium-core/src/services/encryption/data_encryption.ts
new file mode 100644
index 0000000000..ffae2c1e9d
--- /dev/null
+++ b/packages/trilium-core/src/services/encryption/data_encryption.ts
@@ -0,0 +1,115 @@
+import { getLog } from "../log.js";
+import { concat2, decodeBase64, decodeUtf8, encodeBase64, encodeUtf8 } from "../utils/binary.js";
+import { getCrypto } from "./crypto.js";
+
+function arraysIdentical(a: any[] | Uint8Array, b: any[] | Uint8Array) {
+ let i = a.length;
+ if (i !== b.length) return false;
+ while (i--) {
+ if (a[i] !== b[i]) return false;
+ }
+ return true;
+}
+
+function shaArray(content: string | Uint8Array) {
+ // we use this as a simple checksum and don't rely on its security, so SHA-1 is good enough
+ return getCrypto().createHash("sha1", content);
+}
+
+function pad(data: Uint8Array): Uint8Array {
+ if (data.length > 16) {
+ data = data.slice(0, 16);
+ } else if (data.length < 16) {
+ const zeros = Array(16 - data.length).fill(0);
+
+ data = concat2(data, Uint8Array.from(zeros));
+ }
+
+ return Uint8Array.from(data);
+}
+
+function encrypt(key: Uint8Array, plainText: Uint8Array | string) {
+ if (!key) {
+ throw new Error("No data key!");
+ }
+
+ const plainTextUint8Array = ArrayBuffer.isView(plainText) ? plainText : Uint8Array.from(plainText);
+
+ const iv = getCrypto().randomBytes(16);
+ const cipher = getCrypto().createCipheriv("aes-128-cbc", pad(key), pad(iv));
+
+ const digest = shaArray(plainTextUint8Array).slice(0, 4);
+
+ const digestWithPayload = concat2(digest, plainTextUint8Array);
+
+ const encryptedData = concat2(cipher.update(digestWithPayload), cipher.final());
+
+ const encryptedDataWithIv = concat2(iv, encryptedData);
+
+ return encodeBase64(encryptedDataWithIv);
+}
+
+function decrypt(key: Uint8Array, cipherText: string | Uint8Array): Uint8Array | false | null {
+ if (cipherText === null) {
+ return null;
+ }
+
+ if (!key) {
+ return encodeUtf8("[protected]");
+ }
+
+ try {
+ const cipherTextStr = typeof cipherText === "string" ? cipherText : decodeUtf8(cipherText);
+ const cipherTextUint8ArrayWithIv = decodeBase64(cipherTextStr);
+
+ // old encrypted data can have IV of length 13, see some details here: https://github.com/zadam/trilium/issues/3017
+ const ivLength = cipherTextUint8ArrayWithIv.length % 16 === 0 ? 16 : 13;
+
+ const iv = cipherTextUint8ArrayWithIv.slice(0, ivLength);
+
+ const cipherTextUint8Array = cipherTextUint8ArrayWithIv.slice(ivLength);
+
+ const decipher = getCrypto().createDecipheriv("aes-128-cbc", pad(key), pad(iv));
+
+ const decryptedBytes = concat2(decipher.update(cipherTextUint8Array), decipher.final());
+
+ const digest = decryptedBytes.slice(0, 4);
+ const payload = decryptedBytes.slice(4);
+
+ const computedDigest = shaArray(payload).slice(0, 4);
+
+ if (!arraysIdentical(digest, computedDigest)) {
+ return false;
+ }
+
+ return payload;
+ } catch (e: any) {
+ // recovery from https://github.com/zadam/trilium/issues/510
+ if (e.message?.includes("WRONG_FINAL_BLOCK_LENGTH") || e.message?.includes("wrong final block length")) {
+ getLog().info("Caught WRONG_FINAL_BLOCK_LENGTH, returning cipherText instead");
+
+ return (ArrayBuffer.isView(cipherText) ? cipherText : Uint8Array.from(cipherText));
+ }
+ throw e;
+ }
+}
+
+function decryptString(dataKey: Uint8Array, cipherText: string) {
+ const buffer = decrypt(dataKey, cipherText);
+
+ if (buffer === null) {
+ return null;
+ } else if (buffer === false) {
+ getLog().error(`Could not decrypt string. Uint8Array: ${buffer}`);
+
+ throw new Error("Could not decrypt string.");
+ }
+
+ return decodeUtf8(buffer);
+}
+
+export default {
+ encrypt,
+ decrypt,
+ decryptString
+};
diff --git a/packages/trilium-core/src/services/entity_changes.ts b/packages/trilium-core/src/services/entity_changes.ts
new file mode 100644
index 0000000000..c2f6ebb1f2
--- /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 { getLog } from "./log.js";
+import { randomString } from "./utils/index.js";
+import { getSql } from "./sql/index.js";
+import * as cls from "./context.js";
+import events from "./events.js";
+import blobService from "./blob.js";
+import getInstanceId from "./instance_id.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 || getInstanceId();
+ 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: getInstanceId()
+ });
+
+ 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(/*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(
+ `
+ 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(/*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 = {
+ entityName,
+ entityId,
+ isErased: false
+ };
+
+ if (entityName === "blobs") {
+ const blob = sql.getRow>("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("SELECT COALESCE(MAX(id), 0) FROM entity_changes");
+}
+
+export default {
+ putNoteReorderingEntityChange,
+ putEntityChangeForOtherInstances,
+ putEntityChangeWithForcedChange,
+ putEntityChange,
+ putEntityChangeWithInstanceId,
+ fillAllEntityChanges,
+ addEntityChangesForSector,
+ getMaxEntityChangeId: () => maxEntityChangeId,
+ recalculateMaxEntityChangeId
+};
diff --git a/apps/server/src/services/erase.ts b/packages/trilium-core/src/services/erase.ts
similarity index 87%
rename from apps/server/src/services/erase.ts
rename to packages/trilium-core/src/services/erase.ts
index 92b28e5735..91e63cd6a9 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 sqlInit from "./sql_init.js";
-import cls from "./cls.js";
+import dateUtils from "./utils/date.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";
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("SELECT noteId FROM notes WHERE isDeleted = 1 AND deleteId = ?", [deleteId]);
eraseNotes(noteIdsToErase);
@@ -170,13 +177,12 @@ function eraseScheduledAttachments(eraseUnusedAttachmentsAfterSeconds: number |
}
const cutOffDate = dateUtils.utcDateTimeStr(new Date(Date.now() - eraseUnusedAttachmentsAfterSeconds * 1000));
- const attachmentIdsToErase = sql.getColumn("SELECT attachmentId FROM attachments WHERE utcDateScheduledForErasureSince < ?", [cutOffDate]);
+ const attachmentIdsToErase = getSql().getColumn("SELECT attachmentId FROM attachments WHERE utcDateScheduledForErasureSince < ?", [cutOffDate]);
eraseAttachments(attachmentIdsToErase);
}
-
-export function startScheduledCleanup() {
+function startScheduledCleanup() {
sqlInit.dbReady.then(() => {
// first cleanup kickoff 5 minutes after startup
setTimeout(
@@ -205,5 +211,6 @@ export default {
eraseNotesWithDeleteId,
eraseUnusedBlobs,
eraseAttachments,
- eraseRevisions
+ eraseRevisions,
+ startScheduledCleanup
};
diff --git a/apps/server/src/services/events.ts b/packages/trilium-core/src/services/events.ts
similarity index 94%
rename from apps/server/src/services/events.ts
rename to packages/trilium-core/src/services/events.ts
index 5ffc93f82e..f37eef1d6e 100644
--- a/apps/server/src/services/events.ts
+++ b/packages/trilium-core/src/services/events.ts
@@ -1,4 +1,4 @@
-import log from "./log.js";
+import { getLog } from "./log.js";
const NOTE_TITLE_CHANGED = "NOTE_TITLE_CHANGED";
const ENTER_PROTECTED_SESSION = "ENTER_PROTECTED_SESSION";
@@ -52,7 +52,7 @@ function emit(eventType: string, data?: any) {
try {
listener(data);
} catch (e: any) {
- log.error(`Listener threw error: ${e.message}, stack: ${e.stack}`);
+ getLog().error(`Listener threw error: ${e.message}, stack: ${e.stack}`);
// we won't stop execution because of listener
}
}
diff --git a/packages/trilium-core/src/services/handlers.ts b/packages/trilium-core/src/services/handlers.ts
new file mode 100644
index 0000000000..1192fbc8f5
--- /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 * as 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) {
+ 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/packages/trilium-core/src/services/hidden_subtree.ts b/packages/trilium-core/src/services/hidden_subtree.ts
new file mode 100644
index 0000000000..ba8da6da1f
--- /dev/null
+++ b/packages/trilium-core/src/services/hidden_subtree.ts
@@ -0,0 +1,499 @@
+import BAttribute from "../becca/entities/battribute.js";
+import BBranch from "../becca/entities/bbranch.js";
+import type { HiddenSubtreeItem } from "@triliumnext/commons";
+
+import becca from "../becca/becca.js";
+import noteService from "./notes.js";
+import { getLog } from "./log.js";
+import * as migrationService from "./migration.js";
+import { t } from "i18next";
+import { cleanUpHelp, getHelpHiddenSubtreeData } from "./in_app_help.js";
+import buildLaunchBarConfig from "./hidden_subtree_launcherbar.js";
+import buildHiddenSubtreeTemplates from "./hidden_subtree_templates.js";
+
+export const LBTPL_ROOT = "_lbTplRoot";
+export const LBTPL_BASE = "_lbTplBase";
+export const LBTPL_HEADER = "_lbTplHeader";
+export const LBTPL_NOTE_LAUNCHER = "_lbTplLauncherNote";
+export const LBTPL_WIDGET = "_lbTplLauncherWidget";
+export const LBTPL_COMMAND = "_lbTplLauncherCommand";
+export const LBTPL_SCRIPT = "_lbTplLauncherScript";
+export const LBTPL_SPACER = "_lbTplSpacer";
+export const LBTPL_CUSTOM_WIDGET = "_lbTplCustomWidget";
+
+/*
+ * Hidden subtree is generated as a "predictable structure" which means that it avoids generating random IDs to always
+ * produce the same structure. This is needed because it is run on multiple instances in the sync cluster which might produce
+ * duplicate subtrees. This way, all instances will generate the same structure with the same IDs.
+ */
+
+let hiddenSubtreeDefinition: HiddenSubtreeItem;
+
+function buildHiddenSubtreeDefinition(helpSubtree: HiddenSubtreeItem[]): HiddenSubtreeItem {
+ const launchbarConfig = buildLaunchBarConfig();
+
+ return {
+ id: "_hidden",
+ title: t("hidden-subtree.root-title"),
+ type: "doc",
+ icon: "bx bx-hide",
+ // we want to keep the hidden subtree always last, otherwise there will be problems with e.g., keyboard navigation
+ // over tree when it's in the middle
+ notePosition: 999_999_999,
+ enforceAttributes: true,
+ attributes: [
+ { type: "label", name: "docName", value: "hidden" }
+ ],
+ children: [
+ {
+ id: "_search",
+ title: t("hidden-subtree.search-history-title"),
+ type: "doc"
+ },
+ {
+ id: "_globalNoteMap",
+ title: t("hidden-subtree.note-map-title"),
+ type: "noteMap",
+ attributes: [
+ { type: "label", name: "mapRootNoteId", value: "hoisted" },
+ { type: "label", name: "keepCurrentHoisting" }
+ ]
+ },
+ {
+ id: "_sqlConsole",
+ title: t("hidden-subtree.sql-console-history-title"),
+ type: "doc",
+ icon: "bx-data"
+ },
+ {
+ id: "_share",
+ title: t("hidden-subtree.shared-notes-title"),
+ type: "doc",
+ attributes: [{ type: "label", name: "docName", value: "share" }]
+ },
+ {
+ id: "_bulkAction",
+ title: t("hidden-subtree.bulk-action-title"),
+ type: "doc"
+ },
+ {
+ id: "_backendLog",
+ title: t("hidden-subtree.backend-log-title"),
+ type: "contentWidget",
+ icon: "bx-terminal",
+ attributes: [
+ { type: "label", name: "keepCurrentHoisting" },
+ { type: "label", name: "fullContentWidth" }
+ ]
+ },
+ {
+ // place for user scripts hidden stuff (scripts should not create notes directly under hidden root)
+ id: "_userHidden",
+ title: t("hidden-subtree.user-hidden-title"),
+ type: "doc",
+ attributes: [{ type: "label", name: "docName", value: "user_hidden" }]
+ },
+ {
+ id: LBTPL_ROOT,
+ title: t("hidden-subtree.launch-bar-templates-title"),
+ type: "doc",
+ children: [
+ {
+ id: LBTPL_BASE,
+ title: t("hidden-subtree.base-abstract-launcher-title"),
+ type: "doc"
+ },
+ {
+ id: LBTPL_COMMAND,
+ title: t("hidden-subtree.command-launcher-title"),
+ type: "doc",
+ attributes: [
+ { type: "relation", name: "template", value: LBTPL_BASE },
+ { type: "label", name: "launcherType", value: "command" },
+ { type: "label", name: "docName", value: "launchbar_command_launcher" }
+ ]
+ },
+ {
+ id: LBTPL_NOTE_LAUNCHER,
+ title: t("hidden-subtree.note-launcher-title"),
+ type: "doc",
+ attributes: [
+ { type: "relation", name: "template", value: LBTPL_BASE },
+ { type: "label", name: "launcherType", value: "note" },
+ { type: "label", name: "relation:target", value: "promoted" },
+ { type: "label", name: "relation:hoistedNote", value: "promoted" },
+ { type: "label", name: "label:keyboardShortcut", value: "promoted,text" },
+ { type: "label", name: "docName", value: "launchbar_note_launcher" }
+ ]
+ },
+ {
+ id: LBTPL_SCRIPT,
+ title: t("hidden-subtree.script-launcher-title"),
+ type: "doc",
+ attributes: [
+ { type: "relation", name: "template", value: LBTPL_BASE },
+ { type: "label", name: "launcherType", value: "script" },
+ { type: "label", name: "relation:script", value: "promoted" },
+ { type: "label", name: "label:keyboardShortcut", value: "promoted,text" },
+ { type: "label", name: "docName", value: "launchbar_script_launcher" }
+ ]
+ },
+ {
+ id: LBTPL_WIDGET,
+ title: t("hidden-subtree.built-in-widget-title"),
+ type: "doc",
+ attributes: [
+ { type: "relation", name: "template", value: LBTPL_BASE },
+ { type: "label", name: "launcherType", value: "builtinWidget" }
+ ]
+ },
+ {
+ id: LBTPL_SPACER,
+ title: t("hidden-subtree.spacer-title"),
+ type: "doc",
+ icon: "bx-move-vertical",
+ attributes: [
+ { type: "relation", name: "template", value: LBTPL_WIDGET },
+ { type: "label", name: "builtinWidget", value: "spacer" },
+ { type: "label", name: "label:baseSize", value: "promoted,number" },
+ { type: "label", name: "label:growthFactor", value: "promoted,number" },
+ { type: "label", name: "docName", value: "launchbar_spacer" }
+ ]
+ },
+ {
+ id: LBTPL_CUSTOM_WIDGET,
+ title: t("hidden-subtree.custom-widget-title"),
+ type: "doc",
+ attributes: [
+ { type: "relation", name: "template", value: LBTPL_BASE },
+ { type: "label", name: "launcherType", value: "customWidget" },
+ { type: "label", name: "relation:widget", value: "promoted" },
+ { type: "label", name: "docName", value: "launchbar_widget_launcher" }
+ ]
+ }
+ ]
+ },
+ {
+ id: "_lbRoot",
+ title: t("hidden-subtree.launch-bar-title"),
+ type: "doc",
+ icon: "bx-sidebar",
+ isExpanded: true,
+ attributes: [{ type: "label", name: "docName", value: "launchbar_intro" }],
+ children: [
+ {
+ id: "_lbAvailableLaunchers",
+ title: t("hidden-subtree.available-launchers-title"),
+ type: "doc",
+ icon: "bx-hide",
+ isExpanded: true,
+ attributes: [{ type: "label", name: "docName", value: "launchbar_intro" }],
+ children: launchbarConfig.desktopAvailableLaunchers
+ },
+ {
+ id: "_lbVisibleLaunchers",
+ title: t("hidden-subtree.visible-launchers-title"),
+ type: "doc",
+ icon: "bx-show",
+ isExpanded: true,
+ attributes: [{ type: "label", name: "docName", value: "launchbar_intro" }],
+ children: launchbarConfig.desktopVisibleLaunchers
+ }
+ ]
+ },
+ {
+ id: "_lbMobileRoot",
+ title: "Mobile Launch Bar",
+ type: "doc",
+ icon: "bx-mobile",
+ isExpanded: true,
+ attributes: [{ type: "label", name: "docName", value: "launchbar_intro" }],
+ children: [
+ {
+ id: "_lbMobileAvailableLaunchers",
+ title: t("hidden-subtree.available-launchers-title"),
+ type: "doc",
+ icon: "bx-hide",
+ isExpanded: true,
+ attributes: [{ type: "label", name: "docName", value: "launchbar_intro" }],
+ children: launchbarConfig.mobileAvailableLaunchers
+ },
+ {
+ id: "_lbMobileVisibleLaunchers",
+ title: t("hidden-subtree.visible-launchers-title"),
+ type: "doc",
+ icon: "bx-show",
+ isExpanded: true,
+ attributes: [{ type: "label", name: "docName", value: "launchbar_intro" }],
+ children: launchbarConfig.mobileVisibleLaunchers
+ }
+ ]
+ },
+ {
+ id: "_options",
+ title: t("hidden-subtree.options-title"),
+ type: "book",
+ icon: "bx-cog",
+ children: [
+ { id: "_optionsAppearance", title: t("hidden-subtree.appearance-title"), type: "contentWidget", icon: "bx-layout" },
+ { id: "_optionsShortcuts", title: t("hidden-subtree.shortcuts-title"), type: "contentWidget", icon: "bxs-keyboard" },
+ { id: "_optionsTextNotes", title: t("hidden-subtree.text-notes"), type: "contentWidget", icon: "bx-text" },
+ { id: "_optionsCodeNotes", title: t("hidden-subtree.code-notes-title"), type: "contentWidget", icon: "bx-code" },
+ { id: "_optionsImages", title: t("hidden-subtree.images-title"), type: "contentWidget", icon: "bx-image" },
+ { id: "_optionsSpellcheck", title: t("hidden-subtree.spellcheck-title"), type: "contentWidget", icon: "bx-check-double" },
+ { id: "_optionsPassword", title: t("hidden-subtree.password-title"), type: "contentWidget", icon: "bx-lock" },
+ { id: '_optionsMFA', title: t('hidden-subtree.multi-factor-authentication-title'), type: 'contentWidget', icon: 'bx-lock ' },
+ { id: "_optionsEtapi", title: t("hidden-subtree.etapi-title"), type: "contentWidget", icon: "bx-extension" },
+ { id: "_optionsBackup", title: t("hidden-subtree.backup-title"), type: "contentWidget", icon: "bx-data" },
+ { id: "_optionsSync", title: t("hidden-subtree.sync-title"), type: "contentWidget", icon: "bx-wifi" },
+ { id: "_optionsAi", title: t("hidden-subtree.ai-llm-title"), type: "contentWidget", icon: "bx-bot" },
+ { id: "_optionsOther", title: t("hidden-subtree.other"), type: "contentWidget", icon: "bx-dots-horizontal" },
+ { id: "_optionsLocalization", title: t("hidden-subtree.localization"), type: "contentWidget", icon: "bx-world" },
+ { id: "_optionsAdvanced", title: t("hidden-subtree.advanced-title"), type: "contentWidget" }
+ ]
+ },
+ {
+ id: "_help",
+ title: t("hidden-subtree.user-guide"),
+ type: "book",
+ icon: "bx-help-circle",
+ children: helpSubtree,
+ isExpanded: true
+ },
+ buildHiddenSubtreeTemplates()
+ ]
+ };
+}
+
+interface CheckHiddenExtraOpts {
+ restoreNames?: boolean;
+}
+
+function checkHiddenSubtree(force = false, extraOpts: CheckHiddenExtraOpts = {}) {
+ if (!force && !migrationService.isDbUpToDate()) {
+ // on-delete hook might get triggered during some future migration and cause havoc
+ getLog().info("Will not check hidden subtree until migration is finished.");
+ return;
+ }
+
+ const helpSubtree = getHelpHiddenSubtreeData();
+ if (!hiddenSubtreeDefinition || force) {
+ hiddenSubtreeDefinition = buildHiddenSubtreeDefinition(helpSubtree);
+ }
+
+ checkHiddenSubtreeRecursively("root", hiddenSubtreeDefinition, extraOpts);
+
+ try {
+ cleanUpHelp(helpSubtree);
+ } catch (e) {
+ // Non-critical operation should something go wrong.
+ console.error(e);
+ }
+}
+
+/**
+ * Get all expected parent IDs for a given note ID from the hidden subtree definition
+ */
+function getExpectedParentIds(noteId: string, subtree: HiddenSubtreeItem): string[] {
+ const expectedParents: string[] = [];
+
+ function traverse(item: HiddenSubtreeItem, parentId: string) {
+ if (item.id === noteId) {
+ expectedParents.push(parentId);
+ }
+
+ if (item.children) {
+ for (const child of item.children) {
+ traverse(child, item.id);
+ }
+ }
+ }
+
+ // Start traversal from root
+ if (subtree.id === noteId) {
+ expectedParents.push("root");
+ }
+
+ if (subtree.children) {
+ for (const child of subtree.children) {
+ traverse(child, subtree.id);
+ }
+ }
+
+ return expectedParents;
+}
+
+/**
+ * Check if a note ID is within the hidden subtree structure
+ */
+function isWithinHiddenSubtree(noteId: string): boolean {
+ // Consider a note to be within hidden subtree if it starts with underscore
+ // This is the convention used for hidden subtree notes
+ return noteId.startsWith("_") || noteId === "root";
+}
+
+function checkHiddenSubtreeRecursively(parentNoteId: string, item: HiddenSubtreeItem, extraOpts: CheckHiddenExtraOpts = {}) {
+ if (!item.id || !item.type || !item.title) {
+ throw new Error(`Item does not contain mandatory properties: ${JSON.stringify(item)}`);
+ }
+
+ if (item.id.charAt(0) !== "_") {
+ throw new Error(`ID has to start with underscore, given '${item.id}'`);
+ }
+
+ let note = becca.notes[item.id];
+ let branch;
+ const log = getLog();
+
+ if (!note) {
+ // Missing item, add it.
+ ({ note, branch } = noteService.createNewNote({
+ noteId: item.id,
+ title: item.title,
+ type: item.type,
+ parentNoteId: parentNoteId,
+ content: item.content ?? "",
+ ignoreForbiddenParents: true
+ }));
+ } else {
+ // Existing item, check if it's in the right state.
+ branch = note.getParentBranches().find((branch) => branch.parentNoteId === parentNoteId);
+
+ if (item.content && note.getContent() !== item.content) {
+ log.info(`Updating content of ${item.id}.`);
+ note.setContent(item.content);
+ }
+
+ // Clean up any branches that shouldn't exist according to the meta definition
+ // For hidden subtree notes, we want to ensure they only exist in their designated locations
+ if (item.enforceBranches || item.id.startsWith("_help")) {
+ // If the note exists but doesn't have a branch in the expected parent,
+ // create the missing branch to ensure it's in the correct location
+ if (!branch) {
+ log.info(`Creating missing branch for note ${item.id} under parent ${parentNoteId}.`);
+ branch = new BBranch({
+ noteId: item.id,
+ parentNoteId: parentNoteId,
+ notePosition: item.notePosition !== undefined ? item.notePosition : undefined,
+ isExpanded: item.isExpanded !== undefined ? item.isExpanded : false
+ }).save();
+ }
+
+ // Remove any branches that are not in the expected parent.
+ const expectedParents = getExpectedParentIds(item.id, hiddenSubtreeDefinition);
+ const currentBranches = note.getParentBranches();
+
+ for (const currentBranch of currentBranches) {
+ // Only delete branches that are not in the expected locations
+ // and are within the hidden subtree structure (avoid touching user-created clones)
+ if (!expectedParents.includes(currentBranch.parentNoteId) &&
+ isWithinHiddenSubtree(currentBranch.parentNoteId)) {
+ log.info(`Removing unexpected branch for note '${item.id}' from parent '${currentBranch.parentNoteId}'`);
+ currentBranch.markAsDeleted();
+ }
+ }
+ }
+ }
+
+ const attrs = [...(item.attributes || [])];
+
+ if (item.icon) {
+ attrs.push({ type: "label", name: "iconClass", value: `bx ${item.icon}` });
+ }
+
+ if (item.type === "launcher") {
+ if (item.command) {
+ attrs.push({ type: "relation", name: "template", value: LBTPL_COMMAND });
+ attrs.push({ type: "label", name: "command", value: item.command });
+ } else if (item.builtinWidget) {
+ if (item.builtinWidget === "spacer") {
+ attrs.push({ type: "relation", name: "template", value: LBTPL_SPACER });
+ attrs.push({ type: "label", name: "baseSize", value: item.baseSize });
+ attrs.push({ type: "label", name: "growthFactor", value: item.growthFactor });
+ } else {
+ attrs.push({ type: "relation", name: "template", value: LBTPL_WIDGET });
+ }
+
+ attrs.push({ type: "label", name: "builtinWidget", value: item.builtinWidget });
+ } else if (item.targetNoteId) {
+ attrs.push({ type: "relation", name: "template", value: LBTPL_NOTE_LAUNCHER });
+ attrs.push({ type: "relation", name: "target", value: item.targetNoteId });
+ } else {
+ throw new Error(`No action defined for launcher ${JSON.stringify(item)}`);
+ }
+ }
+
+ const shouldRestoreNames = extraOpts.restoreNames || note.noteId.startsWith("_help") || item.id.startsWith("_lb") || item.id.startsWith("_template");
+ if (shouldRestoreNames && note.title !== item.title) {
+ note.title = item.title;
+ note.save();
+ }
+
+ if (note.type !== item.type) {
+ // enforce a correct note type
+ note.type = item.type;
+ note.save();
+ }
+
+ if (branch) {
+ // in case of launchers the branch ID is not preserved and should not be relied upon - launchers which move between
+ // visible and available will change branch since the branch's parent-child relationship is immutable
+ if (item.notePosition !== undefined && branch.notePosition !== item.notePosition) {
+ branch.notePosition = item.notePosition;
+ branch.save();
+ }
+
+ if (item.isExpanded !== undefined && branch.isExpanded !== item.isExpanded) {
+ branch.isExpanded = item.isExpanded;
+ branch.save();
+ }
+ }
+
+ // Enforce attribute structure if needed.
+ if (item.enforceAttributes) {
+ for (const attribute of note.getAttributes()) {
+ // Remove unwanted attributes.
+ const attrDef = attrs.find(a => a.name === attribute.name);
+ if (!attrDef) {
+ attribute.markAsDeleted();
+ continue;
+ }
+
+ // Ensure value is consistent.
+ if (attribute.value !== attrDef.value) {
+ note.setAttributeValueById(attribute.attributeId, attrDef.value);
+ }
+ }
+ }
+
+ for (const attr of attrs) {
+ const attrId = note.noteId + "_" + attr.type.charAt(0) + attr.name;
+
+ const existingAttribute = note.getAttributes().find((attr) => attr.attributeId === attrId);
+
+ if (!existingAttribute) {
+ new BAttribute({
+ attributeId: attrId,
+ noteId: note.noteId,
+ type: attr.type,
+ name: attr.name,
+ value: attr.value,
+ isInheritable: attr.isInheritable
+ }).save();
+ } else if (attr.name === "docName" || (existingAttribute.noteId.startsWith("_help") && attr.name === "iconClass")) {
+ if (existingAttribute.value !== attr.value) {
+ log.info(`Updating attribute ${attrId} from "${existingAttribute.value}" to "${attr.value}"`);
+ existingAttribute.value = attr.value ?? "";
+ existingAttribute.save();
+ }
+ }
+ }
+
+ for (const child of item.children || []) {
+ checkHiddenSubtreeRecursively(item.id, child, extraOpts);
+ }
+}
+
+export default {
+ checkHiddenSubtree
+};
diff --git a/apps/server/src/services/hidden_subtree_launcherbar.ts b/packages/trilium-core/src/services/hidden_subtree_launcherbar.ts
similarity index 98%
rename from apps/server/src/services/hidden_subtree_launcherbar.ts
rename to packages/trilium-core/src/services/hidden_subtree_launcherbar.ts
index d68c10c1c5..d233bde28b 100644
--- a/apps/server/src/services/hidden_subtree_launcherbar.ts
+++ b/packages/trilium-core/src/services/hidden_subtree_launcherbar.ts
@@ -48,7 +48,7 @@ export default function buildLaunchBarConfig() {
id: "_lbBackInHistory",
...sharedLaunchers.backInHistory
},
- {
+ {
id: "_lbForwardInHistory",
...sharedLaunchers.forwardInHistory
},
@@ -59,12 +59,12 @@ export default function buildLaunchBarConfig() {
command: "commandPalette",
icon: "bx bx-chevron-right-square"
},
- {
+ {
id: "_lbBackendLog",
title: t("hidden-subtree.backend-log-title"),
type: "launcher",
targetNoteId: "_backendLog",
- icon: "bx bx-detail"
+ icon: "bx bx-detail"
},
{
id: "_zenMode",
@@ -128,7 +128,7 @@ export default function buildLaunchBarConfig() {
baseSize: "50",
growthFactor: "0"
},
- {
+ {
id: "_lbBookmarks",
title: t("hidden-subtree.bookmarks-title"),
type: "launcher",
@@ -139,7 +139,7 @@ export default function buildLaunchBarConfig() {
id: "_lbToday",
...sharedLaunchers.openToday
},
- {
+ {
id: "_lbSpacer2",
title: t("hidden-subtree.spacer-title"),
type: "launcher",
@@ -214,4 +214,4 @@ export default function buildLaunchBarConfig() {
mobileAvailableLaunchers,
mobileVisibleLaunchers
};
-}
\ No newline at end of file
+}
diff --git a/apps/server/src/services/hidden_subtree_templates.ts b/packages/trilium-core/src/services/hidden_subtree_templates.ts
similarity index 100%
rename from apps/server/src/services/hidden_subtree_templates.ts
rename to packages/trilium-core/src/services/hidden_subtree_templates.ts
diff --git a/packages/trilium-core/src/services/image.ts b/packages/trilium-core/src/services/image.ts
new file mode 100644
index 0000000000..e140ca0507
--- /dev/null
+++ b/packages/trilium-core/src/services/image.ts
@@ -0,0 +1,5 @@
+export default {
+ saveImageToAttachment(noteId: string, imageBuffer: Uint8Array, title: string, b1: boolean, b2: boolean) {
+ console.warn("Image save ignored", noteId, title);
+ }
+}
diff --git a/packages/trilium-core/src/services/in_app_help.ts b/packages/trilium-core/src/services/in_app_help.ts
new file mode 100644
index 0000000000..4f377f0a6a
--- /dev/null
+++ b/packages/trilium-core/src/services/in_app_help.ts
@@ -0,0 +1,8 @@
+export function cleanUpHelp(items: unknown[]) {
+ // TODO: implement.
+}
+
+export function getHelpHiddenSubtreeData() {
+ // TODO: implement.
+ return [];
+}
diff --git a/packages/trilium-core/src/services/instance_id.ts b/packages/trilium-core/src/services/instance_id.ts
new file mode 100644
index 0000000000..7925d54655
--- /dev/null
+++ b/packages/trilium-core/src/services/instance_id.ts
@@ -0,0 +1,11 @@
+import { randomString } from "./utils";
+
+let instanceId: string | null = null;
+
+export default function getInstanceId() {
+ if (instanceId === null) {
+ instanceId = randomString(12);
+ }
+
+ return instanceId;
+}
diff --git a/packages/trilium-core/src/services/keyboard_actions.ts b/packages/trilium-core/src/services/keyboard_actions.ts
new file mode 100644
index 0000000000..0129be0c0c
--- /dev/null
+++ b/packages/trilium-core/src/services/keyboard_actions.ts
@@ -0,0 +1,884 @@
+"use strict";
+
+import optionService from "./options.js";
+import { getLog } from "./log.js";
+import { isElectron, isMac } from "./utils/index.js";
+import type { ActionKeyboardShortcut, KeyboardShortcut } from "@triliumnext/commons";
+import { t } from "i18next";
+
+function getDefaultKeyboardActions() {
+ if (!t("keyboard_actions.note-navigation")) {
+ // TODO: Re-enable.
+ // 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() : [];
+ }
+ }
+
+ const log = getLog();
+ 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/log.ts b/packages/trilium-core/src/services/log.ts
new file mode 100644
index 0000000000..686d497ad3
--- /dev/null
+++ b/packages/trilium-core/src/services/log.ts
@@ -0,0 +1,26 @@
+export default class LogService {
+
+ log(message: string | Error) {
+ console.log(message);
+ }
+
+ info(message: string | Error) {
+ console.info(message);
+ }
+
+ error(message: string | Error | unknown) {
+ console.error("ERROR: ", message);
+ }
+
+}
+
+let log: LogService;
+
+export function initLog() {
+ log = new LogService();
+}
+
+export function getLog() {
+ if (!log) throw new Error("Log service not initialized.");
+ return log;
+}
diff --git a/packages/trilium-core/src/services/migration.ts b/packages/trilium-core/src/services/migration.ts
new file mode 100644
index 0000000000..fe61ef7b92
--- /dev/null
+++ b/packages/trilium-core/src/services/migration.ts
@@ -0,0 +1,4 @@
+export function isDbUpToDate() {
+ // TODO: Implement.
+ return true;
+}
diff --git a/packages/trilium-core/src/services/note_types.ts b/packages/trilium-core/src/services/note_types.ts
new file mode 100644
index 0000000000..2aa86d0b64
--- /dev/null
+++ b/packages/trilium-core/src/services/note_types.ts
@@ -0,0 +1,34 @@
+const noteTypes = [
+ { type: "text", defaultMime: "text/html" },
+ { type: "code", defaultMime: "text/plain" },
+ { type: "render", defaultMime: "" },
+ { type: "file", defaultMime: "application/octet-stream" },
+ { type: "image", defaultMime: "" },
+ { type: "search", defaultMime: "" },
+ { type: "relationMap", defaultMime: "application/json" },
+ { type: "book", defaultMime: "" },
+ { type: "noteMap", defaultMime: "" },
+ { type: "mermaid", defaultMime: "text/vnd.mermaid" },
+ { type: "canvas", defaultMime: "application/json" },
+ { type: "webView", defaultMime: "" },
+ { type: "launcher", defaultMime: "" },
+ { type: "doc", defaultMime: "" },
+ { type: "contentWidget", defaultMime: "" },
+ { type: "mindMap", defaultMime: "application/json" },
+ { type: "aiChat", defaultMime: "application/json" }
+];
+
+function getDefaultMimeForNoteType(typeName: string) {
+ const typeRec = noteTypes.find((nt) => nt.type === typeName);
+
+ if (!typeRec) {
+ throw new Error(`Cannot find note type '${typeName}'`);
+ }
+
+ return typeRec.defaultMime;
+}
+
+export default {
+ getNoteTypeNames: () => noteTypes.map((nt) => nt.type),
+ getDefaultMimeForNoteType
+};
diff --git a/packages/trilium-core/src/services/notes.ts b/packages/trilium-core/src/services/notes.ts
new file mode 100644
index 0000000000..a62a8e7d56
--- /dev/null
+++ b/packages/trilium-core/src/services/notes.ts
@@ -0,0 +1,1117 @@
+import type { AttachmentRow, AttributeRow, BranchRow, NoteRow, NoteType } from "@triliumnext/commons";
+import { dayjs } from "@triliumnext/commons";
+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";
+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 imageService from "./image.js";
+import noteTypesService from "./note_types.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";
+import { sanitizeHtml } from "./sanitizer.js";
+
+interface FoundLink {
+ name: "imageLink" | "internalLink" | "includeNoteLink" | "relationMapLink";
+ value: string;
+}
+
+interface Attachment {
+ attachmentId?: string;
+ title: string;
+}
+
+export 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
+ .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 = sanitizeHtml(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();
+ 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(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 = /]*>/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 = {};
+
+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}' for note '${noteId}' failed due to no attachment ID.`);
+ }
+
+ log.info(`Download of '${imageUrl}' succeeded and was saved as image attachment '${attachment.attachmentId}' of note '${noteId}'`);
+ } catch (e: any) {
+ log.error(`Download of '${imageUrl}' for note '${noteId}' failed with error: ${e.message} ${e.stack}`);
+ }
+}
+
+/** url => download promise */
+const downloadImagePromises: Record> = {};
+
+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 = /
]*?\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)}
{
+ 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 = /]*?\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)}${title}${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("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("SELECT * FROM branches WHERE branchId = ?", [branchId]);
+
+ if (!branchRow.isDeleted) {
+ return;
+ }
+
+ const noteRow = sql.getRow("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(
+ `
+ 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(
+ `
+ 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(
+ `
+ 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(
+ `
+ 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) {
+ 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) {
+ 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 = {};
+
+ // 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
+};
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
diff --git a/packages/trilium-core/src/services/options.ts b/packages/trilium-core/src/services/options.ts
new file mode 100644
index 0000000000..a2ff346ea9
--- /dev/null
+++ b/packages/trilium-core/src/services/options.ts
@@ -0,0 +1,146 @@
+/**
+ * @module
+ *
+ * Options are key-value pairs that are used to store information such as user preferences (for example
+ * the current theme, sync server information), but also information about the state of the application.
+ *
+ * Although options internally are represented as strings, their value can be interpreted as a number or
+ * boolean by calling the appropriate methods from this service (e.g. {@link #getOptionInt}).\
+ *
+ * Generally options are shared across multiple instances of the application via the sync mechanism,
+ * however it is possible to have options that are local to an instance. For example, the user can select
+ * a theme on a device and it will not affect other devices.
+ */
+
+import becca from "../becca/becca.js";
+import BOption from "../becca/entities/boption.js";
+import type { OptionRow } from "@triliumnext/commons";
+import type { FilterOptionsByType, OptionDefinitions, OptionMap, OptionNames } from "@triliumnext/commons";
+import { getSql } from "./sql/index.js";
+
+function getOptionOrNull(name: OptionNames): string | null {
+ let option;
+
+ if (becca.loaded) {
+ option = becca.getOption(name);
+ } else {
+ // e.g. in initial sync becca is not loaded because DB is not initialized
+ try {
+ option = getSql().getRow("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, 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 {
+ 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(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(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 = {};
+
+ for (const option of Object.values(becca.options)) {
+ map[option.name] = option.value;
+ }
+
+ return map as OptionMap;
+}
+
+export default {
+ getOption,
+ getOptionInt,
+ getOptionBool,
+ setOption,
+ createOption,
+ getOptions,
+ getOptionMap,
+ getOptionOrNull
+};
diff --git a/packages/trilium-core/src/services/options_init.ts b/packages/trilium-core/src/services/options_init.ts
new file mode 100644
index 0000000000..3e605711d8
--- /dev/null
+++ b/packages/trilium-core/src/services/options_init.ts
@@ -0,0 +1,281 @@
+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 { getLog } from "./log.js";
+import optionService from "./options.js";
+import { isWindows, randomSecureToken } from "./utils/index.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 in instead, the function is called if the option does not exist (with access to the current options) and the return value is used instead. Useful to migrate a new option with a value depending on some other option that might be initialized.
+ */
+ value: string | ((options: OptionMap) => string);
+ isSynced: boolean;
+}
+
+/**
+ * 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());
+
+ const log = getLog();
+ 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
+};
diff --git a/apps/server/src/services/promoted_attribute_definition_parser.ts b/packages/trilium-core/src/services/promoted_attribute_definition_parser.ts
similarity index 84%
rename from apps/server/src/services/promoted_attribute_definition_parser.ts
rename to packages/trilium-core/src/services/promoted_attribute_definition_parser.ts
index 630c57a729..bc9c66bbc0 100644
--- a/apps/server/src/services/promoted_attribute_definition_parser.ts
+++ b/packages/trilium-core/src/services/promoted_attribute_definition_parser.ts
@@ -1,4 +1,4 @@
-import type { DefinitionObject } from "./promoted_attribute_definition_interface.js";
+import { DefinitionObject, LabelType, Multiplicity } from "@triliumnext/commons";
function parse(value: string): DefinitionObject {
const tokens = value.split(",").map((t) => t.trim());
@@ -8,9 +8,9 @@ function parse(value: string): DefinitionObject {
if (token === "promoted") {
defObj.isPromoted = true;
} else if (["text", "number", "boolean", "date", "datetime", "time", "url"].includes(token)) {
- defObj.labelType = token;
+ defObj.labelType = token as LabelType;
} else if (["single", "multi"].includes(token)) {
- defObj.multiplicity = token;
+ defObj.multiplicity = token as Multiplicity;
} else if (token.startsWith("precision")) {
const chunks = token.split("=");
diff --git a/packages/trilium-core/src/services/protected_session.ts b/packages/trilium-core/src/services/protected_session.ts
new file mode 100644
index 0000000000..107bf298ec
--- /dev/null
+++ b/packages/trilium-core/src/services/protected_session.ts
@@ -0,0 +1,69 @@
+"use strict";
+
+import dataEncryptionService from "./encryption/data_encryption";
+
+let dataKey: Uint8Array | null = null;
+
+function setDataKey(decryptedDataKey: Uint8Array) {
+ dataKey = Uint8Array.from(decryptedDataKey);
+}
+
+function getDataKey() {
+ return dataKey;
+}
+
+export function resetDataKey() {
+ dataKey = null;
+}
+
+export function isProtectedSessionAvailable() {
+ return !!dataKey;
+}
+
+function encrypt(plainText: string | Uint8Array) {
+ const dataKey = getDataKey();
+ if (plainText === null || dataKey === null) {
+ return null;
+ }
+
+ return dataEncryptionService.encrypt(dataKey, plainText);
+}
+
+function decrypt(cipherText: string | Uint8Array): Uint8Array | null {
+ const dataKey = getDataKey();
+ if (cipherText === null || dataKey === null) {
+ return null;
+ }
+
+ return dataEncryptionService.decrypt(dataKey, cipherText) || null;
+}
+
+function decryptString(cipherText: string): string | null {
+ const dataKey = getDataKey();
+ if (dataKey === null) {
+ return null;
+ }
+ return dataEncryptionService.decryptString(dataKey, cipherText);
+}
+
+let lastProtectedSessionOperationDate: number | null = null;
+
+function touchProtectedSession() {
+ if (isProtectedSessionAvailable()) {
+ lastProtectedSessionOperationDate = Date.now();
+ }
+}
+
+export function getLastProtectedSessionOperationDate() {
+ return lastProtectedSessionOperationDate;
+}
+
+export default {
+ setDataKey,
+ resetDataKey,
+ isProtectedSessionAvailable,
+ encrypt,
+ decrypt,
+ decryptString,
+ touchProtectedSession
+};
diff --git a/packages/trilium-core/src/services/request.ts b/packages/trilium-core/src/services/request.ts
new file mode 100644
index 0000000000..bf67168e0c
--- /dev/null
+++ b/packages/trilium-core/src/services/request.ts
@@ -0,0 +1,5 @@
+export default {
+ getImage(url: string) {
+ console.warn("Image download ignored ", url);
+ }
+}
diff --git a/packages/trilium-core/src/services/revisions.ts b/packages/trilium-core/src/services/revisions.ts
new file mode 100644
index 0000000000..b450e05d5b
--- /dev/null
+++ b/packages/trilium-core/src/services/revisions.ts
@@ -0,0 +1,46 @@
+import { getLog } from "./log.js";
+import protectedSessionService from "./protected_session.js";
+import type BNote from "../becca/entities/bnote.js";
+
+function protectRevisions(note: BNote) {
+ if (!protectedSessionService.isProtectedSessionAvailable()) {
+ throw new Error(`Cannot (un)protect revisions of note '${note.noteId}' without active protected session`);
+ }
+
+ const log = getLog();
+ for (const revision of note.getRevisions()) {
+ if (note.isProtected !== revision.isProtected) {
+ try {
+ const content = revision.getContent();
+
+ revision.isProtected = !!note.isProtected;
+
+ // this will force de/encryption
+ revision.setContent(content, { forceSave: true });
+ } catch (e) {
+ log.error(`Could not un/protect note revision '${revision.revisionId}'`);
+
+ throw e;
+ }
+ }
+
+ for (const attachment of revision.getAttachments()) {
+ if (note.isProtected !== attachment.isProtected) {
+ try {
+ const content = attachment.getContent();
+
+ attachment.isProtected = note.isProtected;
+ attachment.setContent(content, { forceSave: true });
+ } catch (e) {
+ log.error(`Could not un/protect attachment '${attachment.attachmentId}'`);
+
+ throw e;
+ }
+ }
+ }
+ }
+}
+
+export default {
+ protectRevisions
+};
diff --git a/apps/server/src/services/html_sanitizer.spec.ts b/packages/trilium-core/src/services/sanitizer.spec.ts
similarity index 91%
rename from apps/server/src/services/html_sanitizer.spec.ts
rename to packages/trilium-core/src/services/sanitizer.spec.ts
index dfbba8fd71..807e73c70b 100644
--- a/apps/server/src/services/html_sanitizer.spec.ts
+++ b/packages/trilium-core/src/services/sanitizer.spec.ts
@@ -1,12 +1,12 @@
import { describe, expect, it } from "vitest";
-import html_sanitizer from "./html_sanitizer.js";
+import { sanitizeHtml } from "./sanitizer.js";
import { trimIndentation } from "@triliumnext/commons";
describe("sanitize", () => {
it("filters out position inline CSS", () => {
const dirty = ``;
const clean = ``;
- 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", () => {
`;
- 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 84%
rename from apps/server/src/services/html_sanitizer.ts
rename to packages/trilium-core/src/services/sanitizer.ts
index 7e2e41b150..3d2446d370 100644
--- a/apps/server/src/services/html_sanitizer.ts
+++ b/packages/trilium-core/src/services/sanitizer.ts
@@ -1,14 +1,15 @@
-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";
+import sanitizeFileNameInternal from "sanitize-filename";
// 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 +39,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 +82,14 @@ 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();
+}
+
+export function sanitizeFileName(fileName: string) {
+ return sanitizeFileNameInternal(fileName);
+}
diff --git a/packages/trilium-core/src/services/script.ts b/packages/trilium-core/src/services/script.ts
new file mode 100644
index 0000000000..4760e21cc1
--- /dev/null
+++ b/packages/trilium-core/src/services/script.ts
@@ -0,0 +1,3 @@
+export function executeNoteNoException(script: unknown) {
+ console.warn("Skipped script execution");
+}
diff --git a/packages/trilium-core/src/services/search/note_set.ts b/packages/trilium-core/src/services/search/note_set.ts
new file mode 100644
index 0000000000..c621017786
--- /dev/null
+++ b/packages/trilium-core/src/services/search/note_set.ts
@@ -0,0 +1,65 @@
+import type BNote from "../../becca/entities/bnote.js";
+
+class NoteSet {
+ private noteIdSet: Set;
+
+ 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;
diff --git a/packages/trilium-core/src/services/search/services/search.ts b/packages/trilium-core/src/services/search/services/search.ts
new file mode 100644
index 0000000000..a6c04adc93
--- /dev/null
+++ b/packages/trilium-core/src/services/search/services/search.ts
@@ -0,0 +1,12 @@
+import BNote from "src/becca/entities/bnote";
+
+export default {
+ searchFromNote(note: BNote) {
+ console.warn("Ignore search ", note.title);
+ },
+
+ searchNotes(searchString: string) {
+ console.warn("Ignore search", searchString);
+ return [];
+ }
+}
diff --git a/packages/trilium-core/src/services/sql/index.ts b/packages/trilium-core/src/services/sql/index.ts
new file mode 100644
index 0000000000..ecabce498c
--- /dev/null
+++ b/packages/trilium-core/src/services/sql/index.ts
@@ -0,0 +1,13 @@
+import type { SqlService } from "./sql";
+
+let sql: SqlService | null = null;
+
+export function initSql(instance: SqlService) {
+ if (sql) throw new Error("SQL already initialized");
+ sql = instance;
+}
+
+export function getSql(): SqlService {
+ if (!sql) throw new Error("SQL not initialized");
+ return sql;
+}
diff --git a/packages/trilium-core/src/services/sql/sql.ts b/packages/trilium-core/src/services/sql/sql.ts
new file mode 100644
index 0000000000..27ab4c2ca0
--- /dev/null
+++ b/packages/trilium-core/src/services/sql/sql.ts
@@ -0,0 +1,359 @@
+import { getContext } from "../context.js";
+import type LogService from "../log.js";
+import type { DatabaseProvider, Params, RunResult, Statement } from "./types.js";
+
+const LOG_ALL_QUERIES = false;
+
+// smaller values can result in better performance due to better usage of statement cache
+const PARAM_LIMIT = 100;
+
+export interface SqlServiceParams {
+ provider: DatabaseProvider;
+ onTransactionRollback: () => void;
+ onTransactionCommit: () => void;
+ isReadOnly: boolean;
+}
+
+export class SqlService {
+
+ private dbConnection: DatabaseProvider;
+ private statementCache: Record = {};
+ private params: Omit;
+
+ constructor({ provider, ...restParams }: SqlServiceParams,
+ private log: LogService
+ ) {
+ this.dbConnection = provider;
+ this.params = restParams;
+ }
+
+ insert(tableName: string, rec: T, replace = false) {
+ const keys = Object.keys(rec || {});
+ if (keys.length === 0) {
+ this.log.error(`Can't insert empty object into table ${tableName}`);
+ return;
+ }
+
+ const columns = keys.join(", ");
+ const questionMarks = keys.map((p) => "?").join(", ");
+
+ const query = `INSERT
+ ${replace ? "OR REPLACE" : ""} INTO
+ ${tableName}
+ (
+ ${columns}
+ )
+ VALUES (${questionMarks})`;
+
+ const res = this.execute(query, Object.values(rec));
+
+ return res ? res.lastInsertRowid : null;
+ }
+
+ replace(tableName: string, rec: T): number | null {
+ return this.insert(tableName, rec, true) as number | null;
+ }
+
+ upsert(tableName: string, primaryKey: string, rec: T) {
+ const keys = Object.keys(rec || {});
+ if (keys.length === 0) {
+ this.log.error(`Can't upsert empty object into table ${tableName}`);
+ return;
+ }
+
+ const columns = keys.join(", ");
+
+ const questionMarks = keys.map((colName) => `@${colName}`).join(", ");
+
+ const updateMarks = keys.map((colName) => `${colName} = @${colName}`).join(", ");
+
+ const query = `INSERT INTO ${tableName} (${columns}) VALUES (${questionMarks})
+ ON CONFLICT (${primaryKey}) DO UPDATE SET ${updateMarks}`;
+
+ for (const idx in rec) {
+ if (rec[idx] === true || rec[idx] === false) {
+ (rec as any)[idx] = rec[idx] ? 1 : 0;
+ }
+ }
+
+ this.execute(query, rec);
+ }
+
+ /**
+ * For the given SQL query, returns a prepared statement. For the same query (string comparison), the same statement is returned.
+ *
+ * @param sql the SQL query for which to return a prepared statement.
+ * @param isRaw indicates whether `.raw()` is going to be called on the prepared statement in order to return the raw rows (e.g. via {@link getRawRows()}). The reason is that the raw state is preserved in the saved statement and would break non-raw calls for the same query.
+ * @returns the corresponding {@link Statement}.
+ */
+ stmt(sql: string, isRaw?: boolean) {
+ const key = (isRaw ? `raw/${sql}` : sql);
+
+ if (!(key in this.statementCache)) {
+ this.statementCache[key] = this.dbConnection.prepare(sql);
+ }
+
+ return this.statementCache[key];
+ }
+
+ /**
+ * Get first returned row.
+ *
+ * @param query - SQL query with ? used as parameter placeholder
+ * @param params - array of params if needed
+ * @returns - map of column name to column value
+ */
+ getRow(query: string, params: Params = []): T {
+ return this.wrap(query, (s) => s.get(params)) as T;
+ }
+
+ getRowOrNull(query: string, params: Params = []): T | null {
+ const all = this.getRows(query, params);
+ if (!all) {
+ return null;
+ }
+
+ return (all.length > 0 ? all[0] : null) as T | null;
+ }
+
+ /**
+ * Get single value from the given query - first column from first returned row.
+ *
+ * @param query - SQL query with ? used as parameter placeholder
+ * @param params - array of params if needed
+ * @returns single value
+ */
+ getValue(query: string, params: Params = []): T {
+ return this.wrap(query, (s) => s.pluck().get(params)) as T;
+ }
+
+ getManyRows(query: string, params: Params): T[] {
+ let results: unknown[] = [];
+
+ while (params.length > 0) {
+ const curParams = params.slice(0, Math.min(params.length, PARAM_LIMIT));
+ params = params.slice(curParams.length);
+
+ const curParamsObj: Record = {};
+
+ let j = 1;
+ for (const param of curParams) {
+ curParamsObj[`param${j++}`] = param;
+ }
+
+ let i = 1;
+ const questionMarks = curParams.map(() => `:param${i++}`).join(",");
+ const curQuery = query.replace(/\?\?\?/g, questionMarks);
+
+ const statement = curParams.length === PARAM_LIMIT ? this.stmt(curQuery) : this.dbConnection.prepare(curQuery);
+
+ const subResults = statement.all(curParamsObj);
+ results = results.concat(subResults);
+ }
+
+ return (results as T[] | null) || [];
+ }
+
+ /**
+ * Get all returned rows.
+ *
+ * @param query - SQL query with ? used as parameter placeholder
+ * @param params - array of params if needed
+ * @returns - array of all rows, each row is a map of column name to column value
+ */
+ getRows(query: string, params: Params = []): T[] {
+ return this.wrap(query, (s) => s.all(params)) as T[];
+ }
+
+ getRawRows(query: string, params: Params = []): T[] {
+ return (this.wrap(query, (s) => s.raw().all(params), true) as T[]) || [];
+ }
+
+ iterateRows(query: string, params: Params = []): IterableIterator {
+ if (LOG_ALL_QUERIES) {
+ console.log(query);
+ }
+
+ return this.stmt(query).iterate(params) as IterableIterator;
+ }
+
+ /**
+ * Get a map of first column mapping to second column.
+ *
+ * @param query - SQL query with ? used as parameter placeholder
+ * @param params - array of params if needed
+ * @returns - map of first column to second column
+ */
+ getMap(query: string, params: Params = []) {
+ const map: Record = {} as Record;
+ const results = this.getRawRows<[K, V]>(query, params);
+
+ for (const row of results || []) {
+ map[row[0]] = row[1];
+ }
+
+ return map;
+ }
+
+ /**
+ * Get a first column in an array.
+ *
+ * @param query - SQL query with ? used as parameter placeholder
+ * @param params - array of params if needed
+ * @returns array of first column of all returned rows
+ */
+ getColumn(query: string, params: Params = []): T[] {
+ return this.wrap(query, (s) => s.pluck().all(params)) as T[];
+ }
+
+ /**
+ * Execute SQL
+ *
+ * @param query - SQL query with ? used as parameter placeholder
+ * @param params - array of params if needed
+ */
+ execute(query: string, params: Params = []): RunResult {
+ if (this.params.isReadOnly && (query.startsWith("UPDATE") || query.startsWith("INSERT") || query.startsWith("DELETE"))) {
+ this.log.error(`read-only DB ignored: ${query} with parameters ${JSON.stringify(params)}`);
+ return {
+ changes: 0,
+ lastInsertRowid: 0
+ };
+ }
+ return this.wrap(query, (s) => s.run(params)) as RunResult;
+ }
+
+ executeMany(query: string, params: Params) {
+ if (LOG_ALL_QUERIES) {
+ console.log(query);
+ }
+
+ while (params.length > 0) {
+ const curParams = params.slice(0, Math.min(params.length, PARAM_LIMIT));
+ params = params.slice(curParams.length);
+
+ const curParamsObj: Record = {};
+
+ let j = 1;
+ for (const param of curParams) {
+ curParamsObj[`param${j++}`] = param;
+ }
+
+ let i = 1;
+ const questionMarks = curParams.map(() => `:param${i++}`).join(",");
+ const curQuery = query.replace(/\?\?\?/g, questionMarks);
+
+ this.dbConnection.prepare(curQuery).run(curParamsObj);
+ }
+ }
+
+ executeScript(query: string) {
+ if (LOG_ALL_QUERIES) {
+ console.log(query);
+ }
+
+ this.dbConnection.exec(query);
+ }
+
+ /**
+ * @param isRaw indicates whether `.raw()` is going to be called on the prepared statement in order to return the raw rows (e.g. via {@link getRawRows()}). The reason is that the raw state is preserved in the saved statement and would break non-raw calls for the same query.
+ */
+ wrap(query: string, func: (statement: Statement) => unknown, isRaw?: boolean): unknown {
+ const startTimestamp = Date.now();
+ let result;
+
+ if (LOG_ALL_QUERIES) {
+ console.log(query);
+ }
+
+ try {
+ result = func(this.stmt(query, isRaw));
+ } catch (e: any) {
+ if (e.message.includes("The database connection is not open")) {
+ // this often happens on killing the app which puts these alerts in front of user
+ // in these cases error should be simply ignored.
+ console.log(e.message);
+
+ return null;
+ }
+
+ throw e;
+ }
+
+ const milliseconds = Date.now() - startTimestamp;
+
+ if (milliseconds >= 20 && !isSlowQueryLoggingDisabled()) {
+ if (query.includes("WITH RECURSIVE")) {
+ this.log.info(`Slow recursive query took ${milliseconds}ms.`);
+ } else {
+ this.log.info(`Slow query took ${milliseconds}ms: ${query.trim().replace(/\s+/g, " ")}`);
+ }
+ }
+
+ return result;
+ }
+
+ transactional(func: (statement: Statement) => T) {
+ try {
+ const ret = (this.dbConnection.transaction(func) as any).deferred();
+
+ if (!this.dbConnection.inTransaction) {
+ // i.e. transaction was really committed (and not just savepoint released)
+ this.params.onTransactionCommit();
+ }
+
+ return ret as T;
+ } catch (e) {
+ console.warn("Got error ", e);
+ this.params.onTransactionRollback();
+ throw e;
+ }
+ }
+
+ fillParamList(paramIds: string[] | Set, truncate = true) {
+ if ("length" in paramIds && paramIds.length === 0) {
+ return;
+ }
+
+ if (truncate) {
+ this.execute("DELETE FROM param_list");
+ }
+
+ paramIds = Array.from(new Set(paramIds));
+
+ if (paramIds.length > 30000) {
+ this.fillParamList(paramIds.slice(30000), false);
+
+ paramIds = paramIds.slice(0, 30000);
+ }
+
+ // doing it manually to avoid this showing up on the slow query list
+ const s = this.stmt(`INSERT INTO param_list VALUES ${paramIds.map((paramId) => `(?)`).join(",")}`);
+
+ s.run(paramIds);
+ }
+
+ async copyDatabase(targetFilePath: string) {
+ await this.dbConnection.backup(targetFilePath);
+ }
+
+ disableSlowQueryLogging(cb: () => T) {
+ const orig = isSlowQueryLoggingDisabled();
+
+ try {
+ disableSlowQueryLogging(true);
+
+ return cb();
+ } finally {
+ disableSlowQueryLogging(orig);
+ }
+ }
+}
+
+function disableSlowQueryLogging(disable: boolean) {
+ getContext().set("disableSlowQueryLogging", disable);
+}
+
+function isSlowQueryLoggingDisabled() {
+ return !!getContext().get("disableSlowQueryLogging");
+}
diff --git a/packages/trilium-core/src/services/sql/types.ts b/packages/trilium-core/src/services/sql/types.ts
new file mode 100644
index 0000000000..e4bd5cb1c7
--- /dev/null
+++ b/packages/trilium-core/src/services/sql/types.ts
@@ -0,0 +1,31 @@
+export type Params = any;
+
+export interface Statement {
+ run(...params: Params): RunResult;
+ get(params: Params): unknown;
+ all(...params: Params): unknown[];
+ iterate(...params: Params): IterableIterator;
+ raw(toggleState?: boolean): this;
+ pluck(toggleState?: boolean): this;
+}
+
+export interface Transaction {
+ deferred(): void;
+}
+
+export interface RunResult {
+ changes: number;
+ lastInsertRowid: number | bigint;
+}
+
+export interface DatabaseProvider {
+ loadFromFile(path: string, isReadOnly: boolean): void;
+ loadFromMemory(): void;
+ loadFromBuffer(buffer: Uint8Array): void;
+ backup(destinationFile: string): void;
+ prepare(query: string): Statement;
+ transaction(func: (statement: Statement) => T): Transaction;
+ get inTransaction(): boolean;
+ exec(query: string): void;
+ close(): void;
+}
diff --git a/packages/trilium-core/src/services/sql_init.ts b/packages/trilium-core/src/services/sql_init.ts
new file mode 100644
index 0000000000..ec44193cb7
--- /dev/null
+++ b/packages/trilium-core/src/services/sql_init.ts
@@ -0,0 +1,8 @@
+import { deferred } from "@triliumnext/commons";
+
+export const dbReady = deferred();
+
+// TODO: Proper impl.
+setTimeout(() => {
+ dbReady.resolve();
+}, 850);
diff --git a/packages/trilium-core/src/services/task_context.ts b/packages/trilium-core/src/services/task_context.ts
new file mode 100644
index 0000000000..79122895bd
--- /dev/null
+++ b/packages/trilium-core/src/services/task_context.ts
@@ -0,0 +1,79 @@
+"use strict";
+
+import type { TaskData, TaskResult, TaskType, WebSocketMessage } from "@triliumnext/commons";
+import ws from "./ws.js";
+
+// taskId => TaskContext
+const taskContexts: Record> = {};
+
+class TaskContext {
+ private taskId: string;
+ private taskType: TaskType;
+ private progressCount: number;
+ private lastSentCountTs: number;
+ data: TaskData;
+ noteDeletionHandlerTriggered: boolean;
+
+ constructor(taskId: string, taskType: T, data: TaskData) {
+ 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(taskId: string, taskType: T, data: TaskData): TaskContext {
+ 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) {
+ ws.sendMessageToAllClients({
+ type: "taskSucceeded",
+ taskId: this.taskId,
+ taskType: this.taskType,
+ data: this.data,
+ result
+ } as WebSocketMessage);
+ }
+}
+
+export default TaskContext;
diff --git a/apps/server/src/services/tree.spec.ts b/packages/trilium-core/src/services/tree.spec.ts
similarity index 100%
rename from apps/server/src/services/tree.spec.ts
rename to packages/trilium-core/src/services/tree.spec.ts
diff --git a/packages/trilium-core/src/services/tree.ts b/packages/trilium-core/src/services/tree.ts
new file mode 100644
index 0000000000..896af1014d
--- /dev/null
+++ b/packages/trilium-core/src/services/tree.ts
@@ -0,0 +1,282 @@
+"use strict";
+
+import { getLog } from "./log.js";
+import BBranch from "../becca/entities/bbranch.js";
+import entityChangesService from "./entity_changes.js";
+import becca from "../becca/becca.js";
+import type BNote from "../becca/entities/bnote.js";
+import { getSql } from "./sql/index.js";
+
+export interface ValidationResponse {
+ branch: BBranch | null;
+ success: boolean;
+ message?: string;
+}
+
+function validateParentChild(parentNoteId: string, childNoteId: string, branchId: string | null = null): ValidationResponse {
+ if (["root", "_hidden", "_share", "_lbRoot", "_lbAvailableLaunchers", "_lbVisibleLaunchers"].includes(childNoteId)) {
+ return { branch: null, success: false, message: `Cannot change this note's location.` };
+ }
+
+ if (parentNoteId === "none") {
+ // this shouldn't happen
+ return { branch: null, success: false, message: `Cannot move anything into 'none' parent.` };
+ }
+
+ const existingBranch = becca.getBranchFromChildAndParent(childNoteId, parentNoteId);
+
+ if (existingBranch && existingBranch.branchId !== branchId) {
+ const parentNote = becca.getNote(parentNoteId);
+ const childNote = becca.getNote(childNoteId);
+
+ return {
+ branch: existingBranch,
+ success: false,
+ message: `Note "${childNote?.title}" note already exists in the "${parentNote?.title}".`
+ };
+ }
+
+ if (wouldAddingBranchCreateCycle(parentNoteId, childNoteId)) {
+ return {
+ branch: null,
+ success: false,
+ message: "Moving/cloning note here would create cycle."
+ };
+ }
+
+ if (parentNoteId !== "_lbBookmarks" && becca.getNote(parentNoteId)?.type === "launcher") {
+ return {
+ branch: null,
+ success: false,
+ message: "Launcher note cannot have any children."
+ };
+ }
+
+ return { branch: null, success: true };
+}
+
+/**
+ * Tree cycle can be created when cloning or when moving existing clone. This method should detect both cases.
+ */
+function wouldAddingBranchCreateCycle(parentNoteId: string, childNoteId: string) {
+ if (parentNoteId === childNoteId) {
+ return true;
+ }
+
+ const childNote = becca.getNote(childNoteId);
+ const parentNote = becca.getNote(parentNoteId);
+
+ if (!childNote || !parentNote) {
+ return false;
+ }
+
+ // we'll load the whole subtree - because the cycle can start in one of the notes in the subtree
+ const childSubtreeNoteIds = new Set(childNote.getSubtreeNoteIds());
+ const parentAncestorNoteIds = parentNote.getAncestorNoteIds();
+
+ return parentAncestorNoteIds.some((parentAncestorNoteId) => childSubtreeNoteIds.has(parentAncestorNoteId));
+}
+
+function sortNotes(parentNoteId: string, customSortBy: string = "title", reverse = false, foldersFirst = false, sortNatural = false, _sortLocale?: string | null) {
+ if (!customSortBy) {
+ customSortBy = "title";
+ }
+
+ // sortLocale can not be empty string or null value, default value must be set to undefined.
+ const sortLocale = _sortLocale || undefined;
+
+ const sql = getSql();
+ sql.transactional(() => {
+ const note = becca.getNote(parentNoteId);
+ if (!note) {
+ throw new Error("Unable to find note");
+ }
+
+ const notes = note.getChildNotes();
+
+ function normalize