diff --git a/apps/client/src/services/promoted_attribute_definition_parser.ts b/apps/client/src/services/promoted_attribute_definition_parser.ts index 0d93aae3c..292e64438 100644 --- a/apps/client/src/services/promoted_attribute_definition_parser.ts +++ b/apps/client/src/services/promoted_attribute_definition_parser.ts @@ -1,14 +1,4 @@ -export type LabelType = "text" | "number" | "boolean" | "date" | "datetime" | "time" | "url" | "color"; -type Multiplicity = "single" | "multi"; - -export interface DefinitionObject { - isPromoted?: boolean; - labelType?: LabelType; - multiplicity?: Multiplicity; - numberPrecision?: number; - promotedAlias?: string; - inverseRelation?: string; -} +import { DefinitionObject, LabelType, Multiplicity } from "@triliumnext/commons"; function parse(value: string) { const tokens = value.split(",").map((t) => t.trim()); diff --git a/apps/client/src/widgets/attribute_widgets/UserAttributesList.tsx b/apps/client/src/widgets/attribute_widgets/UserAttributesList.tsx index f01e70b49..93ed5356c 100644 --- a/apps/client/src/widgets/attribute_widgets/UserAttributesList.tsx +++ b/apps/client/src/widgets/attribute_widgets/UserAttributesList.tsx @@ -1,14 +1,16 @@ -import { useState } from "preact/hooks"; -import FNote from "../../entities/fnote"; import "./UserAttributesList.css"; -import { useTriliumEvent } from "../react/hooks"; -import attributes from "../../services/attributes"; -import { DefinitionObject } from "../../services/promoted_attribute_definition_parser"; -import { formatDateTime } from "../../utils/formatters"; + +import type { DefinitionObject } from "@triliumnext/commons"; import { ComponentChildren, CSSProperties } from "preact"; +import { useState } from "preact/hooks"; + +import FNote from "../../entities/fnote"; +import attributes from "../../services/attributes"; +import { getReadableTextColor } from "../../services/css_class_manager"; +import { formatDateTime } from "../../utils/formatters"; +import { useTriliumEvent } from "../react/hooks"; import Icon from "../react/Icon"; import NoteLink from "../react/NoteLink"; -import { getReadableTextColor } from "../../services/css_class_manager"; interface UserAttributesListProps { note: FNote; @@ -29,7 +31,7 @@ export default function UserAttributesDisplay({ note, ignoredAttributes }: UserA
{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 74db6ddb7..3c2e48763 100644 --- a/apps/client/src/widgets/collections/table/columns.tsx +++ b/apps/client/src/widgets/collections/table/columns.tsx @@ -1,11 +1,12 @@ -import type { CellComponent, ColumnDefinition, EmptyCallback, FormatterParams, ValueBooleanCallback, ValueVoidCallback } from "tabulator-tables"; -import { LabelType } from "../../../services/promoted_attribute_definition_parser.js"; +import { LabelType } from "@triliumnext/commons"; import { JSX } from "preact"; -import { renderReactWidget } from "../../react/react_utils.jsx"; -import Icon from "../../react/Icon.jsx"; import { useEffect, useRef, useState } from "preact/hooks"; +import type { CellComponent, ColumnDefinition, EmptyCallback, FormatterParams, ValueBooleanCallback, ValueVoidCallback } from "tabulator-tables"; + import froca from "../../../services/froca.js"; +import Icon from "../../react/Icon.jsx"; import NoteAutocomplete from "../../react/NoteAutocomplete.jsx"; +import { renderReactWidget } from "../../react/react_utils.jsx"; type ColumnType = LabelType | "relation"; @@ -78,7 +79,7 @@ export function buildColumnDefinitions({ info, movableRows, existingColumnData, rowHandle: movableRows, width: calculateIndexColumnWidth(rowNumberHint, movableRows), formatter: wrapFormatter(({ cell, formatterParams }) =>
- {(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 0f067c321..8c6765924 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -35,8 +35,7 @@ "sucrase": "3.35.1" }, "devDependencies": { - "@anthropic-ai/sdk": "0.71.2", - "@braintree/sanitize-url": "7.1.1", + "@anthropic-ai/sdk": "0.71.2", "@electron/remote": "2.1.3", "@preact/preset-vite": "2.10.2", "@triliumnext/commons": "workspace:*", @@ -50,17 +49,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", @@ -87,8 +83,7 @@ "ejs": "3.1.10", "electron": "39.2.7", "electron-debug": "4.1.0", - "electron-window-state": "5.0.3", - "escape-html": "1.0.3", + "electron-window-state": "5.0.3", "express": "5.2.1", "express-http-proxy": "2.1.2", "express-openid-connect": "2.19.3", @@ -110,15 +105,12 @@ "jimp": "1.6.0", "lorem-ipsum": "2.0.8", "marked": "17.0.1", - "mime-types": "3.0.2", "multer": "2.0.2", "normalize-strings": "1.1.1", "ollama": "0.6.3", "openai": "6.15.0", "rand-token": "1.0.1", - "safe-compare": "1.1.4", - "sanitize-filename": "1.6.3", - "sanitize-html": "2.17.0", + "safe-compare": "1.1.4", "sax": "1.4.3", "serve-favicon": "2.5.1", "stream-throttle": "0.1.3", @@ -129,7 +121,6 @@ "time2fa": "1.4.2", "tmp": "0.2.5", "turndown": "7.2.2", - "unescape": "1.0.1", "vite": "7.3.0", "ws": "8.18.3", "xml2js": "0.6.2", diff --git a/apps/server/src/anonymize.ts b/apps/server/src/anonymize.ts index e78fe0017..aea817831 100644 --- a/apps/server/src/anonymize.ts +++ b/apps/server/src/anonymize.ts @@ -1,6 +1,6 @@ import anonymizationService from "./services/anonymization.js"; import sqlInit from "./services/sql_init.js"; -await import("./becca/entity_constructor.js"); +await import("@triliumnext/core"); sqlInit.dbReady.then(async () => { try { diff --git a/apps/server/src/app.ts b/apps/server/src/app.ts index 8023338c9..c23968485 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 d4270fe09..a2c6aaa80 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 924dd972a..45a2e0cac 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 6ff1246fc..f7c6f70c3 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 e07443024..0c687237b 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 c355d2c8f..114aa7400 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 7f271e88c..523e4e004 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 7c841931b..e17b896ff 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 bfaa46544..e8cee478d 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 88f647db2..c11de52a4 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/crypto_provider.ts b/apps/server/src/crypto_provider.ts index c51b7bc9d..6ddeecf9f 100644 --- a/apps/server/src/crypto_provider.ts +++ b/apps/server/src/crypto_provider.ts @@ -1,5 +1,8 @@ import { CryptoProvider } from "@triliumnext/core"; import crypto from "crypto"; +import { generator } from "rand-token"; + +const randtoken = generator({ source: "crypto" }); export default class NodejsCryptoProvider implements CryptoProvider { @@ -19,4 +22,8 @@ export default class NodejsCryptoProvider implements CryptoProvider { return crypto.randomBytes(size); } + randomString(length: number): string { + return randtoken.generate(length); + } + } diff --git a/apps/server/src/errors/forbidden_error.ts b/apps/server/src/errors/forbidden_error.ts deleted file mode 100644 index 3e62665b0..000000000 --- a/apps/server/src/errors/forbidden_error.ts +++ /dev/null @@ -1,12 +0,0 @@ -import HttpError from "./http_error.js"; - -class ForbiddenError extends HttpError { - - constructor(message: string) { - super(message, 403); - this.name = "ForbiddenError"; - } - -} - -export default ForbiddenError; \ No newline at end of file diff --git a/apps/server/src/errors/http_error.ts b/apps/server/src/errors/http_error.ts deleted file mode 100644 index 2ab806d8b..000000000 --- a/apps/server/src/errors/http_error.ts +++ /dev/null @@ -1,13 +0,0 @@ -class HttpError extends Error { - - statusCode: number; - - constructor(message: string, statusCode: number) { - super(message); - this.name = "HttpError"; - this.statusCode = statusCode; - } - -} - -export default HttpError; \ No newline at end of file diff --git a/apps/server/src/errors/not_found_error.ts b/apps/server/src/errors/not_found_error.ts deleted file mode 100644 index 44f718a2c..000000000 --- a/apps/server/src/errors/not_found_error.ts +++ /dev/null @@ -1,12 +0,0 @@ -import HttpError from "./http_error.js"; - -class NotFoundError extends HttpError { - - constructor(message: string) { - super(message, 404); - this.name = "NotFoundError"; - } - -} - -export default NotFoundError; diff --git a/apps/server/src/errors/open_id_error.ts b/apps/server/src/errors/open_id_error.ts deleted file mode 100644 index 0206a17f3..000000000 --- a/apps/server/src/errors/open_id_error.ts +++ /dev/null @@ -1,9 +0,0 @@ -class OpenIdError { - message: string; - - constructor(message: string) { - this.message = message; - } -} - -export default OpenIdError; \ No newline at end of file diff --git a/apps/server/src/errors/validation_error.ts b/apps/server/src/errors/validation_error.ts deleted file mode 100644 index 25cdd509e..000000000 --- a/apps/server/src/errors/validation_error.ts +++ /dev/null @@ -1,12 +0,0 @@ -import HttpError from "./http_error.js"; - -class ValidationError extends HttpError { - - constructor(message: string) { - super(message, 400) - this.name = "ValidationError"; - } - -} - -export default ValidationError; diff --git a/apps/server/src/etapi/notes.ts b/apps/server/src/etapi/notes.ts index 9fae83070..735291c86 100644 --- a/apps/server/src/etapi/notes.ts +++ b/apps/server/src/etapi/notes.ts @@ -1,20 +1,21 @@ -import becca from "../becca/becca.js"; -import utils from "../services/utils.js"; -import eu from "./etapi_utils.js"; -import mappers from "./mappers.js"; -import noteService from "../services/notes.js"; -import TaskContext from "../services/task_context.js"; -import v from "./validators.js"; -import searchService from "../services/search/services/search.js"; -import SearchContext from "../services/search/search_context.js"; -import zipExportService from "../services/export/zip.js"; -import zipImportService from "../services/import/zip.js"; +import { NoteParams } from "@triliumnext/core"; import type { Request, Router } from "express"; import type { ParsedQs } from "qs"; -import type { NoteParams } from "../services/note-interface.js"; -import type { SearchParams } from "../services/search/services/types.js"; -import type { ValidatorMap } from "./etapi-interface.js"; + +import becca from "../becca/becca.js"; +import zipExportService from "../services/export/zip.js"; import type { ExportFormat } from "../services/export/zip/abstract_provider.js"; +import zipImportService from "../services/import/zip.js"; +import noteService from "../services/notes.js"; +import SearchContext from "../services/search/search_context.js"; +import searchService from "../services/search/services/search.js"; +import type { SearchParams } from "../services/search/services/types.js"; +import TaskContext from "../services/task_context.js"; +import utils from "../services/utils.js"; +import eu from "./etapi_utils.js"; +import type { ValidatorMap } from "./etapi-interface.js"; +import mappers from "./mappers.js"; +import v from "./validators.js"; function register(router: Router) { eu.route(router, "get", "/etapi/notes", (req, res, next) => { diff --git a/apps/server/src/main.ts b/apps/server/src/main.ts index 5d9bb8033..7dd11fa64 100644 --- a/apps/server/src/main.ts +++ b/apps/server/src/main.ts @@ -26,7 +26,7 @@ async function startApplication() { }, async onTransactionRollback() { const cls = (await import("./services/cls.js")).default; - const becca_loader = (await import("./becca/becca_loader.js")).default; + const becca_loader = (await import("@triliumnext/core")).becca_loader; const entity_changes = (await import("./services/entity_changes.js")).default; const log = (await import("./services/log")).default; diff --git a/apps/server/src/migrations/0220__migrate_images_to_attachments.ts b/apps/server/src/migrations/0220__migrate_images_to_attachments.ts index 9e06644c3..f20e21cb8 100644 --- a/apps/server/src/migrations/0220__migrate_images_to_attachments.ts +++ b/apps/server/src/migrations/0220__migrate_images_to_attachments.ts @@ -1,5 +1,6 @@ +import { becca_loader } from "@triliumnext/core"; + import becca from "../becca/becca.js"; -import becca_loader from "../becca/becca_loader.js"; import cls from "../services/cls.js"; import log from "../services/log.js"; import sql from "../services/sql.js"; diff --git a/apps/server/src/migrations/0233__migrate_geo_map_to_collection.ts b/apps/server/src/migrations/0233__migrate_geo_map_to_collection.ts index 7bcf55ebe..ccd32b1b0 100644 --- a/apps/server/src/migrations/0233__migrate_geo_map_to_collection.ts +++ b/apps/server/src/migrations/0233__migrate_geo_map_to_collection.ts @@ -1,5 +1,6 @@ +import { becca_loader } from "@triliumnext/core"; + import becca from "../becca/becca"; -import becca_loader from "../becca/becca_loader"; import cls from "../services/cls.js"; import hidden_subtree from "../services/hidden_subtree"; diff --git a/apps/server/src/routes/api/attachments.ts b/apps/server/src/routes/api/attachments.ts index b2c877fcb..9e409e6f9 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 55c3e3e29..3b0ac8326 100644 --- a/apps/server/src/routes/api/attributes.ts +++ b/apps/server/src/routes/api/attributes.ts @@ -1,13 +1,12 @@ -"use strict"; - -import sql from "../../services/sql.js"; -import log from "../../services/log.js"; -import attributeService from "../../services/attributes.js"; -import BAttribute from "../../becca/entities/battribute.js"; -import becca from "../../becca/becca.js"; -import ValidationError from "../../errors/validation_error.js"; -import type { Request } from "express"; import { UpdateAttributeResponse } from "@triliumnext/commons"; +import { ValidationError } from "@triliumnext/core"; +import type { Request } from "express"; + +import becca from "../../becca/becca.js"; +import BAttribute from "../../becca/entities/battribute.js"; +import attributeService from "../../services/attributes.js"; +import log from "../../services/log.js"; +import sql from "../../services/sql.js"; function getEffectiveNoteAttributes(req: Request) { const note = becca.getNote(req.params.noteId); @@ -47,7 +46,7 @@ function updateNoteAttribute(req: Request) { } attribute = new BAttribute({ - noteId: noteId, + noteId, name: body.name, type: body.type, isInheritable: body.isInheritable @@ -208,7 +207,7 @@ function createRelation(req: Request) { if (!attribute) { attribute = new BAttribute({ noteId: sourceNoteId, - name: name, + name, type: "relation", value: targetNoteId }).save(); diff --git a/apps/server/src/routes/api/autocomplete.ts b/apps/server/src/routes/api/autocomplete.ts index 9915f58cb..8b75dd7a6 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 73ce03a7a..26f030e47 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 133c35a88..fe2486059 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 c29f6e9aa..2fe001a0b 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 944eee841..fc328b444 100644 --- a/apps/server/src/routes/api/export.ts +++ b/apps/server/src/routes/api/export.ts @@ -1,14 +1,12 @@ -"use strict"; - -import zipExportService from "../../services/export/zip.js"; -import singleExportService from "../../services/export/single.js"; -import opmlExportService from "../../services/export/opml.js"; -import becca from "../../becca/becca.js"; -import TaskContext from "../../services/task_context.js"; -import log from "../../services/log.js"; -import NotFoundError from "../../errors/not_found_error.js"; +import { NotFoundError, ValidationError } from "@triliumnext/core"; import type { Request, Response } from "express"; -import ValidationError from "../../errors/validation_error.js"; + +import becca from "../../becca/becca.js"; +import opmlExportService from "../../services/export/opml.js"; +import singleExportService from "../../services/export/single.js"; +import zipExportService from "../../services/export/zip.js"; +import log from "../../services/log.js"; +import TaskContext from "../../services/task_context.js"; import { safeExtractMessageAndStackFromError } from "../../services/utils.js"; function exportBranch(req: Request, res: Response) { diff --git a/apps/server/src/routes/api/files.ts b/apps/server/src/routes/api/files.ts index 4a6e17382..fbfa63a0d 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 aa24e7aae..ec7228d7b 100644 --- a/apps/server/src/routes/api/image.ts +++ b/apps/server/src/routes/api/image.ts @@ -1,11 +1,12 @@ -"use strict"; -import imageService from "../../services/image.js"; -import becca from "../../becca/becca.js"; -import fs from "fs"; + import type { Request, Response } from "express"; +import fs from "fs"; + +import becca from "../../becca/becca.js"; import type BNote from "../../becca/entities/bnote.js"; import type BRevision from "../../becca/entities/brevision.js"; +import imageService from "../../services/image.js"; import { RESOURCE_DIR } from "../../services/resource_dir.js"; function returnImageFromNote(req: Request, res: Response) { @@ -42,7 +43,7 @@ function returnImageInt(image: BNote | BRevision | null, res: Response) { } export function renderSvgAttachment(image: BNote | BRevision, res: Response, attachmentName: string) { - let svg: string | Buffer = ``; + 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 273dc1e1d..a37124bbb 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 22c0e6ab0..6106fc52d 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 3c6db4054..cb5de8478 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 e7377bdfd..123c04804 100644 --- a/apps/server/src/routes/api/options.ts +++ b/apps/server/src/routes/api/options.ts @@ -1,9 +1,9 @@ import type { OptionNames } from "@triliumnext/commons"; +import { ValidationError } from "@triliumnext/core"; import type { Request } from "express"; -import ValidationError from "../../errors/validation_error.js"; import config from "../../services/config.js"; import { changeLanguage, getLocales } from "../../services/i18n.js"; import log from "../../services/log.js"; diff --git a/apps/server/src/routes/api/password.ts b/apps/server/src/routes/api/password.ts index 8a9c93940..959357893 100644 --- a/apps/server/src/routes/api/password.ts +++ b/apps/server/src/routes/api/password.ts @@ -1,16 +1,15 @@ -"use strict"; +import { ChangePasswordResponse } from "@triliumnext/commons"; +import { ValidationError } from "@triliumnext/core"; +import type { Request } from "express"; import passwordService from "../../services/encryption/password.js"; -import ValidationError from "../../errors/validation_error.js"; -import type { Request } from "express"; -import { ChangePasswordResponse } from "@triliumnext/commons"; function changePassword(req: Request): ChangePasswordResponse { if (passwordService.isPasswordSet()) { return passwordService.changePassword(req.body.current_password, req.body.new_password); - } else { - return passwordService.setPassword(req.body.new_password); } + return passwordService.setPassword(req.body.new_password); + } function resetPassword(req: Request) { diff --git a/apps/server/src/routes/api/revisions.ts b/apps/server/src/routes/api/revisions.ts index 9700e7f78..afa5a3c39 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 cbd584529..d406524ed 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 efdf6817a..a4c834846 100644 --- a/apps/server/src/routes/api/sender.ts +++ b/apps/server/src/routes/api/sender.ts @@ -1,9 +1,9 @@ +import { utils } from "@triliumnext/core"; import type { Request } from "express"; import imageType from "image-type"; import imageService from "../../services/image.js"; import noteService from "../../services/notes.js"; -import sanitizeAttributeName from "../../services/sanitize_attribute_name.js"; import specialNotesService from "../../services/special_notes.js"; async function uploadImage(req: Request) { @@ -43,14 +43,14 @@ async function uploadImage(req: Request) { const labels = JSON.parse(labelsStr); for (const { name, value } of labels) { - note.setLabel(sanitizeAttributeName(name), value); + note.setLabel(utils.sanitizeAttributeName(name), value); } } note.setLabel("sentFromSender"); return { - noteId: noteId + noteId }; } @@ -72,7 +72,7 @@ async function saveNote(req: Request) { if (req.body.labels) { for (const { name, value } of req.body.labels) { - note.setLabel(sanitizeAttributeName(name), value); + note.setLabel(utils.sanitizeAttributeName(name), value); } } diff --git a/apps/server/src/routes/api/similar_notes.ts b/apps/server/src/routes/api/similar_notes.ts index 6b9cbb926..afa845b10 100644 --- a/apps/server/src/routes/api/similar_notes.ts +++ b/apps/server/src/routes/api/similar_notes.ts @@ -1,17 +1,15 @@ -"use strict"; - +import { SimilarNoteResponse } from "@triliumnext/commons"; +import { similarity } from "@triliumnext/core"; import type { Request } from "express"; -import similarityService from "../../becca/similarity.js"; import becca from "../../becca/becca.js"; -import { SimilarNoteResponse } from "@triliumnext/commons"; async function getSimilarNotes(req: Request) { const noteId = req.params.noteId; const _note = becca.getNoteOrThrow(noteId); - return (await similarityService.findSimilarNotes(noteId) satisfies SimilarNoteResponse); + return (await similarity.findSimilarNotes(noteId) satisfies SimilarNoteResponse); } export default { diff --git a/apps/server/src/routes/api/sql.ts b/apps/server/src/routes/api/sql.ts index 33cd61b5e..71c404a9d 100644 --- a/apps/server/src/routes/api/sql.ts +++ b/apps/server/src/routes/api/sql.ts @@ -1,9 +1,8 @@ -"use strict"; - -import sql from "../../services/sql.js"; -import becca from "../../becca/becca.js"; +import { ValidationError } from "@triliumnext/core"; import type { Request } from "express"; -import ValidationError from "../../errors/validation_error.js"; + +import becca from "../../becca/becca.js"; +import sql from "../../services/sql.js"; import { safeExtractMessageAndStackFromError } from "../../services/utils.js"; interface Table { diff --git a/apps/server/src/routes/api/sync.ts b/apps/server/src/routes/api/sync.ts index 5e1c53041..da55e4ba4 100644 --- a/apps/server/src/routes/api/sync.ts +++ b/apps/server/src/routes/api/sync.ts @@ -1,21 +1,20 @@ -"use strict"; +import { type EntityChange,SyncTestResponse } from "@triliumnext/commons"; +import { ValidationError } from "@triliumnext/core"; +import type { Request } from "express"; +import { t } from "i18next"; -import syncService from "../../services/sync.js"; -import syncUpdateService from "../../services/sync_update.js"; +import consistencyChecksService from "../../services/consistency_checks.js"; +import contentHashService from "../../services/content_hash.js"; import entityChangesService from "../../services/entity_changes.js"; +import log from "../../services/log.js"; +import optionService from "../../services/options.js"; import sql from "../../services/sql.js"; import sqlInit from "../../services/sql_init.js"; -import optionService from "../../services/options.js"; -import contentHashService from "../../services/content_hash.js"; -import log from "../../services/log.js"; +import syncService from "../../services/sync.js"; import syncOptions from "../../services/sync_options.js"; +import syncUpdateService from "../../services/sync_update.js"; import utils, { safeExtractMessageAndStackFromError } from "../../services/utils.js"; import ws from "../../services/ws.js"; -import type { Request } from "express"; -import ValidationError from "../../errors/validation_error.js"; -import consistencyChecksService from "../../services/consistency_checks.js"; -import { t } from "i18next"; -import { SyncTestResponse, type EntityChange } from "@triliumnext/commons"; async function testSync(): Promise { try { @@ -287,10 +286,10 @@ function update(req: Request) { if (pageIndex !== pageCount - 1) { return; - } else { - body = JSON.parse(partialRequests[requestId].payload); - delete partialRequests[requestId]; - } + } + body = JSON.parse(partialRequests[requestId].payload); + delete partialRequests[requestId]; + } const { entities, instanceId } = body; diff --git a/apps/server/src/routes/api/tree.ts b/apps/server/src/routes/api/tree.ts index b9621d5d0..6c1c3c684 100644 --- a/apps/server/src/routes/api/tree.ts +++ b/apps/server/src/routes/api/tree.ts @@ -1,11 +1,10 @@ -"use strict"; +import type { AttributeRow, BranchRow, NoteRow } from "@triliumnext/commons"; +import { NotFoundError } from "@triliumnext/core"; +import type { Request } from "express"; import becca from "../../becca/becca.js"; -import log from "../../services/log.js"; -import NotFoundError from "../../errors/not_found_error.js"; -import type { Request } from "express"; import type BNote from "../../becca/entities/bnote.js"; -import type { AttributeRow, BranchRow, NoteRow } from "@triliumnext/commons"; +import log from "../../services/log.js"; function getNotesAndBranchesAndAttributes(_noteIds: string[] | Set) { const noteIds = new Set(_noteIds); diff --git a/apps/server/src/routes/error_handlers.ts b/apps/server/src/routes/error_handlers.ts index 05b05f6a4..146d28fbe 100644 --- a/apps/server/src/routes/error_handlers.ts +++ b/apps/server/src/routes/error_handlers.ts @@ -1,8 +1,7 @@ +import { ForbiddenError, HttpError, NotFoundError } from "@triliumnext/core"; import type { Application, NextFunction, Request, Response } from "express"; + import log from "../services/log.js"; -import NotFoundError from "../errors/not_found_error.js"; -import ForbiddenError from "../errors/forbidden_error.js"; -import HttpError from "../errors/http_error.js"; function register(app: Application) { diff --git a/apps/server/src/routes/login.ts b/apps/server/src/routes/login.ts index 2a505a993..bcb81ad85 100644 --- a/apps/server/src/routes/login.ts +++ b/apps/server/src/routes/login.ts @@ -1,18 +1,19 @@ +import { ValidationError } from "@triliumnext/core"; import crypto from "crypto"; -import utils from "../services/utils.js"; -import optionService from "../services/options.js"; -import myScryptService from "../services/encryption/my_scrypt.js"; -import log from "../services/log.js"; -import passwordService from "../services/encryption/password.js"; -import assetPath, { assetUrlFragment } from "../services/asset_path.js"; -import appPath from "../services/app_path.js"; -import ValidationError from "../errors/validation_error.js"; import type { Request, Response } from 'express'; -import totp from '../services/totp.js'; -import recoveryCodeService from '../services/encryption/recovery_codes.js'; -import openID from '../services/open_id.js'; + +import appPath from "../services/app_path.js"; +import assetPath, { assetUrlFragment } from "../services/asset_path.js"; +import myScryptService from "../services/encryption/my_scrypt.js"; import openIDEncryption from '../services/encryption/open_id_encryption.js'; +import passwordService from "../services/encryption/password.js"; +import recoveryCodeService from '../services/encryption/recovery_codes.js'; import { getCurrentLocale } from "../services/i18n.js"; +import log from "../services/log.js"; +import openID from '../services/open_id.js'; +import optionService from "../services/options.js"; +import totp from '../services/totp.js'; +import utils from "../services/utils.js"; function loginPage(req: Request, res: Response) { // Login page is triggered twice. Once here, and another time (see sendLoginError) if the password is failed. @@ -23,9 +24,9 @@ function loginPage(req: Request, res: Response) { ssoEnabled: openID.isOpenIDEnabled(), ssoIssuerName: openID.getSSOIssuerName(), ssoIssuerIcon: openID.getSSOIssuerIcon(), - assetPath: assetPath, + assetPath, assetPathFragment: assetUrlFragment, - appPath: appPath, + appPath, currentLocale: getCurrentLocale() }); } @@ -181,9 +182,9 @@ function sendLoginError(req: Request, res: Response, errorType: 'password' | 'to wrongTotp: errorType === 'totp', totpEnabled: totp.isTotpEnabled(), ssoEnabled: openID.isOpenIDEnabled(), - assetPath: assetPath, + assetPath, assetPathFragment: assetUrlFragment, - appPath: appPath, + appPath, currentLocale: getCurrentLocale() }); } diff --git a/apps/server/src/routes/route_api.ts b/apps/server/src/routes/route_api.ts index 6f3326074..dc11dfb41 100644 --- a/apps/server/src/routes/route_api.ts +++ b/apps/server/src/routes/route_api.ts @@ -1,10 +1,8 @@ +import { AbstractBeccaEntity,NotFoundError, ValidationError } from "@triliumnext/core"; import express, { type RequestHandler } from "express"; import multer from "multer"; -import AbstractBeccaEntity from "../becca/entities/abstract_becca_entity.js"; import { namespace } from "../cls_provider.js"; -import NotFoundError from "../errors/not_found_error.js"; -import ValidationError from "../errors/validation_error.js"; import auth from "../services/auth.js"; import cls from "../services/cls.js"; import entityChangesService from "../services/entity_changes.js"; diff --git a/apps/server/src/services/app_info.ts b/apps/server/src/services/app_info.ts index 2837e8de7..497bbb102 100644 --- a/apps/server/src/services/app_info.ts +++ b/apps/server/src/services/app_info.ts @@ -1,21 +1,11 @@ -import path from "path"; -import build from "./build.js"; -import packageJson from "../../package.json" with { type: "json" }; -import dataDir from "./data_dir.js"; import { AppInfo } from "@triliumnext/commons"; +import { app_info as coreAppInfo } from "@triliumnext/core"; +import path from "path"; -const APP_DB_VERSION = 233; -const SYNC_VERSION = 36; -const CLIPPER_PROTOCOL_VERSION = "1.0"; +import dataDir from "./data_dir.js"; export default { - appVersion: packageJson.version, - dbVersion: APP_DB_VERSION, + ...coreAppInfo, nodeVersion: process.version, - syncVersion: SYNC_VERSION, - buildDate: build.buildDate, - buildRevision: build.buildRevision, dataDirectory: path.resolve(dataDir.TRILIUM_DATA_DIR), - clipperProtocolVersion: CLIPPER_PROTOCOL_VERSION, - utcDateTime: new Date().toISOString() } satisfies AppInfo; diff --git a/apps/server/src/services/backend_script_api.ts b/apps/server/src/services/backend_script_api.ts index b322926ef..5d1d86331 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 4dce6e6a4..c7729ba66 100644 --- a/apps/server/src/services/backend_script_api_interface.ts +++ b/apps/server/src/services/backend_script_api_interface.ts @@ -1,5 +1,6 @@ +import type { AbstractBeccaEntity } from "@triliumnext/core"; import type { Request, Response } from "express"; -import type AbstractBeccaEntity from "../becca/entities/abstract_becca_entity.js"; + import type BNote from "../becca/entities/bnote.js"; export interface ApiParams { diff --git a/apps/server/src/services/blob-interface.ts b/apps/server/src/services/blob-interface.ts deleted file mode 100644 index a0e605278..000000000 --- a/apps/server/src/services/blob-interface.ts +++ /dev/null @@ -1,5 +0,0 @@ -export interface Blob { - blobId: string; - content: string | Buffer; - utcDateModified: string; -} diff --git a/apps/server/src/services/bulk_actions.ts b/apps/server/src/services/bulk_actions.ts index 531bd9976..2c662fba2 100644 --- a/apps/server/src/services/bulk_actions.ts +++ b/apps/server/src/services/bulk_actions.ts @@ -1,11 +1,12 @@ -import log from "./log.js"; -import becca from "../becca/becca.js"; -import cloningService from "./cloning.js"; -import branchService from "./branches.js"; -import { randomString } from "./utils.js"; -import eraseService from "./erase.js"; -import type BNote from "../becca/entities/bnote.js"; import { ActionHandlers, BulkAction, BulkActionData } from "@triliumnext/commons"; +import { erase as eraseService } from "@triliumnext/core"; + +import becca from "../becca/becca.js"; +import type BNote from "../becca/entities/bnote.js"; +import branchService from "./branches.js"; +import cloningService from "./cloning.js"; +import log from "./log.js"; +import { randomString } from "./utils.js"; type ActionHandler = (action: T, note: BNote) => void; diff --git a/apps/server/src/services/cloning.ts b/apps/server/src/services/cloning.ts index fc5552b42..475ee81bc 100644 --- a/apps/server/src/services/cloning.ts +++ b/apps/server/src/services/cloning.ts @@ -1,190 +1,2 @@ -"use strict"; - -import sql from "./sql.js"; -import eventChangesService from "./entity_changes.js"; -import treeService from "./tree.js"; -import BBranch from "../becca/entities/bbranch.js"; -import becca from "../becca/becca.js"; -import log from "./log.js"; -import { CloneResponse } from "@triliumnext/commons"; - -function cloneNoteToParentNote(noteId: string, parentNoteId: string, prefix: string | null = null): CloneResponse { - if (!(noteId in becca.notes) || !(parentNoteId in becca.notes)) { - return { success: false, message: "Note cannot be cloned because either the cloned note or the intended parent is deleted." }; - } - - const parentNote = becca.getNote(parentNoteId); - if (!parentNote) { - return { success: false, message: "Note cannot be cloned because the parent note could not be found." }; - } - - if (parentNote.type === "search") { - return { - success: false, - message: "Can't clone into a search note" - }; - } - - const validationResult = treeService.validateParentChild(parentNoteId, noteId); - - if (!validationResult.success) { - return validationResult; - } - - const branch = new BBranch({ - noteId: noteId, - parentNoteId: parentNoteId, - prefix: prefix, - isExpanded: false - }).save(); - - log.info(`Cloned note '${noteId}' to a new parent note '${parentNoteId}' with prefix '${prefix}'`); - - return { - success: true, - branchId: branch.branchId, - notePath: `${parentNote.getBestNotePathString()}/${noteId}` - }; -} - -function cloneNoteToBranch(noteId: string, parentBranchId: string, prefix?: string) { - const parentBranch = becca.getBranch(parentBranchId); - - if (!parentBranch) { - return { success: false, message: `Parent branch '${parentBranchId}' does not exist.` }; - } - - const ret = cloneNoteToParentNote(noteId, parentBranch.noteId, prefix); - - parentBranch.isExpanded = true; // the new target should be expanded, so it immediately shows up to the user - parentBranch.save(); - - return ret; -} - -function ensureNoteIsPresentInParent(noteId: string, parentNoteId: string, prefix?: string) { - if (!(noteId in becca.notes)) { - return { branch: null, success: false, message: `Note '${noteId}' is deleted.` }; - } else if (!(parentNoteId in becca.notes)) { - return { branch: null, success: false, message: `Note '${parentNoteId}' is deleted.` }; - } - - const parentNote = becca.getNote(parentNoteId); - - if (!parentNote) { - return { branch: null, success: false, message: "Can't find parent note." }; - } - if (parentNote.type === "search") { - return { branch: null, success: false, message: "Can't clone into a search note" }; - } - - const validationResult = treeService.validateParentChild(parentNoteId, noteId); - - if (!validationResult.success) { - return validationResult; - } - - const branch = new BBranch({ - noteId: noteId, - parentNoteId: parentNoteId, - prefix: prefix, - isExpanded: false - }).save(); - - log.info(`Ensured note '${noteId}' is in parent note '${parentNoteId}' with prefix '${branch.prefix}'`); - - return { branch: branch, success: true }; -} - -function ensureNoteIsAbsentFromParent(noteId: string, parentNoteId: string) { - const branchId = sql.getValue(/*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 5835385be..a9c91ede9 100644 --- a/apps/server/src/services/cls.ts +++ b/apps/server/src/services/cls.ts @@ -1,91 +1,72 @@ import type { EntityChange } from "@triliumnext/commons"; -import { getContext } from "@triliumnext/core/src/services/context"; - -type Callback = (...args: any[]) => any; +import { cls } from "@triliumnext/core"; function init(callback: () => T) { - return getContext().init(callback); -} - -function wrap(callback: Callback) { - return () => { - try { - init(callback); - } catch (e: any) { - console.log(`Error occurred: ${e.message}: ${e.stack}`); - } - }; + return cls.getContext().init(callback); } function getHoistedNoteId() { - return getContext().get("hoistedNoteId") || "root"; + return cls.getHoistedNoteId(); } function getComponentId() { - return getContext().get("componentId"); -} - -function getLocalNowDateTime() { - return getContext().get("localNowDateTime"); + return cls.getComponentId(); } +/** @deprecated */ function disableEntityEvents() { - getContext().set("disableEntityEvents", true); + cls.disableEntityEvents(); } +/** @deprecated */ function enableEntityEvents() { - getContext().set("disableEntityEvents", false); + cls.enableEntityEvents(); } function isEntityEventsDisabled() { - return !!getContext().get("disableEntityEvents"); + return cls.isEntityEventsDisabled(); } +/** @deprecated */ function setMigrationRunning(running: boolean) { - getContext().set("migrationRunning", !!running); + cls.setMigrationRunning(running); } +/** @deprecated */ function isMigrationRunning() { - return !!getContext().get("migrationRunning"); + return cls.isMigrationRunning(); } function getAndClearEntityChangeIds() { - const entityChangeIds = getContext().get("entityChangeIds") || []; + const entityChangeIds = cls.getContext().get("entityChangeIds") || []; - getContext().set("entityChangeIds", []); + cls.getContext().set("entityChangeIds", []); return entityChangeIds; } function putEntityChange(entityChange: EntityChange) { - if (getContext().get("ignoreEntityChangeIds")) { - return; - } - - const entityChangeIds = getContext().get("entityChangeIds") || []; - - // store only ID since the record can be modified (e.g., in erase) - entityChangeIds.push(entityChange.id); - - getContext().set("entityChangeIds", entityChangeIds); + cls.putEntityChange(entityChange); } function ignoreEntityChangeIds() { - getContext().set("ignoreEntityChangeIds", true); + cls.getContext().set("ignoreEntityChangeIds", true); } function get(key: string) { - return getContext().get(key); + return cls.getContext().get(key); } function set(key: string, value: unknown) { - getContext().set(key, value); + cls.getContext().set(key, value); } function reset() { - getContext().reset(); + cls.getContext().reset(); } +export const wrap = cls.wrap; + export default { init, wrap, @@ -93,7 +74,6 @@ export default { set, getHoistedNoteId, getComponentId, - getLocalNowDateTime, disableEntityEvents, enableEntityEvents, isEntityEventsDisabled, diff --git a/apps/server/src/services/consistency_checks.ts b/apps/server/src/services/consistency_checks.ts index 7b4ba72ad..b144d41bf 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 6c8365e2b..6b189bbe6 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 0cbe69cfa..0e38fb1ea 100644 --- a/apps/server/src/services/date_utils.ts +++ b/apps/server/src/services/date_utils.ts @@ -1,107 +1,2 @@ -import { dayjs } from "@triliumnext/commons"; -import cls from "./cls.js"; - -const LOCAL_DATETIME_FORMAT = "YYYY-MM-DD HH:mm:ss.SSSZZ"; -const UTC_DATETIME_FORMAT = "YYYY-MM-DD HH:mm:ssZ"; - -function utcNowDateTime() { - return utcDateTimeStr(new Date()); -} - -// CLS date time is important in web deployments - server often runs in different time zone than user is located in, -// so we'd prefer client timezone to be used to record local dates. For this reason, requests from clients contain -// "trilium-local-now-datetime" header which is then stored in CLS -function localNowDateTime() { - return cls.getLocalNowDateTime() || dayjs().format(LOCAL_DATETIME_FORMAT); -} - -function localNowDate() { - const clsDateTime = cls.getLocalNowDateTime(); - - if (clsDateTime) { - return clsDateTime.substr(0, 10); - } else { - const date = new Date(); - - return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`; - } -} - -function pad(num: number) { - return num <= 9 ? `0${num}` : `${num}`; -} - -function utcDateStr(date: Date) { - return date.toISOString().split("T")[0]; -} - -function utcDateTimeStr(date: Date) { - return date.toISOString().replace("T", " "); -} - -/** - * @param str - needs to be in the ISO 8601 format "YYYY-MM-DDTHH:MM:SS.sssZ" format as outputted by dateStr(). - * also is assumed to be GMT time (as indicated by the "Z" at the end), *not* local time - */ -function parseDateTime(str: string) { - try { - return new Date(Date.parse(str)); - } catch (e: any) { - throw new Error(`Can't parse date from '${str}': ${e.stack}`); - } -} - -function parseLocalDate(str: string) { - const datePart = str.substr(0, 10); - - // not specifying the timezone and specifying the time means Date.parse() will use the local timezone - return parseDateTime(`${datePart} 12:00:00.000`); -} - -function getDateTimeForFile() { - return new Date().toISOString().substr(0, 19).replace(/:/g, ""); -} - -function validateLocalDateTime(str: string | null | undefined) { - if (!str) { - return; - } - - if (!/[0-9]{4}-[0-9]{2}-[0-9]{2} [0-9]{2}:[0-9]{2}:[0-9]{2}\.[0-9]{3}[+-][0-9]{4}/.test(str)) { - return `Invalid local date time format in '${str}'. Correct format shoud follow example: '2023-08-21 23:38:51.110+0200'`; - } - - if (!dayjs(str, LOCAL_DATETIME_FORMAT)) { - return `Date '${str}' appears to be in the correct format, but cannot be parsed. It likely represents an invalid date.`; - } -} - -function validateUtcDateTime(str: string | undefined) { - if (!str) { - return; - } - - if (!/[0-9]{4}-[0-9]{2}-[0-9]{2} [0-9]{2}:[0-9]{2}:[0-9]{2}\.[0-9]{3}Z/.test(str)) { - return `Invalid UTC date time format in '${str}'. Correct format shoud follow example: '2023-08-21 23:38:51.110Z'`; - } - - if (!dayjs(str, UTC_DATETIME_FORMAT)) { - return `Date '${str}' appears to be in the correct format, but cannot be parsed. It likely represents an invalid date.`; - } -} - -export default { - LOCAL_DATETIME_FORMAT, - UTC_DATETIME_FORMAT, - utcNowDateTime, - localNowDateTime, - localNowDate, - - utcDateStr, - utcDateTimeStr, - parseDateTime, - parseLocalDate, - getDateTimeForFile, - validateLocalDateTime, - validateUtcDateTime -}; +import { date_utils } from "@triliumnext/core"; +export default date_utils; diff --git a/apps/server/src/services/encryption/open_id_encryption.ts b/apps/server/src/services/encryption/open_id_encryption.ts index a191b782b..5126a0cf5 100644 --- a/apps/server/src/services/encryption/open_id_encryption.ts +++ b/apps/server/src/services/encryption/open_id_encryption.ts @@ -1,6 +1,5 @@ -import { data_encryption } from "@triliumnext/core"; +import { data_encryption, OpenIdError } from "@triliumnext/core"; -import OpenIdError from "../../errors/open_id_error.js"; import sql from "../sql.js"; import sqlInit from "../sql_init.js"; import utils, { constantTimeCompare } from "../utils.js"; diff --git a/apps/server/src/services/entity_changes.ts b/apps/server/src/services/entity_changes.ts index c0a97c7d6..24b210d95 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 16d36807e..e115e0e54 100644 --- a/apps/server/src/services/export/single.ts +++ b/apps/server/src/services/export/single.ts @@ -1,14 +1,15 @@ -"use strict"; -import mimeTypes from "mime-types"; -import html from "html"; -import { getContentDisposition, escapeHtml } from "../utils.js"; -import mdService from "./markdown.js"; -import becca from "../../becca/becca.js"; -import type TaskContext from "../task_context.js"; -import type BBranch from "../../becca/entities/bbranch.js"; + import type { Response } from "express"; +import html from "html"; +import mimeTypes from "mime-types"; + +import becca from "../../becca/becca.js"; +import type BBranch from "../../becca/entities/bbranch.js"; import type BNote from "../../becca/entities/bnote.js"; +import type TaskContext from "../task_context.js"; +import { escapeHtml,getContentDisposition } from "../utils.js"; +import mdService from "./markdown.js"; import type { ExportFormat } from "./zip/abstract_provider.js"; function exportSingleNote(taskContext: TaskContext<"export">, branch: BBranch, format: ExportFormat, res: Response) { @@ -34,7 +35,7 @@ function exportSingleNote(taskContext: TaskContext<"export">, branch: BBranch, f taskContext.taskSucceeded(null); } -export function mapByNoteType(note: BNote, content: string | Buffer, 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 4a9f14041..a0c32ef6b 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,11 @@ 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(content as Buffer, { name: filePathPrefix + noteMeta.dataFileName }); return; } @@ -360,7 +359,7 @@ async function exportToZip(taskContext: TaskContext<"export">, branch: BBranch, if (noteMeta.dataFileName) { const content = prepareContent(noteMeta.title, note.getContent(), noteMeta, note); - archive.append(content, { + archive.append(content as string | Buffer, { name: filePathPrefix + noteMeta.dataFileName, date: dateUtils.parseDateTime(note.utcDateModified) }); @@ -376,7 +375,7 @@ async function exportToZip(taskContext: TaskContext<"export">, branch: BBranch, const attachment = note.getAttachmentById(attachmentMeta.attachmentId); const content = attachment.getContent(); - archive.append(content, { + archive.append(content as Buffer, { name: filePathPrefix + attachmentMeta.dataFileName, date: dateUtils.parseDateTime(note.utcDateModified) }); @@ -421,9 +420,9 @@ async function exportToZip(taskContext: TaskContext<"export">, branch: BBranch, } else if (attr.value === "root" || attr.value?.startsWith("_")) { // relations to "named" noteIds can be preserved return true; - } else { - return false; } + return false; + }); } diff --git a/apps/server/src/services/export/zip/abstract_provider.ts b/apps/server/src/services/export/zip/abstract_provider.ts index 5eda4b076..d3c5ea222 100644 --- a/apps/server/src/services/export/zip/abstract_provider.ts +++ b/apps/server/src/services/export/zip/abstract_provider.ts @@ -52,7 +52,7 @@ export abstract class ZipExportProvider { } abstract prepareMeta(metaFile: NoteMetaFile): void; - abstract prepareContent(title: string, content: string | Buffer, noteMeta: NoteMeta, note: BNote | undefined, branch: BBranch): string | Buffer; + abstract prepareContent(title: string, content: string | Uint8Array, noteMeta: NoteMeta, note: BNote | undefined, branch: BBranch): string | Uint8Array; abstract afterDone(rootMeta: NoteMeta): void; /** diff --git a/apps/server/src/services/export/zip/html.ts b/apps/server/src/services/export/zip/html.ts index 14fb44acc..0ba9ed213 100644 --- a/apps/server/src/services/export/zip/html.ts +++ b/apps/server/src/services/export/zip/html.ts @@ -34,7 +34,7 @@ export default class HtmlExportProvider extends ZipExportProvider { metaFile.files.push(this.cssMeta); } - prepareContent(title: string, content: string | Buffer, noteMeta: NoteMeta): string | Buffer { + prepareContent(title: string, content: string | Uint8Array, noteMeta: NoteMeta): string | Uint8Array { if (noteMeta.format === "html" && typeof content === "string") { if (!content.substr(0, 100).toLowerCase().includes(" 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 a95955f46..4c3d52aa0 100644 --- a/apps/server/src/services/hidden_subtree.ts +++ b/apps/server/src/services/hidden_subtree.ts @@ -1,498 +1,2 @@ -import BAttribute from "../becca/entities/battribute.js"; -import BBranch from "../becca/entities/bbranch.js"; -import type { HiddenSubtreeItem } from "@triliumnext/commons"; - -import becca from "../becca/becca.js"; -import noteService from "./notes.js"; -import log from "./log.js"; -import migrationService from "./migration.js"; -import { t } from "i18next"; -import { cleanUpHelp, getHelpHiddenSubtreeData } from "./in_app_help.js"; -import buildLaunchBarConfig from "./hidden_subtree_launcherbar.js"; -import buildHiddenSubtreeTemplates from "./hidden_subtree_templates.js"; - -export const LBTPL_ROOT = "_lbTplRoot"; -export const LBTPL_BASE = "_lbTplBase"; -export const LBTPL_HEADER = "_lbTplHeader"; -export const LBTPL_NOTE_LAUNCHER = "_lbTplLauncherNote"; -export const LBTPL_WIDGET = "_lbTplLauncherWidget"; -export const LBTPL_COMMAND = "_lbTplLauncherCommand"; -export const LBTPL_SCRIPT = "_lbTplLauncherScript"; -export const LBTPL_SPACER = "_lbTplSpacer"; -export const LBTPL_CUSTOM_WIDGET = "_lbTplCustomWidget"; - -/* - * Hidden subtree is generated as a "predictable structure" which means that it avoids generating random IDs to always - * produce the same structure. This is needed because it is run on multiple instances in the sync cluster which might produce - * duplicate subtrees. This way, all instances will generate the same structure with the same IDs. - */ - -let hiddenSubtreeDefinition: HiddenSubtreeItem; - -function buildHiddenSubtreeDefinition(helpSubtree: HiddenSubtreeItem[]): HiddenSubtreeItem { - const launchbarConfig = buildLaunchBarConfig(); - - return { - id: "_hidden", - title: t("hidden-subtree.root-title"), - type: "doc", - icon: "bx bx-hide", - // we want to keep the hidden subtree always last, otherwise there will be problems with e.g., keyboard navigation - // over tree when it's in the middle - notePosition: 999_999_999, - enforceAttributes: true, - attributes: [ - { type: "label", name: "docName", value: "hidden" } - ], - children: [ - { - id: "_search", - title: t("hidden-subtree.search-history-title"), - type: "doc" - }, - { - id: "_globalNoteMap", - title: t("hidden-subtree.note-map-title"), - type: "noteMap", - attributes: [ - { type: "label", name: "mapRootNoteId", value: "hoisted" }, - { type: "label", name: "keepCurrentHoisting" } - ] - }, - { - id: "_sqlConsole", - title: t("hidden-subtree.sql-console-history-title"), - type: "doc", - icon: "bx-data" - }, - { - id: "_share", - title: t("hidden-subtree.shared-notes-title"), - type: "doc", - attributes: [{ type: "label", name: "docName", value: "share" }] - }, - { - id: "_bulkAction", - title: t("hidden-subtree.bulk-action-title"), - type: "doc" - }, - { - id: "_backendLog", - title: t("hidden-subtree.backend-log-title"), - type: "contentWidget", - icon: "bx-terminal", - attributes: [ - { type: "label", name: "keepCurrentHoisting" }, - { type: "label", name: "fullContentWidth" } - ] - }, - { - // place for user scripts hidden stuff (scripts should not create notes directly under hidden root) - id: "_userHidden", - title: t("hidden-subtree.user-hidden-title"), - type: "doc", - attributes: [{ type: "label", name: "docName", value: "user_hidden" }] - }, - { - id: LBTPL_ROOT, - title: t("hidden-subtree.launch-bar-templates-title"), - type: "doc", - children: [ - { - id: LBTPL_BASE, - title: t("hidden-subtree.base-abstract-launcher-title"), - type: "doc" - }, - { - id: LBTPL_COMMAND, - title: t("hidden-subtree.command-launcher-title"), - type: "doc", - attributes: [ - { type: "relation", name: "template", value: LBTPL_BASE }, - { type: "label", name: "launcherType", value: "command" }, - { type: "label", name: "docName", value: "launchbar_command_launcher" } - ] - }, - { - id: LBTPL_NOTE_LAUNCHER, - title: t("hidden-subtree.note-launcher-title"), - type: "doc", - attributes: [ - { type: "relation", name: "template", value: LBTPL_BASE }, - { type: "label", name: "launcherType", value: "note" }, - { type: "label", name: "relation:target", value: "promoted" }, - { type: "label", name: "relation:hoistedNote", value: "promoted" }, - { type: "label", name: "label:keyboardShortcut", value: "promoted,text" }, - { type: "label", name: "docName", value: "launchbar_note_launcher" } - ] - }, - { - id: LBTPL_SCRIPT, - title: t("hidden-subtree.script-launcher-title"), - type: "doc", - attributes: [ - { type: "relation", name: "template", value: LBTPL_BASE }, - { type: "label", name: "launcherType", value: "script" }, - { type: "label", name: "relation:script", value: "promoted" }, - { type: "label", name: "label:keyboardShortcut", value: "promoted,text" }, - { type: "label", name: "docName", value: "launchbar_script_launcher" } - ] - }, - { - id: LBTPL_WIDGET, - title: t("hidden-subtree.built-in-widget-title"), - type: "doc", - attributes: [ - { type: "relation", name: "template", value: LBTPL_BASE }, - { type: "label", name: "launcherType", value: "builtinWidget" } - ] - }, - { - id: LBTPL_SPACER, - title: t("hidden-subtree.spacer-title"), - type: "doc", - icon: "bx-move-vertical", - attributes: [ - { type: "relation", name: "template", value: LBTPL_WIDGET }, - { type: "label", name: "builtinWidget", value: "spacer" }, - { type: "label", name: "label:baseSize", value: "promoted,number" }, - { type: "label", name: "label:growthFactor", value: "promoted,number" }, - { type: "label", name: "docName", value: "launchbar_spacer" } - ] - }, - { - id: LBTPL_CUSTOM_WIDGET, - title: t("hidden-subtree.custom-widget-title"), - type: "doc", - attributes: [ - { type: "relation", name: "template", value: LBTPL_BASE }, - { type: "label", name: "launcherType", value: "customWidget" }, - { type: "label", name: "relation:widget", value: "promoted" }, - { type: "label", name: "docName", value: "launchbar_widget_launcher" } - ] - } - ] - }, - { - id: "_lbRoot", - title: t("hidden-subtree.launch-bar-title"), - type: "doc", - icon: "bx-sidebar", - isExpanded: true, - attributes: [{ type: "label", name: "docName", value: "launchbar_intro" }], - children: [ - { - id: "_lbAvailableLaunchers", - title: t("hidden-subtree.available-launchers-title"), - type: "doc", - icon: "bx-hide", - isExpanded: true, - attributes: [{ type: "label", name: "docName", value: "launchbar_intro" }], - children: launchbarConfig.desktopAvailableLaunchers - }, - { - id: "_lbVisibleLaunchers", - title: t("hidden-subtree.visible-launchers-title"), - type: "doc", - icon: "bx-show", - isExpanded: true, - attributes: [{ type: "label", name: "docName", value: "launchbar_intro" }], - children: launchbarConfig.desktopVisibleLaunchers - } - ] - }, - { - id: "_lbMobileRoot", - title: "Mobile Launch Bar", - type: "doc", - icon: "bx-mobile", - isExpanded: true, - attributes: [{ type: "label", name: "docName", value: "launchbar_intro" }], - children: [ - { - id: "_lbMobileAvailableLaunchers", - title: t("hidden-subtree.available-launchers-title"), - type: "doc", - icon: "bx-hide", - isExpanded: true, - attributes: [{ type: "label", name: "docName", value: "launchbar_intro" }], - children: launchbarConfig.mobileAvailableLaunchers - }, - { - id: "_lbMobileVisibleLaunchers", - title: t("hidden-subtree.visible-launchers-title"), - type: "doc", - icon: "bx-show", - isExpanded: true, - attributes: [{ type: "label", name: "docName", value: "launchbar_intro" }], - children: launchbarConfig.mobileVisibleLaunchers - } - ] - }, - { - id: "_options", - title: t("hidden-subtree.options-title"), - type: "book", - icon: "bx-cog", - children: [ - { id: "_optionsAppearance", title: t("hidden-subtree.appearance-title"), type: "contentWidget", icon: "bx-layout" }, - { id: "_optionsShortcuts", title: t("hidden-subtree.shortcuts-title"), type: "contentWidget", icon: "bxs-keyboard" }, - { id: "_optionsTextNotes", title: t("hidden-subtree.text-notes"), type: "contentWidget", icon: "bx-text" }, - { id: "_optionsCodeNotes", title: t("hidden-subtree.code-notes-title"), type: "contentWidget", icon: "bx-code" }, - { id: "_optionsImages", title: t("hidden-subtree.images-title"), type: "contentWidget", icon: "bx-image" }, - { id: "_optionsSpellcheck", title: t("hidden-subtree.spellcheck-title"), type: "contentWidget", icon: "bx-check-double" }, - { id: "_optionsPassword", title: t("hidden-subtree.password-title"), type: "contentWidget", icon: "bx-lock" }, - { id: '_optionsMFA', title: t('hidden-subtree.multi-factor-authentication-title'), type: 'contentWidget', icon: 'bx-lock ' }, - { id: "_optionsEtapi", title: t("hidden-subtree.etapi-title"), type: "contentWidget", icon: "bx-extension" }, - { id: "_optionsBackup", title: t("hidden-subtree.backup-title"), type: "contentWidget", icon: "bx-data" }, - { id: "_optionsSync", title: t("hidden-subtree.sync-title"), type: "contentWidget", icon: "bx-wifi" }, - { id: "_optionsAi", title: t("hidden-subtree.ai-llm-title"), type: "contentWidget", icon: "bx-bot" }, - { id: "_optionsOther", title: t("hidden-subtree.other"), type: "contentWidget", icon: "bx-dots-horizontal" }, - { id: "_optionsLocalization", title: t("hidden-subtree.localization"), type: "contentWidget", icon: "bx-world" }, - { id: "_optionsAdvanced", title: t("hidden-subtree.advanced-title"), type: "contentWidget" } - ] - }, - { - id: "_help", - title: t("hidden-subtree.user-guide"), - type: "book", - icon: "bx-help-circle", - children: helpSubtree, - isExpanded: true - }, - buildHiddenSubtreeTemplates() - ] - }; -} - -interface CheckHiddenExtraOpts { - restoreNames?: boolean; -} - -function checkHiddenSubtree(force = false, extraOpts: CheckHiddenExtraOpts = {}) { - if (!force && !migrationService.isDbUpToDate()) { - // on-delete hook might get triggered during some future migration and cause havoc - log.info("Will not check hidden subtree until migration is finished."); - return; - } - - const helpSubtree = getHelpHiddenSubtreeData(); - if (!hiddenSubtreeDefinition || force) { - hiddenSubtreeDefinition = buildHiddenSubtreeDefinition(helpSubtree); - } - - checkHiddenSubtreeRecursively("root", hiddenSubtreeDefinition, extraOpts); - - try { - cleanUpHelp(helpSubtree); - } catch (e) { - // Non-critical operation should something go wrong. - console.error(e); - } -} - -/** - * Get all expected parent IDs for a given note ID from the hidden subtree definition - */ -function getExpectedParentIds(noteId: string, subtree: HiddenSubtreeItem): string[] { - const expectedParents: string[] = []; - - function traverse(item: HiddenSubtreeItem, parentId: string) { - if (item.id === noteId) { - expectedParents.push(parentId); - } - - if (item.children) { - for (const child of item.children) { - traverse(child, item.id); - } - } - } - - // Start traversal from root - if (subtree.id === noteId) { - expectedParents.push("root"); - } - - if (subtree.children) { - for (const child of subtree.children) { - traverse(child, subtree.id); - } - } - - return expectedParents; -} - -/** - * Check if a note ID is within the hidden subtree structure - */ -function isWithinHiddenSubtree(noteId: string): boolean { - // Consider a note to be within hidden subtree if it starts with underscore - // This is the convention used for hidden subtree notes - return noteId.startsWith("_") || noteId === "root"; -} - -function checkHiddenSubtreeRecursively(parentNoteId: string, item: HiddenSubtreeItem, extraOpts: CheckHiddenExtraOpts = {}) { - if (!item.id || !item.type || !item.title) { - throw new Error(`Item does not contain mandatory properties: ${JSON.stringify(item)}`); - } - - if (item.id.charAt(0) !== "_") { - throw new Error(`ID has to start with underscore, given '${item.id}'`); - } - - let note = becca.notes[item.id]; - let branch; - - if (!note) { - // Missing item, add it. - ({ note, branch } = noteService.createNewNote({ - noteId: item.id, - title: item.title, - type: item.type, - parentNoteId: parentNoteId, - content: item.content ?? "", - ignoreForbiddenParents: true - })); - } else { - // Existing item, check if it's in the right state. - branch = note.getParentBranches().find((branch) => branch.parentNoteId === parentNoteId); - - if (item.content && note.getContent() !== item.content) { - log.info(`Updating content of ${item.id}.`); - note.setContent(item.content); - } - - // Clean up any branches that shouldn't exist according to the meta definition - // For hidden subtree notes, we want to ensure they only exist in their designated locations - if (item.enforceBranches || item.id.startsWith("_help")) { - // If the note exists but doesn't have a branch in the expected parent, - // create the missing branch to ensure it's in the correct location - if (!branch) { - log.info(`Creating missing branch for note ${item.id} under parent ${parentNoteId}.`); - branch = new BBranch({ - noteId: item.id, - parentNoteId: parentNoteId, - notePosition: item.notePosition !== undefined ? item.notePosition : undefined, - isExpanded: item.isExpanded !== undefined ? item.isExpanded : false - }).save(); - } - - // Remove any branches that are not in the expected parent. - const expectedParents = getExpectedParentIds(item.id, hiddenSubtreeDefinition); - const currentBranches = note.getParentBranches(); - - for (const currentBranch of currentBranches) { - // Only delete branches that are not in the expected locations - // and are within the hidden subtree structure (avoid touching user-created clones) - if (!expectedParents.includes(currentBranch.parentNoteId) && - isWithinHiddenSubtree(currentBranch.parentNoteId)) { - log.info(`Removing unexpected branch for note '${item.id}' from parent '${currentBranch.parentNoteId}'`); - currentBranch.markAsDeleted(); - } - } - } - } - - const attrs = [...(item.attributes || [])]; - - if (item.icon) { - attrs.push({ type: "label", name: "iconClass", value: `bx ${item.icon}` }); - } - - if (item.type === "launcher") { - if (item.command) { - attrs.push({ type: "relation", name: "template", value: LBTPL_COMMAND }); - attrs.push({ type: "label", name: "command", value: item.command }); - } else if (item.builtinWidget) { - if (item.builtinWidget === "spacer") { - attrs.push({ type: "relation", name: "template", value: LBTPL_SPACER }); - attrs.push({ type: "label", name: "baseSize", value: item.baseSize }); - attrs.push({ type: "label", name: "growthFactor", value: item.growthFactor }); - } else { - attrs.push({ type: "relation", name: "template", value: LBTPL_WIDGET }); - } - - attrs.push({ type: "label", name: "builtinWidget", value: item.builtinWidget }); - } else if (item.targetNoteId) { - attrs.push({ type: "relation", name: "template", value: LBTPL_NOTE_LAUNCHER }); - attrs.push({ type: "relation", name: "target", value: item.targetNoteId }); - } else { - throw new Error(`No action defined for launcher ${JSON.stringify(item)}`); - } - } - - const shouldRestoreNames = extraOpts.restoreNames || note.noteId.startsWith("_help") || item.id.startsWith("_lb") || item.id.startsWith("_template"); - if (shouldRestoreNames && note.title !== item.title) { - note.title = item.title; - note.save(); - } - - if (note.type !== item.type) { - // enforce a correct note type - note.type = item.type; - note.save(); - } - - if (branch) { - // in case of launchers the branch ID is not preserved and should not be relied upon - launchers which move between - // visible and available will change branch since the branch's parent-child relationship is immutable - if (item.notePosition !== undefined && branch.notePosition !== item.notePosition) { - branch.notePosition = item.notePosition; - branch.save(); - } - - if (item.isExpanded !== undefined && branch.isExpanded !== item.isExpanded) { - branch.isExpanded = item.isExpanded; - branch.save(); - } - } - - // Enforce attribute structure if needed. - if (item.enforceAttributes) { - for (const attribute of note.getAttributes()) { - // Remove unwanted attributes. - const attrDef = attrs.find(a => a.name === attribute.name); - if (!attrDef) { - attribute.markAsDeleted(); - continue; - } - - // Ensure value is consistent. - if (attribute.value !== attrDef.value) { - note.setAttributeValueById(attribute.attributeId, attrDef.value); - } - } - } - - for (const attr of attrs) { - const attrId = note.noteId + "_" + attr.type.charAt(0) + attr.name; - - const existingAttribute = note.getAttributes().find((attr) => attr.attributeId === attrId); - - if (!existingAttribute) { - new BAttribute({ - attributeId: attrId, - noteId: note.noteId, - type: attr.type, - name: attr.name, - value: attr.value, - isInheritable: attr.isInheritable - }).save(); - } else if (attr.name === "docName" || (existingAttribute.noteId.startsWith("_help") && attr.name === "iconClass")) { - if (existingAttribute.value !== attr.value) { - log.info(`Updating attribute ${attrId} from "${existingAttribute.value}" to "${attr.value}"`); - existingAttribute.value = attr.value ?? ""; - existingAttribute.save(); - } - } - } - - for (const child of item.children || []) { - checkHiddenSubtreeRecursively(item.id, child, extraOpts); - } -} - -export default { - checkHiddenSubtree -}; +import { hidden_subtree } from "@triliumnext/core"; +export default hidden_subtree; diff --git a/apps/server/src/services/image.ts b/apps/server/src/services/image.ts index 387fcaec5..e57811f74 100644 --- a/apps/server/src/services/image.ts +++ b/apps/server/src/services/image.ts @@ -1,17 +1,18 @@ -"use strict"; + + +import { sanitize } from "@triliumnext/core"; +import imageType from "image-type"; +import isAnimated from "is-animated"; +import isSvg from "is-svg"; +import { Jimp } from "jimp"; +import sanitizeFilename from "sanitize-filename"; import becca from "../becca/becca.js"; import log from "./log.js"; -import protectedSessionService from "./protected_session.js"; import noteService from "./notes.js"; import optionService from "./options.js"; +import protectedSessionService from "./protected_session.js"; import sql from "./sql.js"; -import { Jimp } from "jimp"; -import imageType from "image-type"; -import sanitizeFilename from "sanitize-filename"; -import isSvg from "is-svg"; -import isAnimated from "is-animated"; -import htmlSanitizer from "./html_sanitizer.js"; async function processImage(uploadBuffer: Buffer, originalName: string, shrinkImageSwitch: boolean) { const compressImages = optionService.getOptionBool("compressImages"); @@ -46,9 +47,9 @@ async function processImage(uploadBuffer: Buffer, originalName: string, shrinkIm async function getImageType(buffer: Buffer) { if (isSvg(buffer.toString())) { return { ext: "svg" }; - } else { - return (await imageType(buffer)) || { ext: "jpg" }; // optimistic JPG default - } + } + return (await imageType(buffer)) || { ext: "jpg" }; // optimistic JPG default + } function getImageMimeFromExtension(ext: string) { @@ -60,7 +61,7 @@ function getImageMimeFromExtension(ext: string) { function updateImage(noteId: string, uploadBuffer: Buffer, originalName: string) { log.info(`Updating image ${noteId}: ${originalName}`); - originalName = htmlSanitizer.sanitize(originalName); + originalName = sanitize.sanitizeHtml(originalName); const note = becca.getNote(noteId); if (!note) { diff --git a/apps/server/src/services/import/enex.ts b/apps/server/src/services/import/enex.ts index 5f9166fce..d50f6e417 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 e91670df6..dec4a68e7 100644 --- a/apps/server/src/services/import/markdown.ts +++ b/apps/server/src/services/import/markdown.ts @@ -2,10 +2,10 @@ import { getMimeTypeFromMarkdownName, MIME_TYPE_AUTO } from "@triliumnext/commons"; import { normalizeMimeTypeForCKEditor } from "@triliumnext/commons"; +import { sanitize } from "@triliumnext/core"; import { parse, Renderer, type Tokens,use } from "marked"; import { ADMONITION_TYPE_MAPPINGS } from "../export/markdown.js"; -import htmlSanitizer from "../html_sanitizer.js"; import utils from "../utils.js"; import wikiLinkInternalLink from "./markdown/wikilink_internal_link.js"; import wikiLinkTransclusion from "./markdown/wikilink_transclusion.js"; @@ -151,7 +151,7 @@ function renderToHtml(content: string, title: string) { // h1 handling needs to come before sanitization html = importUtils.handleH1(html, title); - html = htmlSanitizer.sanitize(html); + html = sanitize.sanitizeHtml(html); // Add a trailing semicolon to CSS styles. html = html.replaceAll(/(<(img|figure|col).*?style=".*?)"/g, "$1;\""); diff --git a/apps/server/src/services/import/opml.ts b/apps/server/src/services/import/opml.ts index 130eb8197..d9a2ed8fb 100644 --- a/apps/server/src/services/import/opml.ts +++ b/apps/server/src/services/import/opml.ts @@ -1,11 +1,12 @@ -"use strict"; -import noteService from "../../services/notes.js"; + +import { sanitize } from "@triliumnext/core"; import xml2js from "xml2js"; -import protectedSessionService from "../protected_session.js"; -import htmlSanitizer from "../html_sanitizer.js"; -import type TaskContext from "../task_context.js"; + import type BNote from "../../becca/entities/bnote.js"; +import noteService from "../../services/notes.js"; +import protectedSessionService from "../protected_session.js"; +import type TaskContext from "../task_context.js"; const parseString = xml2js.parseString; interface OpmlXml { @@ -29,8 +30,8 @@ interface OpmlOutline { } async function importOpml(taskContext: TaskContext<"importNotes">, fileBuffer: string | Buffer, parentNote: BNote) { - const xml = await new Promise(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 ac52a43f4..942914027 100644 --- a/apps/server/src/services/import/single.ts +++ b/apps/server/src/services/import/single.ts @@ -1,18 +1,16 @@ -"use strict"; +import type { NoteType } from "@triliumnext/commons"; +import { sanitize } from "@triliumnext/core"; import type BNote from "../../becca/entities/bnote.js"; -import type TaskContext from "../task_context.js"; - -import noteService from "../../services/notes.js"; import imageService from "../../services/image.js"; +import noteService from "../../services/notes.js"; +import { getNoteTitle, processStringOrBuffer } from "../../services/utils.js"; import protectedSessionService from "../protected_session.js"; +import type TaskContext from "../task_context.js"; +import type { File } from "./common.js"; import markdownService from "./markdown.js"; import mimeService from "./mime.js"; -import { getNoteTitle, processStringOrBuffer } from "../../services/utils.js"; import importUtils from "./utils.js"; -import htmlSanitizer from "../html_sanitizer.js"; -import type { File } from "./common.js"; -import type { NoteType } from "@triliumnext/commons"; function importSingleFile(taskContext: TaskContext<"importNotes">, file: File, parentNote: BNote) { const mime = mimeService.getMime(file.originalname) || file.mimetype; @@ -88,7 +86,7 @@ function importCodeNote(taskContext: TaskContext<"importNotes">, file: File, par title, content, type, - mime: mime, + mime, isProtected: parentNote.isProtected && protectedSessionService.isProtectedSessionAvailable() }); @@ -106,7 +104,7 @@ function importCustomType(taskContext: TaskContext<"importNotes">, file: File, p title, content, type, - mime: mime, + mime, isProtected: parentNote.isProtected && protectedSessionService.isProtectedSessionAvailable() }); @@ -157,7 +155,7 @@ function importMarkdown(taskContext: TaskContext<"importNotes">, file: File, par let htmlContent = markdownService.renderToHtml(markdownContent, title); if (taskContext.data?.safeImport) { - htmlContent = htmlSanitizer.sanitize(htmlContent); + htmlContent = sanitize.sanitizeHtml(htmlContent); } const { note } = noteService.createNewNote({ @@ -185,7 +183,7 @@ function importHtml(taskContext: TaskContext<"importNotes">, file: File, parentN content = importUtils.handleH1(content, title); if (taskContext?.data?.safeImport) { - content = htmlSanitizer.sanitize(content); + content = sanitize.sanitizeHtml(content); } const { note } = noteService.createNewNote({ @@ -214,7 +212,7 @@ function importAttachment(taskContext: TaskContext<"importNotes">, file: File, p title: file.originalname, content: file.buffer, role: "file", - mime: mime + mime }); taskContext.increaseProgressCount(); diff --git a/apps/server/src/services/import/zip.ts b/apps/server/src/services/import/zip.ts index c1ac90b91..da27376d9 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 31e620a8f..000000000 --- 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 326672eeb..d6d70b6bc 100644 --- a/apps/server/src/services/keyboard_actions.ts +++ b/apps/server/src/services/keyboard_actions.ts @@ -1,882 +1,2 @@ -"use strict"; - -import optionService from "./options.js"; -import log from "./log.js"; -import { isElectron, isMac } from "./utils.js"; -import type { ActionKeyboardShortcut, KeyboardShortcut } from "@triliumnext/commons"; -import { t } from "i18next"; - -function getDefaultKeyboardActions() { - if (!t("keyboard_actions.note-navigation")) { - throw new Error("Keyboard actions loaded before translations."); - } - - const DEFAULT_KEYBOARD_ACTIONS: KeyboardShortcut[] = [ - { - separator: t("keyboard_actions.note-navigation") - }, - { - actionName: "backInNoteHistory", - friendlyName: t("keyboard_action_names.back-in-note-history"), - iconClass: "bx bxs-chevron-left", - // Mac has a different history navigation shortcuts - https://github.com/zadam/trilium/issues/376 - defaultShortcuts: isMac ? ["CommandOrControl+["] : ["Alt+Left"], - description: t("keyboard_actions.back-in-note-history"), - scope: "window" - }, - { - actionName: "forwardInNoteHistory", - friendlyName: t("keyboard_action_names.forward-in-note-history"), - iconClass: "bx bxs-chevron-right", - // Mac has a different history navigation shortcuts - https://github.com/zadam/trilium/issues/376 - defaultShortcuts: isMac ? ["CommandOrControl+]"] : ["Alt+Right"], - description: t("keyboard_actions.forward-in-note-history"), - scope: "window" - }, - { - actionName: "jumpToNote", - friendlyName: t("keyboard_action_names.jump-to-note"), - defaultShortcuts: ["CommandOrControl+J"], - description: t("keyboard_actions.open-jump-to-note-dialog"), - scope: "window", - ignoreFromCommandPalette: true - }, - { - actionName: "openTodayNote", - friendlyName: t("hidden-subtree.open-today-journal-note-title"), - iconClass: "bx bx-calendar", - defaultShortcuts: [], - description: t("hidden-subtree.open-today-journal-note-title"), - scope: "window" - }, - { - actionName: "commandPalette", - friendlyName: t("keyboard_action_names.command-palette"), - defaultShortcuts: ["CommandOrControl+Shift+J"], - description: t("keyboard_actions.open-command-palette"), - scope: "window", - ignoreFromCommandPalette: true - }, - { - actionName: "scrollToActiveNote", - friendlyName: t("keyboard_action_names.scroll-to-active-note"), - defaultShortcuts: ["CommandOrControl+."], - iconClass: "bx bx-current-location", - description: t("keyboard_actions.scroll-to-active-note"), - scope: "window" - }, - { - actionName: "quickSearch", - friendlyName: t("keyboard_action_names.quick-search"), - iconClass: "bx bx-search", - defaultShortcuts: ["CommandOrControl+S"], - description: t("keyboard_actions.quick-search"), - scope: "window" - }, - { - actionName: "searchInSubtree", - friendlyName: t("keyboard_action_names.search-in-subtree"), - defaultShortcuts: ["CommandOrControl+Shift+S"], - iconClass: "bx bx-search-alt", - description: t("keyboard_actions.search-in-subtree"), - scope: "note-tree" - }, - { - actionName: "expandSubtree", - friendlyName: t("keyboard_action_names.expand-subtree"), - defaultShortcuts: [], - iconClass: "bx bx-layer-plus", - description: t("keyboard_actions.expand-subtree"), - scope: "note-tree" - }, - { - actionName: "collapseTree", - friendlyName: t("keyboard_action_names.collapse-tree"), - defaultShortcuts: ["Alt+C"], - iconClass: "bx bx-layer-minus", - description: t("keyboard_actions.collapse-tree"), - scope: "window" - }, - { - actionName: "collapseSubtree", - friendlyName: t("keyboard_action_names.collapse-subtree"), - iconClass: "bx bxs-layer-minus", - defaultShortcuts: ["Alt+-"], - description: t("keyboard_actions.collapse-subtree"), - scope: "note-tree" - }, - { - actionName: "sortChildNotes", - friendlyName: t("keyboard_action_names.sort-child-notes"), - iconClass: "bx bx-sort-down", - defaultShortcuts: ["Alt+S"], - description: t("keyboard_actions.sort-child-notes"), - scope: "note-tree" - }, - - { - separator: t("keyboard_actions.creating-and-moving-notes") - }, - { - actionName: "createNoteAfter", - friendlyName: t("keyboard_action_names.create-note-after"), - iconClass: "bx bx-plus", - defaultShortcuts: ["CommandOrControl+O"], - description: t("keyboard_actions.create-note-after"), - scope: "window" - }, - { - actionName: "createNoteInto", - friendlyName: t("keyboard_action_names.create-note-into"), - iconClass: "bx bx-plus", - defaultShortcuts: ["CommandOrControl+P"], - description: t("keyboard_actions.create-note-into"), - scope: "window" - }, - { - actionName: "createNoteIntoInbox", - friendlyName: t("keyboard_action_names.create-note-into-inbox"), - iconClass: "bx bxs-inbox", - defaultShortcuts: ["global:CommandOrControl+Alt+P"], - description: t("keyboard_actions.create-note-into-inbox"), - scope: "window" - }, - { - actionName: "deleteNotes", - friendlyName: t("keyboard_action_names.delete-notes"), - iconClass: "bx bx-trash", - defaultShortcuts: ["Delete"], - description: t("keyboard_actions.delete-note"), - scope: "note-tree" - }, - { - actionName: "moveNoteUp", - friendlyName: t("keyboard_action_names.move-note-up"), - iconClass: "bx bx-up-arrow-alt", - defaultShortcuts: isMac ? ["Alt+Up"] : ["CommandOrControl+Up"], - description: t("keyboard_actions.move-note-up"), - scope: "note-tree" - }, - { - actionName: "moveNoteDown", - friendlyName: t("keyboard_action_names.move-note-down"), - iconClass: "bx bx-down-arrow-alt", - defaultShortcuts: isMac ? ["Alt+Down"] : ["CommandOrControl+Down"], - description: t("keyboard_actions.move-note-down"), - scope: "note-tree" - }, - { - actionName: "moveNoteUpInHierarchy", - friendlyName: t("keyboard_action_names.move-note-up-in-hierarchy"), - iconClass: "bx bx-arrow-from-bottom", - defaultShortcuts: isMac ? ["Alt+Left"] : ["CommandOrControl+Left"], - description: t("keyboard_actions.move-note-up-in-hierarchy"), - scope: "note-tree" - }, - { - actionName: "moveNoteDownInHierarchy", - friendlyName: t("keyboard_action_names.move-note-down-in-hierarchy"), - iconClass: "bx bx-arrow-from-top", - defaultShortcuts: isMac ? ["Alt+Right"] : ["CommandOrControl+Right"], - description: t("keyboard_actions.move-note-down-in-hierarchy"), - scope: "note-tree" - }, - { - actionName: "editNoteTitle", - friendlyName: t("keyboard_action_names.edit-note-title"), - iconClass: "bx bx-rename", - defaultShortcuts: ["Enter"], - description: t("keyboard_actions.edit-note-title"), - scope: "note-tree" - }, - { - actionName: "editBranchPrefix", - friendlyName: t("keyboard_action_names.edit-branch-prefix"), - iconClass: "bx bx-rename", - defaultShortcuts: ["F2"], - description: t("keyboard_actions.edit-branch-prefix"), - scope: "note-tree" - }, - { - actionName: "cloneNotesTo", - friendlyName: t("keyboard_action_names.clone-notes-to"), - iconClass: "bx bx-duplicate", - defaultShortcuts: ["CommandOrControl+Shift+C"], - description: t("keyboard_actions.clone-notes-to"), - scope: "window" - }, - { - actionName: "moveNotesTo", - friendlyName: t("keyboard_action_names.move-notes-to"), - iconClass: "bx bx-transfer", - defaultShortcuts: ["CommandOrControl+Shift+X"], - description: t("keyboard_actions.move-notes-to"), - scope: "window" - }, - - { - separator: t("keyboard_actions.note-clipboard") - }, - - { - actionName: "copyNotesToClipboard", - friendlyName: t("keyboard_action_names.copy-notes-to-clipboard"), - iconClass: "bx bx-copy", - defaultShortcuts: ["CommandOrControl+C"], - description: t("keyboard_actions.copy-notes-to-clipboard"), - scope: "note-tree" - }, - { - actionName: "pasteNotesFromClipboard", - friendlyName: t("keyboard_action_names.paste-notes-from-clipboard"), - iconClass: "bx bx-paste", - defaultShortcuts: ["CommandOrControl+V"], - description: t("keyboard_actions.paste-notes-from-clipboard"), - scope: "note-tree" - }, - { - actionName: "cutNotesToClipboard", - friendlyName: t("keyboard_action_names.cut-notes-to-clipboard"), - iconClass: "bx bx-cut", - defaultShortcuts: ["CommandOrControl+X"], - description: t("keyboard_actions.cut-notes-to-clipboard"), - scope: "note-tree" - }, - { - actionName: "selectAllNotesInParent", - friendlyName: t("keyboard_action_names.select-all-notes-in-parent"), - iconClass: "bx bx-select-multiple", - defaultShortcuts: ["CommandOrControl+A"], - description: t("keyboard_actions.select-all-notes-in-parent"), - scope: "note-tree" - }, - { - actionName: "addNoteAboveToSelection", - friendlyName: t("keyboard_action_names.add-note-above-to-selection"), - defaultShortcuts: ["Shift+Up"], - description: t("keyboard_actions.add-note-above-to-the-selection"), - scope: "note-tree", - ignoreFromCommandPalette: true - }, - { - actionName: "addNoteBelowToSelection", - friendlyName: t("keyboard_action_names.add-note-below-to-selection"), - defaultShortcuts: ["Shift+Down"], - description: t("keyboard_actions.add-note-below-to-selection"), - scope: "note-tree", - ignoreFromCommandPalette: true - }, - { - actionName: "duplicateSubtree", - friendlyName: t("keyboard_action_names.duplicate-subtree"), - iconClass: "bx bx-outline", - defaultShortcuts: [], - description: t("keyboard_actions.duplicate-subtree"), - scope: "note-tree" - }, - - { - separator: t("keyboard_actions.tabs-and-windows") - }, - { - actionName: "openNewTab", - friendlyName: t("keyboard_action_names.open-new-tab"), - iconClass: "bx bx-plus", - defaultShortcuts: isElectron ? ["CommandOrControl+T"] : [], - description: t("keyboard_actions.open-new-tab"), - scope: "window" - }, - { - actionName: "closeActiveTab", - friendlyName: t("keyboard_action_names.close-active-tab"), - iconClass: "bx bx-minus", - defaultShortcuts: isElectron ? ["CommandOrControl+W"] : [], - description: t("keyboard_actions.close-active-tab"), - scope: "window" - }, - { - actionName: "reopenLastTab", - friendlyName: t("keyboard_action_names.reopen-last-tab"), - iconClass: "bx bx-undo", - defaultShortcuts: isElectron ? ["CommandOrControl+Shift+T"] : [], - isElectronOnly: true, - description: t("keyboard_actions.reopen-last-tab"), - scope: "window" - }, - { - actionName: "activateNextTab", - friendlyName: t("keyboard_action_names.activate-next-tab"), - iconClass: "bx bx-skip-next", - defaultShortcuts: isElectron ? ["CommandOrControl+Tab", "CommandOrControl+PageDown"] : [], - description: t("keyboard_actions.activate-next-tab"), - scope: "window" - }, - { - actionName: "activatePreviousTab", - friendlyName: t("keyboard_action_names.activate-previous-tab"), - iconClass: "bx bx-skip-previous", - defaultShortcuts: isElectron ? ["CommandOrControl+Shift+Tab", "CommandOrControl+PageUp"] : [], - description: t("keyboard_actions.activate-previous-tab"), - scope: "window" - }, - { - actionName: "openNewWindow", - friendlyName: t("keyboard_action_names.open-new-window"), - iconClass: "bx bx-window-open", - defaultShortcuts: [], - description: t("keyboard_actions.open-new-window"), - scope: "window" - }, - { - actionName: "toggleTray", - friendlyName: t("keyboard_action_names.toggle-system-tray-icon"), - iconClass: "bx bx-show", - defaultShortcuts: [], - isElectronOnly: true, - description: t("keyboard_actions.toggle-tray"), - scope: "window" - }, - { - actionName: "toggleZenMode", - friendlyName: t("keyboard_action_names.toggle-zen-mode"), - iconClass: "bx bxs-yin-yang", - defaultShortcuts: ["F9"], - description: t("keyboard_actions.toggle-zen-mode"), - scope: "window" - }, - { - actionName: "firstTab", - friendlyName: t("keyboard_action_names.switch-to-first-tab"), - iconClass: "bx bx-rectangle", - defaultShortcuts: ["CommandOrControl+1"], - description: t("keyboard_actions.first-tab"), - scope: "window" - }, - { - actionName: "secondTab", - friendlyName: t("keyboard_action_names.switch-to-second-tab"), - iconClass: "bx bx-rectangle", - defaultShortcuts: ["CommandOrControl+2"], - description: t("keyboard_actions.second-tab"), - scope: "window" - }, - { - actionName: "thirdTab", - friendlyName: t("keyboard_action_names.switch-to-third-tab"), - iconClass: "bx bx-rectangle", - defaultShortcuts: ["CommandOrControl+3"], - description: t("keyboard_actions.third-tab"), - scope: "window" - }, - { - actionName: "fourthTab", - friendlyName: t("keyboard_action_names.switch-to-fourth-tab"), - iconClass: "bx bx-rectangle", - defaultShortcuts: ["CommandOrControl+4"], - description: t("keyboard_actions.fourth-tab"), - scope: "window" - }, - { - actionName: "fifthTab", - friendlyName: t("keyboard_action_names.switch-to-fifth-tab"), - iconClass: "bx bx-rectangle", - defaultShortcuts: ["CommandOrControl+5"], - description: t("keyboard_actions.fifth-tab"), - scope: "window" - }, - { - actionName: "sixthTab", - friendlyName: t("keyboard_action_names.switch-to-sixth-tab"), - iconClass: "bx bx-rectangle", - defaultShortcuts: ["CommandOrControl+6"], - description: t("keyboard_actions.sixth-tab"), - scope: "window" - }, - { - actionName: "seventhTab", - friendlyName: t("keyboard_action_names.switch-to-seventh-tab"), - iconClass: "bx bx-rectangle", - defaultShortcuts: ["CommandOrControl+7"], - description: t("keyboard_actions.seventh-tab"), - scope: "window" - }, - { - actionName: "eigthTab", - friendlyName: t("keyboard_action_names.switch-to-eighth-tab"), - iconClass: "bx bx-rectangle", - defaultShortcuts: ["CommandOrControl+8"], - description: t("keyboard_actions.eight-tab"), - scope: "window" - }, - { - actionName: "ninthTab", - friendlyName: t("keyboard_action_names.switch-to-ninth-tab"), - iconClass: "bx bx-rectangle", - defaultShortcuts: ["CommandOrControl+9"], - description: t("keyboard_actions.ninth-tab"), - scope: "window" - }, - { - actionName: "lastTab", - friendlyName: t("keyboard_action_names.switch-to-last-tab"), - iconClass: "bx bx-rectangle", - defaultShortcuts: ["CommandOrControl+0"], - description: t("keyboard_actions.last-tab"), - scope: "window" - }, - - { - separator: t("keyboard_actions.dialogs") - }, - { - friendlyName: t("keyboard_action_names.show-note-source"), - actionName: "showNoteSource", - iconClass: "bx bx-code", - defaultShortcuts: [], - description: t("keyboard_actions.show-note-source"), - scope: "window" - }, - { - friendlyName: t("keyboard_action_names.show-options"), - actionName: "showOptions", - iconClass: "bx bx-cog", - defaultShortcuts: [], - description: t("keyboard_actions.show-options"), - scope: "window" - }, - { - friendlyName: t("keyboard_action_names.show-revisions"), - actionName: "showRevisions", - iconClass: "bx bx-history", - defaultShortcuts: [], - description: t("keyboard_actions.show-revisions"), - scope: "window" - }, - { - friendlyName: t("keyboard_action_names.show-recent-changes"), - actionName: "showRecentChanges", - iconClass: "bx bx-history", - defaultShortcuts: [], - description: t("keyboard_actions.show-recent-changes"), - scope: "window" - }, - { - friendlyName: t("keyboard_action_names.show-sql-console"), - actionName: "showSQLConsole", - iconClass: "bx bx-data", - defaultShortcuts: ["Alt+O"], - description: t("keyboard_actions.show-sql-console"), - scope: "window" - }, - { - friendlyName: t("keyboard_action_names.show-backend-log"), - actionName: "showBackendLog", - iconClass: "bx bx-detail", - defaultShortcuts: [], - description: t("keyboard_actions.show-backend-log"), - scope: "window" - }, - { - friendlyName: t("keyboard_action_names.show-help"), - actionName: "showHelp", - iconClass: "bx bx-help-circle", - defaultShortcuts: ["F1"], - description: t("keyboard_actions.show-help"), - scope: "window" - }, - { - friendlyName: t("keyboard_action_names.show-cheatsheet"), - actionName: "showCheatsheet", - iconClass: "bx bxs-keyboard", - defaultShortcuts: ["Shift+F1"], - description: t("keyboard_actions.show-cheatsheet"), - scope: "window" - }, - - { - separator: t("keyboard_actions.text-note-operations") - }, - - { - friendlyName: t("keyboard_action_names.add-link-to-text"), - actionName: "addLinkToText", - iconClass: "bx bx-link", - defaultShortcuts: ["CommandOrControl+L"], - description: t("keyboard_actions.add-link-to-text"), - scope: "text-detail" - }, - { - friendlyName: t("keyboard_action_names.follow-link-under-cursor"), - actionName: "followLinkUnderCursor", - iconClass: "bx bx-link-external", - defaultShortcuts: ["CommandOrControl+Enter"], - description: t("keyboard_actions.follow-link-under-cursor"), - scope: "text-detail" - }, - { - friendlyName: t("keyboard_action_names.insert-date-and-time-to-text"), - actionName: "insertDateTimeToText", - iconClass: "bx bx-calendar-event", - defaultShortcuts: ["Alt+T"], - description: t("keyboard_actions.insert-date-and-time-to-text"), - scope: "text-detail" - }, - { - friendlyName: t("keyboard_action_names.paste-markdown-into-text"), - actionName: "pasteMarkdownIntoText", - iconClass: "bx bxl-markdown", - defaultShortcuts: [], - description: t("keyboard_actions.paste-markdown-into-text"), - scope: "text-detail" - }, - { - friendlyName: t("keyboard_action_names.cut-into-note"), - actionName: "cutIntoNote", - iconClass: "bx bx-cut", - defaultShortcuts: [], - description: t("keyboard_actions.cut-into-note"), - scope: "text-detail" - }, - { - friendlyName: t("keyboard_action_names.add-include-note-to-text"), - actionName: "addIncludeNoteToText", - iconClass: "bx bx-note", - defaultShortcuts: [], - description: t("keyboard_actions.add-include-note-to-text"), - scope: "text-detail" - }, - { - friendlyName: t("keyboard_action_names.edit-read-only-note"), - actionName: "editReadOnlyNote", - iconClass: "bx bx-edit-alt", - defaultShortcuts: [], - description: t("keyboard_actions.edit-readonly-note"), - scope: "window" - }, - - { - separator: t("keyboard_actions.attributes-labels-and-relations") - }, - - { - friendlyName: t("keyboard_action_names.add-new-label"), - actionName: "addNewLabel", - iconClass: "bx bx-hash", - defaultShortcuts: ["Alt+L"], - description: t("keyboard_actions.add-new-label"), - scope: "window" - }, - { - friendlyName: t("keyboard_action_names.add-new-relation"), - actionName: "addNewRelation", - iconClass: "bx bx-transfer", - defaultShortcuts: ["Alt+R"], - description: t("keyboard_actions.create-new-relation"), - scope: "window" - }, - - { - separator: t("keyboard_actions.ribbon-tabs") - }, - - { - friendlyName: t("keyboard_action_names.toggle-ribbon-tab-classic-editor"), - actionName: "toggleRibbonTabClassicEditor", - iconClass: "bx bx-text", - defaultShortcuts: [], - description: t("keyboard_actions.toggle-classic-editor-toolbar"), - scope: "window" - }, - { - actionName: "toggleRibbonTabBasicProperties", - friendlyName: t("keyboard_action_names.toggle-ribbon-tab-basic-properties"), - iconClass: "bx bx-slider", - defaultShortcuts: [], - description: t("keyboard_actions.toggle-basic-properties"), - scope: "window" - }, - { - actionName: "toggleRibbonTabBookProperties", - friendlyName: t("keyboard_action_names.toggle-ribbon-tab-book-properties"), - iconClass: "bx bx-book", - defaultShortcuts: [], - description: t("keyboard_actions.toggle-book-properties"), - scope: "window" - }, - { - actionName: "toggleRibbonTabFileProperties", - friendlyName: t("keyboard_action_names.toggle-ribbon-tab-file-properties"), - iconClass: "bx bx-file", - defaultShortcuts: [], - description: t("keyboard_actions.toggle-file-properties"), - scope: "window" - }, - { - actionName: "toggleRibbonTabImageProperties", - friendlyName: t("keyboard_action_names.toggle-ribbon-tab-image-properties"), - iconClass: "bx bx-image", - defaultShortcuts: [], - description: t("keyboard_actions.toggle-image-properties"), - scope: "window" - }, - { - actionName: "toggleRibbonTabOwnedAttributes", - friendlyName: t("keyboard_action_names.toggle-ribbon-tab-owned-attributes"), - iconClass: "bx bx-list-check", - defaultShortcuts: ["Alt+A"], - description: t("keyboard_actions.toggle-owned-attributes"), - scope: "window" - }, - { - actionName: "toggleRibbonTabInheritedAttributes", - friendlyName: t("keyboard_action_names.toggle-ribbon-tab-inherited-attributes"), - iconClass: "bx bx-list-plus", - defaultShortcuts: [], - description: t("keyboard_actions.toggle-inherited-attributes"), - scope: "window" - }, - // TODO: Remove or change since promoted attributes have been changed. - { - actionName: "toggleRibbonTabPromotedAttributes", - friendlyName: t("keyboard_action_names.toggle-ribbon-tab-promoted-attributes"), - iconClass: "bx bx-star", - defaultShortcuts: [], - description: t("keyboard_actions.toggle-promoted-attributes"), - scope: "window" - }, - { - actionName: "toggleRibbonTabNoteMap", - friendlyName: t("keyboard_action_names.toggle-ribbon-tab-note-map"), - iconClass: "bx bxs-network-chart", - defaultShortcuts: [], - description: t("keyboard_actions.toggle-link-map"), - scope: "window" - }, - { - actionName: "toggleRibbonTabNoteInfo", - friendlyName: t("keyboard_action_names.toggle-ribbon-tab-note-info"), - iconClass: "bx bx-info-circle", - defaultShortcuts: [], - description: t("keyboard_actions.toggle-note-info"), - scope: "window" - }, - { - actionName: "toggleRibbonTabNotePaths", - friendlyName: t("keyboard_action_names.toggle-ribbon-tab-note-paths"), - iconClass: "bx bx-collection", - defaultShortcuts: [], - description: t("keyboard_actions.toggle-note-paths"), - scope: "window" - }, - { - actionName: "toggleRibbonTabSimilarNotes", - friendlyName: t("keyboard_action_names.toggle-ribbon-tab-similar-notes"), - iconClass: "bx bx-bar-chart", - defaultShortcuts: [], - description: t("keyboard_actions.toggle-similar-notes"), - scope: "window" - }, - - { - separator: t("keyboard_actions.other") - }, - - { - actionName: "toggleRightPane", - friendlyName: t("keyboard_action_names.toggle-right-pane"), - iconClass: "bx bx-dock-right", - defaultShortcuts: [], - description: t("keyboard_actions.toggle-right-pane"), - scope: "window" - }, - { - actionName: "printActiveNote", - friendlyName: t("keyboard_action_names.print-active-note"), - iconClass: "bx bx-printer", - defaultShortcuts: [], - description: t("keyboard_actions.print-active-note"), - scope: "window" - }, - { - actionName: "exportAsPdf", - friendlyName: t("keyboard_action_names.export-active-note-as-pdf"), - iconClass: "bx bxs-file-pdf", - defaultShortcuts: [], - description: t("keyboard_actions.export-as-pdf"), - scope: "window" - }, - { - actionName: "openNoteExternally", - friendlyName: t("keyboard_action_names.open-note-externally"), - iconClass: "bx bx-file-find", - defaultShortcuts: [], - description: t("keyboard_actions.open-note-externally"), - scope: "window" - }, - { - actionName: "renderActiveNote", - friendlyName: t("keyboard_action_names.render-active-note"), - iconClass: "bx bx-refresh", - defaultShortcuts: [], - description: t("keyboard_actions.render-active-note"), - scope: "window" - }, - { - actionName: "runActiveNote", - friendlyName: t("keyboard_action_names.run-active-note"), - iconClass: "bx bx-play", - defaultShortcuts: ["CommandOrControl+Enter"], - description: t("keyboard_actions.run-active-note"), - scope: "code-detail" - }, - { - actionName: "toggleNoteHoisting", - friendlyName: t("keyboard_action_names.toggle-note-hoisting"), - iconClass: "bx bx-chevrons-up", - defaultShortcuts: ["Alt+H"], - description: t("keyboard_actions.toggle-note-hoisting"), - scope: "window" - }, - { - actionName: "unhoist", - friendlyName: t("keyboard_action_names.unhoist-note"), - iconClass: "bx bx-door-open", - defaultShortcuts: ["Alt+U"], - description: t("keyboard_actions.unhoist"), - scope: "window" - }, - { - actionName: "reloadFrontendApp", - friendlyName: t("keyboard_action_names.reload-frontend-app"), - iconClass: "bx bx-refresh", - defaultShortcuts: ["F5", "CommandOrControl+R"], - description: t("keyboard_actions.reload-frontend-app"), - scope: "window" - }, - { - actionName: "openDevTools", - friendlyName: t("keyboard_action_names.open-developer-tools"), - iconClass: "bx bx-bug-alt", - defaultShortcuts: isElectron ? ["CommandOrControl+Shift+I"] : [], - isElectronOnly: true, - description: t("keyboard_actions.open-dev-tools"), - scope: "window" - }, - { - actionName: "findInText", - friendlyName: t("keyboard_action_names.find-in-text"), - iconClass: "bx bx-search", - defaultShortcuts: isElectron ? ["CommandOrControl+F"] : [], - description: t("keyboard_actions.find-in-text"), - scope: "window" - }, - { - actionName: "toggleLeftPane", - friendlyName: t("keyboard_action_names.toggle-left-pane"), - iconClass: "bx bx-sidebar", - defaultShortcuts: [], - description: t("keyboard_actions.toggle-left-note-tree-panel"), - scope: "window" - }, - { - actionName: "toggleFullscreen", - friendlyName: t("keyboard_action_names.toggle-full-screen"), - iconClass: "bx bx-fullscreen", - defaultShortcuts: ["F11"], - description: t("keyboard_actions.toggle-full-screen"), - scope: "window" - }, - { - actionName: "zoomOut", - friendlyName: t("keyboard_action_names.zoom-out"), - iconClass: "bx bx-zoom-out", - defaultShortcuts: isElectron ? ["CommandOrControl+-"] : [], - isElectronOnly: true, - description: t("keyboard_actions.zoom-out"), - scope: "window" - }, - { - actionName: "zoomIn", - friendlyName: t("keyboard_action_names.zoom-in"), - iconClass: "bx bx-zoom-in", - description: t("keyboard_actions.zoom-in"), - defaultShortcuts: isElectron ? ["CommandOrControl+="] : [], - isElectronOnly: true, - scope: "window" - }, - { - actionName: "zoomReset", - friendlyName: t("keyboard_action_names.reset-zoom-level"), - iconClass: "bx bx-search-alt", - description: t("keyboard_actions.reset-zoom-level"), - defaultShortcuts: isElectron ? ["CommandOrControl+0"] : [], - isElectronOnly: true, - scope: "window" - }, - { - actionName: "copyWithoutFormatting", - friendlyName: t("keyboard_action_names.copy-without-formatting"), - iconClass: "bx bx-copy-alt", - defaultShortcuts: ["CommandOrControl+Alt+C"], - description: t("keyboard_actions.copy-without-formatting"), - scope: "text-detail" - }, - { - actionName: "forceSaveRevision", - friendlyName: t("keyboard_action_names.force-save-revision"), - iconClass: "bx bx-save", - defaultShortcuts: [], - description: t("keyboard_actions.force-save-revision"), - scope: "window" - } - ]; - - /* - * Apply macOS-specific tweaks. - */ - const platformModifier = isMac ? "Meta" : "Ctrl"; - - for (const action of DEFAULT_KEYBOARD_ACTIONS) { - if ("defaultShortcuts" in action && action.defaultShortcuts) { - action.defaultShortcuts = action.defaultShortcuts.map((shortcut) => shortcut.replace("CommandOrControl", platformModifier)); - } - } - - return DEFAULT_KEYBOARD_ACTIONS; -} - -function getKeyboardActions() { - const actions: KeyboardShortcut[] = JSON.parse(JSON.stringify(getDefaultKeyboardActions())); - - for (const action of actions) { - if ("effectiveShortcuts" in action && action.effectiveShortcuts) { - action.effectiveShortcuts = action.defaultShortcuts ? action.defaultShortcuts.slice() : []; - } - } - - for (const option of optionService.getOptions()) { - if (option.name.startsWith("keyboardShortcuts")) { - let actionName = option.name.substring(17); - actionName = actionName.charAt(0).toLowerCase() + actionName.slice(1); - - const action = actions.find((ea) => "actionName" in ea && ea.actionName === actionName) as ActionKeyboardShortcut; - - if (action) { - try { - action.effectiveShortcuts = JSON.parse(option.value); - } catch (e) { - log.error(`Could not parse shortcuts for action ${actionName}`); - } - } else { - log.info(`Keyboard action ${actionName} found in database, but not in action definition.`); - } - } - } - - return actions; -} - -export default { - getDefaultKeyboardActions, - getKeyboardActions -}; +import { keyboard_actions } from "@triliumnext/core"; +export default keyboard_actions; diff --git a/apps/server/src/services/llm/ai_service_manager.ts b/apps/server/src/services/llm/ai_service_manager.ts index bd47b4327..5a1614647 100644 --- a/apps/server/src/services/llm/ai_service_manager.ts +++ b/apps/server/src/services/llm/ai_service_manager.ts @@ -1,32 +1,28 @@ -import options from '../options.js'; -import eventService from '../events.js'; -import type { AIService, ChatCompletionOptions, ChatResponse, Message } from './ai_interface.js'; -import { AnthropicService } from './providers/anthropic_service.js'; -import { ContextExtractor } from './context/index.js'; -import agentTools from './context_extractors/index.js'; -import contextService from './context/services/context_service.js'; import log from '../log.js'; -import { OllamaService } from './providers/ollama_service.js'; -import { OpenAIService } from './providers/openai_service.js'; - -// Import interfaces -import type { - ServiceProviders, - IAIServiceManager, - ProviderMetadata -} from './interfaces/ai_service_interfaces.js'; -import type { NoteSearchResult } from './interfaces/context_interfaces.js'; - +import options from '../options.js'; +import type { AIService, ChatCompletionOptions, ChatResponse, Message } from './ai_interface.js'; // Import new configuration system import { - getSelectedProvider, - parseModelIdentifier, - isAIEnabled, - getDefaultModelForProvider, clearConfigurationCache, + getDefaultModelForProvider, + getSelectedProvider, + isAIEnabled, + parseModelIdentifier, validateConfiguration } from './config/configuration_helpers.js'; +import { ContextExtractor } from './context/index.js'; +import contextService from './context/services/context_service.js'; +import agentTools from './context_extractors/index.js'; +// Import interfaces +import type { + IAIServiceManager, + ProviderMetadata, + ServiceProviders} from './interfaces/ai_service_interfaces.js'; import type { ProviderType } from './interfaces/configuration_interfaces.js'; +import type { NoteSearchResult } from './interfaces/context_interfaces.js'; +import { AnthropicService } from './providers/anthropic_service.js'; +import { OllamaService } from './providers/ollama_service.js'; +import { OpenAIService } from './providers/openai_service.js'; /** * Interface representing relevant note context @@ -173,7 +169,7 @@ export class AIServiceManager implements IAIServiceManager { /** * Get list of available providers */ - getAvailableProviders(): ServiceProviders[] { + getAvailableProviders(): ServiceProviders[] { this.ensureInitialized(); const allProviders: ServiceProviders[] = ['openai', 'anthropic', 'ollama']; diff --git a/apps/server/src/services/llm/context/modules/context_formatter.ts b/apps/server/src/services/llm/context/modules/context_formatter.ts index 58198c904..090df06c6 100644 --- a/apps/server/src/services/llm/context/modules/context_formatter.ts +++ b/apps/server/src/services/llm/context/modules/context_formatter.ts @@ -1,11 +1,12 @@ -import sanitizeHtml from 'sanitize-html'; +import { sanitize } from '@triliumnext/core'; + import log from '../../../log.js'; +import type { Message } from '../../ai_interface.js'; import { CONTEXT_PROMPTS, FORMATTING_PROMPTS } from '../../constants/llm_prompt_constants.js'; import { LLM_CONSTANTS } from '../../constants/provider_constants.js'; import type { IContextFormatter, NoteSearchResult } from '../../interfaces/context_interfaces.js'; -import modelCapabilitiesService from '../../model_capabilities_service.js'; import { calculateAvailableContextSize } from '../../interfaces/model_capabilities.js'; -import type { Message } from '../../ai_interface.js'; +import modelCapabilitiesService from '../../model_capabilities_service.js'; // Use constants from the centralized file // const CONTEXT_WINDOW = { @@ -44,7 +45,7 @@ export class ContextFormatter implements IContextFormatter { try { // Get model name from provider - let modelName = providerId; + const modelName = providerId; // Look up model capabilities const modelCapabilities = await modelCapabilitiesService.getChatModelCapabilities(modelName); @@ -59,9 +60,9 @@ export class ContextFormatter implements IContextFormatter { // Use the calculated size or fall back to constants const maxTotalLength = availableContextSize || ( providerId === 'openai' ? LLM_CONSTANTS.CONTEXT_WINDOW.OPENAI : - providerId === 'anthropic' ? LLM_CONSTANTS.CONTEXT_WINDOW.ANTHROPIC : - providerId === 'ollama' ? LLM_CONSTANTS.CONTEXT_WINDOW.OLLAMA : - LLM_CONSTANTS.CONTEXT_WINDOW.DEFAULT + providerId === 'anthropic' ? LLM_CONSTANTS.CONTEXT_WINDOW.ANTHROPIC : + providerId === 'ollama' ? LLM_CONSTANTS.CONTEXT_WINDOW.OLLAMA : + LLM_CONSTANTS.CONTEXT_WINDOW.DEFAULT ); // DEBUG: Log context window size @@ -239,11 +240,11 @@ export class ContextFormatter implements IContextFormatter { // Handle line breaks .replace(/<br\s*\/?>/gi, '\n'); - // Then use sanitize-html to remove remaining HTML - const sanitized = sanitizeHtml(contentWithMarkdown, { + // Then sanitize to remove remaining HTML + const sanitized = sanitize.sanitizeHtmlCustom(contentWithMarkdown, { allowedTags: [], // No tags allowed (strip all HTML) allowedAttributes: {}, // No attributes allowed - textFilter: function(text) { + textFilter(text) { return text .replace(/ /g, ' ') .replace(/</g, '<') @@ -264,7 +265,7 @@ export class ContextFormatter implements IContextFormatter { if (type === 'code' || mime?.includes('application/')) { // For code, limit to a reasonable size if (content.length > 2000) { - return content.substring(0, 2000) + '...\n\n[Content truncated for brevity]'; + return `${content.substring(0, 2000) }...\n\n[Content truncated for brevity]`; } return content; } @@ -288,7 +289,7 @@ export class ContextFormatter implements IContextFormatter { try { // First remove any HTML - let plaintext = sanitizeHtml(content, { + let plaintext = sanitize.sanitizeHtmlCustom(content, { allowedTags: [], allowedAttributes: {}, textFilter: (text) => text diff --git a/apps/server/src/services/llm/context/note_content.ts b/apps/server/src/services/llm/context/note_content.ts index 575aaadd2..2de9aec8e 100644 --- a/apps/server/src/services/llm/context/note_content.ts +++ b/apps/server/src/services/llm/context/note_content.ts @@ -1,4 +1,5 @@ -import sanitizeHtml from 'sanitize-html'; +import { sanitize } from '@triliumnext/core'; + import becca from '../../../becca/becca.js'; // Define interfaces for JSON structures @@ -98,13 +99,13 @@ export function formatNoteContent(content: string, type: string, mime: string, t switch (type) { case 'text': // Remove HTML formatting for text notes - formattedContent += sanitizeHtml(content); + formattedContent += sanitize.sanitizeHtml(content); break; case 'code': // For code, we'll handle this in code_handlers.ts // Just use basic formatting here - formattedContent += '```\n' + content + '\n```'; + formattedContent += `\`\`\`\n${ content }\n\`\`\``; break; case 'canvas': @@ -119,7 +120,7 @@ export function formatNoteContent(content: string, type: string, mime: string, t .filter((element) => element.type === 'text' && element.text) .map((element) => element.text as string); - formattedContent += 'Canvas content:\n' + texts.join('\n'); + formattedContent += `Canvas content:\n${ texts.join('\n')}`; } else { formattedContent += '[Empty canvas]'; } @@ -154,7 +155,7 @@ export function formatNoteContent(content: string, type: string, mime: string, t }; if (jsonContent.root) { - formattedContent += 'Mind map content:\n' + extractMindMapNodes(jsonContent.root).join('\n'); + formattedContent += `Mind map content:\n${ extractMindMapNodes(jsonContent.root).join('\n')}`; } else { formattedContent += '[Empty mind map]'; } @@ -178,14 +179,14 @@ export function formatNoteContent(content: string, type: string, mime: string, t let result = 'Relation map content:\n'; if (jsonContent.notes && Array.isArray(jsonContent.notes)) { - result += 'Notes: ' + jsonContent.notes + result += `Notes: ${ jsonContent.notes .map((note) => note.title || note.name) .filter(Boolean) - .join(', ') + '\n'; + .join(', ') }\n`; } if (jsonContent.relations && Array.isArray(jsonContent.relations)) { - result += 'Relations: ' + jsonContent.relations + result += `Relations: ${ jsonContent.relations .map((rel) => { const sourceNote = jsonContent.notes?.find((n) => n.noteId === rel.sourceNoteId); const targetNote = jsonContent.notes?.find((n) => n.noteId === rel.targetNoteId); @@ -193,7 +194,7 @@ export function formatNoteContent(content: string, type: string, mime: string, t const target = targetNote ? (targetNote.title || targetNote.name) : 'unknown'; return `${source} → ${rel.name || ''} → ${target}`; }) - .join('; '); + .join('; ')}`; } formattedContent += result; @@ -219,7 +220,7 @@ export function formatNoteContent(content: string, type: string, mime: string, t if (jsonContent.markers.length > 0) { result += jsonContent.markers .map((marker) => { - return `Location: ${marker.title || ''} (${marker.lat}, ${marker.lng})${marker.description ? ' - ' + marker.description : ''}`; + return `Location: ${marker.title || ''} (${marker.lat}, ${marker.lng})${marker.description ? ` - ${ marker.description}` : ''}`; }) .join('\n'); } else { @@ -242,7 +243,7 @@ export function formatNoteContent(content: string, type: string, mime: string, t case 'mermaid': // Format mermaid diagrams as code blocks - formattedContent += '```mermaid\n' + content + '\n```'; + formattedContent += `\`\`\`mermaid\n${ content }\n\`\`\``; break; case 'image': @@ -252,7 +253,7 @@ export function formatNoteContent(content: string, type: string, mime: string, t default: // For other notes, just use the content as is - formattedContent += sanitizeHtml(content); + formattedContent += sanitize.sanitizeHtml(content); } return formattedContent; @@ -265,7 +266,7 @@ export function sanitizeHtmlContent(html: string): string { if (!html) return ''; // Use sanitizeHtml to remove all HTML tags - let content = sanitizeHtml(html, { + let content = sanitize.sanitizeHtmlCustom(html, { allowedTags: [], allowedAttributes: {}, textFilter: (text) => { diff --git a/apps/server/src/services/llm/formatters/base_formatter.ts b/apps/server/src/services/llm/formatters/base_formatter.ts index fe4c97f42..f45c45963 100644 --- a/apps/server/src/services/llm/formatters/base_formatter.ts +++ b/apps/server/src/services/llm/formatters/base_formatter.ts @@ -1,16 +1,16 @@ -import sanitizeHtml from 'sanitize-html'; +import { sanitize } from '@triliumnext/core'; + import type { Message } from '../ai_interface.js'; -import type { MessageFormatter } from '../interfaces/message_formatter.js'; -import { DEFAULT_SYSTEM_PROMPT, PROVIDER_PROMPTS } from '../constants/llm_prompt_constants.js'; import { - HTML_ALLOWED_TAGS, - HTML_ALLOWED_ATTRIBUTES, - HTML_TRANSFORMS, - HTML_TO_MARKDOWN_PATTERNS, - HTML_ENTITY_REPLACEMENTS, ENCODING_FIXES, - FORMATTER_LOGS -} from '../constants/formatter_constants.js'; + FORMATTER_LOGS, + HTML_ALLOWED_ATTRIBUTES, + HTML_ALLOWED_TAGS, + HTML_ENTITY_REPLACEMENTS, + HTML_TO_MARKDOWN_PATTERNS, + HTML_TRANSFORMS} from '../constants/formatter_constants.js'; +import { DEFAULT_SYSTEM_PROMPT, PROVIDER_PROMPTS } from '../constants/llm_prompt_constants.js'; +import type { MessageFormatter } from '../interfaces/message_formatter.js'; /** * Base formatter with common functionality for all providers @@ -49,7 +49,7 @@ export abstract class BaseMessageFormatter implements MessageFormatter { const fixedContent = this.fixEncodingIssues(content); // Convert HTML to markdown for better readability - const cleaned = sanitizeHtml(fixedContent, { + const cleaned = sanitize.sanitizeHtmlCustom(fixedContent, { allowedTags: HTML_ALLOWED_TAGS.STANDARD, allowedAttributes: HTML_ALLOWED_ATTRIBUTES.STANDARD, transformTags: HTML_TRANSFORMS.STANDARD diff --git a/apps/server/src/services/llm/formatters/ollama_formatter.ts b/apps/server/src/services/llm/formatters/ollama_formatter.ts index eb780f760..a85ec718e 100644 --- a/apps/server/src/services/llm/formatters/ollama_formatter.ts +++ b/apps/server/src/services/llm/formatters/ollama_formatter.ts @@ -1,15 +1,15 @@ +import { sanitize } from '@triliumnext/core'; + +import log from '../../log.js'; import type { Message } from '../ai_interface.js'; -import { BaseMessageFormatter } from './base_formatter.js'; -import sanitizeHtml from 'sanitize-html'; +import { + FORMATTER_LOGS, + HTML_ALLOWED_ATTRIBUTES, + HTML_ALLOWED_TAGS, + OLLAMA_CLEANING} from '../constants/formatter_constants.js'; import { PROVIDER_PROMPTS } from '../constants/llm_prompt_constants.js'; import { LLM_CONSTANTS } from '../constants/provider_constants.js'; -import { - HTML_ALLOWED_TAGS, - HTML_ALLOWED_ATTRIBUTES, - OLLAMA_CLEANING, - FORMATTER_LOGS -} from '../constants/formatter_constants.js'; -import log from '../../log.js'; +import { BaseMessageFormatter } from './base_formatter.js'; /** * Ollama-specific message formatter @@ -196,7 +196,7 @@ export class OllamaMessageFormatter extends BaseMessageFormatter { // Then apply Ollama-specific aggressive cleaning // Remove any remaining HTML using sanitizeHtml while keeping our markers - let plaintext = sanitizeHtml(sanitized, { + let plaintext = sanitize.sanitizeHtmlCustom(sanitized, { allowedTags: HTML_ALLOWED_TAGS.NONE, allowedAttributes: HTML_ALLOWED_ATTRIBUTES.NONE, textFilter: (text) => text diff --git a/apps/server/src/services/llm/formatters/openai_formatter.ts b/apps/server/src/services/llm/formatters/openai_formatter.ts index d09a3675a..9d551e618 100644 --- a/apps/server/src/services/llm/formatters/openai_formatter.ts +++ b/apps/server/src/services/llm/formatters/openai_formatter.ts @@ -1,16 +1,16 @@ -import sanitizeHtml from 'sanitize-html'; +import { sanitize } from '@triliumnext/core'; + +import log from '../../log.js'; import type { Message } from '../ai_interface.js'; -import { BaseMessageFormatter } from './base_formatter.js'; +import { + FORMATTER_LOGS, + HTML_ALLOWED_ATTRIBUTES, + HTML_ALLOWED_TAGS, + HTML_ENTITY_REPLACEMENTS, + HTML_TO_MARKDOWN_PATTERNS} from '../constants/formatter_constants.js'; import { PROVIDER_PROMPTS } from '../constants/llm_prompt_constants.js'; import { LLM_CONSTANTS } from '../constants/provider_constants.js'; -import { - HTML_ALLOWED_TAGS, - HTML_ALLOWED_ATTRIBUTES, - HTML_TO_MARKDOWN_PATTERNS, - HTML_ENTITY_REPLACEMENTS, - FORMATTER_LOGS -} from '../constants/formatter_constants.js'; -import log from '../../log.js'; +import { BaseMessageFormatter } from './base_formatter.js'; /** * OpenAI-specific message formatter @@ -54,18 +54,18 @@ export class OpenAIMessageFormatter extends BaseMessageFormatter { // If we don't have explicit context but have a system prompt else if (!hasSystemMessage && systemPrompt) { let baseSystemPrompt = systemPrompt || PROVIDER_PROMPTS.COMMON.DEFAULT_ASSISTANT_INTRO; - + // Check if this is a tool-using conversation const hasPreviousToolCalls = messages.some(msg => msg.tool_calls && msg.tool_calls.length > 0); const hasToolResults = messages.some(msg => msg.role === 'tool'); const isToolUsingConversation = useTools || hasPreviousToolCalls || hasToolResults; - + // Add tool instructions for OpenAI when tools are being used if (isToolUsingConversation && PROVIDER_PROMPTS.OPENAI.TOOL_INSTRUCTIONS) { log.info('Adding tool instructions to system prompt for OpenAI'); baseSystemPrompt = `${baseSystemPrompt}\n\n${PROVIDER_PROMPTS.OPENAI.TOOL_INSTRUCTIONS}`; } - + formattedMessages.push({ role: 'system', content: baseSystemPrompt @@ -111,7 +111,7 @@ export class OpenAIMessageFormatter extends BaseMessageFormatter { try { // Convert HTML to Markdown for better readability - const cleaned = sanitizeHtml(content, { + const cleaned = sanitize.sanitizeHtmlCustom(content, { allowedTags: HTML_ALLOWED_TAGS.STANDARD, allowedAttributes: HTML_ALLOWED_ATTRIBUTES.STANDARD }); diff --git a/apps/server/src/services/llm/tools/read_note_tool.ts b/apps/server/src/services/llm/tools/read_note_tool.ts index ddcad559f..0230e59da 100644 --- a/apps/server/src/services/llm/tools/read_note_tool.ts +++ b/apps/server/src/services/llm/tools/read_note_tool.ts @@ -4,16 +4,16 @@ * This tool allows the LLM to read the content of a specific note. */ -import type { Tool, ToolHandler } from './tool_interfaces.js'; -import log from '../../log.js'; import becca from '../../../becca/becca.js'; +import log from '../../log.js'; +import type { Tool, ToolHandler } from './tool_interfaces.js'; // Define type for note response interface NoteResponse { noteId: string; title: string; type: string; - content: string | Buffer; + content: string | Uint8Array; attributes?: Array<{ name: string; value: string; diff --git a/apps/server/src/services/note-interface.ts b/apps/server/src/services/note-interface.ts deleted file mode 100644 index 5ebaa6dfa..000000000 --- a/apps/server/src/services/note-interface.ts +++ /dev/null @@ -1,27 +0,0 @@ -import type { NoteType } from "@triliumnext/commons"; - -export interface NoteParams { - /** optionally can force specific noteId */ - noteId?: string; - branchId?: string; - parentNoteId: string; - templateNoteId?: string; - title: string; - content: string | Buffer; - /** text, code, file, image, search, book, relationMap, canvas, webView */ - type: NoteType; - /** default value is derived from default mimes for type */ - mime?: string; - /** default is false */ - isProtected?: boolean; - /** default is false */ - isExpanded?: boolean; - /** default is empty string */ - prefix?: string; - /** default is the last existing notePosition in a parent + 10 */ - notePosition?: number; - dateCreated?: string; - utcDateCreated?: string; - ignoreForbiddenParents?: boolean; - target?: "into"; -} diff --git a/apps/server/src/services/note_types.ts b/apps/server/src/services/note_types.ts index 2aa86d0b6..ce3108512 100644 --- a/apps/server/src/services/note_types.ts +++ b/apps/server/src/services/note_types.ts @@ -1,34 +1,2 @@ -const noteTypes = [ - { type: "text", defaultMime: "text/html" }, - { type: "code", defaultMime: "text/plain" }, - { type: "render", defaultMime: "" }, - { type: "file", defaultMime: "application/octet-stream" }, - { type: "image", defaultMime: "" }, - { type: "search", defaultMime: "" }, - { type: "relationMap", defaultMime: "application/json" }, - { type: "book", defaultMime: "" }, - { type: "noteMap", defaultMime: "" }, - { type: "mermaid", defaultMime: "text/vnd.mermaid" }, - { type: "canvas", defaultMime: "application/json" }, - { type: "webView", defaultMime: "" }, - { type: "launcher", defaultMime: "" }, - { type: "doc", defaultMime: "" }, - { type: "contentWidget", defaultMime: "" }, - { type: "mindMap", defaultMime: "application/json" }, - { type: "aiChat", defaultMime: "application/json" } -]; - -function getDefaultMimeForNoteType(typeName: string) { - const typeRec = noteTypes.find((nt) => nt.type === typeName); - - if (!typeRec) { - throw new Error(`Cannot find note type '${typeName}'`); - } - - return typeRec.defaultMime; -} - -export default { - getNoteTypeNames: () => noteTypes.map((nt) => nt.type), - getDefaultMimeForNoteType -}; +import { note_types } from "@triliumnext/core"; +export default note_types; diff --git a/apps/server/src/services/notes.ts b/apps/server/src/services/notes.ts index 4964a5797..97756a383 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<string>(); - let match; - - const imgRegExp = /src="[^"]*api\/attachments\/([a-zA-Z0-9_]+)\/image/g; - while ((match = imgRegExp.exec(content))) { - foundAttachmentIds.add(match[1]); - } - - const linkRegExp = /href="[^"]+attachmentId=([a-zA-Z0-9_]+)/g; - while ((match = linkRegExp.exec(content))) { - foundAttachmentIds.add(match[1]); - } - - const attachments = note.getAttachments(); - - for (const attachment of attachments) { - const attachmentInContent = attachment.attachmentId && foundAttachmentIds.has(attachment.attachmentId); - - if (attachment.utcDateScheduledForErasureSince && attachmentInContent) { - attachment.utcDateScheduledForErasureSince = null; - attachment.save(); - } else if (!attachment.utcDateScheduledForErasureSince && !attachmentInContent) { - attachment.utcDateScheduledForErasureSince = dateUtils.utcNowDateTime(); - attachment.save(); - } - } - - const existingAttachmentIds = new Set<string | undefined>(attachments.map((att) => att.attachmentId)); - const unknownAttachmentIds = Array.from(foundAttachmentIds).filter((foundAttId) => !existingAttachmentIds.has(foundAttId)); - const unknownAttachments = becca.getAttachments(unknownAttachmentIds); - - for (const unknownAttachment of unknownAttachments) { - // the attachment belongs to a different note (was copy-pasted). Attachments can be linked only from the note - // which owns it, so either find an existing attachment having the same content or make a copy. - let localAttachment = note.getAttachments().find((att) => att.role === unknownAttachment.role && att.blobId === unknownAttachment.blobId); - - if (localAttachment) { - if (localAttachment.utcDateScheduledForErasureSince) { - // the attachment is for sure linked now, so reset the scheduled deletion - localAttachment.utcDateScheduledForErasureSince = null; - localAttachment.save(); - } - - log.info( - `Found equivalent attachment '${localAttachment.attachmentId}' of note '${note.noteId}' for the linked foreign attachment '${unknownAttachment.attachmentId}' of note '${unknownAttachment.ownerId}'` - ); - } else { - localAttachment = unknownAttachment.copy(); - localAttachment.ownerId = note.noteId; - localAttachment.setContent(unknownAttachment.getContent(), { forceSave: true }); - - ws.sendMessageToAllClients({ type: "toast", message: `Attachment '${localAttachment.title}' has been copied to note '${note.title}'.` }); - log.info(`Copied attachment '${unknownAttachment.attachmentId}' of note '${unknownAttachment.ownerId}' to new '${localAttachment.attachmentId}' of note '${note.noteId}'`); - } - - // replace image links - content = content.replace(`api/attachments/${unknownAttachment.attachmentId}/image`, `api/attachments/${localAttachment.attachmentId}/image`); - // replace reference links - content = content.replace( - new RegExp(`href="[^"]+attachmentId=${unknownAttachment.attachmentId}[^"]*"`, "g"), - `href="#root/${localAttachment.ownerId}?viewMode=attachments&attachmentId=${localAttachment.attachmentId}"` - ); - } - - return { - forceFrontendReload: unknownAttachments.length > 0, - content - }; -} - -function findImageLinks(content: string, foundLinks: FoundLink[]) { - const re = /src="[^"]*api\/images\/([a-zA-Z0-9_]+)\//g; - let match; - - while ((match = re.exec(content))) { - foundLinks.push({ - name: "imageLink", - value: match[1] - }); - } - - // removing absolute references to server to keep it working between instances, - // we also omit / at the beginning to keep the paths relative - return content.replace(/src="[^"]*\/api\/images\//g, 'src="api/images/'); -} - -function findInternalLinks(content: string, foundLinks: FoundLink[]) { - const re = /href="[^"]*#root[a-zA-Z0-9_\/]*\/([a-zA-Z0-9_]+)\/?"/g; - let match; - - while ((match = re.exec(content))) { - foundLinks.push({ - name: "internalLink", - value: match[1] - }); - } - - // removing absolute references to server to keep it working between instances - return content.replace(/href="[^"]*#root/g, 'href="#root'); -} - -function findIncludeNoteLinks(content: string, foundLinks: FoundLink[]) { - const re = /<section class="include-note[^>]+data-note-id="([a-zA-Z0-9_]+)"[^>]*>/g; - let match; - - while ((match = re.exec(content))) { - foundLinks.push({ - name: "includeNoteLink", - value: match[1] - }); - } - - return content; -} - -function findRelationMapLinks(content: string, foundLinks: FoundLink[]) { - try { - const obj = JSON.parse(content); - - for (const note of obj.notes) { - foundLinks.push({ - name: "relationMapLink", - value: note.noteId - }); - } - } catch (e: any) { - log.error("Could not scan for relation map links: " + e.message); - } -} - -const imageUrlToAttachmentIdMapping: Record<string, string> = {}; - -async function downloadImage(noteId: string, imageUrl: string) { - const unescapedUrl = unescapeHtml(imageUrl); - - try { - let imageBuffer: Buffer; - - if (imageUrl.toLowerCase().startsWith("file://")) { - imageBuffer = await new Promise((res, rej) => { - const localFilePath = imageUrl.substring("file://".length); - - return fs.readFile(localFilePath, (err, data) => { - if (err) { - rej(err); - } else { - res(data); - } - }); - }); - } else { - imageBuffer = await request.getImage(unescapedUrl); - } - - const parsedUrl = url.parse(unescapedUrl); - const title = path.basename(parsedUrl.pathname || ""); - - const attachment = imageService.saveImageToAttachment(noteId, imageBuffer, title, true, true); - - if (attachment.attachmentId) { - imageUrlToAttachmentIdMapping[imageUrl] = attachment.attachmentId; - } else { - log.error(`Download of '${imageUrl}' due to no attachment ID.`); - } - - log.info(`Download of '${imageUrl}' succeeded and was saved as image attachment '${attachment.attachmentId}' of note '${noteId}'`); - } catch (e: any) { - log.error(`Download of '${imageUrl}' for note '${noteId}' failed with error: ${e.message} ${e.stack}`); - } -} - -/** url => download promise */ -const downloadImagePromises: Record<string, Promise<void>> = {}; - -function replaceUrl(content: string, url: string, attachment: Attachment) { - const quotedUrl = quoteRegex(url); - - return content.replace(new RegExp(`\\s+src=[\"']${quotedUrl}[\"']`, "ig"), ` src="api/attachments/${attachment.attachmentId}/image/${encodeURIComponent(attachment.title)}"`); -} - -function downloadImages(noteId: string, content: string) { - const imageRe = /<img[^>]*?\ssrc=['"]([^'">]+)['"]/gi; - let imageMatch; - - while ((imageMatch = imageRe.exec(content))) { - const url = imageMatch[1]; - const inlineImageMatch = /^data:image\/[a-z]+;base64,/.exec(url); - - if (inlineImageMatch) { - const imageBase64 = url.substring(inlineImageMatch[0].length); - const imageBuffer = Buffer.from(imageBase64, "base64"); - - const attachment = imageService.saveImageToAttachment(noteId, imageBuffer, "inline image", true, true); - - const encodedTitle = encodeURIComponent(attachment.title); - - content = `${content.substring(0, imageMatch.index)}<img src="api/attachments/${attachment.attachmentId}/image/${encodedTitle}"${content.substring(imageMatch.index + imageMatch[0].length)}`; - } else if ( - !url.includes("api/images/") && - !/api\/attachments\/.+\/image\/?.*/.test(url) && - // this is an exception for the web clipper's "imageId" - (url.length !== 20 || url.toLowerCase().startsWith("http")) - ) { - if (!optionService.getOptionBool("downloadImagesAutomatically")) { - continue; - } - - if (url in imageUrlToAttachmentIdMapping) { - const attachment = becca.getAttachment(imageUrlToAttachmentIdMapping[url]); - - if (!attachment) { - delete imageUrlToAttachmentIdMapping[url]; - } else { - content = replaceUrl(content, url, attachment); - continue; - } - } - - if (url in downloadImagePromises) { - // download is already in progress - continue; - } - - // this is done asynchronously, it would be too slow to wait for the download - // given that save can be triggered very often - downloadImagePromises[url] = downloadImage(noteId, url); - } - } - - Promise.all(Object.values(downloadImagePromises)).then(() => { - setTimeout(() => { - // the normal expected flow of the offline image saving is that users will paste the image(s) - // which will get asynchronously downloaded, during that time they keep editing the note - // once the download is finished, the image note representing the downloaded image will be used - // to replace the IMG link. - // However, there's another flow where the user pastes the image and leaves the note before the images - // are downloaded and the IMG references are not updated. For this occasion we have this code - // which upon the download of all the images will update the note if the links have not been fixed before - - sql.transactional(() => { - const imageNotes = becca.getNotes(Object.values(imageUrlToAttachmentIdMapping), true); - - const origNote = becca.getNote(noteId); - - if (!origNote) { - log.error(`Cannot find note '${noteId}' to replace image link.`); - return; - } - - const origContent = origNote.getContent(); - let updatedContent = origContent; - - if (typeof updatedContent !== "string") { - log.error(`Note '${noteId}' has a non-string content, cannot replace image link.`); - return; - } - - for (const url in imageUrlToAttachmentIdMapping) { - const imageNote = imageNotes.find((note) => note.noteId === imageUrlToAttachmentIdMapping[url]); - - if (imageNote) { - updatedContent = replaceUrl(updatedContent, url, imageNote); - } - } - - // update only if the links have not been already fixed. - if (updatedContent !== origContent) { - origNote.setContent(updatedContent); - - asyncPostProcessContent(origNote, updatedContent); - - console.log(`Fixed the image links for note '${noteId}' to the offline saved.`); - } - }); - }, 5000); - }); - - return content; -} - -function saveAttachments(note: BNote, content: string) { - const inlineAttachmentRe = /<a[^>]*?\shref=['"]data:([^;'">]+);base64,([^'">]+)['"][^>]*>(.*?)<\/a>/gim; - let attachmentMatch; - - while ((attachmentMatch = inlineAttachmentRe.exec(content))) { - const mime = attachmentMatch[1].toLowerCase(); - - const base64data = attachmentMatch[2]; - const buffer = Buffer.from(base64data, "base64"); - - const title = html2plaintext(attachmentMatch[3]); - - const attachment = note.saveAttachment({ - role: "file", - mime: mime, - title: title, - content: buffer - }); - - content = `${content.substring(0, attachmentMatch.index)}<a class="reference-link" href="#root/${note.noteId}?viewMode=attachments&attachmentId=${attachment.attachmentId}">${title}</a>${content.substring(attachmentMatch.index + attachmentMatch[0].length)}`; - } - - // removing absolute references to server to keep it working between instances, - // we also omit / at the beginning to keep the paths relative - content = content.replace(/src="[^"]*\/api\/attachments\//g, 'src="api/attachments/'); - - return content; -} - -function saveLinks(note: BNote, content: string | 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<NoteRow>("SELECT * FROM notes WHERE noteId = ?", [noteId]); - - if (!noteRow.isDeleted || !noteRow.deleteId) { - log.error(`Note '${noteId}' is not deleted and thus cannot be undeleted.`); - return; - } - - const undeletedParentBranchIds = getUndeletedParentBranchIds(noteId, noteRow.deleteId); - - if (undeletedParentBranchIds.length === 0) { - // cannot undelete if there's no undeleted parent - return; - } - - for (const parentBranchId of undeletedParentBranchIds) { - undeleteBranch(parentBranchId, noteRow.deleteId, taskContext); - } -} - -function undeleteBranch(branchId: string, deleteId: string, taskContext: TaskContext<"undeleteNotes">) { - const branchRow = sql.getRow<BranchRow>("SELECT * FROM branches WHERE branchId = ?", [branchId]); - - if (!branchRow.isDeleted) { - return; - } - - const noteRow = sql.getRow<NoteRow>("SELECT * FROM notes WHERE noteId = ?", [branchRow.noteId]); - - if (noteRow.isDeleted && noteRow.deleteId !== deleteId) { - return; - } - - new BBranch(branchRow).save(); - - taskContext.increaseProgressCount(); - - if (noteRow.isDeleted && noteRow.deleteId === deleteId) { - // becca entity was already created as skeleton in "new Branch()" above - const noteEntity = becca.getNote(noteRow.noteId); - if (!noteEntity) { - throw new Error("Unable to find the just restored branch."); - } - - noteEntity.updateFromRow(noteRow); - noteEntity.save(); - - const attributeRows = sql.getRows<AttributeRow>( - ` - SELECT * FROM attributes - WHERE isDeleted = 1 - AND deleteId = ? - AND (noteId = ? - OR (type = 'relation' AND value = ?))`, - [deleteId, noteRow.noteId, noteRow.noteId] - ); - - for (const attributeRow of attributeRows) { - // relation might point to a note which hasn't been undeleted yet and would thus throw up - new BAttribute(attributeRow).save({ skipValidation: true }); - } - - const attachmentRows = sql.getRows<AttachmentRow>( - ` - SELECT * FROM attachments - WHERE isDeleted = 1 - AND deleteId = ? - AND ownerId = ?`, - [deleteId, noteRow.noteId] - ); - - for (const attachmentRow of attachmentRows) { - new BAttachment(attachmentRow).save(); - } - - const childBranchIds = sql.getColumn<string>( - ` - SELECT branches.branchId - FROM branches - WHERE branches.isDeleted = 1 - AND branches.deleteId = ? - AND branches.parentNoteId = ?`, - [deleteId, noteRow.noteId] - ); - - for (const childBranchId of childBranchIds) { - undeleteBranch(childBranchId, deleteId, taskContext); - } - } -} - -/** - * @returns return deleted branchIds of an undeleted parent note - */ -function getUndeletedParentBranchIds(noteId: string, deleteId: string) { - return sql.getColumn<string>( - ` - SELECT branches.branchId - FROM branches - JOIN notes AS parentNote ON parentNote.noteId = branches.parentNoteId - WHERE branches.noteId = ? - AND branches.isDeleted = 1 - AND branches.deleteId = ? - AND parentNote.isDeleted = 0`, - [noteId, deleteId] - ); -} - -function scanForLinks(note: BNote, content: string | 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<string, string>) { - if (!mapObj) { - return str; - } - - const re = new RegExp(Object.keys(mapObj).join("|"), "g"); - - return str.replace(re, (matched) => mapObj[matched]); -} - -function duplicateSubtree(origNoteId: string, newParentNoteId: string) { - if (origNoteId === "root") { - throw new Error("Duplicating root is not possible"); - } - - log.info(`Duplicating '${origNoteId}' subtree into '${newParentNoteId}'`); - - const origNote = becca.notes[origNoteId]; - // might be null if orig note is not in the target newParentNoteId - const origBranch = origNote.getParentBranches().find((branch) => branch.parentNoteId === newParentNoteId); - - const noteIdMapping = getNoteIdMapping(origNote); - - const res = duplicateSubtreeInner(origNote, origBranch, newParentNoteId, noteIdMapping); - - const duplicateNoteSuffix = t("notes.duplicate-note-suffix"); - - if (!res.note.title.endsWith(duplicateNoteSuffix) && !res.note.title.startsWith(duplicateNoteSuffix)) { - res.note.title = t("notes.duplicate-note-title", { noteTitle: res.note.title, duplicateNoteSuffix: duplicateNoteSuffix }); - } - - res.note.save(); - - return res; -} - -function duplicateSubtreeWithoutRoot(origNoteId: string, newNoteId: string) { - if (origNoteId === "root") { - throw new Error("Duplicating root is not possible"); - } - - const origNote = becca.getNote(origNoteId); - if (origNote == null) { - throw new Error("Unable to find note to duplicate."); - } - - const noteIdMapping = getNoteIdMapping(origNote); - for (const childBranch of origNote.getChildBranches()) { - if (childBranch) { - duplicateSubtreeInner(childBranch.getNote(), childBranch, newNoteId, noteIdMapping); - } - } -} - -function duplicateSubtreeInner(origNote: BNote, origBranch: BBranch | null | undefined, newParentNoteId: string, noteIdMapping: Record<string, string>) { - if (origNote.isProtected && !protectedSessionService.isProtectedSessionAvailable()) { - throw new Error(`Cannot duplicate note '${origNote.noteId}' because it is protected and protected session is not available. Enter protected session and try again.`); - } - - const newNoteId = noteIdMapping[origNote.noteId]; - - function createDuplicatedBranch() { - return new BBranch({ - noteId: newNoteId, - parentNoteId: newParentNoteId, - // here increasing just by 1 to make sure it's directly after original - notePosition: origBranch ? origBranch.notePosition + 1 : null - }).save(); - } - - function createDuplicatedNote() { - const newNote = new BNote({ - ...origNote, - noteId: newNoteId, - dateCreated: 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<string, string> = {}; - - // pregenerate new noteIds since we'll need to fix relation references even for not yet created notes - for (const origNoteId of origNote.getDescendantNoteIds()) { - noteIdMapping[origNoteId] = newEntityId(); - } - - return noteIdMapping; -} - -export default { - createNewNote, - createNewNoteWithTarget, - updateNoteData, - undeleteNote, - protectNoteRecursively, - duplicateSubtree, - duplicateSubtreeWithoutRoot, - getUndeletedParentBranchIds, - triggerNoteTitleChanged, - saveRevisionIfNeeded, - downloadImages, - asyncPostProcessContent -}; +import { note_service } from "@triliumnext/core"; +export default note_service; diff --git a/apps/server/src/services/options.ts b/apps/server/src/services/options.ts index 1cc67df5a..68c69c5ab 100644 --- a/apps/server/src/services/options.ts +++ b/apps/server/src/services/options.ts @@ -1,145 +1,2 @@ -/** - * @module - * - * Options are key-value pairs that are used to store information such as user preferences (for example - * the current theme, sync server information), but also information about the state of the application. - * - * Although options internally are represented as strings, their value can be interpreted as a number or - * boolean by calling the appropriate methods from this service (e.g. {@link #getOptionInt}).\ - * - * Generally options are shared across multiple instances of the application via the sync mechanism, - * however it is possible to have options that are local to an instance. For example, the user can select - * a theme on a device and it will not affect other devices. - */ - -import becca from "../becca/becca.js"; -import BOption from "../becca/entities/boption.js"; -import type { OptionRow } from "@triliumnext/commons"; -import type { FilterOptionsByType, OptionDefinitions, OptionMap, OptionNames } from "@triliumnext/commons"; -import sql from "./sql.js"; - -function getOptionOrNull(name: OptionNames): string | null { - let option; - - if (becca.loaded) { - option = becca.getOption(name); - } else { - // e.g. in initial sync becca is not loaded because DB is not initialized - try { - option = sql.getRow<OptionRow>("SELECT * FROM options WHERE name = ?", [name]); - } catch (e: unknown) { - // DB is not initialized. - return null; - } - } - - return option ? option.value : null; -} - -function getOption(name: OptionNames) { - const val = getOptionOrNull(name); - - if (val === null) { - throw new Error(`Option '${name}' doesn't exist`); - } - - return val; -} - -function getOptionInt(name: FilterOptionsByType<number>, defaultValue?: number): number { - const val = getOption(name); - - const intVal = parseInt(val); - - if (isNaN(intVal)) { - if (defaultValue === undefined) { - throw new Error(`Could not parse '${val}' into integer for option '${name}'`); - } else { - return defaultValue; - } - } - - return intVal; -} - -function getOptionBool(name: FilterOptionsByType<boolean>): boolean { - const val = getOption(name); - - if (typeof val !== "string" || !["true", "false"].includes(val)) { - throw new Error(`Could not parse '${val}' into boolean for option '${name}'`); - } - - return val === "true"; -} - -function setOption<T extends OptionNames>(name: T, value: string | OptionDefinitions[T]) { - const option = becca.getOption(name); - - if (option) { - option.value = value as string; - - option.save(); - } else { - createOption(name, value, false); - } - - // Clear current AI provider when AI-related options change - const aiOptions = [ - 'aiSelectedProvider', 'openaiApiKey', 'openaiBaseUrl', 'openaiDefaultModel', - 'anthropicApiKey', 'anthropicBaseUrl', 'anthropicDefaultModel', - 'ollamaBaseUrl', 'ollamaDefaultModel' - ]; - - if (aiOptions.includes(name)) { - // Import dynamically to avoid circular dependencies - setImmediate(async () => { - try { - const aiServiceManager = (await import('./llm/ai_service_manager.js')).default; - aiServiceManager.getInstance().clearCurrentProvider(); - console.log(`Cleared AI provider after ${name} option changed`); - } catch (error) { - console.log(`Could not clear AI provider: ${error}`); - } - }); - } -} - -/** - * Creates a new option in the database, with the given name, value and whether it should be synced. - * - * @param name the name of the option to be created. - * @param value the value of the option, as a string. It can then be interpreted as other types such as a number of boolean. - * @param isSynced `true` if the value should be synced across multiple instances (e.g. locale) or `false` if it should be local-only (e.g. theme). - */ -function createOption<T extends OptionNames>(name: T, value: string | OptionDefinitions[T], isSynced: boolean) { - new BOption({ - name: name, - value: value as string, - isSynced: isSynced - }).save(); -} - -function getOptions() { - return Object.values(becca.options); -} - -function getOptionMap() { - const map: Record<string, string> = {}; - - for (const option of Object.values(becca.options)) { - map[option.name] = option.value; - } - - return map as OptionMap; -} - -export default { - getOption, - getOptionInt, - getOptionBool, - setOption, - createOption, - getOptions, - getOptionMap, - getOptionOrNull -}; +import { options } from "@triliumnext/core"; +export default options; diff --git a/apps/server/src/services/options_init.ts b/apps/server/src/services/options_init.ts index b23e532a8..ce14b7741 100644 --- a/apps/server/src/services/options_init.ts +++ b/apps/server/src/services/options_init.ts @@ -1,280 +1,2 @@ -import { type KeyboardShortcutWithRequiredActionName, type OptionMap, type OptionNames, SANITIZER_DEFAULT_ALLOWED_TAGS } from "@triliumnext/commons"; - -import appInfo from "./app_info.js"; -import dateUtils from "./date_utils.js"; -import keyboardActions from "./keyboard_actions.js"; -import log from "./log.js"; -import optionService from "./options.js"; -import { isWindows, randomSecureToken } from "./utils.js"; - -function initDocumentOptions() { - optionService.createOption("documentId", randomSecureToken(16), false); - optionService.createOption("documentSecret", randomSecureToken(16), false); -} - -/** - * Contains additional options to be initialized for a new database, containing the information entered by the user. - */ -interface NotSyncedOpts { - syncServerHost?: string; - syncProxy?: string; -} - -/** - * Represents a correspondence between an option and its default value, to be initialized when the database is missing that particular option (after a migration from an older version, or when creating a new database). - */ -interface DefaultOption { - name: OptionNames; - /** - * The value to initialize the option with, if the option is not already present in the database. - * - * If a function is passed Gin instead, the function is called if the option does not exist (with access to the current options) and the return value is used instead. Useful to migrate a new option with a value depending on some other option that might be initialized. - */ - value: string | ((options: OptionMap) => string); - isSynced: boolean; -} - -/** - * Initializes the default options for new databases only. - * - * @param initialized `true` if the database has been fully initialized (i.e. a new database was created), or `false` if the database is created for sync. - * @param opts additional options to be initialized, for example the sync configuration. - */ -async function initNotSyncedOptions(initialized: boolean, opts: NotSyncedOpts = {}) { - optionService.createOption( - "openNoteContexts", - JSON.stringify([ - { - notePath: "root", - active: true - } - ]), - false - ); - - optionService.createOption("lastDailyBackupDate", dateUtils.utcNowDateTime(), false); - optionService.createOption("lastWeeklyBackupDate", dateUtils.utcNowDateTime(), false); - optionService.createOption("lastMonthlyBackupDate", dateUtils.utcNowDateTime(), false); - optionService.createOption("dbVersion", appInfo.dbVersion.toString(), false); - - optionService.createOption("initialized", initialized ? "true" : "false", false); - - optionService.createOption("lastSyncedPull", "0", false); - optionService.createOption("lastSyncedPush", "0", false); - - optionService.createOption("theme", "next", false); - optionService.createOption("textNoteEditorType", "ckeditor-classic", true); - - optionService.createOption("syncServerHost", opts.syncServerHost || "", false); - optionService.createOption("syncServerTimeout", "120000", false); - optionService.createOption("syncProxy", opts.syncProxy || "", false); -} - -/** - * Contains all the default options that must be initialized on new and existing databases (at startup). The value can also be determined based on other options, provided they have already been initialized. - */ -const defaultOptions: DefaultOption[] = [ - { name: "revisionSnapshotTimeInterval", value: "600", isSynced: true }, - { name: "revisionSnapshotTimeIntervalTimeScale", value: "60", isSynced: true }, // default to Minutes - { name: "revisionSnapshotNumberLimit", value: "-1", isSynced: true }, - { name: "protectedSessionTimeout", value: "600", isSynced: true }, - { name: "protectedSessionTimeoutTimeScale", value: "60", isSynced: true }, - { name: "zoomFactor", value: isWindows ? "0.9" : "1.0", isSynced: false }, - { name: "overrideThemeFonts", value: "false", isSynced: false }, - { name: "mainFontFamily", value: "theme", isSynced: false }, - { name: "mainFontSize", value: "100", isSynced: false }, - { name: "treeFontFamily", value: "theme", isSynced: false }, - { name: "treeFontSize", value: "100", isSynced: false }, - { name: "detailFontFamily", value: "theme", isSynced: false }, - { name: "detailFontSize", value: "110", isSynced: false }, - { name: "monospaceFontFamily", value: "theme", isSynced: false }, - { name: "monospaceFontSize", value: "110", isSynced: false }, - { name: "spellCheckEnabled", value: "true", isSynced: false }, - { name: "spellCheckLanguageCode", value: "en-US", isSynced: false }, - { name: "imageMaxWidthHeight", value: "2000", isSynced: true }, - { name: "imageJpegQuality", value: "75", isSynced: true }, - { name: "autoFixConsistencyIssues", value: "true", isSynced: false }, - { name: "vimKeymapEnabled", value: "false", isSynced: false }, - { name: "codeLineWrapEnabled", value: "true", isSynced: false }, - { - name: "codeNotesMimeTypes", - value: '["text/x-csrc","text/x-c++src","text/x-csharp","text/css","text/x-elixir","text/x-go","text/x-groovy","text/x-haskell","text/html","message/http","text/x-java","application/javascript;env=frontend","application/javascript;env=backend","application/json","text/x-kotlin","text/x-markdown","text/x-perl","text/x-php","text/x-python","text/x-ruby",null,"text/x-sql","text/x-sqlite;schema=trilium","text/x-swift","text/xml","text/x-yaml","text/x-sh","application/typescript"]', - isSynced: true - }, - { name: "leftPaneWidth", value: "25", isSynced: false }, - { name: "leftPaneVisible", value: "true", isSynced: false }, - { name: "rightPaneWidth", value: "25", isSynced: false }, - { name: "rightPaneVisible", value: "true", isSynced: false }, - { name: "rightPaneCollapsedItems", value: "[]", isSynced: false }, - { name: "nativeTitleBarVisible", value: "false", isSynced: false }, - { name: "eraseEntitiesAfterTimeInSeconds", value: "604800", isSynced: true }, // default is 7 days - { name: "eraseEntitiesAfterTimeScale", value: "86400", isSynced: true }, // default 86400 seconds = Day - { name: "hideArchivedNotes_main", value: "false", isSynced: false }, - { name: "debugModeEnabled", value: "false", isSynced: false }, - { name: "headingStyle", value: "underline", isSynced: true }, - { name: "autoCollapseNoteTree", value: "true", isSynced: true }, - { name: "autoReadonlySizeText", value: "32000", isSynced: false }, - { name: "autoReadonlySizeCode", value: "64000", isSynced: false }, - { name: "dailyBackupEnabled", value: "true", isSynced: false }, - { name: "weeklyBackupEnabled", value: "true", isSynced: false }, - { name: "monthlyBackupEnabled", value: "true", isSynced: false }, - { name: "maxContentWidth", value: "1200", isSynced: false }, - { name: "centerContent", value: "false", isSynced: false }, - { name: "compressImages", value: "true", isSynced: true }, - { name: "downloadImagesAutomatically", value: "true", isSynced: true }, - { name: "minTocHeadings", value: "5", isSynced: true }, - { name: "highlightsList", value: '["underline","color","bgColor"]', isSynced: true }, - { name: "checkForUpdates", value: "true", isSynced: true }, - { name: "disableTray", value: "false", isSynced: false }, - { name: "eraseUnusedAttachmentsAfterSeconds", value: "2592000", isSynced: true }, // default 30 days - { name: "eraseUnusedAttachmentsAfterTimeScale", value: "86400", isSynced: true }, // default 86400 seconds = Day - { name: "logRetentionDays", value: "90", isSynced: false }, // default 90 days - { name: "customSearchEngineName", value: "DuckDuckGo", isSynced: true }, - { name: "customSearchEngineUrl", value: "https://duckduckgo.com/?q={keyword}", isSynced: true }, - { name: "editedNotesOpenInRibbon", value: "true", isSynced: true }, - { name: "mfaEnabled", value: "false", isSynced: false }, - { name: "mfaMethod", value: "totp", isSynced: false }, - { name: "encryptedRecoveryCodes", value: "false", isSynced: false }, - { name: "userSubjectIdentifierSaved", value: "false", isSynced: false }, - - // Appearance - { name: "splitEditorOrientation", value: "horizontal", isSynced: true }, - { - name: "codeNoteTheme", - value: (optionsMap) => { - switch (optionsMap.theme) { - case "light": - case "next-light": - return "default:vs-code-light"; - case "dark": - case "next-dark": - default: - return "default:vs-code-dark"; - } - }, - isSynced: false - }, - { name: "motionEnabled", value: "true", isSynced: false }, - { name: "shadowsEnabled", value: "true", isSynced: false }, - { name: "backdropEffectsEnabled", value: "true", isSynced: false }, - { name: "smoothScrollEnabled", value: "true", isSynced: false }, - { name: "newLayout", value: "true", isSynced: true }, - - // Internationalization - { name: "locale", value: "en", isSynced: true }, - { name: "formattingLocale", value: "", isSynced: true }, // no value means auto-detect - { name: "firstDayOfWeek", value: "1", isSynced: true }, - { name: "firstWeekOfYear", value: "0", isSynced: true }, - { name: "minDaysInFirstWeek", value: "4", isSynced: true }, - { name: "languages", value: "[]", isSynced: true }, - - // Code block configuration - { - name: "codeBlockTheme", - value: (optionsMap) => { - if (optionsMap.theme === "light") { - return "default:stackoverflow-light"; - } - return "default:stackoverflow-dark"; - - }, - isSynced: false - }, - { name: "codeBlockWordWrap", value: "false", isSynced: true }, - - // Text note configuration - { name: "textNoteEditorType", value: "ckeditor-balloon", isSynced: true }, - { name: "textNoteEditorMultilineToolbar", value: "false", isSynced: true }, - { name: "textNoteEmojiCompletionEnabled", value: "true", isSynced: true }, - { name: "textNoteCompletionEnabled", value: "true", isSynced: true }, - { name: "textNoteSlashCommandsEnabled", value: "true", isSynced: true }, - - // HTML import configuration - { name: "layoutOrientation", value: "vertical", isSynced: false }, - { name: "backgroundEffects", value: "true", isSynced: false }, - { - name: "allowedHtmlTags", - value: JSON.stringify(SANITIZER_DEFAULT_ALLOWED_TAGS), - isSynced: true - }, - - // Share settings - { name: "redirectBareDomain", value: "false", isSynced: true }, - { name: "showLoginInShareTheme", value: "false", isSynced: true }, - - // AI Options - { name: "aiEnabled", value: "false", isSynced: true }, - { name: "openaiApiKey", value: "", isSynced: false }, - { name: "openaiDefaultModel", value: "", isSynced: true }, - { name: "openaiBaseUrl", value: "https://api.openai.com/v1", isSynced: true }, - { name: "anthropicApiKey", value: "", isSynced: false }, - { name: "anthropicDefaultModel", value: "", isSynced: true }, - { name: "voyageApiKey", value: "", isSynced: false }, - { name: "anthropicBaseUrl", value: "https://api.anthropic.com/v1", isSynced: true }, - { name: "ollamaEnabled", value: "false", isSynced: true }, - { name: "ollamaDefaultModel", value: "", isSynced: true }, - { name: "ollamaBaseUrl", value: "http://localhost:11434", isSynced: true }, - { name: "aiTemperature", value: "0.7", isSynced: true }, - { name: "aiSystemPrompt", value: "", isSynced: true }, - { name: "aiSelectedProvider", value: "openai", isSynced: true }, - - { - name: "seenCallToActions", - value: JSON.stringify([ - "new_layout", "background_effects", "next_theme" - ]), - isSynced: true - }, - { name: "experimentalFeatures", value: "[]", isSynced: true } -]; - -/** - * Initializes the options, by checking which options from {@link #allDefaultOptions()} are missing and registering them. It will also check some environment variables such as safe mode, to make any necessary adjustments. - * - * This method is called regardless of whether a new database is created, or an existing database is used. - */ -function initStartupOptions() { - const optionsMap = optionService.getOptionMap(); - - const allDefaultOptions = defaultOptions.concat(getKeyboardDefaultOptions()); - - for (const { name, value, isSynced } of allDefaultOptions) { - if (!(name in optionsMap)) { - let resolvedValue; - if (typeof value === "function") { - resolvedValue = value(optionsMap); - } else { - resolvedValue = value; - } - - optionService.createOption(name, resolvedValue, isSynced); - log.info(`Created option "${name}" with default value "${resolvedValue}"`); - } - } - - if (process.env.TRILIUM_START_NOTE_ID || process.env.TRILIUM_SAFE_MODE) { - optionService.setOption( - "openNoteContexts", - JSON.stringify([ - { - notePath: process.env.TRILIUM_START_NOTE_ID || "root", - active: true - } - ]) - ); - } -} - -function getKeyboardDefaultOptions() { - return (keyboardActions.getDefaultKeyboardActions().filter((ka) => "actionName" in ka) as KeyboardShortcutWithRequiredActionName[]).map((ka) => ({ - name: `keyboardShortcuts${ka.actionName.charAt(0).toUpperCase()}${ka.actionName.slice(1)}`, - value: JSON.stringify(ka.defaultShortcuts), - isSynced: false - })) as DefaultOption[]; -} - -export default { - initDocumentOptions, - initNotSyncedOptions, - initStartupOptions -}; +import { options_init } from "@triliumnext/core"; +export default options_init; diff --git a/apps/server/src/services/promoted_attribute_definition_interface.ts b/apps/server/src/services/promoted_attribute_definition_interface.ts deleted file mode 100644 index 2f68aa7ac..000000000 --- a/apps/server/src/services/promoted_attribute_definition_interface.ts +++ /dev/null @@ -1,8 +0,0 @@ -export interface DefinitionObject { - isPromoted?: boolean; - labelType?: string; - multiplicity?: string; - numberPrecision?: number; - promotedAlias?: string; - inverseRelation?: string; -} diff --git a/apps/server/src/services/revisions.ts b/apps/server/src/services/revisions.ts index 1265d4927..ae8f826b0 100644 --- a/apps/server/src/services/revisions.ts +++ b/apps/server/src/services/revisions.ts @@ -1,49 +1,2 @@ -"use strict"; - -import log from "./log.js"; -import sql from "./sql.js"; -import protectedSessionService from "./protected_session.js"; -import dateUtils from "./date_utils.js"; -import type BNote from "../becca/entities/bnote.js"; - -function protectRevisions(note: BNote) { - if (!protectedSessionService.isProtectedSessionAvailable()) { - throw new Error(`Cannot (un)protect revisions of note '${note.noteId}' without active protected session`); - } - - for (const revision of note.getRevisions()) { - if (note.isProtected !== revision.isProtected) { - try { - const content = revision.getContent(); - - revision.isProtected = !!note.isProtected; - - // this will force de/encryption - revision.setContent(content, { forceSave: true }); - } catch (e) { - log.error(`Could not un/protect note revision '${revision.revisionId}'`); - - throw e; - } - } - - for (const attachment of revision.getAttachments()) { - if (note.isProtected !== attachment.isProtected) { - try { - const content = attachment.getContent(); - - attachment.isProtected = note.isProtected; - attachment.setContent(content, { forceSave: true }); - } catch (e) { - log.error(`Could not un/protect attachment '${attachment.attachmentId}'`); - - throw e; - } - } - } - } -} - -export default { - protectRevisions -}; +import { revisions } from "@triliumnext/core"; +export default revisions; diff --git a/apps/server/src/services/sanitize_attribute_name.ts b/apps/server/src/services/sanitize_attribute_name.ts deleted file mode 100644 index 3e597e94a..000000000 --- a/apps/server/src/services/sanitize_attribute_name.ts +++ /dev/null @@ -1,6 +0,0 @@ -export default function sanitizeAttributeName(origName: string) { - const fixedName = origName === "" ? "unnamed" : origName.replace(/[^\p{L}\p{N}_:]/gu, "_"); - // any not allowed character should be replaced with underscore - - return fixedName; -} diff --git a/apps/server/src/services/script.ts b/apps/server/src/services/script.ts index 97dd99898..4b335f11d 100644 --- a/apps/server/src/services/script.ts +++ b/apps/server/src/services/script.ts @@ -1,3 +1,4 @@ +import { binary_utils } from "@triliumnext/core"; import { transform } from "sucrase"; import becca from "../becca/becca.js"; @@ -217,8 +218,8 @@ return module.exports; return bundle; } -export function buildJsx(contentRaw: string | Buffer) { - const content = Buffer.isBuffer(contentRaw) ? contentRaw.toString("utf-8") : contentRaw; +export function buildJsx(contentRaw: string | Uint8Array) { + const content = binary_utils.unwrapStringOrBuffer(contentRaw); const output = transform(content, { transforms: ["jsx", "imports"], jsxPragma: "api.preact.h", diff --git a/apps/server/src/services/search/expressions/note_content_fulltext.ts b/apps/server/src/services/search/expressions/note_content_fulltext.ts index 89ba1bc98..4bd3408f9 100644 --- a/apps/server/src/services/search/expressions/note_content_fulltext.ts +++ b/apps/server/src/services/search/expressions/note_content_fulltext.ts @@ -1,24 +1,23 @@ -"use strict"; + import type { NoteRow } from "@triliumnext/commons"; -import type SearchContext from "../search_context.js"; - -import Expression from "./expression.js"; -import NoteSet from "../note_set.js"; -import log from "../../log.js"; -import becca from "../../../becca/becca.js"; -import protectedSessionService from "../../protected_session.js"; import striptags from "striptags"; -import { normalize } from "../../utils.js"; + +import becca from "../../../becca/becca.js"; +import log from "../../log.js"; +import protectedSessionService from "../../protected_session.js"; import sql from "../../sql.js"; -import { - normalizeSearchText, - calculateOptimizedEditDistance, - validateFuzzySearchTokens, - validateAndPreprocessContent, +import { normalize } from "../../utils.js"; +import NoteSet from "../note_set.js"; +import type SearchContext from "../search_context.js"; +import { + calculateOptimizedEditDistance, + FUZZY_SEARCH_CONFIG, fuzzyMatchWord, - FUZZY_SEARCH_CONFIG -} from "../utils/text_utils.js"; + normalizeSearchText, + validateAndPreprocessContent, + validateFuzzySearchTokens} from "../utils/text_utils.js"; +import Expression from "./expression.js"; const ALLOWED_OPERATORS = new Set(["=", "!=", "*=*", "*=", "=*", "%=", "~=", "~*"]); @@ -295,7 +294,7 @@ class NoteContentFulltextExp extends Expression { return content; } - preprocessContent(content: string | Buffer, type: string, mime: string) { + preprocessContent(content: string | Uint8Array, type: string, mime: string) { content = normalize(content.toString()); if (type === "text" && mime === "text/html") { @@ -338,16 +337,16 @@ class NoteContentFulltextExp extends Expression { private tokenMatchesContent(token: string, content: string, noteId: string): boolean { const normalizedToken = normalizeSearchText(token); const normalizedContent = normalizeSearchText(content); - + if (normalizedContent.includes(normalizedToken)) { return true; } - + // Check flat text for default fulltext search if (!this.flatText || !becca.notes[noteId].getFlatText().includes(token)) { return false; } - + return true; } @@ -358,15 +357,15 @@ class NoteContentFulltextExp extends Expression { try { const normalizedContent = normalizeSearchText(content); const flatText = this.flatText ? normalizeSearchText(becca.notes[noteId].getFlatText()) : ""; - + // For phrase matching, check if tokens appear within reasonable proximity if (this.tokens.length > 1) { return this.matchesPhrase(normalizedContent, flatText); } - + // Single token fuzzy matching const token = normalizeSearchText(this.tokens[0]); - return this.fuzzyMatchToken(token, normalizedContent) || + return this.fuzzyMatchToken(token, normalizedContent) || (this.flatText && this.fuzzyMatchToken(token, flatText)); } catch (error) { log.error(`Error in fuzzy matching for note ${noteId}: ${error}`); @@ -379,45 +378,45 @@ class NoteContentFulltextExp extends Expression { */ private matchesPhrase(content: string, flatText: string): boolean { const searchText = this.flatText ? `${content} ${flatText}` : content; - + // Apply content size limits for phrase matching const limitedText = validateAndPreprocessContent(searchText); if (!limitedText) { return false; } - + const words = limitedText.toLowerCase().split(/\s+/); - + // Only skip phrase matching for truly extreme word counts that could crash the system if (words.length > FUZZY_SEARCH_CONFIG.ABSOLUTE_MAX_WORD_COUNT) { console.error(`Phrase matching skipped due to extreme word count that could cause system instability: ${words.length} words`); return false; } - + // Warn about large word counts but still attempt matching if (words.length > FUZZY_SEARCH_CONFIG.PERFORMANCE_WARNING_WORDS) { console.info(`Large word count for phrase matching: ${words.length} words - may take longer but will attempt full matching`); } - + // Find positions of each token const tokenPositions: number[][] = this.tokens.map(token => { const normalizedToken = normalizeSearchText(token); const positions: number[] = []; - + words.forEach((word, index) => { if (this.fuzzyMatchSingle(normalizedToken, word)) { positions.push(index); } }); - + return positions; }); - + // Check if we found all tokens if (tokenPositions.some(positions => positions.length === 0)) { return false; } - + // Check for phrase proximity using configurable distance return this.hasProximityMatch(tokenPositions, FUZZY_SEARCH_CONFIG.MAX_PHRASE_PROXIMITY); } @@ -431,18 +430,18 @@ class NoteContentFulltextExp extends Expression { const [pos1, pos2] = tokenPositions; return pos1.some(p1 => pos2.some(p2 => Math.abs(p1 - p2) <= maxDistance)); } - + // For more tokens, check if we can find a sequence where all tokens are within range const findSequence = (remaining: number[][], currentPos: number): boolean => { if (remaining.length === 0) return true; - + const [nextPositions, ...rest] = remaining; - return nextPositions.some(pos => - Math.abs(pos - currentPos) <= maxDistance && + return nextPositions.some(pos => + Math.abs(pos - currentPos) <= maxDistance && findSequence(rest, pos) ); }; - + const [firstPositions, ...rest] = tokenPositions; return firstPositions.some(startPos => findSequence(rest, startPos)); } @@ -455,12 +454,12 @@ class NoteContentFulltextExp extends Expression { // For short tokens, require exact match to avoid too many false positives return content.includes(token); } - + const words = content.split(/\s+/); - + // Only limit word processing for truly extreme cases to prevent system instability const limitedWords = words.slice(0, FUZZY_SEARCH_CONFIG.ABSOLUTE_MAX_WORD_COUNT); - + return limitedWords.some(word => this.fuzzyMatchSingle(token, word)); } diff --git a/apps/server/src/services/search/expressions/note_flat_text.ts b/apps/server/src/services/search/expressions/note_flat_text.ts index b9ad19c36..18bb0944d 100644 --- a/apps/server/src/services/search/expressions/note_flat_text.ts +++ b/apps/server/src/services/search/expressions/note_flat_text.ts @@ -1,14 +1,12 @@ -"use strict"; +import { becca_service } from "@triliumnext/core"; -import type BNote from "../../../becca/entities/bnote.js"; -import type SearchContext from "../search_context.js"; - -import Expression from "./expression.js"; -import NoteSet from "../note_set.js"; import becca from "../../../becca/becca.js"; +import type BNote from "../../../becca/entities/bnote.js"; import { normalize } from "../../utils.js"; -import { normalizeSearchText, fuzzyMatchWord, fuzzyMatchWordWithResult } from "../utils/text_utils.js"; -import beccaService from "../../../becca/becca_service.js"; +import NoteSet from "../note_set.js"; +import type SearchContext from "../search_context.js"; +import { fuzzyMatchWord, fuzzyMatchWordWithResult,normalizeSearchText } from "../utils/text_utils.js"; +import Expression from "./expression.js"; class NoteFlatTextExp extends Expression { tokens: string[]; @@ -60,7 +58,7 @@ class NoteFlatTextExp extends Expression { // Add defensive checks for undefined properties const typeMatches = note.type && note.type.includes(token); const mimeMatches = note.mime && note.mime.includes(token); - + if (typeMatches || mimeMatches) { foundAttrTokens.push(token); } @@ -78,7 +76,7 @@ class NoteFlatTextExp extends Expression { } for (const parentNote of note.parents) { - const title = normalizeSearchText(beccaService.getNoteTitle(note.noteId, parentNote.noteId)); + const title = normalizeSearchText(becca_service.getNoteTitle(note.noteId, parentNote.noteId)); const foundTokens: string[] = foundAttrTokens.slice(); for (const token of remainingTokens) { @@ -112,7 +110,7 @@ class NoteFlatTextExp extends Expression { // Add defensive checks for undefined properties const typeMatches = note.type && note.type.includes(token); const mimeMatches = note.mime && note.mime.includes(token); - + if (typeMatches || mimeMatches) { foundAttrTokens.push(token); } @@ -125,7 +123,7 @@ class NoteFlatTextExp extends Expression { } for (const parentNote of note.parents) { - const title = normalizeSearchText(beccaService.getNoteTitle(note.noteId, parentNote.noteId)); + const title = normalizeSearchText(becca_service.getNoteTitle(note.noteId, parentNote.noteId)); const foundTokens = foundAttrTokens.slice(); for (const token of this.tokens) { @@ -190,7 +188,7 @@ class NoteFlatTextExp extends Expression { if (text.includes(token)) { return true; } - + // Fuzzy fallback only if enabled and for tokens >= 4 characters if (searchContext?.enableFuzzyMatching && token.length >= 4) { const matchedWord = fuzzyMatchWordWithResult(token, text); @@ -202,7 +200,7 @@ class NoteFlatTextExp extends Expression { return true; } } - + return false; } } diff --git a/apps/server/src/services/search/note_set.ts b/apps/server/src/services/search/note_set.ts index bab76afa5..10ecb4134 100644 --- a/apps/server/src/services/search/note_set.ts +++ b/apps/server/src/services/search/note_set.ts @@ -1,67 +1,2 @@ -"use strict"; - -import type BNote from "../../becca/entities/bnote.js"; - -class NoteSet { - private noteIdSet: Set<string>; - - notes: BNote[]; - sorted: boolean; - - constructor(notes: BNote[] = []) { - this.notes = notes; - this.noteIdSet = new Set(notes.map((note) => note.noteId)); - this.sorted = false; - } - - add(note: BNote) { - if (!this.hasNote(note)) { - this.notes.push(note); - this.noteIdSet.add(note.noteId); - } - } - - addAll(notes: BNote[]) { - for (const note of notes) { - this.add(note); - } - } - - hasNote(note: BNote) { - return this.hasNoteId(note.noteId); - } - - hasNoteId(noteId: string) { - return this.noteIdSet.has(noteId); - } - - mergeIn(anotherNoteSet: NoteSet) { - this.addAll(anotherNoteSet.notes); - } - - minus(anotherNoteSet: NoteSet) { - const newNoteSet = new NoteSet(); - - for (const note of this.notes) { - if (!anotherNoteSet.hasNoteId(note.noteId)) { - newNoteSet.add(note); - } - } - - return newNoteSet; - } - - intersection(anotherNoteSet: NoteSet) { - const newNoteSet = new NoteSet(); - - for (const note of this.notes) { - if (anotherNoteSet.hasNote(note)) { - newNoteSet.add(note); - } - } - - return newNoteSet; - } -} - +import { NoteSet } from "@triliumnext/core"; export default NoteSet; diff --git a/apps/server/src/services/search/search_result.ts b/apps/server/src/services/search/search_result.ts index bf8a33524..984a6d97a 100644 --- a/apps/server/src/services/search/search_result.ts +++ b/apps/server/src/services/search/search_result.ts @@ -1,12 +1,10 @@ -"use strict"; +import { becca_service } from "@triliumnext/core"; -import beccaService from "../../becca/becca_service.js"; import becca from "../../becca/becca.js"; -import { - normalizeSearchText, - calculateOptimizedEditDistance, - FUZZY_SEARCH_CONFIG -} from "./utils/text_utils.js"; +import { + calculateOptimizedEditDistance, + FUZZY_SEARCH_CONFIG, + normalizeSearchText} from "./utils/text_utils.js"; // Scoring constants for better maintainability const SCORE_WEIGHTS = { @@ -41,7 +39,7 @@ class SearchResult { constructor(notePathArray: string[]) { this.notePathArray = notePathArray; - this.notePathTitle = beccaService.getNoteTitleForPath(notePathArray); + this.notePathTitle = becca_service.getNoteTitleForPath(notePathArray); this.score = 0; this.fuzzyScore = 0; } @@ -98,7 +96,7 @@ class SearchResult { for (const chunk of chunks) { for (const token of tokens) { const normalizedToken = normalizeSearchText(token.toLowerCase()); - + if (chunk === normalizedToken) { tokenScore += SCORE_WEIGHTS.TOKEN_EXACT_MATCH * token.length * factor; } else if (chunk.startsWith(normalizedToken)) { @@ -108,10 +106,10 @@ class SearchResult { } else { // Try fuzzy matching for individual tokens with caps applied const editDistance = calculateOptimizedEditDistance(chunk, normalizedToken, FUZZY_SEARCH_CONFIG.MAX_EDIT_DISTANCE); - if (editDistance <= FUZZY_SEARCH_CONFIG.MAX_EDIT_DISTANCE && + if (editDistance <= FUZZY_SEARCH_CONFIG.MAX_EDIT_DISTANCE && normalizedToken.length >= FUZZY_SEARCH_CONFIG.MIN_FUZZY_TOKEN_LENGTH && this.fuzzyScore < SCORE_WEIGHTS.MAX_TOTAL_FUZZY_SCORE) { - + const fuzzyWeight = SCORE_WEIGHTS.TOKEN_FUZZY_MATCH * (1 - editDistance / FUZZY_SEARCH_CONFIG.MAX_EDIT_DISTANCE); // Apply caps: limit token length multiplier and per-token contribution const cappedTokenLength = Math.min(token.length, SCORE_WEIGHTS.MAX_FUZZY_TOKEN_LENGTH_MULTIPLIER); @@ -119,7 +117,7 @@ class SearchResult { fuzzyWeight * cappedTokenLength * factor, SCORE_WEIGHTS.MAX_FUZZY_SCORE_PER_TOKEN ); - + tokenScore += fuzzyTokenScore; this.fuzzyScore += fuzzyTokenScore; } @@ -134,8 +132,8 @@ class SearchResult { * Checks if the query matches as a complete word in the text */ private isWordMatch(text: string, query: string): boolean { - return text.includes(` ${query} `) || - text.startsWith(`${query} `) || + return text.includes(` ${query} `) || + text.startsWith(`${query} `) || text.endsWith(` ${query}`); } @@ -147,21 +145,21 @@ class SearchResult { if (this.fuzzyScore >= SCORE_WEIGHTS.MAX_TOTAL_FUZZY_SCORE) { return 0; } - + const editDistance = calculateOptimizedEditDistance(title, query, FUZZY_SEARCH_CONFIG.MAX_EDIT_DISTANCE); const maxLen = Math.max(title.length, query.length); - + // Only apply fuzzy matching if the query is reasonably long and edit distance is small - if (query.length >= FUZZY_SEARCH_CONFIG.MIN_FUZZY_TOKEN_LENGTH && - editDistance <= FUZZY_SEARCH_CONFIG.MAX_EDIT_DISTANCE && + if (query.length >= FUZZY_SEARCH_CONFIG.MIN_FUZZY_TOKEN_LENGTH && + editDistance <= FUZZY_SEARCH_CONFIG.MAX_EDIT_DISTANCE && editDistance / maxLen <= 0.3) { const similarity = 1 - (editDistance / maxLen); const baseFuzzyScore = SCORE_WEIGHTS.TITLE_WORD_MATCH * similarity * 0.7; // Reduced weight for fuzzy matches - + // Apply cap to ensure fuzzy title matches don't exceed reasonable bounds return Math.min(baseFuzzyScore, SCORE_WEIGHTS.MAX_TOTAL_FUZZY_SCORE * 0.3); } - + return 0; } diff --git a/apps/server/src/services/search/services/search.ts b/apps/server/src/services/search/services/search.ts index 5ca4bda4a..16eeb70cc 100644 --- a/apps/server/src/services/search/services/search.ts +++ b/apps/server/src/services/search/services/search.ts @@ -1,24 +1,22 @@ -"use strict"; - +import { becca_service } from "@triliumnext/core"; import normalizeString from "normalize-strings"; -import lex from "./lex.js"; -import handleParens from "./handle_parens.js"; -import parse from "./parse.js"; -import SearchResult from "../search_result.js"; -import SearchContext from "../search_context.js"; -import becca from "../../../becca/becca.js"; -import beccaService from "../../../becca/becca_service.js"; -import { normalize, escapeHtml, escapeRegExp } from "../../utils.js"; -import log from "../../log.js"; -import hoistedNoteService from "../../hoisted_note.js"; -import type BNote from "../../../becca/entities/bnote.js"; -import type BAttribute from "../../../becca/entities/battribute.js"; -import type { SearchParams, TokenStructure } from "./types.js"; -import type Expression from "../expressions/expression.js"; -import sql from "../../sql.js"; -import scriptService from "../../script.js"; import striptags from "striptags"; + +import becca from "../../../becca/becca.js"; +import type BNote from "../../../becca/entities/bnote.js"; +import hoistedNoteService from "../../hoisted_note.js"; +import log from "../../log.js"; import protectedSessionService from "../../protected_session.js"; +import scriptService from "../../script.js"; +import sql from "../../sql.js"; +import { escapeHtml, escapeRegExp } from "../../utils.js"; +import type Expression from "../expressions/expression.js"; +import SearchContext from "../search_context.js"; +import SearchResult from "../search_result.js"; +import handleParens from "./handle_parens.js"; +import lex from "./lex.js"; +import parse from "./parse.js"; +import type { SearchParams, TokenStructure } from "./types.js"; export interface SearchNoteResult { searchResultNoteIds: string[]; @@ -67,7 +65,7 @@ function searchFromNote(note: BNote): SearchNoteResult { return { searchResultNoteIds: searchResultNoteIds.filter((resultNoteId) => !["root", note.noteId].includes(resultNoteId)), highlightedTokens, - error: error + error }; } @@ -252,21 +250,21 @@ function findResultsWithExpression(expression: Expression, searchContext: Search // Phase 1: Try exact matches first (without fuzzy matching) const exactResults = performSearch(expression, searchContext, false); - + // Check if we have sufficient high-quality results const minResultThreshold = 5; const minScoreForQuality = 10; // Minimum score to consider a result "high quality" - + const highQualityResults = exactResults.filter(result => result.score >= minScoreForQuality); - + // If we have enough high-quality exact matches, return them if (highQualityResults.length >= minResultThreshold) { return exactResults; } - + // Phase 2: Add fuzzy matching as fallback when exact matches are insufficient const fuzzyResults = performSearch(expression, searchContext, true); - + // Merge results, ensuring exact matches always rank higher than fuzzy matches return mergeExactAndFuzzyResults(exactResults, fuzzyResults); } @@ -326,10 +324,10 @@ function performSearch(expression: Expression, searchContext: SearchContext, ena function mergeExactAndFuzzyResults(exactResults: SearchResult[], fuzzyResults: SearchResult[]): SearchResult[] { // Create a map of exact result note IDs for deduplication const exactNoteIds = new Set(exactResults.map(result => result.noteId)); - + // Add fuzzy results that aren't already in exact results const additionalFuzzyResults = fuzzyResults.filter(result => !exactNoteIds.has(result.noteId)); - + // Sort exact results by score (best exact matches first) exactResults.sort((a, b) => { if (a.score > b.score) { @@ -345,7 +343,7 @@ function mergeExactAndFuzzyResults(exactResults: SearchResult[], fuzzyResults: S return a.notePathArray.length < b.notePathArray.length ? -1 : 1; }); - + // Sort fuzzy results by score (best fuzzy matches first) additionalFuzzyResults.sort((a, b) => { if (a.score > b.score) { @@ -361,7 +359,7 @@ function mergeExactAndFuzzyResults(exactResults: SearchResult[], fuzzyResults: S return a.notePathArray.length < b.notePathArray.length ? -1 : 1; }); - + // CRITICAL: Always put exact matches before fuzzy matches, regardless of scores return [...exactResults, ...additionalFuzzyResults]; } @@ -417,10 +415,10 @@ function findResultsWithQuery(query: string, searchContext: SearchContext): Sear } // If the query starts with '#', it's a pure expression query. - // Don't use progressive search for these as they may have complex + // Don't use progressive search for these as they may have complex // ordering or other logic that shouldn't be interfered with. const isPureExpressionQuery = query.trim().startsWith('#'); - + if (isPureExpressionQuery) { // For pure expression queries, use standard search without progressive phases return performSearch(expression, searchContext, searchContext.enableFuzzyMatching); @@ -448,7 +446,7 @@ function extractContentSnippet(noteId: string, searchTokens: string[], maxLength try { let content = note.getContent(); - + if (!content || typeof content !== "string") { return ""; } @@ -489,7 +487,7 @@ function extractContentSnippet(noteId: string, searchTokens: string[], maxLength for (const token of searchTokens) { const normalizedToken = normalizeString(token.toLowerCase()); const matchIndex = normalizedContent.indexOf(normalizedToken); - + if (matchIndex !== -1) { // Center the snippet around the match snippetStart = Math.max(0, matchIndex - maxLength / 2); @@ -528,7 +526,7 @@ function extractContentSnippet(noteId: string, searchTokens: string[], maxLength snippet = lines.slice(0, 4).join('\n'); } // Add ellipsis if we truncated lines - snippet = snippet + "..."; + snippet = `${snippet }...`; } else if (lines.length > 1) { // For multi-line snippets that are 4 or fewer lines, keep them as-is // No need to truncate @@ -540,15 +538,15 @@ function extractContentSnippet(noteId: string, searchTokens: string[], maxLength if (firstSpace > 0 && firstSpace < 20) { snippet = snippet.substring(firstSpace + 1); } - snippet = "..." + snippet; + snippet = `...${ snippet}`; } - + if (snippetStart + maxLength < content.length) { const lastSpace = snippet.search(/\s[^\s]*$/); if (lastSpace > snippet.length - 20 && lastSpace > 0) { snippet = snippet.substring(0, lastSpace); } - snippet = snippet + "..."; + snippet = `${snippet }...`; } } @@ -572,20 +570,20 @@ function extractAttributeSnippet(noteId: string, searchTokens: string[], maxLeng return ""; } - let matchingAttributes: Array<{name: string, value: string, type: string}> = []; - + const matchingAttributes: Array<{name: string, value: string, type: string}> = []; + // Look for attributes that match the search tokens for (const attr of attributes) { const attrName = attr.name?.toLowerCase() || ""; const attrValue = attr.value?.toLowerCase() || ""; const attrType = attr.type || ""; - + // Check if any search token matches the attribute name or value const hasMatch = searchTokens.some(token => { const normalizedToken = normalizeString(token.toLowerCase()); return attrName.includes(normalizedToken) || attrValue.includes(normalizedToken); }); - + if (hasMatch) { matchingAttributes.push({ name: attr.name || "", @@ -611,20 +609,20 @@ function extractAttributeSnippet(noteId: string, searchTokens: string[], maxLeng const targetTitle = targetNote ? targetNote.title : attr.value; line = `~${attr.name}="${targetTitle}"`; } - + if (line) { lines.push(line); } } let snippet = lines.join('\n'); - + // Apply length limit while preserving line structure if (snippet.length > maxLength) { // Try to truncate at word boundaries but keep lines intact const truncated = snippet.substring(0, maxLength); const lastNewline = truncated.lastIndexOf('\n'); - + if (lastNewline > maxLength / 2) { // If we can keep most content by truncating to last complete line snippet = truncated.substring(0, lastNewline); @@ -632,7 +630,7 @@ function extractAttributeSnippet(noteId: string, searchTokens: string[], maxLeng // Otherwise just truncate and add ellipsis const lastSpace = truncated.lastIndexOf(' '); snippet = truncated.substring(0, lastSpace > maxLength / 2 ? lastSpace : maxLength - 3); - snippet = snippet + "..."; + snippet = `${snippet }...`; } } @@ -645,7 +643,7 @@ function extractAttributeSnippet(noteId: string, searchTokens: string[], maxLeng function searchNotesForAutocomplete(query: string, fastSearch: boolean = true) { const searchContext = new SearchContext({ - fastSearch: fastSearch, + fastSearch, includeArchivedNotes: false, includeHiddenNotes: true, fuzzyAttributeSearch: true, @@ -666,7 +664,7 @@ function searchNotesForAutocomplete(query: string, fastSearch: boolean = true) { highlightSearchResults(trimmed, searchContext.highlightedTokens, searchContext.ignoreInternalAttributes); return trimmed.map((result) => { - const { title, icon } = beccaService.getNoteTitleAndIcon(result.noteId); + const { title, icon } = becca_service.getNoteTitleAndIcon(result.noteId); return { notePath: result.notePath, noteTitle: title, @@ -698,7 +696,7 @@ function highlightSearchResults(searchResults: SearchResult[], highlightedTokens for (const result of searchResults) { result.highlightedNotePathTitle = result.notePathTitle.replace(/[<{}]/g, ""); - + // Initialize highlighted content snippet if (result.contentSnippet) { // Escape HTML but preserve newlines for later conversion to <br> @@ -706,7 +704,7 @@ function highlightSearchResults(searchResults: SearchResult[], highlightedTokens // Remove any stray < { } that might interfere with our highlighting markers result.highlightedContentSnippet = result.highlightedContentSnippet.replace(/[<{}]/g, ""); } - + // Initialize highlighted attribute snippet if (result.attributeSnippet) { // Escape HTML but preserve newlines for later conversion to <br> @@ -767,14 +765,14 @@ function highlightSearchResults(searchResults: SearchResult[], highlightedTokens if (result.highlightedNotePathTitle) { result.highlightedNotePathTitle = result.highlightedNotePathTitle.replace(/{/g, "<b>").replace(/}/g, "</b>"); } - + if (result.highlightedContentSnippet) { // Replace highlighting markers with HTML tags result.highlightedContentSnippet = result.highlightedContentSnippet.replace(/{/g, "<b>").replace(/}/g, "</b>"); // Convert newlines to <br> tags for HTML display result.highlightedContentSnippet = result.highlightedContentSnippet.replace(/\n/g, "<br>"); } - + if (result.highlightedAttributeSnippet) { // Replace highlighting markers with HTML tags result.highlightedAttributeSnippet = result.highlightedAttributeSnippet.replace(/{/g, "<b>").replace(/}/g, "</b>"); diff --git a/apps/server/src/services/sql_init.ts b/apps/server/src/services/sql_init.ts index 93452669f..c50b81acf 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<void>(); @@ -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 ef3bd6cba..7720e13f7 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<SyncContext> { const syncContext: SyncContext = { cookieJar: {} }; const resp = await syncRequest<SyncResponse>(syncContext, "POST", "/api/login/sync", { - timestamp: timestamp, + timestamp, syncVersion: appInfo.syncVersion, - hash: hash + hash }); if (!resp) { 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<T extends {}>(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<EntityRow>(/*sql*/`SELECT * FROM ${entityName} WHERE ${primaryKey} = ?`, [entityId]); - - if (!entityRow) { - log.error(`Cannot find entity for entity change ${JSON.stringify(entityChange)}`); - return null; - } - - if (entityName === "blobs" && entityRow.content !== null) { - if (typeof entityRow.content === "string") { - entityRow.content = Buffer.from(entityRow.content, "utf-8"); - } - - if (entityRow.content) { - entityRow.content = entityRow.content.toString("base64"); - } - } - - - return entityRow; } + const primaryKey = entity_constructor.getEntityFromEntityName(entityName).primaryKeyName; + + if (!primaryKey) { + throw new Error(`Unknown entity for entity change ${JSON.stringify(entityChange)}`); + } + + const entityRow = sql.getRow<EntityRow>(/*sql*/`SELECT * FROM ${entityName} WHERE ${primaryKey} = ?`, [entityId]); + + if (!entityRow) { + log.error(`Cannot find entity for entity change ${JSON.stringify(entityChange)}`); + return null; + } + + if (entityName === "blobs" && entityRow.content !== null) { + if (typeof entityRow.content === "string") { + entityRow.content = Buffer.from(entityRow.content, "utf-8"); + } + + if (entityRow.content) { + entityRow.content = 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 9d4ff5c4c..0e28f8b01 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 79122895b..59bd04f05 100644 --- a/apps/server/src/services/task_context.ts +++ b/apps/server/src/services/task_context.ts @@ -1,79 +1,2 @@ -"use strict"; - -import type { TaskData, TaskResult, TaskType, WebSocketMessage } from "@triliumnext/commons"; -import ws from "./ws.js"; - -// taskId => TaskContext -const taskContexts: Record<string, TaskContext<any>> = {}; - -class TaskContext<T extends TaskType> { - private taskId: string; - private taskType: TaskType; - private progressCount: number; - private lastSentCountTs: number; - data: TaskData<T>; - noteDeletionHandlerTriggered: boolean; - - constructor(taskId: string, taskType: T, data: TaskData<T>) { - this.taskId = taskId; - this.taskType = taskType; - this.data = data; - this.noteDeletionHandlerTriggered = false; - - // progressCount is meant to represent just some progress - to indicate the task is not stuck - this.progressCount = -1; // we're incrementing immediately - this.lastSentCountTs = 0; // 0 will guarantee the first message will be sent - - // just the fact this has been initialized is a progress which should be sent to clients - // this is esp. important when importing big files/images which take a long time to upload/process - // which means that first "real" increaseProgressCount() will be called quite late and user is without - // feedback until then - this.increaseProgressCount(); - } - - static getInstance<T extends TaskType>(taskId: string, taskType: T, data: TaskData<T>): TaskContext<T> { - if (!taskContexts[taskId]) { - taskContexts[taskId] = new TaskContext(taskId, taskType, data); - } - - return taskContexts[taskId]; - } - - increaseProgressCount() { - this.progressCount++; - - if (Date.now() - this.lastSentCountTs >= 300 && this.taskId !== "no-progress-reporting") { - this.lastSentCountTs = Date.now(); - - ws.sendMessageToAllClients({ - type: "taskProgressCount", - taskId: this.taskId, - taskType: this.taskType, - data: this.data, - progressCount: this.progressCount - } as WebSocketMessage); - } - } - - reportError(message: string) { - ws.sendMessageToAllClients({ - type: "taskError", - taskId: this.taskId, - taskType: this.taskType, - data: this.data, - message - } as WebSocketMessage); - } - - taskSucceeded(result: TaskResult<T>) { - ws.sendMessageToAllClients({ - type: "taskSucceeded", - taskId: this.taskId, - taskType: this.taskType, - data: this.data, - result - } as WebSocketMessage); - } -} - +import { TaskContext } from "@triliumnext/core"; export default TaskContext; diff --git a/apps/server/src/services/tray.ts b/apps/server/src/services/tray.ts index 504e81b21..95d158fa3 100644 --- a/apps/server/src/services/tray.ts +++ b/apps/server/src/services/tray.ts @@ -1,15 +1,15 @@ -import electron from "electron"; +import type { KeyboardActionNames } from "@triliumnext/commons"; +import { becca_service } from "@triliumnext/core"; import type { BrowserWindow, Tray } from "electron"; +import electron from "electron"; import { default as i18next, t } from "i18next"; import path from "path"; import becca from "../becca/becca.js"; -import becca_service from "../becca/becca_service.js"; import type BNote from "../becca/entities/bnote.js"; import type BRecentNote from "../becca/entities/brecent_note.js"; import cls from "./cls.js"; import date_notes from "./date_notes.js"; -import type { KeyboardActionNames } from "@triliumnext/commons"; import optionService from "./options.js"; import { getResourceDir, isDev, isMac } from "./utils.js"; import windowService from "./window.js"; @@ -31,9 +31,9 @@ function getTrayIconPath() { if (process.env.NODE_ENV === "development") { return path.join(__dirname, "../../../desktop/src/assets/images/tray", `${name}.png`); - } else { - return path.resolve(path.join(getResourceDir(), "assets", "images", "tray", `${name}.png`)); - } + } + return path.resolve(path.join(getResourceDir(), "assets", "images", "tray", `${name}.png`)); + } function getIconPath(name: string) { @@ -41,9 +41,9 @@ function getIconPath(name: string) { if (process.env.NODE_ENV === "development") { return path.join(__dirname, "../../../desktop/src/assets/images/tray", `${name}Template${suffix}.png`); - } else { - return path.resolve(path.join(getResourceDir(), "assets", "images", "tray", `${name}Template${suffix}.png`)); - } + } + return path.resolve(path.join(getResourceDir(), "assets", "images", "tray", `${name}Template${suffix}.png`)); + } function registerVisibilityListener(window: BrowserWindow) { @@ -74,7 +74,7 @@ function getWindowTitle(window: BrowserWindow | null) { // Limit title maximum length to 17 if (titleWithoutAppName.length > 20) { - return titleWithoutAppName.substring(0, 17) + '...'; + return `${titleWithoutAppName.substring(0, 17) }...`; } return titleWithoutAppName; diff --git a/apps/server/src/services/tree.ts b/apps/server/src/services/tree.ts index 05c9ecdd9..98b889292 100644 --- a/apps/server/src/services/tree.ts +++ b/apps/server/src/services/tree.ts @@ -1,280 +1,2 @@ -"use strict"; - -import sql from "./sql.js"; -import log from "./log.js"; -import BBranch from "../becca/entities/bbranch.js"; -import entityChangesService from "./entity_changes.js"; -import becca from "../becca/becca.js"; -import type BNote from "../becca/entities/bnote.js"; - -export interface ValidationResponse { - branch: BBranch | null; - success: boolean; - message?: string; -} - -function validateParentChild(parentNoteId: string, childNoteId: string, branchId: string | null = null): ValidationResponse { - if (["root", "_hidden", "_share", "_lbRoot", "_lbAvailableLaunchers", "_lbVisibleLaunchers"].includes(childNoteId)) { - return { branch: null, success: false, message: `Cannot change this note's location.` }; - } - - if (parentNoteId === "none") { - // this shouldn't happen - return { branch: null, success: false, message: `Cannot move anything into 'none' parent.` }; - } - - const existingBranch = becca.getBranchFromChildAndParent(childNoteId, parentNoteId); - - if (existingBranch && existingBranch.branchId !== branchId) { - const parentNote = becca.getNote(parentNoteId); - const childNote = becca.getNote(childNoteId); - - return { - branch: existingBranch, - success: false, - message: `Note "${childNote?.title}" note already exists in the "${parentNote?.title}".` - }; - } - - if (wouldAddingBranchCreateCycle(parentNoteId, childNoteId)) { - return { - branch: null, - success: false, - message: "Moving/cloning note here would create cycle." - }; - } - - if (parentNoteId !== "_lbBookmarks" && becca.getNote(parentNoteId)?.type === "launcher") { - return { - branch: null, - success: false, - message: "Launcher note cannot have any children." - }; - } - - return { branch: null, success: true }; -} - -/** - * Tree cycle can be created when cloning or when moving existing clone. This method should detect both cases. - */ -function wouldAddingBranchCreateCycle(parentNoteId: string, childNoteId: string) { - if (parentNoteId === childNoteId) { - return true; - } - - const childNote = becca.getNote(childNoteId); - const parentNote = becca.getNote(parentNoteId); - - if (!childNote || !parentNote) { - return false; - } - - // we'll load the whole subtree - because the cycle can start in one of the notes in the subtree - const childSubtreeNoteIds = new Set(childNote.getSubtreeNoteIds()); - const parentAncestorNoteIds = parentNote.getAncestorNoteIds(); - - return parentAncestorNoteIds.some((parentAncestorNoteId) => childSubtreeNoteIds.has(parentAncestorNoteId)); -} - -function sortNotes(parentNoteId: string, customSortBy: string = "title", reverse = false, foldersFirst = false, sortNatural = false, _sortLocale?: string | null) { - if (!customSortBy) { - customSortBy = "title"; - } - - // sortLocale can not be empty string or null value, default value must be set to undefined. - const sortLocale = _sortLocale || undefined; - - sql.transactional(() => { - const note = becca.getNote(parentNoteId); - if (!note) { - throw new Error("Unable to find note"); - } - - const notes = note.getChildNotes(); - - function normalize<T>(obj: T | string) { - return obj && typeof obj === "string" ? obj.toLowerCase() : obj; - } - - notes.sort((a, b) => { - if (foldersFirst) { - const aHasChildren = a.hasChildren(); - const bHasChildren = b.hasChildren(); - - if ((aHasChildren && !bHasChildren) || (!aHasChildren && bHasChildren)) { - // exactly one note of the two is a directory, so the sorting will be done based on this status - return aHasChildren ? -1 : 1; - } - } - - function fetchValue(note: BNote, key: string) { - let rawValue: string | null; - - if (key === "title") { - const branch = note.getParentBranches().find((branch) => branch.parentNoteId === parentNoteId); - const prefix = branch?.prefix; - rawValue = prefix ? `${prefix} - ${note.title}` : note.title; - } else { - rawValue = ["dateCreated", "dateModified"].includes(key) ? (note as any)[key] : note.getLabelValue(key); - } - - return normalize(rawValue); - } - - function compare(a: string, b: string) { - if (!sortNatural) { - // alphabetical sort - return b === null || b === undefined || a < b ? -1 : 1; - } else { - // natural sort - return a.localeCompare(b, sortLocale, { numeric: true, sensitivity: "base" }); - } - } - - const topAEl = fetchValue(a, "top"); - const topBEl = fetchValue(b, "top"); - - if (topAEl !== topBEl) { - if (topAEl === null) return reverse ? -1 : 1; - if (topBEl === null) return reverse ? 1 : -1; - - // since "top" should not be reversible, we'll reverse it once more to nullify this effect - return compare(topAEl, topBEl) * (reverse ? -1 : 1); - } - - const bottomAEl = fetchValue(a, "bottom"); - const bottomBEl = fetchValue(b, "bottom"); - - if (bottomAEl !== bottomBEl) { - if (bottomAEl === null) return reverse ? 1 : -1; - if (bottomBEl === null) return reverse ? -1 : 1; - - // since "bottom" should not be reversible, we'll reverse it once more to nullify this effect - return compare(bottomBEl, bottomAEl) * (reverse ? -1 : 1); - } - - const customAEl = fetchValue(a, customSortBy) ?? fetchValue(a, "title") as string; - const customBEl = fetchValue(b, customSortBy) ?? fetchValue(b, "title") as string; - - if (customAEl !== customBEl) { - return compare(customAEl, customBEl); - } - - const titleAEl = fetchValue(a, "title") as string; - const titleBEl = fetchValue(b, "title") as string; - - return compare(titleAEl, titleBEl); - }); - - if (reverse) { - notes.reverse(); - } - - let position = 10; - let someBranchUpdated = false; - - for (const note of notes) { - const branch = note.getParentBranches().find((b) => b.parentNoteId === parentNoteId); - if (!branch) { - continue; - } - - if (branch.noteId === "_hidden") { - position = 999_999_999; - } - - if (branch.notePosition !== position) { - sql.execute("UPDATE branches SET notePosition = ? WHERE branchId = ?", [position, branch.branchId]); - - branch.notePosition = position; - someBranchUpdated = true; - } - - position += 10; - } - - if (someBranchUpdated) { - entityChangesService.putNoteReorderingEntityChange(parentNoteId); - } - }); -} - -function sortNotesIfNeeded(parentNoteId: string) { - const parentNote = becca.getNote(parentNoteId); - if (!parentNote) { - return; - } - - const sortedLabel = parentNote.getLabel("sorted"); - - if (!sortedLabel || sortedLabel.value === "off") { - return; - } - - const sortReversed = parentNote.getLabelValue("sortDirection")?.toLowerCase() === "desc"; - const sortFoldersFirst = parentNote.isLabelTruthy("sortFoldersFirst"); - const sortNatural = parentNote.isLabelTruthy("sortNatural"); - const sortLocale = parentNote.getLabelValue("sortLocale"); - - sortNotes(parentNoteId, sortedLabel.value, sortReversed, sortFoldersFirst, sortNatural, sortLocale); -} - -/** - * @deprecated this will be removed in the future - */ -function setNoteToParent(noteId: string, prefix: string, parentNoteId: string) { - const parentNote = becca.getNote(parentNoteId); - - if (parentNoteId && !parentNote) { - // null parentNoteId is a valid value - throw new Error(`Cannot move note to deleted / missing parent note '${parentNoteId}'`); - } - - // case where there might be more such branches is ignored. It's expected there should be just one - const branchId = sql.getValue<string>("SELECT branchId FROM branches WHERE isDeleted = 0 AND noteId = ? AND prefix = ?", [noteId, prefix]); - const branch = becca.getBranch(branchId); - - if (branch) { - if (!parentNoteId) { - log.info(`Removing note '${noteId}' from parent '${parentNoteId}'`); - - branch.markAsDeleted(); - } else { - const newBranch = branch.createClone(parentNoteId); - newBranch.save(); - - branch.markAsDeleted(); - } - } else if (parentNoteId) { - const note = becca.getNote(noteId); - if (!note) { - throw new Error(`Cannot find note '${noteId}.`); - } - - if (note.isDeleted) { - throw new Error(`Cannot create a branch for '${noteId}' which is deleted.`); - } - - const branchId = sql.getValue<string>("SELECT branchId FROM branches WHERE isDeleted = 0 AND noteId = ? AND parentNoteId = ?", [noteId, parentNoteId]); - const branch = becca.getBranch(branchId); - - if (branch) { - branch.prefix = prefix; - branch.save(); - } else { - new BBranch({ - noteId: noteId, - parentNoteId: parentNoteId, - prefix: prefix - }).save(); - } - } -} - -export default { - validateParentChild, - sortNotes, - sortNotesIfNeeded, - setNoteToParent -}; +import { tree } from "@triliumnext/core"; +export default tree; diff --git a/apps/server/src/services/utils.ts b/apps/server/src/services/utils.ts index 370f9297f..5ad481211 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<T, K extends string | number | symbol, V>(array: T[], fn: (item: T) => [K, V]): Record<K, V> { const obj: Record<K, V> = {} as Record<K, V>; // TODO: unsafe? @@ -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<T>(promise: Promise<T>, limitMs: number, errorMessage? }); } +/** @deprecated */ export function removeDiacritic(str: string) { - if (!str) { - return ""; - } - str = str.toString(); - return str.normalize("NFD").replace(/\p{Diacritic}/gu, ""); + return coreUtils.removeDiacritic(str); } +/** @deprecated */ export function normalize(str: string) { - return removeDiacritic(str).toLowerCase(); + return coreUtils.normalize(str); } +/** @deprecated */ export function toMap<T extends Record<string, any>>(list: T[], key: keyof T) { - const map = new Map<string, T>(); - for (const el of list) { - const keyForMap = el[key]; - if (!keyForMap) continue; - // TriliumNextTODO: do we need to handle the case when the same key is used? - // currently this will overwrite the existing entry in the map - map.set(keyForMap, el); - } - return map; + return coreUtils.toMap(list, key); } // try to turn 'true' and 'false' strings from process.env variables into boolean values or undefined @@ -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 9dfcbc019..32e7f9c55 100644 --- a/apps/server/src/services/ws.ts +++ b/apps/server/src/services/ws.ts @@ -1,16 +1,16 @@ -import { WebSocketServer as WebSocketServer, WebSocket } from "ws"; -import { isElectron, randomString } from "./utils.js"; -import log from "./log.js"; -import sql from "./sql.js"; +import { type EntityChange,WebSocketMessage } from "@triliumnext/commons"; +import { AbstractBeccaEntity } from "@triliumnext/core"; +import type { IncomingMessage, Server as HttpServer } from "http"; +import { WebSocket,WebSocketServer } from "ws"; + +import becca from "../becca/becca.js"; import cls from "./cls.js"; import config from "./config.js"; -import syncMutexService from "./sync_mutex.js"; +import log from "./log.js"; import protectedSessionService from "./protected_session.js"; -import becca from "../becca/becca.js"; -import AbstractBeccaEntity from "../becca/entities/abstract_becca_entity.js"; - -import type { IncomingMessage, Server as HttpServer } from "http"; -import { WebSocketMessage, type EntityChange } from "@triliumnext/commons"; +import sql from "./sql.js"; +import syncMutexService from "./sync_mutex.js"; +import { isElectron, randomString } from "./utils.js"; let webSocketServer!: WebSocketServer; let lastSyncedPush: number; @@ -80,7 +80,7 @@ function sendMessageToAllClients(message: WebSocketMessage) { } let clientCount = 0; - webSocketServer.clients.forEach(function each(client) { + webSocketServer.clients.forEach((client) => { if (client.readyState === WebSocket.OPEN) { client.send(jsonStr); clientCount++; diff --git a/apps/server/src/share/content_renderer.ts b/apps/server/src/share/content_renderer.ts index e37ac8ab4..c777c5cbc 100644 --- a/apps/server/src/share/content_renderer.ts +++ b/apps/server/src/share/content_renderer.ts @@ -1,4 +1,4 @@ -import { sanitizeUrl } from "@braintree/sanitize-url"; +import { sanitize } from "@triliumnext/core"; import { highlightAuto } from "@triliumnext/highlightjs"; import ejs from "ejs"; import escapeHtml from "escape-html"; @@ -30,7 +30,7 @@ const templateCache: Map<string, string> = new Map(); */ export interface Result { header: string; - content: string | Buffer | undefined; + content: string | Uint8Array | undefined; /** Set to `true` if the provided content should be rendered as empty. */ isEmpty?: boolean; } @@ -491,7 +491,7 @@ function renderWebView(note: SNote | BNote, result: Result) { const url = note.getLabelValue("webViewSrc"); if (!url) return; - result.content = `<iframe class="webview" src="${sanitizeUrl(url)}" sandbox="allow-same-origin allow-scripts allow-popups"></iframe>`; + result.content = `<iframe class="webview" src="${sanitize.sanitizeUrl(url)}" sandbox="allow-same-origin allow-scripts allow-popups"></iframe>`; } export default { diff --git a/apps/server/src/share/shaca/entities/sattachment.ts b/apps/server/src/share/shaca/entities/sattachment.ts index 11d3af096..090eaf734 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<Pick<Blob, "content">>(/*sql*/`SELECT content FROM blobs WHERE blobId = ?`, [this.blobId]); + const row = sql.getRow<Pick<BlobRow, "content">>(/*sql*/`SELECT content FROM blobs WHERE blobId = ?`, [this.blobId]); if (!row) { if (silentNotFoundError) { return undefined; - } else { - throw new Error(`Cannot find blob for attachment '${this.attachmentId}', blob '${this.blobId}'`); } + throw new Error(`Cannot find blob for attachment '${this.attachmentId}', blob '${this.blobId}'`); + } const content = row.content; if (this.hasStringContent()) { - return content === null ? "" : content.toString("utf-8"); - } else { - return content; + return content === 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 da72cd419..ab51a434f 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<Pick<Blob, "content">>(/*sql*/`SELECT content FROM blobs WHERE blobId = ?`, [this.blobId]); + const row = sql.getRow<Pick<BlobRow, "content">>(/*sql*/`SELECT content FROM blobs WHERE blobId = ?`, [this.blobId]); if (!row) { if (silentNotFoundError) { @@ -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 c0834cb8b..4e374a784 100644 --- a/apps/server/src/share/shaca/shaca_loader.ts +++ b/apps/server/src/share/shaca/shaca_loader.ts @@ -1,15 +1,14 @@ -"use strict"; +import { events as eventService } from "@triliumnext/core"; -import sql from "../sql.js"; -import shaca from "./shaca.js"; import log from "../../services/log.js"; -import SNote from "./entities/snote.js"; -import SBranch from "./entities/sbranch.js"; -import SAttribute from "./entities/sattribute.js"; -import SAttachment from "./entities/sattachment.js"; import shareRoot from "../share_root.js"; -import eventService from "../../services/events.js"; +import sql from "../sql.js"; import type { SAttachmentRow, SAttributeRow, SBranchRow, SNoteRow } from "./entities/rows.js"; +import SAttachment from "./entities/sattachment.js"; +import SAttribute from "./entities/sattribute.js"; +import SBranch from "./entities/sbranch.js"; +import SNote from "./entities/snote.js"; +import shaca from "./shaca.js"; function load() { const start = Date.now(); diff --git a/packages/commons/src/lib/rows.ts b/packages/commons/src/lib/rows.ts index 809e36481..03599a8b6 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 a15192fd2..a26a8f286 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<ArrayBufferLike>; + 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 86ebfb0d6..cf1797361 100644 --- a/packages/commons/src/lib/test-utils.ts +++ b/packages/commons/src/lib/test-utils.ts @@ -55,10 +55,6 @@ export function trimIndentation(strings: TemplateStringsArray, ...values: any[]) return output.join("\n"); } -export function flushPromises() { - return new Promise(setImmediate); -} - export function sleepFor(duration: number) { return new Promise(resolve => setTimeout(resolve, duration)); } diff --git a/packages/commons/src/lib/ws_api.ts b/packages/commons/src/lib/ws_api.ts index 67beb0b42..5b2b164a3 100644 --- a/packages/commons/src/lib/ws_api.ts +++ b/packages/commons/src/lib/ws_api.ts @@ -18,7 +18,7 @@ export interface EntityChange { export interface EntityRow { isDeleted?: boolean; - content?: Buffer | string; + content?: Uint8Array | string; } export interface EntityChangeRecord { diff --git a/packages/commons/tsconfig.lib.json b/packages/commons/tsconfig.lib.json index 31ab54c99..754cce2a3 100644 --- a/packages/commons/tsconfig.lib.json +++ b/packages/commons/tsconfig.lib.json @@ -6,10 +6,7 @@ "outDir": "dist", "tsBuildInfoFile": "dist/tsconfig.lib.tsbuildinfo", "emitDeclarationOnly": true, - "forceConsistentCasingInFileNames": true, - "types": [ - "node" - ] + "forceConsistentCasingInFileNames": true }, "include": [ "src/**/*.ts" diff --git a/packages/commons/tsconfig.spec.json b/packages/commons/tsconfig.spec.json index 699ed8438..e56c0502f 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 index 7e6df61d3..fd2f90a18 100644 --- a/packages/trilium-core/package.json +++ b/packages/trilium-core/package.json @@ -5,5 +5,19 @@ "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 1a8203f43..0c2c3690d 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<RevisionRow | null>("SELECT * FROM revisions WHERE revisionId = ?", [revisionId]); + const row = getSql().getRow<RevisionRow | null>("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<AttachmentRow>(query, [attachmentId]).map((row) => new BAttachment(row))[0]; + return getSql().getRows<AttachmentRow>(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<AttachmentRow>("SELECT * FROM attachments WHERE attachmentId IN (???) AND isDeleted = 0", attachmentIds).map((row) => new BAttachment(row)); + return getSql().getManyRows<AttachmentRow>("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<BlobRow | null>("SELECT *, LENGTH(content) AS contentLength FROM blobs WHERE blobId = ?", [entity.blobId]); + const row = getSql().getRow<BlobRow | null>("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<BRecentNote>(query, params); + const rows = getSql().getRows<BRecentNote>(query, params); return rows.map((row) => new BRecentNote(row)); } getRevisionsFromQuery(query: string, params: string[] = []): BRevision[] { - const rows = sql.getRows<RevisionRow>(query, params); + const rows = getSql().getRows<RevisionRow>(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 000000000..d4270fe09 --- /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 f7faf1309..5ee7e252b 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<void>(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 92967da34..c7b5731e2 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 84% rename from apps/server/src/becca/entities/abstract_becca_entity.ts rename to packages/trilium-core/src/becca/entities/abstract_becca_entity.ts index 3b30eccd8..dd84718e8 100644 --- a/apps/server/src/becca/entities/abstract_becca_entity.ts +++ b/packages/trilium-core/src/becca/entities/abstract_becca_entity.ts @@ -1,14 +1,16 @@ +import eventService from "../../services/events"; + import blobService from "../../services/blob.js"; -import cls from "../../services/cls.js"; -import dateUtils from "../../services/date_utils.js"; +import * as cls from "../../services/context"; +import dateUtils from "../../services/utils/date"; import entityChangesService from "../../services/entity_changes.js"; -import eventService from "../../services/events.js"; -import log from "../../services/log.js"; +import { getLog } from "../../services/log.js"; import protectedSessionService from "../../services/protected_session.js"; -import sql from "../../services/sql.js"; -import utils from "../../services/utils.js"; import becca from "../becca.js"; import type { ConstructorData,default as Becca } from "../becca-interface.js"; +import { getSql } from "../../services/sql"; +import { concat2, encodeUtf8, unwrapStringOrBuffer, wrapStringOrBuffer } from "../../services/utils/binary"; +import { hash, hashedBlobId, newEntityId, randomString } from "../../services/utils"; interface ContentOpts { forceSave?: boolean; @@ -34,7 +36,7 @@ abstract class AbstractBeccaEntity<T extends AbstractBeccaEntity<T>> { protected beforeSaving(opts?: {}) { const constructorData = this.constructor as unknown as ConstructorData<T>; if (!(this as any)[constructorData.primaryKeyName]) { - (this as any)[constructorData.primaryKeyName] = utils.newEntityId(); + (this as any)[constructorData.primaryKeyName] = newEntityId(); } } @@ -70,7 +72,7 @@ abstract class AbstractBeccaEntity<T extends AbstractBeccaEntity<T>> { contentToHash += "|deleted"; } - return utils.hash(contentToHash).substr(0, 10); + return hash(contentToHash).substr(0, 10); } protected getPojoToSave() { @@ -109,6 +111,7 @@ abstract class AbstractBeccaEntity<T extends AbstractBeccaEntity<T>> { const pojo = this.getPojoToSave(); + const sql = getSql(); sql.transactional(() => { sql.upsert(entityName, primaryKeyName, pojo); @@ -135,7 +138,7 @@ abstract class AbstractBeccaEntity<T extends AbstractBeccaEntity<T>> { return this; } - protected _setContent(content: string | Buffer, opts: ContentOpts = {}) { + protected _setContent(content: string | Uint8Array, opts: ContentOpts = {}) { // client code asks to save entity even if blobId didn't change (something else was changed) opts.forceSave = !!opts.forceSave; opts.forceFrontendReload = !!opts.forceFrontendReload; @@ -146,9 +149,9 @@ abstract class AbstractBeccaEntity<T extends AbstractBeccaEntity<T>> { } if (this.hasStringContent()) { - content = content.toString(); + content = unwrapStringOrBuffer(content); } else { - content = Buffer.isBuffer(content) ? content : Buffer.from(content); + content = wrapStringOrBuffer(content); } const unencryptedContentForHashCalculation = this.getUnencryptedContentForHashCalculation(content); @@ -165,7 +168,7 @@ abstract class AbstractBeccaEntity<T extends AbstractBeccaEntity<T>> { } } - sql.transactional(() => { + getSql().transactional(() => { const newBlobId = this.saveBlob(content, unencryptedContentForHashCalculation, opts); const oldBlobId = this.blobId; @@ -181,6 +184,7 @@ abstract class AbstractBeccaEntity<T extends AbstractBeccaEntity<T>> { } private deleteBlobIfNotUsed(oldBlobId: string) { + const sql = getSql(); if (sql.getValue("SELECT 1 FROM notes WHERE blobId = ? LIMIT 1", [oldBlobId])) { return; } @@ -199,24 +203,29 @@ abstract class AbstractBeccaEntity<T extends AbstractBeccaEntity<T>> { sql.execute("DELETE FROM entity_changes WHERE entityName = 'blobs' AND entityId = ?", [oldBlobId]); } - private getUnencryptedContentForHashCalculation(unencryptedContent: Buffer | string) { + private getUnencryptedContentForHashCalculation(unencryptedContent: Uint8Array | string) { if (this.isProtected) { // a "random" prefix makes sure that the calculated hash/blobId is different for a decrypted/encrypted content const encryptedPrefixSuffix = "t$[nvQg7q)&_ENCRYPTED_?M:Bf&j3jr_"; - return Buffer.isBuffer(unencryptedContent) ? Buffer.concat([Buffer.from(encryptedPrefixSuffix), unencryptedContent]) : `${encryptedPrefixSuffix}${unencryptedContent}`; + if (typeof unencryptedContent === "string") { + return `${encryptedPrefixSuffix}${unencryptedContent}`; + } else { + return concat2(encodeUtf8(encryptedPrefixSuffix), unencryptedContent) + } } return unencryptedContent; } - private saveBlob(content: string | Buffer, unencryptedContentForHashCalculation: string | Buffer, opts: ContentOpts = {}) { + private saveBlob(content: string | Uint8Array, unencryptedContentForHashCalculation: string | Uint8Array, opts: ContentOpts = {}) { /* * We're using the unencrypted blob for the hash calculation, because otherwise the random IV would * cause every content blob to be unique which would balloon the database size (esp. with revisioning). * 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) { @@ -245,7 +254,7 @@ abstract class AbstractBeccaEntity<T extends AbstractBeccaEntity<T>> { isSynced: true, // overriding componentId will cause the frontend to think the change is coming from a different component // and thus reload - componentId: opts.forceFrontendReload ? utils.randomString(10) : null + componentId: opts.forceFrontendReload ? randomString(10) : null }); eventService.emit(eventService.ENTITY_CHANGED, { @@ -256,15 +265,16 @@ abstract class AbstractBeccaEntity<T extends AbstractBeccaEntity<T>> { 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<T>; throw new Error(`Cannot find content for ${constructorData.primaryKeyName} '${(this as any)[constructorData.primaryKeyName]}', blobId '${this.blobId}'`); } - return blobService.processContent(row.content, this.isProtected || false, this.hasStringContent()) as string | Buffer; + return blobService.processContent(row.content, this.isProtected || false, this.hasStringContent()) as string | Uint8Array; } /** @@ -279,6 +289,7 @@ abstract class AbstractBeccaEntity<T extends AbstractBeccaEntity<T>> { this.utcDateModified = dateUtils.utcNowDateTime(); + const sql = getSql(); sql.execute( /*sql*/`UPDATE ${entityName} SET isDeleted = 1, deleteId = ?, utcDateModified = ? WHERE ${constructorData.primaryKeyName} = ?`, @@ -291,7 +302,7 @@ abstract class AbstractBeccaEntity<T extends AbstractBeccaEntity<T>> { 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); @@ -305,13 +316,14 @@ abstract class AbstractBeccaEntity<T extends AbstractBeccaEntity<T>> { 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 000000000..027413a65 --- /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<BAttachment> { + 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<number>( + /*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 000000000..915601779 --- /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<BAttribute> { + 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 2cff185d5..a3ec26138 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<BBlob> { 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 000000000..1468e0459 --- /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<BBranch> { + 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 000000000..d76ede6bd --- /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 "<etapiTokenId>_<tokenHash>". This is also called "authToken" to distinguish it + * from tokenHash and token. + */ +class BEtapiToken extends AbstractBeccaEntity<BEtapiToken> { + 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 000000000..ad97d93fb --- /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<BNote> { + 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<NoteRow>) { + super(); + + if (!row) { + return; + } + + this.updateFromRow(row); + this.init(); + } + + updateFromRow(row: Partial<NoteRow>) { + 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 <i>strong</i> (as opposed to <i>weak</i>) 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<BNote>(); + + 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<BNote>(); + 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<RevisionRow>("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<AttachmentRow>(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<AttachmentRow>(query, [this.noteId, attachmentId]).map((row) => new BAttachment(row))[0]; + } + + getAttachmentsByRole(role: string): BAttachment[] { + return getSql() + .getRows<AttachmentRow>( + ` + 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() { + // 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<AttachmentRow, "ownerId">, 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 000000000..f0cd473e9 --- /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<BOption> { + 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 000000000..d950df369 --- /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<BRecentNote> { + 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 000000000..a3a9b4ce0 --- /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<BRevision> { + 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<AttachmentRow>( + ` + 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<AttachmentRow>(query, [this.revisionId, attachmentId]).map((row) => new BAttachment(row))[0]; + } + + getAttachmentsByRole(role: string): BAttachment[] { + return getSql() + .getRows<AttachmentRow>( + ` + 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 10a0e706d..2696c9c51 100644 --- a/apps/server/src/becca/similarity.ts +++ b/packages/trilium-core/src/becca/similarity.ts @@ -1,7 +1,7 @@ import becca from "./becca.js"; -import log from "../services/log.js"; +import { getLog } from "../services/log.js"; import beccaService from "./becca_service.js"; -import dateUtils from "../services/date_utils.js"; +import dateUtils from "../services/utils/date"; import { parse } from "node-html-parser"; import type BNote from "./entities/bnote.js"; import { SimilarNote } from "@triliumnext/commons"; @@ -359,7 +359,7 @@ async function findSimilarNotes(noteId: string): Promise<SimilarNote[] | undefin let factor = 1; if (!value.startsWith) { - log.info(`Unexpected falsy value for attribute ${JSON.stringify(attr.getPojo())}`); + getLog().info(`Unexpected falsy value for attribute ${JSON.stringify(attr.getPojo())}`); continue; } else if (value.startsWith("http")) { value = filterUrlValue(value); diff --git a/packages/trilium-core/src/errors.ts b/packages/trilium-core/src/errors.ts new file mode 100644 index 000000000..f2127f280 --- /dev/null +++ b/packages/trilium-core/src/errors.ts @@ -0,0 +1,46 @@ +export class HttpError extends Error { + + statusCode: number; + + constructor(message: string, statusCode: number) { + super(message); + this.name = "HttpError"; + this.statusCode = statusCode; + } + +} + +export class NotFoundError extends HttpError { + + constructor(message: string) { + super(message, 404); + this.name = "NotFoundError"; + } + +} + +export class ForbiddenError extends HttpError { + + constructor(message: string) { + super(message, 403); + this.name = "ForbiddenError"; + } + +} + +export class OpenIdError { + message: string; + + constructor(message: string) { + this.message = message; + } +} + +export class ValidationError extends HttpError { + + constructor(message: string) { + super(message, 400) + this.name = "ValidationError"; + } + +} diff --git a/packages/trilium-core/src/index.ts b/packages/trilium-core/src/index.ts index eee6b2d65..97aba9f2a 100644 --- a/packages/trilium-core/src/index.ts +++ b/packages/trilium-core/src/index.ts @@ -6,11 +6,54 @@ import { SqlService, SqlServiceParams } from "./services/sql/sql"; export type * from "./services/sql/types"; export * from "./services/sql/index"; -export * as protected_session from "./services/encryption/protected_session"; +export * as protected_session from "./services/protected_session"; export { default as data_encryption } from "./services/encryption/data_encryption" export * as binary_utils from "./services/utils/binary"; -export type { ExecutionContext } from "./services/context"; +export * as utils from "./services/utils/index"; +export * from "./services/build"; +export { default as date_utils } from "./services/utils/date"; +export { default as events } from "./services/events"; +export { default as blob } from "./services/blob"; +export { default as options } from "./services/options"; +export { default as options_init } from "./services/options_init"; +export { default as app_info } from "./services/app_info"; +export { default as keyboard_actions } from "./services/keyboard_actions"; +export { default as entity_changes } from "./services/entity_changes"; +export { default as hidden_subtree } from "./services/hidden_subtree"; +export { getContext, type ExecutionContext } from "./services/context"; +export * as cls from "./services/context"; +export * from "./errors"; +export { default as getInstanceId } from "./services/instance_id"; export type { CryptoProvider } from "./services/encryption/crypto"; +export { default as note_types } from "./services/note_types"; +export { default as tree } from "./services/tree"; +export { default as cloning } from "./services/cloning"; +export { default as handlers } from "./services/handlers"; +export { default as TaskContext } from "./services/task_context"; +export { default as revisions } from "./services/revisions"; +export { default as erase } from "./services/erase"; + +export { default as becca } from "./becca/becca"; +export { default as becca_loader } from "./becca/becca_loader"; +export { default as becca_service } from "./becca/becca_service"; +export { default as entity_constructor } from "./becca/entity_constructor"; +export { default as similarity } from "./becca/similarity"; +export { default as BAttachment } from "./becca/entities/battachment"; +export { default as BAttribute } from "./becca/entities/battribute"; +export { default as BBlob } from "./becca/entities/bblob"; +export { default as BBranch } from "./becca/entities/bbranch"; +export { default as BEtapiToken } from "./becca/entities/betapi_token"; +export { default as BNote, NOTE_TYPE_ICONS } from "./becca/entities/bnote"; +export { default as BOption } from "./becca/entities/boption"; +export { default as BRecentNote } from "./becca/entities/brecent_note"; +export { default as BRevision } from "./becca/entities/brevision"; +export { default as AbstractBeccaEntity } from "./becca/entities/abstract_becca_entity"; +export { default as Becca } from "./becca/becca-interface"; +export type { NotePojo } from "./becca/becca-interface"; + +export { default as NoteSet } from "./services/search/note_set"; +export { default as note_service, NoteParams } from "./services/notes"; +export * as sanitize from "./services/sanitizer"; export function initializeCore({ dbConfig, executionContext, crypto }: { dbConfig: SqlServiceParams, diff --git a/packages/trilium-core/src/services/app_info.ts b/packages/trilium-core/src/services/app_info.ts new file mode 100644 index 000000000..269b15b35 --- /dev/null +++ b/packages/trilium-core/src/services/app_info.ts @@ -0,0 +1,17 @@ +import build from "./build.js"; +import packageJson from "../../package.json" with { type: "json" }; +import { AppInfo } from "@triliumnext/commons"; + +const APP_DB_VERSION = 233; +const SYNC_VERSION = 36; +const CLIPPER_PROTOCOL_VERSION = "1.0"; + +export default { + appVersion: packageJson.version, + dbVersion: APP_DB_VERSION, + syncVersion: SYNC_VERSION, + buildDate: build.buildDate, + buildRevision: build.buildRevision, + clipperProtocolVersion: CLIPPER_PROTOCOL_VERSION, + utcDateTime: new Date().toISOString() +} satisfies AppInfo; diff --git a/apps/server/src/services/blob.ts b/packages/trilium-core/src/services/blob.ts similarity index 70% rename from apps/server/src/services/blob.ts rename to packages/trilium-core/src/services/blob.ts index 6804f1ecb..6792c7527 100644 --- a/apps/server/src/services/blob.ts +++ b/packages/trilium-core/src/services/blob.ts @@ -1,10 +1,9 @@ -import { binary_utils } from "@triliumnext/core"; - +import { BlobRow } from "@triliumnext/commons"; import becca from "../becca/becca.js"; -import NotFoundError from "../errors/not_found_error.js"; -import type { Blob } from "./blob-interface.js"; +import { NotFoundError } from "../errors"; import protectedSessionService from "./protected_session.js"; -import { hash } from "./utils.js"; +import { decodeUtf8 } from "./utils/binary.js"; +import { hash } from "./utils/index.js"; function getBlobPojo(entityName: string, entityId: string, opts?: { preview: boolean }) { // TODO: Unused opts. @@ -23,13 +22,13 @@ function getBlobPojo(entityName: string, entityId: string, opts?: { preview: boo if (!entity.hasStringContent()) { pojo.content = null; } else { - pojo.content = processContent(pojo.content, !!entity.isProtected, true) as string | Buffer; + pojo.content = processContent(pojo.content, !!entity.isProtected, true) as string | Uint8Array; } return pojo; } -function processContent(content: Buffer | Uint8Array | string | null, isProtected: boolean, isStringContent: boolean) { +function processContent(content: Uint8Array | string | null, isProtected: boolean, isStringContent: boolean) { if (isProtected) { if (protectedSessionService.isProtectedSessionAvailable()) { content = content === null ? null : protectedSessionService.decrypt(content as Uint8Array); @@ -39,19 +38,21 @@ function processContent(content: Buffer | Uint8Array | string | null, isProtecte } if (isStringContent) { - return content === null ? "" : binary_utils.decodeUtf8(content as Uint8Array); + if (content === null) return ""; + if (typeof content === "string") return content; + return decodeUtf8(content as Uint8Array); } // see https://github.com/zadam/trilium/issues/3523 // IIRC a zero-sized buffer can be returned as null from the database if (content === null) { // this will force de/encryption - content = Buffer.alloc(0); + content = new Uint8Array(0); } return content; } -function calculateContentHash({ blobId, content }: Blob) { +function calculateContentHash({ blobId, content }: Pick<BlobRow, "blobId" | "content">) { 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 000000000..741723bfa --- /dev/null +++ b/packages/trilium-core/src/services/cloning.ts @@ -0,0 +1,190 @@ +"use strict"; + +import eventChangesService from "./entity_changes.js"; +import treeService from "./tree.js"; +import BBranch from "../becca/entities/bbranch.js"; +import becca from "../becca/becca.js"; +import { getLog } from "./log.js"; +import { CloneResponse } from "@triliumnext/commons"; +import { getSql } from "./sql/index.js"; + +function cloneNoteToParentNote(noteId: string, parentNoteId: string, prefix: string | null = null): CloneResponse { + if (!(noteId in becca.notes) || !(parentNoteId in becca.notes)) { + return { success: false, message: "Note cannot be cloned because either the cloned note or the intended parent is deleted." }; + } + + const parentNote = becca.getNote(parentNoteId); + if (!parentNote) { + return { success: false, message: "Note cannot be cloned because the parent note could not be found." }; + } + + if (parentNote.type === "search") { + return { + success: false, + message: "Can't clone into a search note" + }; + } + + const validationResult = treeService.validateParentChild(parentNoteId, noteId); + + if (!validationResult.success) { + return validationResult; + } + + const branch = new BBranch({ + noteId: noteId, + parentNoteId: parentNoteId, + prefix: prefix, + isExpanded: false + }).save(); + + getLog().info(`Cloned note '${noteId}' to a new parent note '${parentNoteId}' with prefix '${prefix}'`); + + return { + success: true, + branchId: branch.branchId, + notePath: `${parentNote.getBestNotePathString()}/${noteId}` + }; +} + +function cloneNoteToBranch(noteId: string, parentBranchId: string, prefix?: string) { + const parentBranch = becca.getBranch(parentBranchId); + + if (!parentBranch) { + return { success: false, message: `Parent branch '${parentBranchId}' does not exist.` }; + } + + const ret = cloneNoteToParentNote(noteId, parentBranch.noteId, prefix); + + parentBranch.isExpanded = true; // the new target should be expanded, so it immediately shows up to the user + parentBranch.save(); + + return ret; +} + +function ensureNoteIsPresentInParent(noteId: string, parentNoteId: string, prefix?: string) { + if (!(noteId in becca.notes)) { + return { branch: null, success: false, message: `Note '${noteId}' is deleted.` }; + } else if (!(parentNoteId in becca.notes)) { + return { branch: null, success: false, message: `Note '${parentNoteId}' is deleted.` }; + } + + const parentNote = becca.getNote(parentNoteId); + + if (!parentNote) { + return { branch: null, success: false, message: "Can't find parent note." }; + } + if (parentNote.type === "search") { + return { branch: null, success: false, message: "Can't clone into a search note" }; + } + + const validationResult = treeService.validateParentChild(parentNoteId, noteId); + + if (!validationResult.success) { + return validationResult; + } + + const branch = new BBranch({ + noteId: noteId, + parentNoteId: parentNoteId, + prefix: prefix, + isExpanded: false + }).save(); + + getLog().info(`Ensured note '${noteId}' is in parent note '${parentNoteId}' with prefix '${branch.prefix}'`); + + return { branch: branch, success: true }; +} + +function ensureNoteIsAbsentFromParent(noteId: string, parentNoteId: string) { + const branchId = getSql().getValue<string>(/*sql*/`SELECT branchId FROM branches WHERE noteId = ? AND parentNoteId = ? AND isDeleted = 0`, [noteId, parentNoteId]); + const branch = becca.getBranch(branchId); + + if (branch) { + if (!branch.isWeak && branch.getNote().getStrongParentBranches().length <= 1) { + return { + success: false, + message: `Cannot remove branch '${branch.branchId}' between child '${noteId}' and parent '${parentNoteId}' because this would delete the note as well.` + }; + } + + branch.deleteBranch(); + + getLog().info(`Ensured note '${noteId}' is NOT in parent note '${parentNoteId}'`); + + return { success: true }; + } +} + +function toggleNoteInParent(present: boolean, noteId: string, parentNoteId: string, prefix?: string) { + if (present) { + return ensureNoteIsPresentInParent(noteId, parentNoteId, prefix); + } else { + return ensureNoteIsAbsentFromParent(noteId, parentNoteId); + } +} + +function cloneNoteAfter(noteId: string, afterBranchId: string) { + if (["_hidden", "root"].includes(noteId)) { + return { success: false, message: `Cloning the note '${noteId}' is forbidden.` }; + } + + const afterBranch = becca.getBranch(afterBranchId); + + if (!afterBranch) { + return { success: false, message: `Branch '${afterBranchId}' does not exist.` }; + } + + if (afterBranch.noteId === "_hidden") { + return { success: false, message: "Cannot clone after the hidden branch." }; + } + + const afterNote = becca.getBranch(afterBranchId); + + if (!(noteId in becca.notes)) { + return { success: false, message: `Note to be cloned '${noteId}' is deleted or does not exist.` }; + } else if (!afterNote || !(afterNote.parentNoteId in becca.notes)) { + return { success: false, message: `After note '${afterNote?.parentNoteId}' is deleted or does not exist.` }; + } + + const parentNote = becca.getNote(afterNote.parentNoteId); + + if (!parentNote || parentNote.type === "search") { + return { + success: false, + message: "Can't clone into a search note" + }; + } + + const validationResult = treeService.validateParentChild(afterNote.parentNoteId, noteId); + + if (!validationResult.success) { + return validationResult; + } + + // we don't change utcDateModified, so other changes are prioritized in case of conflict + // also we would have to sync all those modified branches otherwise hash checks would fail + getSql().execute("UPDATE branches SET notePosition = notePosition + 10 WHERE parentNoteId = ? AND notePosition > ? AND isDeleted = 0", [afterNote.parentNoteId, afterNote.notePosition]); + + eventChangesService.putNoteReorderingEntityChange(afterNote.parentNoteId); + + const branch = new BBranch({ + noteId: noteId, + parentNoteId: afterNote.parentNoteId, + notePosition: afterNote.notePosition + 10, + isExpanded: false + }).save(); + + getLog().info(`Cloned note '${noteId}' into parent note '${afterNote.parentNoteId}' after note '${afterNote.noteId}', branch '${afterBranchId}'`); + + return { success: true, branchId: branch.branchId }; +} + +export default { + cloneNoteToBranch, + cloneNoteToParentNote, + ensureNoteIsPresentInParent, + ensureNoteIsAbsentFromParent, + toggleNoteInParent, + cloneNoteAfter +}; diff --git a/packages/trilium-core/src/services/context.ts b/packages/trilium-core/src/services/context.ts index 99cf35665..91db0e836 100644 --- a/packages/trilium-core/src/services/context.ts +++ b/packages/trilium-core/src/services/context.ts @@ -1,3 +1,5 @@ +import { EntityChange } from "@triliumnext/commons"; + export interface ExecutionContext { init<T>(fn: () => T): T; get<T = any>(key: string): T | undefined; @@ -16,3 +18,54 @@ 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 index 94ebcb1ad..87cd8fb6c 100644 --- a/packages/trilium-core/src/services/encryption/crypto.ts +++ b/packages/trilium-core/src/services/encryption/crypto.ts @@ -5,8 +5,9 @@ interface Cipher { export interface CryptoProvider { - createHash(algorithm: "sha1", content: string | Uint8Array): Uint8Array; + createHash(algorithm: "sha1" | "sha512", content: string | Uint8Array): Uint8Array; randomBytes(size: number): Uint8Array; + randomString(length: number): string; createCipheriv(algorithm: "aes-128-cbc", key: Uint8Array, iv: Uint8Array): Cipher; createDecipheriv(algorithm: "aes-128-cbc", key: Uint8Array, iv: Uint8Array): Cipher; } diff --git a/packages/trilium-core/src/services/encryption/data_encryption.ts b/packages/trilium-core/src/services/encryption/data_encryption.ts index e6bb9d1e8..ffae2c1e9 100644 --- a/packages/trilium-core/src/services/encryption/data_encryption.ts +++ b/packages/trilium-core/src/services/encryption/data_encryption.ts @@ -1,5 +1,5 @@ import { getLog } from "../log.js"; -import { concat2, decodeBase64, decodeUtf8, encodeBase64 } from "../utils/binary.js"; +import { concat2, decodeBase64, decodeUtf8, encodeBase64, encodeUtf8 } from "../utils/binary.js"; import { getCrypto } from "./crypto.js"; function arraysIdentical(a: any[] | Uint8Array, b: any[] | Uint8Array) { @@ -55,7 +55,7 @@ function decrypt(key: Uint8Array, cipherText: string | Uint8Array): Uint8Array | } if (!key) { - return Uint8Array.from("[protected]"); + return encodeUtf8("[protected]"); } try { diff --git a/packages/trilium-core/src/services/entity_changes.ts b/packages/trilium-core/src/services/entity_changes.ts new file mode 100644 index 000000000..c2f6ebb1f --- /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<EntityChange>(/*sql*/`SELECT * FROM entity_changes WHERE entityName = ? AND SUBSTR(entityId, 1, 1) = ?`, [entityName, sector]); + + let entitiesInserted = entityChanges.length; + + sql.transactional(() => { + if (entityName === "blobs") { + entitiesInserted += addEntityChangesForDependingEntity(sector, "notes", "noteId"); + entitiesInserted += addEntityChangesForDependingEntity(sector, "attachments", "attachmentId"); + entitiesInserted += addEntityChangesForDependingEntity(sector, "revisions", "revisionId"); + } + + for (const ec of entityChanges) { + putEntityChangeWithForcedChange(ec); + } + }); + + getLog().info(`Added sector ${sector} of '${entityName}' (${entitiesInserted} entities) to the sync queue.`); +} + +function addEntityChangesForDependingEntity(sector: string, tableName: string, primaryKeyColumn: string) { + // problem in blobs might be caused by problem in entity referencing the blob + const dependingEntityChanges = getSql().getRows<EntityChange>( + ` + SELECT dep_change.* + FROM entity_changes orig_sector + JOIN ${tableName} ON ${tableName}.blobId = orig_sector.entityId + JOIN entity_changes dep_change ON dep_change.entityName = '${tableName}' AND dep_change.entityId = ${tableName}.${primaryKeyColumn} + WHERE orig_sector.entityName = 'blobs' AND SUBSTR(orig_sector.entityId, 1, 1) = ?`, + [sector] + ); + + for (const ec of dependingEntityChanges) { + putEntityChangeWithForcedChange(ec); + } + + return dependingEntityChanges.length; +} + +function cleanupEntityChangesForMissingEntities(entityName: string, entityPrimaryKey: string) { + getSql().execute(` + DELETE + FROM entity_changes + WHERE + isErased = 0 + AND entityName = '${entityName}' + AND entityId NOT IN (SELECT ${entityPrimaryKey} FROM ${entityName})`); +} + +function fillEntityChanges(entityName: string, entityPrimaryKey: string, condition = "") { + cleanupEntityChangesForMissingEntities(entityName, entityPrimaryKey); + + const sql = getSql(); + sql.transactional(() => { + const entityIds = sql.getColumn<string>(/*sql*/`SELECT ${entityPrimaryKey} FROM ${entityName} ${condition}`); + + let createdCount = 0; + + for (const entityId of entityIds) { + const existingRows = sql.getValue("SELECT COUNT(1) FROM entity_changes WHERE entityName = ? AND entityId = ?", [entityName, entityId]); + + if (existingRows !== 0) { + // we don't want to replace existing entities (which would effectively cause full resync) + continue; + } + + createdCount++; + + const ec: Partial<EntityChange> = { + entityName, + entityId, + isErased: false + }; + + if (entityName === "blobs") { + const blob = sql.getRow<Pick<BlobRow, "blobId" | "content" | "utcDateModified">>("SELECT blobId, content, utcDateModified FROM blobs WHERE blobId = ?", [entityId]); + ec.hash = blobService.calculateContentHash(blob); + ec.utcDateChanged = blob.utcDateModified; + ec.isSynced = true; // blobs are always synced + } else { + const entity = becca.getEntity(entityName, entityId); + + if (entity) { + ec.hash = entity.generateHash(); + ec.utcDateChanged = entity.getUtcDateChanged() || dateUtils.utcNowDateTime(); + ec.isSynced = entityName !== "options" || !!entity.isSynced; + } else { + // entity might be null (not present in becca) when it's deleted + // this will produce different hash value than when entity is being deleted since then + // all normal hashed attributes are being used. Sync should recover from that, though. + ec.hash = "deleted"; + ec.utcDateChanged = dateUtils.utcNowDateTime(); + ec.isSynced = true; // deletable (the ones with isDeleted) entities are synced + } + } + + putEntityChange(ec as EntityChange); + } + + if (createdCount > 0) { + getLog().info(`Created ${createdCount} missing entity changes for entity '${entityName}'.`); + } + }); +} + +function fillAllEntityChanges() { + const sql = getSql(); + sql.transactional(() => { + sql.execute("DELETE FROM entity_changes WHERE isErased = 0"); + + fillEntityChanges("notes", "noteId"); + fillEntityChanges("branches", "branchId"); + fillEntityChanges("revisions", "revisionId"); + fillEntityChanges("attachments", "attachmentId"); + fillEntityChanges("blobs", "blobId"); + fillEntityChanges("attributes", "attributeId"); + fillEntityChanges("etapi_tokens", "etapiTokenId"); + fillEntityChanges("options", "name", "WHERE isSynced = 1"); + }); +} + +function recalculateMaxEntityChangeId() { + maxEntityChangeId = getSql().getValue<number>("SELECT COALESCE(MAX(id), 0) FROM entity_changes"); +} + +export default { + putNoteReorderingEntityChange, + putEntityChangeForOtherInstances, + putEntityChangeWithForcedChange, + putEntityChange, + putEntityChangeWithInstanceId, + fillAllEntityChanges, + addEntityChangesForSector, + getMaxEntityChangeId: () => maxEntityChangeId, + recalculateMaxEntityChangeId +}; diff --git a/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 92b28e573..91e63cd6a 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<string>("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<string>("SELECT attachmentId FROM attachments WHERE utcDateScheduledForErasureSince < ?", [cutOffDate]); + const attachmentIdsToErase = getSql().getColumn<string>("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 5ffc93f82..dcdc5793c 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 log, { 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 000000000..1192fbc8f --- /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<any>) { + if (!note) { + return; + } + + // the same script note can get here with multiple ways, but execute only once + const notesToRun = new Set( + note + .getRelations(relationName) + .map((relation) => relation.getTargetNote()) + .filter((note) => !!note) as BNote[] + ); + + for (const noteToRun of notesToRun) { + scriptService.executeNoteNoException(noteToRun, { originEntity }); + } +} + +eventService.subscribe(eventService.NOTE_TITLE_CHANGED, (note) => { + runAttachedRelations(note, "runOnNoteTitleChange", note); + + if (!note.isRoot()) { + const noteFromCache = becca.notes[note.noteId]; + + if (!noteFromCache) { + return; + } + + for (const parentNote of noteFromCache.parents) { + if (parentNote.hasLabel("sorted")) { + treeService.sortNotesIfNeeded(parentNote.noteId); + } + } + } +}); + +eventService.subscribe([eventService.ENTITY_CHANGED, eventService.ENTITY_DELETED], ({ entityName, entity }) => { + if (entityName === "attributes") { + runAttachedRelations(entity.getNote(), "runOnAttributeChange", entity); + + if (entity.type === "label" && ["sorted", "sortDirection", "sortFoldersFirst", "sortNatural", "sortLocale"].includes(entity.name)) { + handleSortedAttribute(entity); + } else if (entity.type === "label") { + handleMaybeSortingLabel(entity); + } + } else if (entityName === "notes") { + // ENTITY_DELETED won't trigger anything since all branches/attributes are already deleted at this point + runAttachedRelations(entity, "runOnNoteChange", entity); + } +}); + +eventService.subscribe(eventService.ENTITY_CHANGED, ({ entityName, entity }) => { + if (entityName === "branches") { + const parentNote = becca.getNote(entity.parentNoteId); + + if (parentNote?.hasLabel("sorted")) { + treeService.sortNotesIfNeeded(parentNote.noteId); + } + + const childNote = becca.getNote(entity.noteId); + + if (childNote) { + runAttachedRelations(childNote, "runOnBranchChange", entity); + } + } +}); + +eventService.subscribe(eventService.NOTE_CONTENT_CHANGE, ({ entity }) => { + runAttachedRelations(entity, "runOnNoteContentChange", entity); +}); + +eventService.subscribe(eventService.ENTITY_CREATED, ({ entityName, entity }) => { + if (entityName === "attributes") { + runAttachedRelations(entity.getNote(), "runOnAttributeCreation", entity); + + if (entity.type === "relation" && entity.name === "template") { + const note = becca.getNote(entity.noteId); + if (!note) { + return; + } + + const templateNote = becca.getNote(entity.value); + + if (!templateNote) { + return; + } + + const content = note.getContent(); + + if ( + ["text", "code", "mermaid", "canvas", "relationMap", "mindMap", "webView"].includes(note.type) && + typeof content === "string" && + // if the note has already content we're not going to overwrite it with template's one + (!content || content.trim().length === 0) && + templateNote.hasStringContent() + ) { + const templateNoteContent = templateNote.getContent(); + + if (templateNoteContent) { + note.setContent(templateNoteContent); + } + + note.type = templateNote.type; + note.mime = templateNote.mime; + note.save(); + } + + // we'll copy the children notes only if there's none so far + // this protects against e.g. multiple assignment of template relation resulting in having multiple copies of the subtree + if (note.getChildNotes().length === 0 && !note.isDescendantOfNote(templateNote.noteId)) { + noteService.duplicateSubtreeWithoutRoot(templateNote.noteId, note.noteId); + } + } else if (entity.type === "label" && ["sorted", "sortDirection", "sortFoldersFirst", "sortNatural", "sortLocale"].includes(entity.name)) { + handleSortedAttribute(entity); + } else if (entity.type === "label") { + handleMaybeSortingLabel(entity); + } + } else if (entityName === "branches") { + runAttachedRelations(entity.getNote(), "runOnBranchCreation", entity); + + if (entity.parentNote?.hasLabel("sorted")) { + treeService.sortNotesIfNeeded(entity.parentNoteId); + } + } else if (entityName === "notes") { + runAttachedRelations(entity, "runOnNoteCreation", entity); + } +}); + +eventService.subscribe(eventService.CHILD_NOTE_CREATED, ({ parentNote, childNote }) => { + runAttachedRelations(parentNote, "runOnChildNoteCreation", childNote); +}); + +function processInverseRelations(entityName: string, entity: BAttribute, handler: Handler) { + if (entityName === "attributes" && entity.type === "relation") { + const note = entity.getNote(); + const relDefinitions = note.getLabels(`relation:${entity.name}`); + + for (const relDefinition of relDefinitions) { + const definition = relDefinition.getDefinition(); + + if (definition.inverseRelation && definition.inverseRelation.trim()) { + const targetNote = entity.getTargetNote(); + + if (targetNote) { + handler(definition, note, targetNote); + } + } + } + } +} + +function handleSortedAttribute(entity: BAttribute) { + treeService.sortNotesIfNeeded(entity.noteId); + + if (entity.isInheritable) { + const note = becca.notes[entity.noteId]; + + if (note) { + for (const noteId of note.getSubtreeNoteIds()) { + treeService.sortNotesIfNeeded(noteId); + } + } + } +} + +function handleMaybeSortingLabel(entity: BAttribute) { + // check if this label is used for sorting, if yes force re-sort + const note = becca.notes[entity.noteId]; + + // this will not work on deleted notes, but in that case we don't really need to re-sort + if (note) { + for (const parentNote of note.getParentNotes()) { + const sorted = parentNote.getLabelValue("sorted"); + if (sorted === null) { + // checking specifically for null since that means the label doesn't exist + // empty valued "sorted" is still valid + continue; + } + + if ( + sorted.includes(entity.name) || // hacky check if this label is used in the sort + entity.name === "top" || + entity.name === "bottom" + ) { + treeService.sortNotesIfNeeded(parentNote.noteId); + } + } + } +} + +eventService.subscribe(eventService.ENTITY_CHANGED, ({ entityName, entity }) => { + processInverseRelations(entityName, entity, (definition, note, targetNote) => { + // we need to make sure that also target's inverse attribute exists and if not, then create it + // inverse attribute has to target our note as well + const hasInverseAttribute = targetNote.getRelations(definition.inverseRelation).some((attr) => attr.value === note.noteId); + + if (!hasInverseAttribute) { + new BAttribute({ + noteId: targetNote.noteId, + type: "relation", + name: definition.inverseRelation || "", + value: note.noteId, + isInheritable: entity.isInheritable + }).save(); + + // becca will not be updated before we'll check from the other side which would create infinite relation creation (#2269) + targetNote.invalidateThisCache(); + } + }); +}); + +eventService.subscribe(eventService.ENTITY_DELETED, ({ entityName, entity }) => { + processInverseRelations(entityName, entity, (definition: DefinitionObject, note: BNote, targetNote: BNote) => { + // if one inverse attribute is deleted, then the other should be deleted as well + const relations = targetNote.getOwnedRelations(definition.inverseRelation); + + for (const relation of relations) { + if (relation.value === note.noteId) { + relation.markAsDeleted(); + } + } + }); + + if (entityName === "branches") { + runAttachedRelations(entity.getNote(), "runOnBranchDeletion", entity); + } + + if (entityName === "notes" && entity.noteId.startsWith("_")) { + // "named" note has been deleted, we will probably need to rebuild the hidden subtree + // scheduling so that bulk deletes won't trigger so many checks + oneTimeTimer.scheduleExecution("hidden-subtree-check", 1000, () => hiddenSubtreeService.checkHiddenSubtree()); + } +}); + +export default { + runAttachedRelations +}; diff --git a/packages/trilium-core/src/services/hidden_subtree.ts b/packages/trilium-core/src/services/hidden_subtree.ts new file mode 100644 index 000000000..ba8da6da1 --- /dev/null +++ b/packages/trilium-core/src/services/hidden_subtree.ts @@ -0,0 +1,499 @@ +import BAttribute from "../becca/entities/battribute.js"; +import BBranch from "../becca/entities/bbranch.js"; +import type { HiddenSubtreeItem } from "@triliumnext/commons"; + +import becca from "../becca/becca.js"; +import noteService from "./notes.js"; +import { getLog } from "./log.js"; +import * as migrationService from "./migration.js"; +import { t } from "i18next"; +import { cleanUpHelp, getHelpHiddenSubtreeData } from "./in_app_help.js"; +import buildLaunchBarConfig from "./hidden_subtree_launcherbar.js"; +import buildHiddenSubtreeTemplates from "./hidden_subtree_templates.js"; + +export const LBTPL_ROOT = "_lbTplRoot"; +export const LBTPL_BASE = "_lbTplBase"; +export const LBTPL_HEADER = "_lbTplHeader"; +export const LBTPL_NOTE_LAUNCHER = "_lbTplLauncherNote"; +export const LBTPL_WIDGET = "_lbTplLauncherWidget"; +export const LBTPL_COMMAND = "_lbTplLauncherCommand"; +export const LBTPL_SCRIPT = "_lbTplLauncherScript"; +export const LBTPL_SPACER = "_lbTplSpacer"; +export const LBTPL_CUSTOM_WIDGET = "_lbTplCustomWidget"; + +/* + * Hidden subtree is generated as a "predictable structure" which means that it avoids generating random IDs to always + * produce the same structure. This is needed because it is run on multiple instances in the sync cluster which might produce + * duplicate subtrees. This way, all instances will generate the same structure with the same IDs. + */ + +let hiddenSubtreeDefinition: HiddenSubtreeItem; + +function buildHiddenSubtreeDefinition(helpSubtree: HiddenSubtreeItem[]): HiddenSubtreeItem { + const launchbarConfig = buildLaunchBarConfig(); + + return { + id: "_hidden", + title: t("hidden-subtree.root-title"), + type: "doc", + icon: "bx bx-hide", + // we want to keep the hidden subtree always last, otherwise there will be problems with e.g., keyboard navigation + // over tree when it's in the middle + notePosition: 999_999_999, + enforceAttributes: true, + attributes: [ + { type: "label", name: "docName", value: "hidden" } + ], + children: [ + { + id: "_search", + title: t("hidden-subtree.search-history-title"), + type: "doc" + }, + { + id: "_globalNoteMap", + title: t("hidden-subtree.note-map-title"), + type: "noteMap", + attributes: [ + { type: "label", name: "mapRootNoteId", value: "hoisted" }, + { type: "label", name: "keepCurrentHoisting" } + ] + }, + { + id: "_sqlConsole", + title: t("hidden-subtree.sql-console-history-title"), + type: "doc", + icon: "bx-data" + }, + { + id: "_share", + title: t("hidden-subtree.shared-notes-title"), + type: "doc", + attributes: [{ type: "label", name: "docName", value: "share" }] + }, + { + id: "_bulkAction", + title: t("hidden-subtree.bulk-action-title"), + type: "doc" + }, + { + id: "_backendLog", + title: t("hidden-subtree.backend-log-title"), + type: "contentWidget", + icon: "bx-terminal", + attributes: [ + { type: "label", name: "keepCurrentHoisting" }, + { type: "label", name: "fullContentWidth" } + ] + }, + { + // place for user scripts hidden stuff (scripts should not create notes directly under hidden root) + id: "_userHidden", + title: t("hidden-subtree.user-hidden-title"), + type: "doc", + attributes: [{ type: "label", name: "docName", value: "user_hidden" }] + }, + { + id: LBTPL_ROOT, + title: t("hidden-subtree.launch-bar-templates-title"), + type: "doc", + children: [ + { + id: LBTPL_BASE, + title: t("hidden-subtree.base-abstract-launcher-title"), + type: "doc" + }, + { + id: LBTPL_COMMAND, + title: t("hidden-subtree.command-launcher-title"), + type: "doc", + attributes: [ + { type: "relation", name: "template", value: LBTPL_BASE }, + { type: "label", name: "launcherType", value: "command" }, + { type: "label", name: "docName", value: "launchbar_command_launcher" } + ] + }, + { + id: LBTPL_NOTE_LAUNCHER, + title: t("hidden-subtree.note-launcher-title"), + type: "doc", + attributes: [ + { type: "relation", name: "template", value: LBTPL_BASE }, + { type: "label", name: "launcherType", value: "note" }, + { type: "label", name: "relation:target", value: "promoted" }, + { type: "label", name: "relation:hoistedNote", value: "promoted" }, + { type: "label", name: "label:keyboardShortcut", value: "promoted,text" }, + { type: "label", name: "docName", value: "launchbar_note_launcher" } + ] + }, + { + id: LBTPL_SCRIPT, + title: t("hidden-subtree.script-launcher-title"), + type: "doc", + attributes: [ + { type: "relation", name: "template", value: LBTPL_BASE }, + { type: "label", name: "launcherType", value: "script" }, + { type: "label", name: "relation:script", value: "promoted" }, + { type: "label", name: "label:keyboardShortcut", value: "promoted,text" }, + { type: "label", name: "docName", value: "launchbar_script_launcher" } + ] + }, + { + id: LBTPL_WIDGET, + title: t("hidden-subtree.built-in-widget-title"), + type: "doc", + attributes: [ + { type: "relation", name: "template", value: LBTPL_BASE }, + { type: "label", name: "launcherType", value: "builtinWidget" } + ] + }, + { + id: LBTPL_SPACER, + title: t("hidden-subtree.spacer-title"), + type: "doc", + icon: "bx-move-vertical", + attributes: [ + { type: "relation", name: "template", value: LBTPL_WIDGET }, + { type: "label", name: "builtinWidget", value: "spacer" }, + { type: "label", name: "label:baseSize", value: "promoted,number" }, + { type: "label", name: "label:growthFactor", value: "promoted,number" }, + { type: "label", name: "docName", value: "launchbar_spacer" } + ] + }, + { + id: LBTPL_CUSTOM_WIDGET, + title: t("hidden-subtree.custom-widget-title"), + type: "doc", + attributes: [ + { type: "relation", name: "template", value: LBTPL_BASE }, + { type: "label", name: "launcherType", value: "customWidget" }, + { type: "label", name: "relation:widget", value: "promoted" }, + { type: "label", name: "docName", value: "launchbar_widget_launcher" } + ] + } + ] + }, + { + id: "_lbRoot", + title: t("hidden-subtree.launch-bar-title"), + type: "doc", + icon: "bx-sidebar", + isExpanded: true, + attributes: [{ type: "label", name: "docName", value: "launchbar_intro" }], + children: [ + { + id: "_lbAvailableLaunchers", + title: t("hidden-subtree.available-launchers-title"), + type: "doc", + icon: "bx-hide", + isExpanded: true, + attributes: [{ type: "label", name: "docName", value: "launchbar_intro" }], + children: launchbarConfig.desktopAvailableLaunchers + }, + { + id: "_lbVisibleLaunchers", + title: t("hidden-subtree.visible-launchers-title"), + type: "doc", + icon: "bx-show", + isExpanded: true, + attributes: [{ type: "label", name: "docName", value: "launchbar_intro" }], + children: launchbarConfig.desktopVisibleLaunchers + } + ] + }, + { + id: "_lbMobileRoot", + title: "Mobile Launch Bar", + type: "doc", + icon: "bx-mobile", + isExpanded: true, + attributes: [{ type: "label", name: "docName", value: "launchbar_intro" }], + children: [ + { + id: "_lbMobileAvailableLaunchers", + title: t("hidden-subtree.available-launchers-title"), + type: "doc", + icon: "bx-hide", + isExpanded: true, + attributes: [{ type: "label", name: "docName", value: "launchbar_intro" }], + children: launchbarConfig.mobileAvailableLaunchers + }, + { + id: "_lbMobileVisibleLaunchers", + title: t("hidden-subtree.visible-launchers-title"), + type: "doc", + icon: "bx-show", + isExpanded: true, + attributes: [{ type: "label", name: "docName", value: "launchbar_intro" }], + children: launchbarConfig.mobileVisibleLaunchers + } + ] + }, + { + id: "_options", + title: t("hidden-subtree.options-title"), + type: "book", + icon: "bx-cog", + children: [ + { id: "_optionsAppearance", title: t("hidden-subtree.appearance-title"), type: "contentWidget", icon: "bx-layout" }, + { id: "_optionsShortcuts", title: t("hidden-subtree.shortcuts-title"), type: "contentWidget", icon: "bxs-keyboard" }, + { id: "_optionsTextNotes", title: t("hidden-subtree.text-notes"), type: "contentWidget", icon: "bx-text" }, + { id: "_optionsCodeNotes", title: t("hidden-subtree.code-notes-title"), type: "contentWidget", icon: "bx-code" }, + { id: "_optionsImages", title: t("hidden-subtree.images-title"), type: "contentWidget", icon: "bx-image" }, + { id: "_optionsSpellcheck", title: t("hidden-subtree.spellcheck-title"), type: "contentWidget", icon: "bx-check-double" }, + { id: "_optionsPassword", title: t("hidden-subtree.password-title"), type: "contentWidget", icon: "bx-lock" }, + { id: '_optionsMFA', title: t('hidden-subtree.multi-factor-authentication-title'), type: 'contentWidget', icon: 'bx-lock ' }, + { id: "_optionsEtapi", title: t("hidden-subtree.etapi-title"), type: "contentWidget", icon: "bx-extension" }, + { id: "_optionsBackup", title: t("hidden-subtree.backup-title"), type: "contentWidget", icon: "bx-data" }, + { id: "_optionsSync", title: t("hidden-subtree.sync-title"), type: "contentWidget", icon: "bx-wifi" }, + { id: "_optionsAi", title: t("hidden-subtree.ai-llm-title"), type: "contentWidget", icon: "bx-bot" }, + { id: "_optionsOther", title: t("hidden-subtree.other"), type: "contentWidget", icon: "bx-dots-horizontal" }, + { id: "_optionsLocalization", title: t("hidden-subtree.localization"), type: "contentWidget", icon: "bx-world" }, + { id: "_optionsAdvanced", title: t("hidden-subtree.advanced-title"), type: "contentWidget" } + ] + }, + { + id: "_help", + title: t("hidden-subtree.user-guide"), + type: "book", + icon: "bx-help-circle", + children: helpSubtree, + isExpanded: true + }, + buildHiddenSubtreeTemplates() + ] + }; +} + +interface CheckHiddenExtraOpts { + restoreNames?: boolean; +} + +function checkHiddenSubtree(force = false, extraOpts: CheckHiddenExtraOpts = {}) { + if (!force && !migrationService.isDbUpToDate()) { + // on-delete hook might get triggered during some future migration and cause havoc + getLog().info("Will not check hidden subtree until migration is finished."); + return; + } + + const helpSubtree = getHelpHiddenSubtreeData(); + if (!hiddenSubtreeDefinition || force) { + hiddenSubtreeDefinition = buildHiddenSubtreeDefinition(helpSubtree); + } + + checkHiddenSubtreeRecursively("root", hiddenSubtreeDefinition, extraOpts); + + try { + cleanUpHelp(helpSubtree); + } catch (e) { + // Non-critical operation should something go wrong. + console.error(e); + } +} + +/** + * Get all expected parent IDs for a given note ID from the hidden subtree definition + */ +function getExpectedParentIds(noteId: string, subtree: HiddenSubtreeItem): string[] { + const expectedParents: string[] = []; + + function traverse(item: HiddenSubtreeItem, parentId: string) { + if (item.id === noteId) { + expectedParents.push(parentId); + } + + if (item.children) { + for (const child of item.children) { + traverse(child, item.id); + } + } + } + + // Start traversal from root + if (subtree.id === noteId) { + expectedParents.push("root"); + } + + if (subtree.children) { + for (const child of subtree.children) { + traverse(child, subtree.id); + } + } + + return expectedParents; +} + +/** + * Check if a note ID is within the hidden subtree structure + */ +function isWithinHiddenSubtree(noteId: string): boolean { + // Consider a note to be within hidden subtree if it starts with underscore + // This is the convention used for hidden subtree notes + return noteId.startsWith("_") || noteId === "root"; +} + +function checkHiddenSubtreeRecursively(parentNoteId: string, item: HiddenSubtreeItem, extraOpts: CheckHiddenExtraOpts = {}) { + if (!item.id || !item.type || !item.title) { + throw new Error(`Item does not contain mandatory properties: ${JSON.stringify(item)}`); + } + + if (item.id.charAt(0) !== "_") { + throw new Error(`ID has to start with underscore, given '${item.id}'`); + } + + let note = becca.notes[item.id]; + let branch; + const log = getLog(); + + if (!note) { + // Missing item, add it. + ({ note, branch } = noteService.createNewNote({ + noteId: item.id, + title: item.title, + type: item.type, + parentNoteId: parentNoteId, + content: item.content ?? "", + ignoreForbiddenParents: true + })); + } else { + // Existing item, check if it's in the right state. + branch = note.getParentBranches().find((branch) => branch.parentNoteId === parentNoteId); + + if (item.content && note.getContent() !== item.content) { + log.info(`Updating content of ${item.id}.`); + note.setContent(item.content); + } + + // Clean up any branches that shouldn't exist according to the meta definition + // For hidden subtree notes, we want to ensure they only exist in their designated locations + if (item.enforceBranches || item.id.startsWith("_help")) { + // If the note exists but doesn't have a branch in the expected parent, + // create the missing branch to ensure it's in the correct location + if (!branch) { + log.info(`Creating missing branch for note ${item.id} under parent ${parentNoteId}.`); + branch = new BBranch({ + noteId: item.id, + parentNoteId: parentNoteId, + notePosition: item.notePosition !== undefined ? item.notePosition : undefined, + isExpanded: item.isExpanded !== undefined ? item.isExpanded : false + }).save(); + } + + // Remove any branches that are not in the expected parent. + const expectedParents = getExpectedParentIds(item.id, hiddenSubtreeDefinition); + const currentBranches = note.getParentBranches(); + + for (const currentBranch of currentBranches) { + // Only delete branches that are not in the expected locations + // and are within the hidden subtree structure (avoid touching user-created clones) + if (!expectedParents.includes(currentBranch.parentNoteId) && + isWithinHiddenSubtree(currentBranch.parentNoteId)) { + log.info(`Removing unexpected branch for note '${item.id}' from parent '${currentBranch.parentNoteId}'`); + currentBranch.markAsDeleted(); + } + } + } + } + + const attrs = [...(item.attributes || [])]; + + if (item.icon) { + attrs.push({ type: "label", name: "iconClass", value: `bx ${item.icon}` }); + } + + if (item.type === "launcher") { + if (item.command) { + attrs.push({ type: "relation", name: "template", value: LBTPL_COMMAND }); + attrs.push({ type: "label", name: "command", value: item.command }); + } else if (item.builtinWidget) { + if (item.builtinWidget === "spacer") { + attrs.push({ type: "relation", name: "template", value: LBTPL_SPACER }); + attrs.push({ type: "label", name: "baseSize", value: item.baseSize }); + attrs.push({ type: "label", name: "growthFactor", value: item.growthFactor }); + } else { + attrs.push({ type: "relation", name: "template", value: LBTPL_WIDGET }); + } + + attrs.push({ type: "label", name: "builtinWidget", value: item.builtinWidget }); + } else if (item.targetNoteId) { + attrs.push({ type: "relation", name: "template", value: LBTPL_NOTE_LAUNCHER }); + attrs.push({ type: "relation", name: "target", value: item.targetNoteId }); + } else { + throw new Error(`No action defined for launcher ${JSON.stringify(item)}`); + } + } + + const shouldRestoreNames = extraOpts.restoreNames || note.noteId.startsWith("_help") || item.id.startsWith("_lb") || item.id.startsWith("_template"); + if (shouldRestoreNames && note.title !== item.title) { + note.title = item.title; + note.save(); + } + + if (note.type !== item.type) { + // enforce a correct note type + note.type = item.type; + note.save(); + } + + if (branch) { + // in case of launchers the branch ID is not preserved and should not be relied upon - launchers which move between + // visible and available will change branch since the branch's parent-child relationship is immutable + if (item.notePosition !== undefined && branch.notePosition !== item.notePosition) { + branch.notePosition = item.notePosition; + branch.save(); + } + + if (item.isExpanded !== undefined && branch.isExpanded !== item.isExpanded) { + branch.isExpanded = item.isExpanded; + branch.save(); + } + } + + // Enforce attribute structure if needed. + if (item.enforceAttributes) { + for (const attribute of note.getAttributes()) { + // Remove unwanted attributes. + const attrDef = attrs.find(a => a.name === attribute.name); + if (!attrDef) { + attribute.markAsDeleted(); + continue; + } + + // Ensure value is consistent. + if (attribute.value !== attrDef.value) { + note.setAttributeValueById(attribute.attributeId, attrDef.value); + } + } + } + + for (const attr of attrs) { + const attrId = note.noteId + "_" + attr.type.charAt(0) + attr.name; + + const existingAttribute = note.getAttributes().find((attr) => attr.attributeId === attrId); + + if (!existingAttribute) { + new BAttribute({ + attributeId: attrId, + noteId: note.noteId, + type: attr.type, + name: attr.name, + value: attr.value, + isInheritable: attr.isInheritable + }).save(); + } else if (attr.name === "docName" || (existingAttribute.noteId.startsWith("_help") && attr.name === "iconClass")) { + if (existingAttribute.value !== attr.value) { + log.info(`Updating attribute ${attrId} from "${existingAttribute.value}" to "${attr.value}"`); + existingAttribute.value = attr.value ?? ""; + existingAttribute.save(); + } + } + } + + for (const child of item.children || []) { + checkHiddenSubtreeRecursively(item.id, child, extraOpts); + } +} + +export default { + checkHiddenSubtree +}; diff --git a/apps/server/src/services/hidden_subtree_launcherbar.ts b/packages/trilium-core/src/services/hidden_subtree_launcherbar.ts similarity index 98% rename from apps/server/src/services/hidden_subtree_launcherbar.ts rename to packages/trilium-core/src/services/hidden_subtree_launcherbar.ts index d68c10c1c..d233bde28 100644 --- a/apps/server/src/services/hidden_subtree_launcherbar.ts +++ b/packages/trilium-core/src/services/hidden_subtree_launcherbar.ts @@ -48,7 +48,7 @@ export default function buildLaunchBarConfig() { id: "_lbBackInHistory", ...sharedLaunchers.backInHistory }, - { + { id: "_lbForwardInHistory", ...sharedLaunchers.forwardInHistory }, @@ -59,12 +59,12 @@ export default function buildLaunchBarConfig() { command: "commandPalette", icon: "bx bx-chevron-right-square" }, - { + { id: "_lbBackendLog", title: t("hidden-subtree.backend-log-title"), type: "launcher", targetNoteId: "_backendLog", - icon: "bx bx-detail" + icon: "bx bx-detail" }, { id: "_zenMode", @@ -128,7 +128,7 @@ export default function buildLaunchBarConfig() { baseSize: "50", growthFactor: "0" }, - { + { id: "_lbBookmarks", title: t("hidden-subtree.bookmarks-title"), type: "launcher", @@ -139,7 +139,7 @@ export default function buildLaunchBarConfig() { id: "_lbToday", ...sharedLaunchers.openToday }, - { + { id: "_lbSpacer2", title: t("hidden-subtree.spacer-title"), type: "launcher", @@ -214,4 +214,4 @@ export default function buildLaunchBarConfig() { mobileAvailableLaunchers, mobileVisibleLaunchers }; -} \ No newline at end of file +} diff --git a/apps/server/src/services/hidden_subtree_templates.ts b/packages/trilium-core/src/services/hidden_subtree_templates.ts similarity index 100% rename from apps/server/src/services/hidden_subtree_templates.ts rename to packages/trilium-core/src/services/hidden_subtree_templates.ts diff --git a/packages/trilium-core/src/services/image.ts b/packages/trilium-core/src/services/image.ts new file mode 100644 index 000000000..e140ca050 --- /dev/null +++ b/packages/trilium-core/src/services/image.ts @@ -0,0 +1,5 @@ +export default { + saveImageToAttachment(noteId: string, imageBuffer: Uint8Array, title: string, b1: boolean, b2: boolean) { + console.warn("Image save ignored", noteId, title); + } +} diff --git a/packages/trilium-core/src/services/in_app_help.ts b/packages/trilium-core/src/services/in_app_help.ts new file mode 100644 index 000000000..4f377f0a6 --- /dev/null +++ b/packages/trilium-core/src/services/in_app_help.ts @@ -0,0 +1,8 @@ +export function cleanUpHelp(items: unknown[]) { + // TODO: implement. +} + +export function getHelpHiddenSubtreeData() { + // TODO: implement. + return []; +} diff --git a/packages/trilium-core/src/services/instance_id.ts b/packages/trilium-core/src/services/instance_id.ts new file mode 100644 index 000000000..7925d5465 --- /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 000000000..0129be0c0 --- /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/migration.ts b/packages/trilium-core/src/services/migration.ts new file mode 100644 index 000000000..fe61ef7b9 --- /dev/null +++ b/packages/trilium-core/src/services/migration.ts @@ -0,0 +1,4 @@ +export function isDbUpToDate() { + // TODO: Implement. + return true; +} diff --git a/packages/trilium-core/src/services/note_types.ts b/packages/trilium-core/src/services/note_types.ts new file mode 100644 index 000000000..2aa86d0b6 --- /dev/null +++ b/packages/trilium-core/src/services/note_types.ts @@ -0,0 +1,34 @@ +const noteTypes = [ + { type: "text", defaultMime: "text/html" }, + { type: "code", defaultMime: "text/plain" }, + { type: "render", defaultMime: "" }, + { type: "file", defaultMime: "application/octet-stream" }, + { type: "image", defaultMime: "" }, + { type: "search", defaultMime: "" }, + { type: "relationMap", defaultMime: "application/json" }, + { type: "book", defaultMime: "" }, + { type: "noteMap", defaultMime: "" }, + { type: "mermaid", defaultMime: "text/vnd.mermaid" }, + { type: "canvas", defaultMime: "application/json" }, + { type: "webView", defaultMime: "" }, + { type: "launcher", defaultMime: "" }, + { type: "doc", defaultMime: "" }, + { type: "contentWidget", defaultMime: "" }, + { type: "mindMap", defaultMime: "application/json" }, + { type: "aiChat", defaultMime: "application/json" } +]; + +function getDefaultMimeForNoteType(typeName: string) { + const typeRec = noteTypes.find((nt) => nt.type === typeName); + + if (!typeRec) { + throw new Error(`Cannot find note type '${typeName}'`); + } + + return typeRec.defaultMime; +} + +export default { + getNoteTypeNames: () => noteTypes.map((nt) => nt.type), + getDefaultMimeForNoteType +}; diff --git a/packages/trilium-core/src/services/notes.ts b/packages/trilium-core/src/services/notes.ts new file mode 100644 index 000000000..d1b908fdf --- /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<string>(); + let match; + + const imgRegExp = /src="[^"]*api\/attachments\/([a-zA-Z0-9_]+)\/image/g; + while ((match = imgRegExp.exec(content))) { + foundAttachmentIds.add(match[1]); + } + + const linkRegExp = /href="[^"]+attachmentId=([a-zA-Z0-9_]+)/g; + while ((match = linkRegExp.exec(content))) { + foundAttachmentIds.add(match[1]); + } + + const attachments = note.getAttachments(); + + for (const attachment of attachments) { + const attachmentInContent = attachment.attachmentId && foundAttachmentIds.has(attachment.attachmentId); + + if (attachment.utcDateScheduledForErasureSince && attachmentInContent) { + attachment.utcDateScheduledForErasureSince = null; + attachment.save(); + } else if (!attachment.utcDateScheduledForErasureSince && !attachmentInContent) { + attachment.utcDateScheduledForErasureSince = date_utils.utcNowDateTime(); + attachment.save(); + } + } + + const existingAttachmentIds = new Set<string | undefined>(attachments.map((att) => att.attachmentId)); + const unknownAttachmentIds = Array.from(foundAttachmentIds).filter((foundAttId) => !existingAttachmentIds.has(foundAttId)); + const unknownAttachments = becca.getAttachments(unknownAttachmentIds); + + for (const unknownAttachment of unknownAttachments) { + // the attachment belongs to a different note (was copy-pasted). Attachments can be linked only from the note + // which owns it, so either find an existing attachment having the same content or make a copy. + let localAttachment = note.getAttachments().find((att) => att.role === unknownAttachment.role && att.blobId === unknownAttachment.blobId); + + if (localAttachment) { + if (localAttachment.utcDateScheduledForErasureSince) { + // the attachment is for sure linked now, so reset the scheduled deletion + localAttachment.utcDateScheduledForErasureSince = null; + localAttachment.save(); + } + + getLog().info( + `Found equivalent attachment '${localAttachment.attachmentId}' of note '${note.noteId}' for the linked foreign attachment '${unknownAttachment.attachmentId}' of note '${unknownAttachment.ownerId}'` + ); + } else { + localAttachment = unknownAttachment.copy(); + localAttachment.ownerId = note.noteId; + localAttachment.setContent(unknownAttachment.getContent(), { forceSave: true }); + + ws.sendMessageToAllClients({ type: "toast", message: `Attachment '${localAttachment.title}' has been copied to note '${note.title}'.` }); + getLog().info(`Copied attachment '${unknownAttachment.attachmentId}' of note '${unknownAttachment.ownerId}' to new '${localAttachment.attachmentId}' of note '${note.noteId}'`); + } + + // replace image links + content = content.replace(`api/attachments/${unknownAttachment.attachmentId}/image`, `api/attachments/${localAttachment.attachmentId}/image`); + // replace reference links + content = content.replace( + new RegExp(`href="[^"]+attachmentId=${unknownAttachment.attachmentId}[^"]*"`, "g"), + `href="#root/${localAttachment.ownerId}?viewMode=attachments&attachmentId=${localAttachment.attachmentId}"` + ); + } + + return { + forceFrontendReload: unknownAttachments.length > 0, + content + }; +} + +function findImageLinks(content: string, foundLinks: FoundLink[]) { + const re = /src="[^"]*api\/images\/([a-zA-Z0-9_]+)\//g; + let match; + + while ((match = re.exec(content))) { + foundLinks.push({ + name: "imageLink", + value: match[1] + }); + } + + // removing absolute references to server to keep it working between instances, + // we also omit / at the beginning to keep the paths relative + return content.replace(/src="[^"]*\/api\/images\//g, 'src="api/images/'); +} + +function findInternalLinks(content: string, foundLinks: FoundLink[]) { + const re = /href="[^"]*#root[a-zA-Z0-9_\/]*\/([a-zA-Z0-9_]+)\/?"/g; + let match; + + while ((match = re.exec(content))) { + foundLinks.push({ + name: "internalLink", + value: match[1] + }); + } + + // removing absolute references to server to keep it working between instances + return content.replace(/href="[^"]*#root/g, 'href="#root'); +} + +function findIncludeNoteLinks(content: string, foundLinks: FoundLink[]) { + const re = /<section class="include-note[^>]+data-note-id="([a-zA-Z0-9_]+)"[^>]*>/g; + let match; + + while ((match = re.exec(content))) { + foundLinks.push({ + name: "includeNoteLink", + value: match[1] + }); + } + + return content; +} + +function findRelationMapLinks(content: string, foundLinks: FoundLink[]) { + try { + const obj = JSON.parse(content); + + for (const note of obj.notes) { + foundLinks.push({ + name: "relationMapLink", + value: note.noteId + }); + } + } catch (e: any) { + getLog().error(`Could not scan for relation map links: ${ e.message}`); + } +} + +const imageUrlToAttachmentIdMapping: Record<string, string> = {}; + +async function downloadImage(noteId: string, imageUrl: string) { + const unescapedUrl = unescapeHtml(imageUrl); + const log = getLog(); + + try { + let imageBuffer: Uint8Array; + + if (imageUrl.toLowerCase().startsWith("file://")) { + imageBuffer = await new Promise((res, rej) => { + const localFilePath = imageUrl.substring("file://".length); + + return fs.readFile(localFilePath, (err, data) => { + if (err) { + rej(err); + } else { + res(data); + } + }); + }); + } else { + imageBuffer = await request.getImage(unescapedUrl); + } + + const parsedUrl = url.parse(unescapedUrl); + const title = path.basename(parsedUrl.pathname || ""); + + const attachment = imageService.saveImageToAttachment(noteId, imageBuffer, title, true, true); + + if (attachment.attachmentId) { + imageUrlToAttachmentIdMapping[imageUrl] = attachment.attachmentId; + } else { + log.error(`Download of '${imageUrl}' due to no attachment ID.`); + } + + log.info(`Download of '${imageUrl}' succeeded and was saved as image attachment '${attachment.attachmentId}' of note '${noteId}'`); + } catch (e: any) { + log.error(`Download of '${imageUrl}' for note '${noteId}' failed with error: ${e.message} ${e.stack}`); + } +} + +/** url => download promise */ +const downloadImagePromises: Record<string, Promise<void>> = {}; + +function replaceUrl(content: string, url: string, attachment: Attachment) { + const quotedUrl = quoteRegex(url); + + return content.replace(new RegExp(`\\s+src=[\"']${quotedUrl}[\"']`, "ig"), ` src="api/attachments/${attachment.attachmentId}/image/${encodeURIComponent(attachment.title)}"`); +} + +function downloadImages(noteId: string, content: string) { + const imageRe = /<img[^>]*?\ssrc=['"]([^'">]+)['"]/gi; + let imageMatch; + + while ((imageMatch = imageRe.exec(content))) { + const url = imageMatch[1]; + const inlineImageMatch = /^data:image\/[a-z]+;base64,/.exec(url); + + if (inlineImageMatch) { + const imageBase64 = url.substring(inlineImageMatch[0].length); + const imageBuffer = decodeBase64(imageBase64); + + const attachment = imageService.saveImageToAttachment(noteId, imageBuffer, "inline image", true, true); + + const encodedTitle = encodeURIComponent(attachment.title); + + content = `${content.substring(0, imageMatch.index)}<img src="api/attachments/${attachment.attachmentId}/image/${encodedTitle}"${content.substring(imageMatch.index + imageMatch[0].length)}`; + } else if ( + !url.includes("api/images/") && + !/api\/attachments\/.+\/image\/?.*/.test(url) && + // this is an exception for the web clipper's "imageId" + (url.length !== 20 || url.toLowerCase().startsWith("http")) + ) { + if (!optionService.getOptionBool("downloadImagesAutomatically")) { + continue; + } + + if (url in imageUrlToAttachmentIdMapping) { + const attachment = becca.getAttachment(imageUrlToAttachmentIdMapping[url]); + + if (!attachment) { + delete imageUrlToAttachmentIdMapping[url]; + } else { + content = replaceUrl(content, url, attachment); + continue; + } + } + + if (url in downloadImagePromises) { + // download is already in progress + continue; + } + + // this is done asynchronously, it would be too slow to wait for the download + // given that save can be triggered very often + downloadImagePromises[url] = downloadImage(noteId, url); + } + } + + Promise.all(Object.values(downloadImagePromises)).then(() => { + setTimeout(() => { + // the normal expected flow of the offline image saving is that users will paste the image(s) + // which will get asynchronously downloaded, during that time they keep editing the note + // once the download is finished, the image note representing the downloaded image will be used + // to replace the IMG link. + // However, there's another flow where the user pastes the image and leaves the note before the images + // are downloaded and the IMG references are not updated. For this occasion we have this code + // which upon the download of all the images will update the note if the links have not been fixed before + + getSql().transactional(() => { + const imageNotes = becca.getNotes(Object.values(imageUrlToAttachmentIdMapping), true); + const log = getLog(); + + const origNote = becca.getNote(noteId); + + if (!origNote) { + log.error(`Cannot find note '${noteId}' to replace image link.`); + return; + } + + const origContent = origNote.getContent(); + let updatedContent = origContent; + + if (typeof updatedContent !== "string") { + log.error(`Note '${noteId}' has a non-string content, cannot replace image link.`); + return; + } + + for (const url in imageUrlToAttachmentIdMapping) { + const imageNote = imageNotes.find((note) => note.noteId === imageUrlToAttachmentIdMapping[url]); + + if (imageNote) { + updatedContent = replaceUrl(updatedContent, url, imageNote); + } + } + + // update only if the links have not been already fixed. + if (updatedContent !== origContent) { + origNote.setContent(updatedContent); + + asyncPostProcessContent(origNote, updatedContent); + + console.log(`Fixed the image links for note '${noteId}' to the offline saved.`); + } + }); + }, 5000); + }); + + return content; +} + +function saveAttachments(note: BNote, content: string) { + const inlineAttachmentRe = /<a[^>]*?\shref=['"]data:([^;'">]+);base64,([^'">]+)['"][^>]*>(.*?)<\/a>/gim; + let attachmentMatch; + + while ((attachmentMatch = inlineAttachmentRe.exec(content))) { + const mime = attachmentMatch[1].toLowerCase(); + + const base64data = attachmentMatch[2]; + const buffer = decodeBase64(base64data); + + const title = html2plaintext(attachmentMatch[3]); + + const attachment = note.saveAttachment({ + role: "file", + mime, + title, + content: buffer + }); + + content = `${content.substring(0, attachmentMatch.index)}<a class="reference-link" href="#root/${note.noteId}?viewMode=attachments&attachmentId=${attachment.attachmentId}">${title}</a>${content.substring(attachmentMatch.index + attachmentMatch[0].length)}`; + } + + // removing absolute references to server to keep it working between instances, + // we also omit / at the beginning to keep the paths relative + content = content.replace(/src="[^"]*\/api\/attachments\//g, 'src="api/attachments/'); + + return content; +} + +function saveLinks(note: BNote, content: string | Uint8Array) { + if ((note.type !== "text" && note.type !== "relationMap") || (note.isProtected && !protectedSessionService.isProtectedSessionAvailable())) { + return { + forceFrontendReload: false, + content + }; + } + + const foundLinks: FoundLink[] = []; + let forceFrontendReload = false; + + if (note.type === "text" && typeof content === "string") { + content = downloadImages(note.noteId, content); + content = saveAttachments(note, content); + + content = findImageLinks(content, foundLinks); + content = findInternalLinks(content, foundLinks); + content = findIncludeNoteLinks(content, foundLinks); + + ({ forceFrontendReload, content } = checkImageAttachments(note, content)); + } else if (note.type === "relationMap" && typeof content === "string") { + findRelationMapLinks(content, foundLinks); + } else { + throw new Error(`Unrecognized type '${note.type}'`); + } + + const existingLinks = note.getRelations().filter((rel) => ["internalLink", "imageLink", "relationMapLink", "includeNoteLink"].includes(rel.name)); + + for (const foundLink of foundLinks) { + const targetNote = becca.notes[foundLink.value]; + if (!targetNote) { + continue; + } + + const existingLink = existingLinks.find((existingLink) => existingLink.value === foundLink.value && existingLink.name === foundLink.name); + + if (!existingLink) { + const newLink = new BAttribute({ + noteId: note.noteId, + type: "relation", + name: foundLink.name, + value: foundLink.value + }).save(); + + existingLinks.push(newLink); + } + // else the link exists, so we don't need to do anything + } + + // marking links as deleted if they are not present on the page anymore + const unusedLinks = existingLinks.filter((existingLink) => !foundLinks.some((foundLink) => existingLink.value === foundLink.value && existingLink.name === foundLink.name)); + + for (const unusedLink of unusedLinks) { + unusedLink.markAsDeleted(); + } + + return { forceFrontendReload, content }; +} + +function saveRevisionIfNeeded(note: BNote) { + // files and images are versioned separately + if (note.type === "file" || note.type === "image" || note.isLabelTruthy("disableVersioning")) { + return; + } + + const now = new Date(); + const revisionSnapshotTimeInterval = parseInt(optionService.getOption("revisionSnapshotTimeInterval")); + + const revisionCutoff = date_utils.utcDateTimeStr(new Date(now.getTime() - revisionSnapshotTimeInterval * 1000)); + + const existingRevisionId = getSql().getValue("SELECT revisionId FROM revisions WHERE noteId = ? AND utcDateCreated >= ?", [note.noteId, revisionCutoff]); + + const msSinceDateCreated = now.getTime() - date_utils.parseDateTime(note.utcDateCreated).getTime(); + + if (!existingRevisionId && msSinceDateCreated >= revisionSnapshotTimeInterval * 1000) { + note.saveRevision(); + } +} + +function updateNoteData(noteId: string, content: string, attachments: AttachmentRow[] = []) { + const note = becca.getNote(noteId); + + if (!note || !note.isContentAvailable()) { + throw new Error(`Note '${noteId}' is not available for change!`); + } + + saveRevisionIfNeeded(note); + + const { forceFrontendReload, content: newContent } = saveLinks(note, content); + + note.setContent(newContent, { forceFrontendReload }); + + if (attachments?.length > 0) { + const existingAttachmentsByTitle = toMap(note.getAttachments(), "title"); + + for (const { attachmentId, role, mime, title, position, content } of attachments) { + const existingAttachment = existingAttachmentsByTitle.get(title); + if (attachmentId || !existingAttachment) { + note.saveAttachment({ attachmentId, role, mime, title, content, position }); + } else { + existingAttachment.role = role; + existingAttachment.mime = mime; + existingAttachment.position = position; + if (content) { + existingAttachment.setContent(content, { forceSave: true }); + } + } + } + } +} + +function undeleteNote(noteId: string, taskContext: TaskContext<"undeleteNotes">) { + const noteRow = getSql().getRow<NoteRow>("SELECT * FROM notes WHERE noteId = ?", [noteId]); + + if (!noteRow.isDeleted || !noteRow.deleteId) { + getLog().error(`Note '${noteId}' is not deleted and thus cannot be undeleted.`); + return; + } + + const undeletedParentBranchIds = getUndeletedParentBranchIds(noteId, noteRow.deleteId); + + if (undeletedParentBranchIds.length === 0) { + // cannot undelete if there's no undeleted parent + return; + } + + for (const parentBranchId of undeletedParentBranchIds) { + undeleteBranch(parentBranchId, noteRow.deleteId, taskContext); + } +} + +function undeleteBranch(branchId: string, deleteId: string, taskContext: TaskContext<"undeleteNotes">) { + const sql = getSql(); + const branchRow = sql.getRow<BranchRow>("SELECT * FROM branches WHERE branchId = ?", [branchId]); + + if (!branchRow.isDeleted) { + return; + } + + const noteRow = sql.getRow<NoteRow>("SELECT * FROM notes WHERE noteId = ?", [branchRow.noteId]); + + if (noteRow.isDeleted && noteRow.deleteId !== deleteId) { + return; + } + + new BBranch(branchRow).save(); + + taskContext.increaseProgressCount(); + + if (noteRow.isDeleted && noteRow.deleteId === deleteId) { + // becca entity was already created as skeleton in "new Branch()" above + const noteEntity = becca.getNote(noteRow.noteId); + if (!noteEntity) { + throw new Error("Unable to find the just restored branch."); + } + + noteEntity.updateFromRow(noteRow); + noteEntity.save(); + + const attributeRows = sql.getRows<AttributeRow>( + ` + SELECT * FROM attributes + WHERE isDeleted = 1 + AND deleteId = ? + AND (noteId = ? + OR (type = 'relation' AND value = ?))`, + [deleteId, noteRow.noteId, noteRow.noteId] + ); + + for (const attributeRow of attributeRows) { + // relation might point to a note which hasn't been undeleted yet and would thus throw up + new BAttribute(attributeRow).save({ skipValidation: true }); + } + + const attachmentRows = sql.getRows<AttachmentRow>( + ` + SELECT * FROM attachments + WHERE isDeleted = 1 + AND deleteId = ? + AND ownerId = ?`, + [deleteId, noteRow.noteId] + ); + + for (const attachmentRow of attachmentRows) { + new BAttachment(attachmentRow).save(); + } + + const childBranchIds = sql.getColumn<string>( + ` + SELECT branches.branchId + FROM branches + WHERE branches.isDeleted = 1 + AND branches.deleteId = ? + AND branches.parentNoteId = ?`, + [deleteId, noteRow.noteId] + ); + + for (const childBranchId of childBranchIds) { + undeleteBranch(childBranchId, deleteId, taskContext); + } + } +} + +/** + * @returns return deleted branchIds of an undeleted parent note + */ +function getUndeletedParentBranchIds(noteId: string, deleteId: string) { + return getSql().getColumn<string>( + ` + SELECT branches.branchId + FROM branches + JOIN notes AS parentNote ON parentNote.noteId = branches.parentNoteId + WHERE branches.noteId = ? + AND branches.isDeleted = 1 + AND branches.deleteId = ? + AND parentNote.isDeleted = 0`, + [noteId, deleteId] + ); +} + +function scanForLinks(note: BNote, content: string | Uint8Array) { + if (!note || !["text", "relationMap"].includes(note.type)) { + return; + } + + try { + getSql().transactional(() => { + const { forceFrontendReload, content: newContent } = saveLinks(note, content); + + if (content !== newContent) { + note.setContent(newContent, { forceFrontendReload }); + } + }); + } catch (e: any) { + getLog().error(`Could not scan for links note '${note.noteId}': ${e.message} ${e.stack}`); + } +} + +/** + * Things which have to be executed after updating content, but asynchronously (separate transaction) + */ +async function asyncPostProcessContent(note: BNote, content: string | Uint8Array) { + if (cls.isMigrationRunning()) { + // this is rarely needed for migrations, but can cause trouble by e.g. triggering downloads + return; + } + + if (note.hasStringContent() && typeof content !== "string") { + content = content.toString(); + } + + scanForLinks(note, content); +} + +// all keys should be replaced by the corresponding values +function replaceByMap(str: string, mapObj: Record<string, string>) { + if (!mapObj) { + return str; + } + + const re = new RegExp(Object.keys(mapObj).join("|"), "g"); + + return str.replace(re, (matched) => mapObj[matched]); +} + +function duplicateSubtree(origNoteId: string, newParentNoteId: string) { + if (origNoteId === "root") { + throw new Error("Duplicating root is not possible"); + } + + getLog().info(`Duplicating '${origNoteId}' subtree into '${newParentNoteId}'`); + + const origNote = becca.notes[origNoteId]; + // might be null if orig note is not in the target newParentNoteId + const origBranch = origNote.getParentBranches().find((branch) => branch.parentNoteId === newParentNoteId); + + const noteIdMapping = getNoteIdMapping(origNote); + + const res = duplicateSubtreeInner(origNote, origBranch, newParentNoteId, noteIdMapping); + + const duplicateNoteSuffix = t("notes.duplicate-note-suffix"); + + if (!res.note.title.endsWith(duplicateNoteSuffix) && !res.note.title.startsWith(duplicateNoteSuffix)) { + res.note.title = t("notes.duplicate-note-title", { noteTitle: res.note.title, duplicateNoteSuffix }); + } + + res.note.save(); + + return res; +} + +function duplicateSubtreeWithoutRoot(origNoteId: string, newNoteId: string) { + if (origNoteId === "root") { + throw new Error("Duplicating root is not possible"); + } + + const origNote = becca.getNote(origNoteId); + if (origNote == null) { + throw new Error("Unable to find note to duplicate."); + } + + const noteIdMapping = getNoteIdMapping(origNote); + for (const childBranch of origNote.getChildBranches()) { + if (childBranch) { + duplicateSubtreeInner(childBranch.getNote(), childBranch, newNoteId, noteIdMapping); + } + } +} + +function duplicateSubtreeInner(origNote: BNote, origBranch: BBranch | null | undefined, newParentNoteId: string, noteIdMapping: Record<string, string>) { + if (origNote.isProtected && !protectedSessionService.isProtectedSessionAvailable()) { + throw new Error(`Cannot duplicate note '${origNote.noteId}' because it is protected and protected session is not available. Enter protected session and try again.`); + } + + const newNoteId = noteIdMapping[origNote.noteId]; + + function createDuplicatedBranch() { + return new BBranch({ + noteId: newNoteId, + parentNoteId: newParentNoteId, + // here increasing just by 1 to make sure it's directly after original + notePosition: origBranch ? origBranch.notePosition + 1 : null + }).save(); + } + + function createDuplicatedNote() { + const newNote = new BNote({ + ...origNote, + noteId: newNoteId, + dateCreated: date_utils.localNowDateTime(), + utcDateCreated: date_utils.utcNowDateTime() + }).save(); + + let content = origNote.getContent(); + + if (typeof content === "string" && ["text", "relationMap", "search"].includes(origNote.type)) { + // fix links in the content + content = replaceByMap(content, noteIdMapping); + } + + newNote.setContent(content); + + for (const attribute of origNote.getOwnedAttributes()) { + const attr = new BAttribute({ + ...attribute, + attributeId: undefined, + noteId: newNote.noteId + }); + + // if relation points to within the duplicated tree then replace the target to the duplicated note + // if it points outside of duplicated tree then keep the original target + if (attr.type === "relation" && attr.value in noteIdMapping) { + attr.value = noteIdMapping[attr.value]; + } + + // the relation targets may not be created yet, the mapping is pre-generated + attr.save({ skipValidation: true }); + } + + for (const childBranch of origNote.getChildBranches()) { + if (childBranch) { + duplicateSubtreeInner(childBranch.getNote(), childBranch, newNote.noteId, noteIdMapping); + } + } + + asyncPostProcessContent(newNote, content); + + return newNote; + } + + const existingNote = becca.notes[newNoteId]; + + if (existingNote && existingNote.title !== undefined) { + // checking that it's not just note's skeleton created because of Branch above + // note has multiple clones and was already created from another placement in the tree, + // so a branch is all we need for this clone + return { + note: existingNote, + branch: createDuplicatedBranch() + }; + } + return { + // order here is important, note needs to be created first to not mess up the becca + note: createDuplicatedNote(), + branch: createDuplicatedBranch() + }; + +} + +function getNoteIdMapping(origNote: BNote) { + const noteIdMapping: Record<string, string> = {}; + + // pregenerate new noteIds since we'll need to fix relation references even for not yet created notes + for (const origNoteId of origNote.getDescendantNoteIds()) { + noteIdMapping[origNoteId] = newEntityId(); + } + + return noteIdMapping; +} + +export default { + createNewNote, + createNewNoteWithTarget, + updateNoteData, + undeleteNote, + protectNoteRecursively, + duplicateSubtree, + duplicateSubtreeWithoutRoot, + getUndeletedParentBranchIds, + triggerNoteTitleChanged, + saveRevisionIfNeeded, + downloadImages, + asyncPostProcessContent +}; 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 000000000..a2ff346ea --- /dev/null +++ b/packages/trilium-core/src/services/options.ts @@ -0,0 +1,146 @@ +/** + * @module + * + * Options are key-value pairs that are used to store information such as user preferences (for example + * the current theme, sync server information), but also information about the state of the application. + * + * Although options internally are represented as strings, their value can be interpreted as a number or + * boolean by calling the appropriate methods from this service (e.g. {@link #getOptionInt}).\ + * + * Generally options are shared across multiple instances of the application via the sync mechanism, + * however it is possible to have options that are local to an instance. For example, the user can select + * a theme on a device and it will not affect other devices. + */ + +import becca from "../becca/becca.js"; +import BOption from "../becca/entities/boption.js"; +import type { OptionRow } from "@triliumnext/commons"; +import type { FilterOptionsByType, OptionDefinitions, OptionMap, OptionNames } from "@triliumnext/commons"; +import { getSql } from "./sql/index.js"; + +function getOptionOrNull(name: OptionNames): string | null { + let option; + + if (becca.loaded) { + option = becca.getOption(name); + } else { + // e.g. in initial sync becca is not loaded because DB is not initialized + try { + option = getSql().getRow<OptionRow>("SELECT * FROM options WHERE name = ?", [name]); + } catch (e: unknown) { + // DB is not initialized. + return null; + } + } + + return option ? option.value : null; +} + +function getOption(name: OptionNames) { + const val = getOptionOrNull(name); + + if (val === null) { + throw new Error(`Option '${name}' doesn't exist`); + } + + return val; +} + +function getOptionInt(name: FilterOptionsByType<number>, defaultValue?: number): number { + const val = getOption(name); + + const intVal = parseInt(val); + + if (isNaN(intVal)) { + if (defaultValue === undefined) { + throw new Error(`Could not parse '${val}' into integer for option '${name}'`); + } else { + return defaultValue; + } + } + + return intVal; +} + +function getOptionBool(name: FilterOptionsByType<boolean>): boolean { + const val = getOption(name); + + if (typeof val !== "string" || !["true", "false"].includes(val)) { + throw new Error(`Could not parse '${val}' into boolean for option '${name}'`); + } + + return val === "true"; +} + +function setOption<T extends OptionNames>(name: T, value: string | OptionDefinitions[T]) { + const option = becca.getOption(name); + + if (option) { + option.value = value as string; + + option.save(); + } else { + createOption(name, value, false); + } + + // Clear current AI provider when AI-related options change + const aiOptions = [ + 'aiSelectedProvider', 'openaiApiKey', 'openaiBaseUrl', 'openaiDefaultModel', + 'anthropicApiKey', 'anthropicBaseUrl', 'anthropicDefaultModel', + 'ollamaBaseUrl', 'ollamaDefaultModel' + ]; + + // TODO: Disabled AI integration. + // if (aiOptions.includes(name)) { + // // Import dynamically to avoid circular dependencies + // setImmediate(async () => { + // try { + // const aiServiceManager = (await import('./llm/ai_service_manager.js')).default; + // aiServiceManager.getInstance().clearCurrentProvider(); + // console.log(`Cleared AI provider after ${name} option changed`); + // } catch (error) { + // console.log(`Could not clear AI provider: ${error}`); + // } + // }); + // } +} + +/** + * Creates a new option in the database, with the given name, value and whether it should be synced. + * + * @param name the name of the option to be created. + * @param value the value of the option, as a string. It can then be interpreted as other types such as a number of boolean. + * @param isSynced `true` if the value should be synced across multiple instances (e.g. locale) or `false` if it should be local-only (e.g. theme). + */ +function createOption<T extends OptionNames>(name: T, value: string | OptionDefinitions[T], isSynced: boolean) { + new BOption({ + name: name, + value: value as string, + isSynced: isSynced + }).save(); +} + +function getOptions() { + return Object.values(becca.options); +} + +function getOptionMap() { + const map: Record<string, string> = {}; + + for (const option of Object.values(becca.options)) { + map[option.name] = option.value; + } + + return map as OptionMap; +} + +export default { + getOption, + getOptionInt, + getOptionBool, + setOption, + createOption, + getOptions, + getOptionMap, + getOptionOrNull +}; diff --git a/packages/trilium-core/src/services/options_init.ts b/packages/trilium-core/src/services/options_init.ts new file mode 100644 index 000000000..9b9ed9099 --- /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 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()); + + 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 630c57a72..bc9c66bbc 100644 --- a/apps/server/src/services/promoted_attribute_definition_parser.ts +++ b/packages/trilium-core/src/services/promoted_attribute_definition_parser.ts @@ -1,4 +1,4 @@ -import type { DefinitionObject } from "./promoted_attribute_definition_interface.js"; +import { DefinitionObject, LabelType, Multiplicity } from "@triliumnext/commons"; function parse(value: string): DefinitionObject { const tokens = value.split(",").map((t) => t.trim()); @@ -8,9 +8,9 @@ function parse(value: string): DefinitionObject { if (token === "promoted") { defObj.isPromoted = true; } else if (["text", "number", "boolean", "date", "datetime", "time", "url"].includes(token)) { - defObj.labelType = token; + defObj.labelType = token as LabelType; } else if (["single", "multi"].includes(token)) { - defObj.multiplicity = token; + defObj.multiplicity = token as Multiplicity; } else if (token.startsWith("precision")) { const chunks = token.split("="); diff --git a/packages/trilium-core/src/services/encryption/protected_session.ts b/packages/trilium-core/src/services/protected_session.ts similarity index 95% rename from packages/trilium-core/src/services/encryption/protected_session.ts rename to packages/trilium-core/src/services/protected_session.ts index ae4d69029..107bf298e 100644 --- a/packages/trilium-core/src/services/encryption/protected_session.ts +++ b/packages/trilium-core/src/services/protected_session.ts @@ -1,6 +1,6 @@ "use strict"; -import dataEncryptionService from "./data_encryption.js"; +import dataEncryptionService from "./encryption/data_encryption"; let dataKey: Uint8Array | null = null; diff --git a/packages/trilium-core/src/services/request.ts b/packages/trilium-core/src/services/request.ts new file mode 100644 index 000000000..bf67168e0 --- /dev/null +++ b/packages/trilium-core/src/services/request.ts @@ -0,0 +1,5 @@ +export default { + getImage(url: string) { + console.warn("Image download ignored ", url); + } +} diff --git a/packages/trilium-core/src/services/revisions.ts b/packages/trilium-core/src/services/revisions.ts new file mode 100644 index 000000000..b450e05d5 --- /dev/null +++ b/packages/trilium-core/src/services/revisions.ts @@ -0,0 +1,46 @@ +import { getLog } from "./log.js"; +import protectedSessionService from "./protected_session.js"; +import type BNote from "../becca/entities/bnote.js"; + +function protectRevisions(note: BNote) { + if (!protectedSessionService.isProtectedSessionAvailable()) { + throw new Error(`Cannot (un)protect revisions of note '${note.noteId}' without active protected session`); + } + + const log = getLog(); + for (const revision of note.getRevisions()) { + if (note.isProtected !== revision.isProtected) { + try { + const content = revision.getContent(); + + revision.isProtected = !!note.isProtected; + + // this will force de/encryption + revision.setContent(content, { forceSave: true }); + } catch (e) { + log.error(`Could not un/protect note revision '${revision.revisionId}'`); + + throw e; + } + } + + for (const attachment of revision.getAttachments()) { + if (note.isProtected !== attachment.isProtected) { + try { + const content = attachment.getContent(); + + attachment.isProtected = note.isProtected; + attachment.setContent(content, { forceSave: true }); + } catch (e) { + log.error(`Could not un/protect attachment '${attachment.attachmentId}'`); + + throw e; + } + } + } + } +} + +export default { + protectRevisions +}; diff --git a/apps/server/src/services/html_sanitizer.spec.ts b/packages/trilium-core/src/services/sanitizer.spec.ts similarity index 91% rename from apps/server/src/services/html_sanitizer.spec.ts rename to packages/trilium-core/src/services/sanitizer.spec.ts index dfbba8fd7..807e73c70 100644 --- a/apps/server/src/services/html_sanitizer.spec.ts +++ b/packages/trilium-core/src/services/sanitizer.spec.ts @@ -1,12 +1,12 @@ import { describe, expect, it } from "vitest"; -import html_sanitizer from "./html_sanitizer.js"; +import { sanitizeHtml } from "./sanitizer.js"; import { trimIndentation } from "@triliumnext/commons"; describe("sanitize", () => { it("filters out position inline CSS", () => { const dirty = `<div style="z-index:999999999;margin:0px;left:250px;height:100px;display:table;background:none;position:fixed;top:250px;"></div>`; const clean = `<div></div>`; - expect(html_sanitizer.sanitize(dirty)).toBe(clean); + expect(sanitizeHtml(dirty)).toBe(clean); }); it("keeps inline styles defined in CKEDitor", () => { @@ -48,6 +48,6 @@ describe("sanitize", () => { </tbody> </table> </figure>`; - expect(html_sanitizer.sanitize(dirty)).toBe(clean); + expect(sanitizeHtml(dirty)).toBe(clean); }); }); diff --git a/apps/server/src/services/html_sanitizer.ts b/packages/trilium-core/src/services/sanitizer.ts similarity index 84% rename from apps/server/src/services/html_sanitizer.ts rename to packages/trilium-core/src/services/sanitizer.ts index 7e2e41b15..3d2446d37 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 000000000..4760e21cc --- /dev/null +++ b/packages/trilium-core/src/services/script.ts @@ -0,0 +1,3 @@ +export function executeNoteNoException(script: unknown) { + console.warn("Skipped script execution"); +} diff --git a/packages/trilium-core/src/services/search/note_set.ts b/packages/trilium-core/src/services/search/note_set.ts new file mode 100644 index 000000000..c62101778 --- /dev/null +++ b/packages/trilium-core/src/services/search/note_set.ts @@ -0,0 +1,65 @@ +import type BNote from "../../becca/entities/bnote.js"; + +class NoteSet { + private noteIdSet: Set<string>; + + notes: BNote[]; + sorted: boolean; + + constructor(notes: BNote[] = []) { + this.notes = notes; + this.noteIdSet = new Set(notes.map((note) => note.noteId)); + this.sorted = false; + } + + add(note: BNote) { + if (!this.hasNote(note)) { + this.notes.push(note); + this.noteIdSet.add(note.noteId); + } + } + + addAll(notes: BNote[]) { + for (const note of notes) { + this.add(note); + } + } + + hasNote(note: BNote) { + return this.hasNoteId(note.noteId); + } + + hasNoteId(noteId: string) { + return this.noteIdSet.has(noteId); + } + + mergeIn(anotherNoteSet: NoteSet) { + this.addAll(anotherNoteSet.notes); + } + + minus(anotherNoteSet: NoteSet) { + const newNoteSet = new NoteSet(); + + for (const note of this.notes) { + if (!anotherNoteSet.hasNoteId(note.noteId)) { + newNoteSet.add(note); + } + } + + return newNoteSet; + } + + intersection(anotherNoteSet: NoteSet) { + const newNoteSet = new NoteSet(); + + for (const note of this.notes) { + if (anotherNoteSet.hasNote(note)) { + newNoteSet.add(note); + } + } + + return newNoteSet; + } +} + +export default NoteSet; diff --git a/packages/trilium-core/src/services/search/services/search.ts b/packages/trilium-core/src/services/search/services/search.ts new file mode 100644 index 000000000..a6c04adc9 --- /dev/null +++ b/packages/trilium-core/src/services/search/services/search.ts @@ -0,0 +1,12 @@ +import BNote from "src/becca/entities/bnote"; + +export default { + searchFromNote(note: BNote) { + console.warn("Ignore search ", note.title); + }, + + searchNotes(searchString: string) { + console.warn("Ignore search", searchString); + return []; + } +} diff --git a/packages/trilium-core/src/services/sql/types.ts b/packages/trilium-core/src/services/sql/types.ts index eff81ae23..e4bd5cb1c 100644 --- a/packages/trilium-core/src/services/sql/types.ts +++ b/packages/trilium-core/src/services/sql/types.ts @@ -21,7 +21,7 @@ export interface RunResult { export interface DatabaseProvider { loadFromFile(path: string, isReadOnly: boolean): void; loadFromMemory(): void; - loadFromBuffer(buffer: NonSharedBuffer): void; + loadFromBuffer(buffer: Uint8Array): void; backup(destinationFile: string): void; prepare(query: string): Statement; transaction<T>(func: (statement: Statement) => T): Transaction; diff --git a/packages/trilium-core/src/services/sql_init.ts b/packages/trilium-core/src/services/sql_init.ts new file mode 100644 index 000000000..ec44193cb --- /dev/null +++ b/packages/trilium-core/src/services/sql_init.ts @@ -0,0 +1,8 @@ +import { deferred } from "@triliumnext/commons"; + +export const dbReady = deferred<void>(); + +// 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 000000000..79122895b --- /dev/null +++ b/packages/trilium-core/src/services/task_context.ts @@ -0,0 +1,79 @@ +"use strict"; + +import type { TaskData, TaskResult, TaskType, WebSocketMessage } from "@triliumnext/commons"; +import ws from "./ws.js"; + +// taskId => TaskContext +const taskContexts: Record<string, TaskContext<any>> = {}; + +class TaskContext<T extends TaskType> { + private taskId: string; + private taskType: TaskType; + private progressCount: number; + private lastSentCountTs: number; + data: TaskData<T>; + noteDeletionHandlerTriggered: boolean; + + constructor(taskId: string, taskType: T, data: TaskData<T>) { + this.taskId = taskId; + this.taskType = taskType; + this.data = data; + this.noteDeletionHandlerTriggered = false; + + // progressCount is meant to represent just some progress - to indicate the task is not stuck + this.progressCount = -1; // we're incrementing immediately + this.lastSentCountTs = 0; // 0 will guarantee the first message will be sent + + // just the fact this has been initialized is a progress which should be sent to clients + // this is esp. important when importing big files/images which take a long time to upload/process + // which means that first "real" increaseProgressCount() will be called quite late and user is without + // feedback until then + this.increaseProgressCount(); + } + + static getInstance<T extends TaskType>(taskId: string, taskType: T, data: TaskData<T>): TaskContext<T> { + if (!taskContexts[taskId]) { + taskContexts[taskId] = new TaskContext(taskId, taskType, data); + } + + return taskContexts[taskId]; + } + + increaseProgressCount() { + this.progressCount++; + + if (Date.now() - this.lastSentCountTs >= 300 && this.taskId !== "no-progress-reporting") { + this.lastSentCountTs = Date.now(); + + ws.sendMessageToAllClients({ + type: "taskProgressCount", + taskId: this.taskId, + taskType: this.taskType, + data: this.data, + progressCount: this.progressCount + } as WebSocketMessage); + } + } + + reportError(message: string) { + ws.sendMessageToAllClients({ + type: "taskError", + taskId: this.taskId, + taskType: this.taskType, + data: this.data, + message + } as WebSocketMessage); + } + + taskSucceeded(result: TaskResult<T>) { + ws.sendMessageToAllClients({ + type: "taskSucceeded", + taskId: this.taskId, + taskType: this.taskType, + data: this.data, + result + } as WebSocketMessage); + } +} + +export default TaskContext; diff --git a/apps/server/src/services/tree.spec.ts b/packages/trilium-core/src/services/tree.spec.ts similarity index 100% rename from apps/server/src/services/tree.spec.ts rename to packages/trilium-core/src/services/tree.spec.ts diff --git a/packages/trilium-core/src/services/tree.ts b/packages/trilium-core/src/services/tree.ts new file mode 100644 index 000000000..896af1014 --- /dev/null +++ b/packages/trilium-core/src/services/tree.ts @@ -0,0 +1,282 @@ +"use strict"; + +import { getLog } from "./log.js"; +import BBranch from "../becca/entities/bbranch.js"; +import entityChangesService from "./entity_changes.js"; +import becca from "../becca/becca.js"; +import type BNote from "../becca/entities/bnote.js"; +import { getSql } from "./sql/index.js"; + +export interface ValidationResponse { + branch: BBranch | null; + success: boolean; + message?: string; +} + +function validateParentChild(parentNoteId: string, childNoteId: string, branchId: string | null = null): ValidationResponse { + if (["root", "_hidden", "_share", "_lbRoot", "_lbAvailableLaunchers", "_lbVisibleLaunchers"].includes(childNoteId)) { + return { branch: null, success: false, message: `Cannot change this note's location.` }; + } + + if (parentNoteId === "none") { + // this shouldn't happen + return { branch: null, success: false, message: `Cannot move anything into 'none' parent.` }; + } + + const existingBranch = becca.getBranchFromChildAndParent(childNoteId, parentNoteId); + + if (existingBranch && existingBranch.branchId !== branchId) { + const parentNote = becca.getNote(parentNoteId); + const childNote = becca.getNote(childNoteId); + + return { + branch: existingBranch, + success: false, + message: `Note "${childNote?.title}" note already exists in the "${parentNote?.title}".` + }; + } + + if (wouldAddingBranchCreateCycle(parentNoteId, childNoteId)) { + return { + branch: null, + success: false, + message: "Moving/cloning note here would create cycle." + }; + } + + if (parentNoteId !== "_lbBookmarks" && becca.getNote(parentNoteId)?.type === "launcher") { + return { + branch: null, + success: false, + message: "Launcher note cannot have any children." + }; + } + + return { branch: null, success: true }; +} + +/** + * Tree cycle can be created when cloning or when moving existing clone. This method should detect both cases. + */ +function wouldAddingBranchCreateCycle(parentNoteId: string, childNoteId: string) { + if (parentNoteId === childNoteId) { + return true; + } + + const childNote = becca.getNote(childNoteId); + const parentNote = becca.getNote(parentNoteId); + + if (!childNote || !parentNote) { + return false; + } + + // we'll load the whole subtree - because the cycle can start in one of the notes in the subtree + const childSubtreeNoteIds = new Set(childNote.getSubtreeNoteIds()); + const parentAncestorNoteIds = parentNote.getAncestorNoteIds(); + + return parentAncestorNoteIds.some((parentAncestorNoteId) => childSubtreeNoteIds.has(parentAncestorNoteId)); +} + +function sortNotes(parentNoteId: string, customSortBy: string = "title", reverse = false, foldersFirst = false, sortNatural = false, _sortLocale?: string | null) { + if (!customSortBy) { + customSortBy = "title"; + } + + // sortLocale can not be empty string or null value, default value must be set to undefined. + const sortLocale = _sortLocale || undefined; + + const sql = getSql(); + sql.transactional(() => { + const note = becca.getNote(parentNoteId); + if (!note) { + throw new Error("Unable to find note"); + } + + const notes = note.getChildNotes(); + + function normalize<T>(obj: T | string) { + return obj && typeof obj === "string" ? obj.toLowerCase() : obj; + } + + notes.sort((a, b) => { + if (foldersFirst) { + const aHasChildren = a.hasChildren(); + const bHasChildren = b.hasChildren(); + + if ((aHasChildren && !bHasChildren) || (!aHasChildren && bHasChildren)) { + // exactly one note of the two is a directory, so the sorting will be done based on this status + return aHasChildren ? -1 : 1; + } + } + + function fetchValue(note: BNote, key: string) { + let rawValue: string | null; + + if (key === "title") { + const branch = note.getParentBranches().find((branch) => branch.parentNoteId === parentNoteId); + const prefix = branch?.prefix; + rawValue = prefix ? `${prefix} - ${note.title}` : note.title; + } else { + rawValue = ["dateCreated", "dateModified"].includes(key) ? (note as any)[key] : note.getLabelValue(key); + } + + return normalize(rawValue); + } + + function compare(a: string, b: string) { + if (!sortNatural) { + // alphabetical sort + return b === null || b === undefined || a < b ? -1 : 1; + } else { + // natural sort + return a.localeCompare(b, sortLocale, { numeric: true, sensitivity: "base" }); + } + } + + const topAEl = fetchValue(a, "top"); + const topBEl = fetchValue(b, "top"); + + if (topAEl !== topBEl) { + if (topAEl === null) return reverse ? -1 : 1; + if (topBEl === null) return reverse ? 1 : -1; + + // since "top" should not be reversible, we'll reverse it once more to nullify this effect + return compare(topAEl, topBEl) * (reverse ? -1 : 1); + } + + const bottomAEl = fetchValue(a, "bottom"); + const bottomBEl = fetchValue(b, "bottom"); + + if (bottomAEl !== bottomBEl) { + if (bottomAEl === null) return reverse ? 1 : -1; + if (bottomBEl === null) return reverse ? -1 : 1; + + // since "bottom" should not be reversible, we'll reverse it once more to nullify this effect + return compare(bottomBEl, bottomAEl) * (reverse ? -1 : 1); + } + + const customAEl = fetchValue(a, customSortBy) ?? fetchValue(a, "title") as string; + const customBEl = fetchValue(b, customSortBy) ?? fetchValue(b, "title") as string; + + if (customAEl !== customBEl) { + return compare(customAEl, customBEl); + } + + const titleAEl = fetchValue(a, "title") as string; + const titleBEl = fetchValue(b, "title") as string; + + return compare(titleAEl, titleBEl); + }); + + if (reverse) { + notes.reverse(); + } + + let position = 10; + let someBranchUpdated = false; + + for (const note of notes) { + const branch = note.getParentBranches().find((b) => b.parentNoteId === parentNoteId); + if (!branch) { + continue; + } + + if (branch.noteId === "_hidden") { + position = 999_999_999; + } + + if (branch.notePosition !== position) { + sql.execute("UPDATE branches SET notePosition = ? WHERE branchId = ?", [position, branch.branchId]); + + branch.notePosition = position; + someBranchUpdated = true; + } + + position += 10; + } + + if (someBranchUpdated) { + entityChangesService.putNoteReorderingEntityChange(parentNoteId); + } + }); +} + +function sortNotesIfNeeded(parentNoteId: string) { + const parentNote = becca.getNote(parentNoteId); + if (!parentNote) { + return; + } + + const sortedLabel = parentNote.getLabel("sorted"); + + if (!sortedLabel || sortedLabel.value === "off") { + return; + } + + const sortReversed = parentNote.getLabelValue("sortDirection")?.toLowerCase() === "desc"; + const sortFoldersFirst = parentNote.isLabelTruthy("sortFoldersFirst"); + const sortNatural = parentNote.isLabelTruthy("sortNatural"); + const sortLocale = parentNote.getLabelValue("sortLocale"); + + sortNotes(parentNoteId, sortedLabel.value, sortReversed, sortFoldersFirst, sortNatural, sortLocale); +} + +/** + * @deprecated this will be removed in the future + */ +function setNoteToParent(noteId: string, prefix: string, parentNoteId: string) { + const parentNote = becca.getNote(parentNoteId); + + if (parentNoteId && !parentNote) { + // null parentNoteId is a valid value + throw new Error(`Cannot move note to deleted / missing parent note '${parentNoteId}'`); + } + + // case where there might be more such branches is ignored. It's expected there should be just one + const sql = getSql(); + const branchId = sql.getValue<string>("SELECT branchId FROM branches WHERE isDeleted = 0 AND noteId = ? AND prefix = ?", [noteId, prefix]); + const branch = becca.getBranch(branchId); + + if (branch) { + if (!parentNoteId) { + getLog().info(`Removing note '${noteId}' from parent '${parentNoteId}'`); + + branch.markAsDeleted(); + } else { + const newBranch = branch.createClone(parentNoteId); + newBranch.save(); + + branch.markAsDeleted(); + } + } else if (parentNoteId) { + const note = becca.getNote(noteId); + if (!note) { + throw new Error(`Cannot find note '${noteId}.`); + } + + if (note.isDeleted) { + throw new Error(`Cannot create a branch for '${noteId}' which is deleted.`); + } + + const branchId = sql.getValue<string>("SELECT branchId FROM branches WHERE isDeleted = 0 AND noteId = ? AND parentNoteId = ?", [noteId, parentNoteId]); + const branch = becca.getBranch(branchId); + + if (branch) { + branch.prefix = prefix; + branch.save(); + } else { + new BBranch({ + noteId: noteId, + parentNoteId: parentNoteId, + prefix: prefix + }).save(); + } + } +} + +export default { + validateParentChild, + sortNotes, + sortNotesIfNeeded, + setNoteToParent +}; diff --git a/packages/trilium-core/src/services/utils/binary.ts b/packages/trilium-core/src/services/utils/binary.ts index 5861ac906..54b734ca7 100644 --- a/packages/trilium-core/src/services/utils/binary.ts +++ b/packages/trilium-core/src/services/utils/binary.ts @@ -1,4 +1,5 @@ const utf8Decoder = new TextDecoder("utf-8"); +const utf8Encoder = new TextEncoder(); export function concat2(a: Uint8Array, b: Uint8Array): Uint8Array { const out = new Uint8Array(a.length + b.length); @@ -7,7 +8,8 @@ export function concat2(a: Uint8Array, b: Uint8Array): Uint8Array { return out; } -export function encodeBase64(bytes: Uint8Array): string { +export function encodeBase64(stringOrBuffer: string | Uint8Array): string { + const bytes = wrapStringOrBuffer(stringOrBuffer); let binary = ""; const len = bytes.length; @@ -30,6 +32,30 @@ export function decodeBase64(base64: string): Uint8Array { return bytes; } -export function decodeUtf8(bytes: Uint8Array) { - return utf8Decoder.decode(bytes); +export function decodeUtf8(stringOrBuffer: string | Uint8Array) { + if (typeof stringOrBuffer === "string") { + return stringOrBuffer; + } else { + return utf8Decoder.decode(stringOrBuffer); + } +} + +export function encodeUtf8(string: string | Uint8Array) { + return utf8Encoder.encode(wrapStringOrBuffer(string)); +} + +export function unwrapStringOrBuffer(stringOrBuffer: string | Uint8Array) { + if (typeof stringOrBuffer === "string") { + return stringOrBuffer; + } else { + return decodeUtf8(stringOrBuffer); + } +} + +export function wrapStringOrBuffer(stringOrBuffer: string | Uint8Array) { + if (typeof stringOrBuffer === "string") { + return encodeUtf8(stringOrBuffer); + } else { + return stringOrBuffer; + } } diff --git a/packages/trilium-core/src/services/utils/date.ts b/packages/trilium-core/src/services/utils/date.ts new file mode 100644 index 000000000..657eea11e --- /dev/null +++ b/packages/trilium-core/src/services/utils/date.ts @@ -0,0 +1,111 @@ +import { dayjs } from "@triliumnext/commons"; +import { getContext } from "../context.js"; + +const LOCAL_DATETIME_FORMAT = "YYYY-MM-DD HH:mm:ss.SSSZZ"; +const UTC_DATETIME_FORMAT = "YYYY-MM-DD HH:mm:ssZ"; + +function utcNowDateTime() { + return utcDateTimeStr(new Date()); +} + +// CLS date time is important in web deployments - server often runs in different time zone than user is located in, +// so we'd prefer client timezone to be used to record local dates. For this reason, requests from clients contain +// "trilium-local-now-datetime" header which is then stored in CLS +function localNowDateTime() { + return getLocalNowDateTime() || dayjs().format(LOCAL_DATETIME_FORMAT); +} + +function localNowDate() { + const clsDateTime = getLocalNowDateTime(); + + if (clsDateTime) { + return clsDateTime.substr(0, 10); + } else { + const date = new Date(); + + return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`; + } +} + +function pad(num: number) { + return num <= 9 ? `0${num}` : `${num}`; +} + +function utcDateStr(date: Date) { + return date.toISOString().split("T")[0]; +} + +function utcDateTimeStr(date: Date) { + return date.toISOString().replace("T", " "); +} + +/** + * @param str - needs to be in the ISO 8601 format "YYYY-MM-DDTHH:MM:SS.sssZ" format as outputted by dateStr(). + * also is assumed to be GMT time (as indicated by the "Z" at the end), *not* local time + */ +function parseDateTime(str: string) { + try { + return new Date(Date.parse(str)); + } catch (e: any) { + throw new Error(`Can't parse date from '${str}': ${e.stack}`); + } +} + +function parseLocalDate(str: string) { + const datePart = str.substr(0, 10); + + // not specifying the timezone and specifying the time means Date.parse() will use the local timezone + return parseDateTime(`${datePart} 12:00:00.000`); +} + +function getDateTimeForFile() { + return new Date().toISOString().substr(0, 19).replace(/:/g, ""); +} + +function validateLocalDateTime(str: string | null | undefined) { + if (!str) { + return; + } + + if (!/[0-9]{4}-[0-9]{2}-[0-9]{2} [0-9]{2}:[0-9]{2}:[0-9]{2}\.[0-9]{3}[+-][0-9]{4}/.test(str)) { + return `Invalid local date time format in '${str}'. Correct format shoud follow example: '2023-08-21 23:38:51.110+0200'`; + } + + if (!dayjs(str, LOCAL_DATETIME_FORMAT)) { + return `Date '${str}' appears to be in the correct format, but cannot be parsed. It likely represents an invalid date.`; + } +} + +function validateUtcDateTime(str: string | undefined) { + if (!str) { + return; + } + + if (!/[0-9]{4}-[0-9]{2}-[0-9]{2} [0-9]{2}:[0-9]{2}:[0-9]{2}\.[0-9]{3}Z/.test(str)) { + return `Invalid UTC date time format in '${str}'. Correct format shoud follow example: '2023-08-21 23:38:51.110Z'`; + } + + if (!dayjs(str, UTC_DATETIME_FORMAT)) { + return `Date '${str}' appears to be in the correct format, but cannot be parsed. It likely represents an invalid date.`; + } +} + +function getLocalNowDateTime() { + return getContext().get("localNowDateTime"); +} + +export default { + LOCAL_DATETIME_FORMAT, + UTC_DATETIME_FORMAT, + utcNowDateTime, + localNowDateTime, + localNowDate, + + utcDateStr, + utcDateTimeStr, + parseDateTime, + parseLocalDate, + getDateTimeForFile, + validateLocalDateTime, + validateUtcDateTime +}; diff --git a/apps/server/src/services/sanitize_attribute_name.spec.ts b/packages/trilium-core/src/services/utils/index.spec.ts similarity index 93% rename from apps/server/src/services/sanitize_attribute_name.spec.ts rename to packages/trilium-core/src/services/utils/index.spec.ts index 3755da5a7..368a81c03 100644 --- a/apps/server/src/services/sanitize_attribute_name.spec.ts +++ b/packages/trilium-core/src/services/utils/index.spec.ts @@ -1,5 +1,5 @@ import { expect, describe, it } from "vitest"; -import sanitizeAttributeName from "./sanitize_attribute_name.js"; +import { sanitizeAttributeName } from "./index"; // fn value, expected value const testCases: [fnValue: string, expectedValue: string][] = [ diff --git a/packages/trilium-core/src/services/utils/index.ts b/packages/trilium-core/src/services/utils/index.ts new file mode 100644 index 000000000..e966f7237 --- /dev/null +++ b/packages/trilium-core/src/services/utils/index.ts @@ -0,0 +1,128 @@ +import { getCrypto } from "../encryption/crypto"; +import { sanitizeFileName } from "../sanitizer"; +import { encodeBase64 } from "./binary"; +import mimeTypes from "mime-types"; +import escape from "escape-html"; +import unescape from "unescape"; + +// TODO: Implement platform detection. +export const isElectron = false; +export const isMac = false; +export const isWindows = false; + +// render and book are string note in the sense that they are expected to contain empty string +const STRING_NOTE_TYPES = new Set(["text", "code", "relationMap", "search", "render", "book", "mermaid", "canvas", "webView"]); +const STRING_MIME_TYPES = new Set(["application/javascript", "application/x-javascript", "application/json", "application/x-sql", "image/svg+xml"]); + +export function hash(text: string) { + return encodeBase64(getCrypto().createHash("sha1", text.normalize())); +} + +export function isStringNote(type: string | undefined, mime: string) { + return (type && STRING_NOTE_TYPES.has(type)) || mime.startsWith("text/") || STRING_MIME_TYPES.has(mime); +} + +// TODO: Refactor to use getCrypto() directly. +export function randomString(length: number) { + return getCrypto().randomString(length); +} + +export function newEntityId() { + return randomString(12); +} + +export function hashedBlobId(content: string | Uint8Array) { + if (content === null || content === undefined) { + content = ""; + } + + // sha512 is faster than sha256 + const base64Hash = encodeBase64(getCrypto().createHash("sha512", content)); + + // we don't want such + and / in the IDs + const kindaBase62Hash = base64Hash.replaceAll("+", "X").replaceAll("/", "Y"); + + // 20 characters of base62 gives us ~120 bit of entropy which is plenty enough + return kindaBase62Hash.substr(0, 20); +} + +export function quoteRegex(url: string) { + return url.replace(/[.*+\-?^${}()|[\]\\]/g, "\\$&"); +} + +export function replaceAll(string: string, replaceWhat: string, replaceWith: string) { + const quotedReplaceWhat = quoteRegex(replaceWhat); + + return string.replace(new RegExp(quotedReplaceWhat, "g"), replaceWith); +} + +export function removeDiacritic(str: string) { + if (!str) { + return ""; + } + str = str.toString(); + return str.normalize("NFD").replace(/\p{Diacritic}/gu, ""); +} + +export function normalize(str: string) { + return removeDiacritic(str).toLowerCase(); +} + +export function sanitizeAttributeName(origName: string) { + const fixedName = origName === "" ? "unnamed" : origName.replace(/[^\p{L}\p{N}_:]/gu, "_"); + // any not allowed character should be replaced with underscore + + return fixedName; +} + +export function getContentDisposition(filename: string) { + const sanitizedFilename = sanitizeFileName(filename).trim() || "file"; + const uriEncodedFilename = encodeURIComponent(sanitizedFilename); + return `file; filename="${uriEncodedFilename}"; filename*=UTF-8''${uriEncodedFilename}`; +} + +export function formatDownloadTitle(fileName: string, type: string | null, mime: string) { + const fileNameBase = !fileName ? "untitled" : sanitizeFileName(fileName); + + const getExtension = () => { + if (type === "text") return ".html"; + if (type === "relationMap" || type === "canvas" || type === "search") return ".json"; + if (!mime) return ""; + + const mimeLc = mime.toLowerCase(); + + // better to just return the current name without a fake extension + // it's possible that the title still preserves the correct extension anyways + if (mimeLc === "application/octet-stream") return ""; + + // if fileName has an extension matching the mime already - reuse it + const mimeTypeFromFileName = mimeTypes.lookup(fileName); + if (mimeTypeFromFileName === mimeLc) return ""; + + // as last resort try to get extension from mimeType + const extensions = mimeTypes.extension(mime); + return extensions ? `.${extensions}` : ""; + }; + + return `${fileNameBase}${getExtension()}`; +} + +export function toMap<T extends Record<string, any>>(list: T[], key: keyof T) { + const map = new Map<string, T>(); + for (const el of list) { + const keyForMap = el[key]; + if (!keyForMap) continue; + // TriliumNextTODO: do we need to handle the case when the same key is used? + // currently this will overwrite the existing entry in the map + map.set(keyForMap, el); + } + return map; +} + +export const escapeHtml = escape; + +export const unescapeHtml = unescape; + +export function randomSecureToken(bytes = 32) { + return encodeBase64(getCrypto().randomBytes(32)); +} diff --git a/packages/trilium-core/src/services/ws.ts b/packages/trilium-core/src/services/ws.ts new file mode 100644 index 000000000..95a41ee20 --- /dev/null +++ b/packages/trilium-core/src/services/ws.ts @@ -0,0 +1,5 @@ +export default { + sendMessageToAllClients(message: object) { + console.warn("Ignored ws", message); + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3e4783a2c..00fb2eb19 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1447,7 +1447,11 @@ importers: specifier: 5.1.0 version: 5.1.0(karma@6.4.4(bufferutil@4.0.9)(utf-8-validate@6.0.5)) - packages/trilium-core: {} + packages/trilium-core: + dependencies: + '@triliumnext/commons': + specifier: workspace:* + version: link:../commons packages/turndown-plugin-gfm: devDependencies: @@ -15567,6 +15571,8 @@ snapshots: '@ckeditor/ckeditor5-utils': 47.3.0 ckeditor5: 47.3.0 es-toolkit: 1.39.5 + transitivePeerDependencies: + - supports-color '@ckeditor/ckeditor5-editor-classic@47.3.0': dependencies: