chore(nx): move all monorepo-style in subfolder for processing

This commit is contained in:
Elian Doran
2025-04-22 10:06:06 +03:00
parent 2e200eab39
commit 62dbcc0a2e
1469 changed files with 16 additions and 16 deletions

View File

@@ -1,44 +0,0 @@
# ignored Files
.dockerignore
.editorconfig
.git*
.prettier*
electron*
entitlements.plist
nodemon.json
renovate.json
trilium.iml
Dockerfile
Dockerfile.*
npm-debug.log
/src/**/*.spec.ts
# ignored folders
/.cache
/.git
/.github
/.idea
/.vscode
/bin
/build
/dist
/docs
/dump-db
/e2e
/integration-tests
/spec
/test
/test-etapi
/node_modules
# exceptions
!/bin/copy-dist.ts
!/bin/cleanupNodeModules.ts
# temporary exception to make copy-dist inside Docker build not fail
# TriliumNextTODO: make copy-dist *not* requiring to copy these file for builds other than electron-forge
!forge.config.cjs
!/bin/tpl
!/bin/electron-forge/desktop.ejs
!/bin/electron-forge/sign-windows.cjs

View File

@@ -1,6 +0,0 @@
node_modules
build
build-ts
data
data-integration
!data-integration/document.db

View File

@@ -1,51 +0,0 @@
# Build stage
FROM node:22.14.0-bullseye-slim AS builder
WORKDIR /usr/src/app/build
# Copy only necessary files for build
COPY . .
# Build and cleanup in a single layer
RUN npm ci && \
npm run build:prepare-dist && \
npm cache clean --force && \
rm -rf build/node_modules && \
mv build/* \
start-docker.sh \
/usr/src/app/ && \
rm -rf \
/usr/src/app/build \
/tmp/node-compile-cache
#TODO: improve node_modules handling in copy-dist/Dockerfile -> remove duplicated work
# currently copy-dist will copy certain node_module folders, but in the Dockerfile we delete them again (to keep image size down),
# as we install necessary dependencies in runtime buildstage anyways
# Runtime stage
FROM node:22.14.0-bullseye-slim
WORKDIR /usr/src/app
# Install only runtime dependencies
RUN apt-get update && \
apt-get install -y --no-install-recommends \
gosu && \
rm -rf \
/var/lib/apt/lists/* \
/var/cache/apt/*
COPY --from=builder /usr/src/app ./
RUN sed -i "/electron/d" package.json && \
npm ci --omit=dev && \
node --experimental-strip-types ./bin/cleanupNodeModules.ts . --skip-prune-dev-deps && \
npm cache clean --force && \
rm -rf \
/tmp/node-compile-cache \
/usr/src/app/bin/cleanupNodeModules.ts
# Configure container
EXPOSE 8080
CMD [ "./start-docker.sh" ]
HEALTHCHECK --start-period=10s CMD exec gosu node node docker_healthcheck.js

View File

@@ -1,49 +0,0 @@
# Build stage
FROM node:22.14.0-alpine AS builder
WORKDIR /usr/src/app/build
# Copy only necessary files for build
COPY . .
# Build and cleanup in a single layer
RUN npm ci && \
npm run build:prepare-dist && \
npm cache clean --force && \
rm -rf build/node_modules && \
mv build/* \
start-docker.sh \
/usr/src/app/ && \
rm -rf \
/usr/src/app/build \
/tmp/node-compile-cache
#TODO: improve node_modules handling in copy-dist/Dockerfile -> remove duplicated work
# currently copy-dist will copy certain node_module folders, but in the Dockerfile we delete them again (to keep image size down),
# as we install necessary dependencies in runtime buildstage anyways
# Runtime stage
FROM node:22.14.0-alpine
# Install runtime dependencies
RUN apk add --no-cache su-exec shadow
WORKDIR /usr/src/app
COPY --from=builder /usr/src/app ./
RUN sed -i "/electron/d" package.json && \
npm ci --omit=dev && \
node --experimental-strip-types ./bin/cleanupNodeModules.ts . --skip-prune-dev-deps && \
npm cache clean --force && \
rm -rf \
/tmp/node-compile-cache \
/usr/src/app/bin/cleanupNodeModules.ts
# Add application user
RUN adduser -s /bin/false node; exit 0
# Configure container
EXPOSE 8080
CMD [ "./start-docker.sh" ]
HEALTHCHECK --start-period=10s CMD exec su-exec node node docker_healthcheck.js

Binary file not shown.

Before

Width:  |  Height:  |  Size: 112 KiB

View File

@@ -1,57 +0,0 @@
[General]
# Instance name can be used to distinguish between different instances using backend api.getInstanceName()
instanceName=
# set to true to allow using Trilium without authentication (makes sense for server build only, desktop build doesn't need password)
noAuthentication=false
# set to true to disable backups (e.g. because of limited space on server)
noBackup=false
[Network]
# host setting is relevant only for web deployments - set the host on which the server will listen
# host=0.0.0.0
# port setting is relevant only for web deployments, desktop builds run on a fixed port (changeable with TRILIUM_PORT environment variable)
port=8080
# true for TLS/SSL/HTTPS (secure), false for HTTP (insecure).
https=false
# path to certificate (run "bash bin/generate-cert.sh" to generate self-signed certificate). Relevant only if https=true
certPath=
keyPath=
# setting to give trust to reverse proxies, a comma-separated list of trusted rev. proxy IPs can be specified (CIDR notation is permitted),
# alternatively 'true' will make use of the leftmost IP in X-Forwarded-For, ultimately an integer can be used to tell about the number of hops between
# Trilium (which is hop 0) and the first trusted rev. proxy.
# once set, expressjs will use the X-Forwarded-For header set by the rev. proxy to determinate the real IPs of clients.
# expressjs shortcuts are supported: loopback(127.0.0.1/8, ::1/128), linklocal(169.254.0.0/16, fe80::/10), uniquelocal(10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, fc00::/7)
trustedReverseProxy=false
# setting the CORS headers for cross-origin requests
# corsAllowOrigin='*'
# corsAllowMethods='GET,POST,PUT,DELETE,PATCH'
# corsAllowHeaders='Content-Type,Authorization'
[Session]
# Use this setting to set a custom value for the "Max-Age" Attribute of the session cookie.
# This controls how long your session will be valid, before it expires and you need to log in again, when you use the "Remember Me" option.
# Value needs to be entered in Seconds.
# Default value is 1814400 Seconds, which is 21 Days.
cookieMaxAge=1814400
[Sync]
#syncServerHost=
#syncServerTimeout=
#syncServerProxy=
[MultiFactorAuthentication]
# Set the base URL for OAuth/OpenID authentication
# This is the URL of the service that will be used to verify the user's identity
oauthBaseUrl=
# Set the client ID for OAuth/OpenID authentication
# This is the ID of the client that will be used to verify the user's identity
oauthClientId=
# Set the client secret for OAuth/OpenID authentication
# This is the secret of the client that will be used to verify the user's identity
oauthClientSecret=

View File

@@ -1,5 +0,0 @@
- isDeleted = 0 by default
- unify readOnly handling to a single attribute:
* readOnly - like now
* readOnly=auto - like without readOnly (used to override inherited readOnly)
* readOnly=never - like autoReadOnlyDisabled

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.4 KiB

View File

@@ -1,13 +0,0 @@
CREATE TABLE IF NOT EXISTS "blobs" (
`blobId` TEXT NOT NULL,
`content` TEXT NULL DEFAULT NULL,
`dateModified` TEXT NOT NULL,
`utcDateModified` TEXT NOT NULL,
PRIMARY KEY(`blobId`)
);
ALTER TABLE notes ADD blobId TEXT DEFAULT NULL;
ALTER TABLE note_revisions ADD blobId TEXT DEFAULT NULL;
CREATE INDEX IF NOT EXISTS IDX_notes_blobId on notes (blobId);
CREATE INDEX IF NOT EXISTS IDX_note_revisions_blobId on note_revisions (blobId);

View File

@@ -1,75 +0,0 @@
import sql from "../../src/services/sql";
import utils from "../../src/services/utils";
interface NoteContentsRow {
noteId: string;
content: string | Buffer;
dateModified: string;
utcDateModified: string;
}
interface NoteRevisionContents {
noteRevisionId: string;
content: string | Buffer;
utcDateModified: string;
}
export default () => {
const existingBlobIds = new Set();
for (const noteId of sql.getColumn<string>(/*sql*/`SELECT noteId FROM note_contents`)) {
const row = sql.getRow<NoteContentsRow>(/*sql*/`SELECT noteId, content, dateModified, utcDateModified FROM note_contents WHERE noteId = ?`, [noteId]);
const blobId = utils.hashedBlobId(row.content);
if (!existingBlobIds.has(blobId)) {
existingBlobIds.add(blobId);
sql.insert("blobs", {
blobId,
content: row.content,
dateModified: row.dateModified,
utcDateModified: row.utcDateModified
});
sql.execute("UPDATE entity_changes SET entityName = 'blobs', entityId = ? WHERE entityName = 'note_contents' AND entityId = ?", [blobId, row.noteId]);
} else {
// duplicates
sql.execute("DELETE FROM entity_changes WHERE entityName = 'note_contents' AND entityId = ?", [row.noteId]);
}
sql.execute("UPDATE notes SET blobId = ? WHERE noteId = ?", [blobId, row.noteId]);
}
for (const noteRevisionId of sql.getColumn(/*sql*/`SELECT noteRevisionId FROM note_revision_contents`)) {
const row = sql.getRow<NoteRevisionContents>(/*sql*/`SELECT noteRevisionId, content, utcDateModified FROM note_revision_contents WHERE noteRevisionId = ?`, [noteRevisionId]);
const blobId = utils.hashedBlobId(row.content);
if (!existingBlobIds.has(blobId)) {
existingBlobIds.add(blobId);
sql.insert("blobs", {
blobId,
content: row.content,
dateModified: row.utcDateModified,
utcDateModified: row.utcDateModified
});
sql.execute("UPDATE entity_changes SET entityName = 'blobs', entityId = ? WHERE entityName = 'note_revision_contents' AND entityId = ?", [blobId, row.noteRevisionId]);
} else {
// duplicates
sql.execute("DELETE FROM entity_changes WHERE entityName = 'note_revision_contents' AND entityId = ?", [row.noteRevisionId]);
}
sql.execute("UPDATE note_revisions SET blobId = ? WHERE noteRevisionId = ?", [blobId, row.noteRevisionId]);
}
const notesWithoutBlobIds = sql.getColumn("SELECT noteId FROM notes WHERE blobId IS NULL");
if (notesWithoutBlobIds.length > 0) {
throw new Error("BlobIds were not filled correctly in notes: " + JSON.stringify(notesWithoutBlobIds));
}
const noteRevisionsWithoutBlobIds = sql.getColumn("SELECT noteRevisionId FROM note_revisions WHERE blobId IS NULL");
if (noteRevisionsWithoutBlobIds.length > 0) {
throw new Error("BlobIds were not filled correctly in note revisions: " + JSON.stringify(noteRevisionsWithoutBlobIds));
}
};

View File

@@ -1,4 +0,0 @@
DROP TABLE note_contents;
DROP TABLE note_revision_contents;
DELETE FROM entity_changes WHERE entityName IN ('note_contents', 'note_revision_contents');

View File

@@ -1,26 +0,0 @@
CREATE TABLE IF NOT EXISTS "revisions" (`revisionId` TEXT NOT NULL PRIMARY KEY,
`noteId` TEXT NOT NULL,
type TEXT DEFAULT '' NOT NULL,
mime TEXT DEFAULT '' NOT NULL,
`title` TEXT NOT NULL,
`isProtected` INT NOT NULL DEFAULT 0,
blobId TEXT DEFAULT NULL,
`utcDateLastEdited` TEXT NOT NULL,
`utcDateCreated` TEXT NOT NULL,
`utcDateModified` TEXT NOT NULL,
`dateLastEdited` TEXT NOT NULL,
`dateCreated` TEXT NOT NULL);
INSERT INTO revisions (revisionId, noteId, type, mime, title, isProtected, utcDateLastEdited, utcDateCreated, utcDateModified, dateLastEdited, dateCreated, blobId)
SELECT noteRevisionId, noteId, type, mime, title, isProtected, utcDateLastEdited, utcDateCreated, utcDateModified, dateLastEdited, dateCreated, blobId FROM note_revisions;
DROP TABLE note_revisions;
CREATE INDEX `IDX_revisions_noteId` ON `revisions` (`noteId`);
CREATE INDEX `IDX_revisions_utcDateCreated` ON `revisions` (`utcDateCreated`);
CREATE INDEX `IDX_revisions_utcDateLastEdited` ON `revisions` (`utcDateLastEdited`);
CREATE INDEX `IDX_revisions_dateCreated` ON `revisions` (`dateCreated`);
CREATE INDEX `IDX_revisions_dateLastEdited` ON `revisions` (`dateLastEdited`);
CREATE INDEX IF NOT EXISTS IDX_revisions_blobId on revisions (blobId);
UPDATE entity_changes SET entityName = 'revisions' WHERE entityName = 'note_revisions';

View File

@@ -1,23 +0,0 @@
CREATE TABLE IF NOT EXISTS "attachments"
(
attachmentId TEXT not null primary key,
ownerId TEXT not null,
role TEXT not null,
mime TEXT not null,
title TEXT not null,
isProtected INT not null DEFAULT 0,
position INT default 0 not null,
blobId TEXT DEFAULT null,
dateModified TEXT NOT NULL,
utcDateModified TEXT not null,
utcDateScheduledForErasureSince TEXT DEFAULT NULL,
isDeleted INT not null,
deleteId TEXT DEFAULT NULL);
CREATE INDEX IDX_attachments_ownerId_role
on attachments (ownerId, role);
CREATE INDEX IDX_attachments_utcDateScheduledForErasureSince
on attachments (utcDateScheduledForErasureSince);
CREATE INDEX IF NOT EXISTS IDX_attachments_blobId on attachments (blobId);

View File

@@ -1,26 +0,0 @@
import becca from "../../src/becca/becca";
import becca_loader from "../../src/becca/becca_loader";
import cls from "../../src/services/cls";
import log from "../../src/services/log";
import sql from "../../src/services/sql";
export default () => {
cls.init(() => {
// emergency disabling of image compression since it appears to make problems in migration to 0.61
sql.execute(/*sql*/`UPDATE options SET value = 'false' WHERE name = 'compressImages'`);
becca_loader.load();
for (const note of Object.values(becca.notes)) {
try {
const attachment = note.convertToParentAttachment({ autoConversion: true });
if (attachment) {
log.info(`Auto-converted note '${note.noteId}' into attachment '${attachment.attachmentId}'.`);
}
} catch (e) {
log.error(`Cannot convert note '${note.noteId}' to attachment: ${e.message} ${e.stack}`);
}
}
});
};

View File

@@ -1,2 +0,0 @@
DELETE FROM options WHERE name = 'hideIncludedImages_main';
DELETE FROM entity_changes WHERE entityName = 'options' AND entityId = 'hideIncludedImages_main';

View File

@@ -1,2 +0,0 @@
UPDATE options SET name = 'openNoteContexts' WHERE name = 'openTabs';
UPDATE entity_changes SET entityId = 'openNoteContexts' WHERE entityName = 'options' AND entityId = 'openTabs';

View File

@@ -1 +0,0 @@
SELECT 1;

View File

@@ -1,14 +0,0 @@
UPDATE blobs SET blobId = REPLACE(blobId, '+', 'X');
UPDATE blobs SET blobId = REPLACE(blobId, '/', 'Y');
UPDATE notes SET blobId = REPLACE(blobId, '+', 'X');
UPDATE notes SET blobId = REPLACE(blobId, '/', 'Y');
UPDATE attachments SET blobId = REPLACE(blobId, '+', 'X');
UPDATE attachments SET blobId = REPLACE(blobId, '/', 'Y');
UPDATE revisions SET blobId = REPLACE(blobId, '+', 'X');
UPDATE revisions SET blobId = REPLACE(blobId, '/', 'Y');
UPDATE entity_changes SET entityId = REPLACE(entityId, '+', 'X') WHERE entityName = 'blobs';
UPDATE entity_changes SET entityId = REPLACE(entityId, '/', 'Y') WHERE entityName = 'blobs';

View File

@@ -1,3 +0,0 @@
CREATE INDEX IF NOT EXISTS IDX_notes_blobId on notes (blobId);
CREATE INDEX IF NOT EXISTS IDX_revisions_blobId on revisions (blobId);
CREATE INDEX IF NOT EXISTS IDX_attachments_blobId on attachments (blobId);

View File

@@ -1 +0,0 @@
UPDATE attributes SET value = 'contentAndAttachmentsAndRevisionsSize' WHERE name = 'orderBy' AND value = 'noteSize';

View File

@@ -1,2 +0,0 @@
-- emergency disabling of image compression since it appears to make problems in migration to 0.61
UPDATE options SET value = 'false' WHERE name = 'compressImages';

View File

@@ -1,17 +0,0 @@
-- + is normally replaced by X and / by Y, but this can temporarily cause UNIQUE key exception
-- this might create blob duplicates, but cleanup will eventually take care of it
UPDATE blobs SET blobId = REPLACE(blobId, '+', 'A');
UPDATE blobs SET blobId = REPLACE(blobId, '/', 'B');
UPDATE notes SET blobId = REPLACE(blobId, '+', 'A');
UPDATE notes SET blobId = REPLACE(blobId, '/', 'B');
UPDATE attachments SET blobId = REPLACE(blobId, '+', 'A');
UPDATE attachments SET blobId = REPLACE(blobId, '/', 'B');
UPDATE revisions SET blobId = REPLACE(blobId, '+', 'A');
UPDATE revisions SET blobId = REPLACE(blobId, '/', 'B');
UPDATE entity_changes SET entityId = REPLACE(entityId, '+', 'A') WHERE entityName = 'blobs';
UPDATE entity_changes SET entityId = REPLACE(entityId, '/', 'B') WHERE entityName = 'blobs';

View File

@@ -1,14 +0,0 @@
-- Add the oauth user data table
CREATE TABLE IF NOT EXISTS "user_data"
(
tmpID INT,
username TEXT,
email TEXT,
userIDEncryptedDataKey TEXT,
userIDVerificationHash TEXT,
salt TEXT,
derivedKey TEXT,
isSetup TEXT DEFAULT "false",
UNIQUE (tmpID),
PRIMARY KEY (tmpID)
);

View File

@@ -1,46 +0,0 @@
-- Add tables for vector embeddings storage and management
-- This migration adds embedding support to the main document.db database
-- Store embeddings for notes
CREATE TABLE IF NOT EXISTS "note_embeddings" (
"embedId" TEXT NOT NULL PRIMARY KEY,
"noteId" TEXT NOT NULL,
"providerId" TEXT NOT NULL,
"modelId" TEXT NOT NULL,
"dimension" INTEGER NOT NULL,
"embedding" BLOB NOT NULL,
"version" INTEGER NOT NULL DEFAULT 1,
"dateCreated" TEXT NOT NULL,
"utcDateCreated" TEXT NOT NULL,
"dateModified" TEXT NOT NULL,
"utcDateModified" TEXT NOT NULL
);
CREATE INDEX "IDX_note_embeddings_noteId" ON "note_embeddings" ("noteId");
CREATE INDEX "IDX_note_embeddings_providerId_modelId" ON "note_embeddings" ("providerId", "modelId");
-- Table to track which notes need embedding updates
CREATE TABLE IF NOT EXISTS "embedding_queue" (
"noteId" TEXT NOT NULL PRIMARY KEY,
"operation" TEXT NOT NULL, -- CREATE, UPDATE, DELETE
"dateQueued" TEXT NOT NULL,
"utcDateQueued" TEXT NOT NULL,
"priority" INTEGER NOT NULL DEFAULT 0,
"attempts" INTEGER NOT NULL DEFAULT 0,
"lastAttempt" TEXT NULL,
"error" TEXT NULL,
"failed" INTEGER NOT NULL DEFAULT 0,
"isProcessing" INTEGER NOT NULL DEFAULT 0
);
-- Table to store embedding provider configurations
CREATE TABLE IF NOT EXISTS "embedding_providers" (
"providerId" TEXT NOT NULL PRIMARY KEY,
"name" TEXT NOT NULL,
"priority" INTEGER NOT NULL DEFAULT 0,
"config" TEXT NOT NULL, -- JSON config object
"dateCreated" TEXT NOT NULL,
"utcDateCreated" TEXT NOT NULL,
"dateModified" TEXT NOT NULL,
"utcDateModified" TEXT NOT NULL
);

View File

@@ -1,189 +0,0 @@
CREATE TABLE IF NOT EXISTS "entity_changes" (
`id` INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
`entityName` TEXT NOT NULL,
`entityId` TEXT NOT NULL,
`hash` TEXT NOT NULL,
`isErased` INT NOT NULL,
`changeId` TEXT NOT NULL,
`componentId` TEXT NOT NULL,
`instanceId` TEXT NOT NULL,
`isSynced` INTEGER NOT NULL,
`utcDateChanged` TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS "etapi_tokens"
(
etapiTokenId TEXT PRIMARY KEY NOT NULL,
name TEXT NOT NULL,
tokenHash TEXT NOT NULL,
utcDateCreated TEXT NOT NULL,
utcDateModified TEXT NOT NULL,
isDeleted INT NOT NULL DEFAULT 0);
CREATE TABLE IF NOT EXISTS "branches" (
`branchId` TEXT NOT NULL,
`noteId` TEXT NOT NULL,
`parentNoteId` TEXT NOT NULL,
`notePosition` INTEGER NOT NULL,
`prefix` TEXT,
`isExpanded` INTEGER NOT NULL DEFAULT 0,
`isDeleted` INTEGER NOT NULL DEFAULT 0,
`deleteId` TEXT DEFAULT NULL,
`utcDateModified` TEXT NOT NULL,
PRIMARY KEY(`branchId`));
CREATE TABLE IF NOT EXISTS "notes" (
`noteId` TEXT NOT NULL,
`title` TEXT NOT NULL DEFAULT "note",
`isProtected` INT NOT NULL DEFAULT 0,
`type` TEXT NOT NULL DEFAULT 'text',
`mime` TEXT NOT NULL DEFAULT 'text/html',
blobId TEXT DEFAULT NULL,
`isDeleted` INT NOT NULL DEFAULT 0,
`deleteId` TEXT DEFAULT NULL,
`dateCreated` TEXT NOT NULL,
`dateModified` TEXT NOT NULL,
`utcDateCreated` TEXT NOT NULL,
`utcDateModified` TEXT NOT NULL,
PRIMARY KEY(`noteId`));
CREATE TABLE IF NOT EXISTS "revisions" (`revisionId` TEXT NOT NULL PRIMARY KEY,
`noteId` TEXT NOT NULL,
type TEXT DEFAULT '' NOT NULL,
mime TEXT DEFAULT '' NOT NULL,
`title` TEXT NOT NULL,
`isProtected` INT NOT NULL DEFAULT 0,
blobId TEXT DEFAULT NULL,
`utcDateLastEdited` TEXT NOT NULL,
`utcDateCreated` TEXT NOT NULL,
`utcDateModified` TEXT NOT NULL,
`dateLastEdited` TEXT NOT NULL,
`dateCreated` TEXT NOT NULL);
CREATE TABLE IF NOT EXISTS "options"
(
name TEXT not null PRIMARY KEY,
value TEXT not null,
isSynced INTEGER default 0 not null,
utcDateModified TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS "attributes"
(
attributeId TEXT not null primary key,
noteId TEXT not null,
type TEXT not null,
name TEXT not null,
value TEXT default '' not null,
position INT default 0 not null,
utcDateModified TEXT not null,
isDeleted INT not null,
`deleteId` TEXT DEFAULT NULL,
isInheritable int DEFAULT 0 NULL);
CREATE UNIQUE INDEX `IDX_entityChanges_entityName_entityId` ON "entity_changes" (
`entityName`,
`entityId`
);
CREATE INDEX `IDX_branches_noteId_parentNoteId` ON `branches` (`noteId`,`parentNoteId`);
CREATE INDEX IDX_branches_parentNoteId ON branches (parentNoteId);
CREATE INDEX `IDX_notes_title` ON `notes` (`title`);
CREATE INDEX `IDX_notes_type` ON `notes` (`type`);
CREATE INDEX `IDX_notes_dateCreated` ON `notes` (`dateCreated`);
CREATE INDEX `IDX_notes_dateModified` ON `notes` (`dateModified`);
CREATE INDEX `IDX_notes_utcDateModified` ON `notes` (`utcDateModified`);
CREATE INDEX `IDX_notes_utcDateCreated` ON `notes` (`utcDateCreated`);
CREATE INDEX `IDX_revisions_noteId` ON `revisions` (`noteId`);
CREATE INDEX `IDX_revisions_utcDateCreated` ON `revisions` (`utcDateCreated`);
CREATE INDEX `IDX_revisions_utcDateLastEdited` ON `revisions` (`utcDateLastEdited`);
CREATE INDEX `IDX_revisions_dateCreated` ON `revisions` (`dateCreated`);
CREATE INDEX `IDX_revisions_dateLastEdited` ON `revisions` (`dateLastEdited`);
CREATE INDEX `IDX_entity_changes_changeId` ON `entity_changes` (`changeId`);
CREATE INDEX IDX_attributes_name_value
on attributes (name, value);
CREATE INDEX IDX_attributes_noteId_index
on attributes (noteId);
CREATE INDEX IDX_attributes_value_index
on attributes (value);
CREATE TABLE IF NOT EXISTS "recent_notes"
(
noteId TEXT not null primary key,
notePath TEXT not null,
utcDateCreated TEXT not null
);
CREATE TABLE IF NOT EXISTS "blobs" (
`blobId` TEXT NOT NULL,
`content` TEXT NULL DEFAULT NULL,
`dateModified` TEXT NOT NULL,
`utcDateModified` TEXT NOT NULL,
PRIMARY KEY(`blobId`)
);
CREATE TABLE IF NOT EXISTS "attachments"
(
attachmentId TEXT not null primary key,
ownerId TEXT not null,
role TEXT not null,
mime TEXT not null,
title TEXT not null,
isProtected INT not null DEFAULT 0,
position INT default 0 not null,
blobId TEXT DEFAULT null,
dateModified TEXT NOT NULL,
utcDateModified TEXT not null,
utcDateScheduledForErasureSince TEXT DEFAULT NULL,
isDeleted INT not null,
deleteId TEXT DEFAULT NULL);
CREATE TABLE IF NOT EXISTS "user_data"
(
tmpID INT,
username TEXT,
email TEXT,
userIDEncryptedDataKey TEXT,
userIDVerificationHash TEXT,
salt TEXT,
derivedKey TEXT,
isSetup TEXT DEFAULT "false",
UNIQUE (tmpID),
PRIMARY KEY (tmpID)
);
CREATE INDEX IDX_attachments_ownerId_role
on attachments (ownerId, role);
CREATE INDEX IDX_notes_blobId on notes (blobId);
CREATE INDEX IDX_revisions_blobId on revisions (blobId);
CREATE INDEX IDX_attachments_blobId on attachments (blobId);
CREATE TABLE IF NOT EXISTS "note_embeddings" (
"embedId" TEXT NOT NULL PRIMARY KEY,
"noteId" TEXT NOT NULL,
"providerId" TEXT NOT NULL,
"modelId" TEXT NOT NULL,
"dimension" INTEGER NOT NULL,
"embedding" BLOB NOT NULL,
"version" INTEGER NOT NULL DEFAULT 1,
"dateCreated" TEXT NOT NULL,
"utcDateCreated" TEXT NOT NULL,
"dateModified" TEXT NOT NULL,
"utcDateModified" TEXT NOT NULL
);
CREATE INDEX "IDX_note_embeddings_noteId" ON "note_embeddings" ("noteId");
CREATE INDEX "IDX_note_embeddings_providerId_modelId" ON "note_embeddings" ("providerId", "modelId");
CREATE TABLE IF NOT EXISTS "embedding_queue" (
"noteId" TEXT NOT NULL PRIMARY KEY,
"operation" TEXT NOT NULL,
"dateQueued" TEXT NOT NULL,
"utcDateQueued" TEXT NOT NULL,
"priority" INTEGER NOT NULL DEFAULT 0,
"attempts" INTEGER NOT NULL DEFAULT 0,
"lastAttempt" TEXT NULL,
"error" TEXT NULL,
"failed" INTEGER NOT NULL DEFAULT 0,
"isProcessing" INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS "embedding_providers" (
"providerId" TEXT NOT NULL PRIMARY KEY,
"name" TEXT NOT NULL,
"isEnabled" INTEGER NOT NULL DEFAULT 0,
"priority" INTEGER NOT NULL DEFAULT 0,
"config" TEXT NOT NULL,
"dateCreated" TEXT NOT NULL,
"utcDateCreated" TEXT NOT NULL,
"dateModified" TEXT NOT NULL,
"utcDateModified" TEXT NOT NULL
);

View File

@@ -1,25 +0,0 @@
# Running `docker-compose up` will create/use the "trilium-data" directory in the user home
# Run `TRILIUM_DATA_DIR=/path/of/your/choice docker-compose up` to set a different directory
# To run in the background, use `docker-compose up -d`
services:
trilium:
# Optionally, replace `latest` with a version tag like `v0.90.3`
# Using `latest` may cause unintended updates to the container
image: triliumnext/notes:latest
# Restart the container unless it was stopped by the user
restart: unless-stopped
environment:
- TRILIUM_DATA_DIR=/home/node/trilium-data
ports:
# By default, Trilium will be available at http://localhost:8080
# It will also be accessible at http://<host-ip>:8080
# You might want to limit this with something like Docker Networks, reverse proxies, or firewall rules,
# however be aware that using UFW is known to not work with default Docker installations, see:
# https://docs.docker.com/engine/network/packet-filtering-firewalls/#docker-and-ufw
- '8080:8080'
volumes:
# Unless TRILIUM_DATA_DIR is set, the data will be stored in the "trilium-data" directory in the home directory.
# This can also be changed with by replacing the line below with `- /path/of/your/choice:/home/node/trilium-data
- ${TRILIUM_DATA_DIR:-~/trilium-data}:/home/node/trilium-data
- /etc/timezone:/etc/timezone:ro
- /etc/localtime:/etc/localtime:ro

View File

@@ -1,41 +0,0 @@
import http from "http";
import config from "./src/services/config.js";
if (config.Network.https) {
// built-in TLS (terminated by trilium) is not supported yet, PRs are welcome
// for reverse proxy terminated TLS this will works since config.https will be false
process.exit(0);
}
import port from "./src/services/port.js";
import host from "./src/services/host.js";
const options: http.RequestOptions = { timeout: 2000 };
const callback: (res: http.IncomingMessage) => void = (res) => {
console.log(`STATUS: ${res.statusCode}`);
if (res.statusCode === 200) {
process.exit(0);
} else {
process.exit(1);
}
};
let request;
if (port !== 0) {
// TCP socket.
const url = `http://${host}:${port}/api/health-check`;
request = http.request(url, options, callback);
} else {
// Unix socket.
options.socketPath = host;
options.path = "/api/health-check";
request = http.request(options, callback);
}
request.on("error", (err) => {
console.log("ERROR");
process.exit(1);
});
request.end();

View File

@@ -1,12 +0,0 @@
{
"restartable": "rs",
"ignore": [".git", "node_modules/**/node_modules", "src/public/"],
"verbose": false,
"exec": "tsx",
"watch": ["src/", "translations/"],
"signal": "SIGTERM",
"env": {
"NODE_ENV": "development"
},
"ext": "ts,js,json"
}

View File

@@ -1,150 +0,0 @@
{
"name": "@triliumnext/server",
"version": "0.0.1",
"description": "The server-side component of TriliumNext, which exposes the client via the web, allows for sync and provides a REST API for both internal and external use.",
"homepage": "https://github.com/TriliumNext/Notes#readme",
"bugs": {
"url": "https://github.com/TriliumNext/Notes/issues"
},
"repository": {
"type": "git",
"url": "git+https://github.com/TriliumNext/Notes.git"
},
"license": "AGPL-3.0-only",
"author": {
"name": "TriliumNext Notes Team",
"email": "contact@eliandoran.me",
"url": "https://github.com/TriliumNext/Notes"
},
"type": "module",
"main": "index.js",
"scripts": {
"build:clean": "rimraf ./dist ./build",
"build:copy-dist": "tsx ./scripts/copy-dist.ts",
"build:prepare-dist": "npm run build:clean && npm run build:ts && npm run build:copy-dist",
"build:ts": "tsc",
"dist:start": "npm run build:prepare-dist && cross-env TRILIUM_DATA_DIR=./data node build/src/main.js",
"start": "cross-env TRILIUM_DATA_DIR=./data TRILIUM_ENV=dev nodemon src/main.ts",
"test": "cross-env TRILIUM_ENV=dev TRILIUM_DATA_DIR=./spec/data TRILIUM_INTEGRATION_TEST=memory vitest",
"coverage": "cross-env TRILIUM_ENV=dev TRILIUM_DATA_DIR=./spec/data TRILIUM_INTEGRATION_TEST=memory vitest --coverage",
"package": "bash ./scripts/build-server.sh"
},
"dependencies": {
"express": "4.21.2",
"express-openid-connect": "^2.17.1",
"express-rate-limit": "7.5.0",
"express-session": "1.18.1",
"serve-favicon": "2.5.0",
"cookie-parser": "1.4.7",
"helmet": "8.1.0",
"turndown": "7.2.0",
"compression": "1.8.0",
"i18next": "25.0.0",
"i18next-fs-backend": "2.6.0",
"tmp": "0.2.3",
"jsdom": "26.1.0",
"better-sqlite3": "11.9.1",
"safe-compare": "1.1.4",
"debounce": "2.2.0",
"chardet": "2.1.0",
"rand-token": "1.0.1",
"sanitize-filename": "1.6.3",
"sanitize-html": "2.16.0",
"time2fa": "^1.3.0",
"axios": "1.8.4",
"xml2js": "0.6.2",
"swagger-jsdoc": "6.2.8",
"strip-bom": "5.0.0",
"cls-hooked": "4.2.2",
"ejs": "3.1.10",
"cheerio": "1.0.0",
"multer": "1.4.5-lts.2",
"dayjs": "1.11.13",
"chokidar": "4.0.3",
"archiver": "7.0.1",
"jimp": "1.6.0",
"image-type": "5.2.0",
"supertest": "7.1.0",
"async-mutex": "0.5.0",
"striptags": "3.2.0",
"@braintree/sanitize-url": "7.1.1",
"html": "1.0.0",
"csrf-csrf": "3.1.0",
"@triliumnext/express-partial-content": "1.0.1",
"session-file-store": "1.5.0",
"is-svg": "5.1.0",
"stream-throttle": "0.1.3",
"marked": "15.0.8",
"webpack": "5.99.6",
"js-yaml": "4.1.0",
"fs-extra": "11.3.0",
"escape-html": "1.0.3",
"ws": "8.18.1",
"ini": "5.0.0",
"unescape": "1.0.1",
"html2plaintext": "2.1.4",
"normalize-strings": "1.1.1",
"is-animated": "2.0.2",
"mime-types": "3.0.1",
"@triliumnext/turndown-plugin-gfm": "1.0.61",
"yauzl": "3.2.0",
"electron-debug": "4.1.0",
"electron-window-state": "5.0.3",
"swagger-ui-express": "5.0.1",
"ollama": "0.5.14",
"openai": "4.95.1",
"@anthropic-ai/sdk": "0.39.0",
"jquery-hotkeys": "0.2.2",
"jquery.fancytree": "2.38.5",
"jquery": "3.7.1",
"katex": "0.16.22",
"autocomplete.js": "0.38.1",
"boxicons": "2.1.4",
"normalize.css": "8.0.1",
"codemirror": "5.65.19",
"@highlightjs/cdn-assets": "11.11.1"
},
"devDependencies": {
"typescript": "5.8.3",
"typescript-eslint": "8.30.1",
"@types/cookie-parser": "1.4.8",
"@types/jsdom": "21.1.7",
"@types/better-sqlite3": "7.6.13",
"@types/safe-compare": "1.1.2",
"@types/debounce": "1.2.4",
"@types/xml2js": "0.4.14",
"@types/swagger-ui-express": "4.1.8",
"@types/cls-hooked": "4.3.9",
"@types/ejs": "3.1.5",
"@types/cheerio": "0.22.35",
"@types/multer": "1.4.12",
"@types/archiver": "6.0.3",
"@types/supertest": "6.0.3",
"@types/serve-favicon": "2.5.7",
"@types/compression": "1.7.5",
"@types/tmp": "0.2.6",
"@types/js-yaml": "4.0.9",
"@types/html": "1.0.4",
"@types/session-file-store": "1.2.5",
"@types/fs-extra": "11.0.4",
"@types/ini": "4.1.1",
"@types/turndown": "5.0.5",
"@types/sanitize-html": "2.15.0",
"@types/stream-throttle": "0.1.4",
"@types/mime-types": "2.1.4",
"@types/sax": "1.2.7",
"nodemon": "3.1.9",
"cross-env": "7.0.3",
"tsx": "4.19.3",
"@types/express-session": "1.18.1",
"@types/escape-html": "1.0.4",
"@types/ws": "8.18.1",
"vitest": "^3.1.1",
"@excalidraw/excalidraw": "0.18.0"
}
}

View File

@@ -1,72 +0,0 @@
#!/usr/bin/env bash
set -e # Fail on any command error
# Debug output
echo "Matrix Arch: $MATRIX_ARCH"
# Detect architecture from matrix input, fallback to system architecture
if [ -n "$MATRIX_ARCH" ]; then
ARCH=$MATRIX_ARCH
else
ARCH=$(uname -m)
# Convert system architecture to our naming convention
case $ARCH in
x86_64) ARCH="x64" ;;
aarch64) ARCH="arm64" ;;
esac
fi
# Debug output
echo "Selected Arch: $ARCH"
# Set Node.js version and architecture-specific filename
NODE_VERSION=22.14.0
BUILD_DIR="./build"
DIST_DIR="./dist"
CLEANUP_SCRIPT="./scripts/cleanupNodeModules.ts"
# Trigger the build
echo "Build start"
npm run build:prepare-dist
echo "Build finished"
# pruning of unnecessary files and devDeps in node_modules
node --experimental-strip-types $CLEANUP_SCRIPT $BUILD_DIR
NODE_FILENAME=node-v${NODE_VERSION}-linux-${ARCH}
echo "Downloading Node.js runtime $NODE_FILENAME..."
cd $BUILD_DIR
wget -qO- https://nodejs.org/dist/v${NODE_VERSION}/${NODE_FILENAME}.tar.xz | tar xfJ -
mv $NODE_FILENAME node
cd ..
rm -rf $BUILD_DIR/node/lib/node_modules/{npm,corepack} \
$BUILD_DIR/node/bin/{npm,npx,corepack} \
$BUILD_DIR/node/CHANGELOG.md \
$BUILD_DIR/node/include/node \
$BUILD_DIR/node_modules/electron* \
$BUILD_DIR/electron*.{js,map}
printf "#!/bin/sh\n./node/bin/node src/main\n" > $BUILD_DIR/trilium.sh
chmod 755 $BUILD_DIR/trilium.sh
# TriliumNextTODO: is this still required? If yes → move to copy-dist/copy-trilium
cp tpl/anonymize-database.sql $BUILD_DIR/
VERSION=`jq -r ".version" package.json`
ARCHIVE_NAME="TriliumNextNotes-Server-${VERSION}-linux-${ARCH}"
echo "Creating Archive $ARCHIVE_NAME..."
mkdir $DIST_DIR
cp -r "$BUILD_DIR" "$DIST_DIR/$ARCHIVE_NAME"
cd $DIST_DIR
tar cJf "$ARCHIVE_NAME.tar.xz" "$ARCHIVE_NAME"
rm -rf "$ARCHIVE_NAME"
echo "Server Build Completed!"

View File

@@ -1,102 +0,0 @@
import fs from "fs-extra";
import path from "path";
import type { Dirent } from "fs-extra";
import { execSync } from "node:child_process";
/**
* Example usage with node >= v22:
* node --experimental-strip-types bin/cleanupNodeModules.ts /path/to/build/folder [--skip-prune-dev-deps]
* Example usage with tsx:
* tsx bin/cleanupNodeModules.ts /path/to/build/folder [--skip-prune-dev-deps]
*/
function main() {
if (process.argv.length > 4 || process.argv.length < 3) {
console.error("Usage: cleanupNodeModules.ts [path-to-build-folder] [--skip-prune-dev-deps]");
process.exit(1);
}
const basePath = process.argv[2];
const pruneDevDeps = process.argv[3] !== "--skip-prune-dev-deps";
if (!fs.existsSync(basePath)) {
console.error(`Supplied path '${basePath}' does not exist. Aborting.`);
process.exit(1);
}
console.log(`Starting pruning of node_modules ${!pruneDevDeps ? '(skipping npm pruning)' : ''} in '${basePath}'...`);
cleanupNodeModules(basePath, pruneDevDeps);
console.log("Successfully pruned node_modules.");
}
function cleanupNodeModules(basePath: string, pruneDevDeps: boolean = true) {
const nodeModulesDirPath = path.join(basePath, "node_modules");
const nodeModulesContent = fs.readdirSync(nodeModulesDirPath, { recursive: true, withFileTypes: true });
//const libDir = fs.readdirSync(path.join(basePath, "./libraries"), { recursive: true, withFileTypes: true });
/**
* Delete unnecessary folders
*/
const filterableDirs = new Set([
"demo",
"demos",
"doc",
"docs",
"example",
"examples",
"test",
"tests"
]);
nodeModulesContent
.filter(el => el.isDirectory() && filterableDirs.has(el.name))
.forEach(dir => removeDirent(dir));
/**
* Delete unnecessary files based on file extension
* TODO filter out useless (README).md files
*/
const filterableFileExt = new Set([
"ts",
"map"
]);
nodeModulesContent
// TriliumNextTODO: check if we can improve this naive file ext matching, without introducing any additional dependency
.filter(el => el.isFile() && filterableFileExt.has(el.name.split(".").at(-1) || ""))
.forEach(dir => removeDirent(dir));
/**
* Delete specific unnecessary folders
* TODO: check if we want removeSync to throw an error, if path does not exist anymore -> currently it will silently fail
*/
const extraFoldersDelete = new Set([
path.join(nodeModulesDirPath, ".bin"),
path.join(nodeModulesDirPath, "@excalidraw", "excalidraw", "dist", "dev"),
path.join(nodeModulesDirPath, "boxicons", "svg"),
path.join(nodeModulesDirPath, "boxicons", "node_modules"),
path.join(nodeModulesDirPath, "boxicons", "src"),
path.join(nodeModulesDirPath, "boxicons", "iconjar"),
path.join(nodeModulesDirPath, "@jimp", "plugin-print", "fonts"),
path.join(nodeModulesDirPath, "jimp", "dist", "browser") // missing "@" in front of jimp is not a typo here
]);
nodeModulesContent
.filter(el => el.isDirectory() && extraFoldersDelete.has(path.join(el.parentPath, el.name)))
.forEach(dir => removeDirent(dir))
}
function removeDirent(el: Dirent) {
const elementToDelete = path.join(el.parentPath, el.name);
fs.removeSync(elementToDelete);
if (process.env.VERBOSE) {
console.log(`Deleted ${elementToDelete}`);
}
}
main()

View File

@@ -1,115 +0,0 @@
import fs from "fs-extra";
import path from "path";
const DEST_DIR = "./build";
const VERBOSE = process.env.VERBOSE;
function log(...args: any[]) {
if (VERBOSE) {
console.log(...args);
}
}
import { fileURLToPath } from "url";
import { dirname } from "path";
import { execSync } from "child_process";
const scriptDir = dirname(fileURLToPath(import.meta.url));
const rootDir = path.resolve(scriptDir, "..", "..", "..");
const clientDir = path.join(rootDir, "apps", "client");
const serverDir = path.join(rootDir, "apps", "server");
function copyAssets(baseDir: string, destDir: string, files: string[]) {
for (const file of files) {
const src = path.join(baseDir, file);
const dest = path.join(destDir, file);
log(`${src} -> ${dest}`);
fs.copySync(src, dest);
}
}
/**
* We cannot copy the node_modules directory directly because we are in a monorepo and all the packages are gathered at root level.
* We cannot copy the files manually because we'd have to implement all the npm lookup logic, especially since there are issues with the same library having multiple versions across dependencies.
*
* @param packageJsonPath
*/
function copyNodeModules(packageJsonPath: string) {
const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, "utf8"));
// Skip monorepo packages
packageJson.dependencies = Object.fromEntries(
Object.entries(packageJson.dependencies).filter(([key]) => {
return (key === "@triliumnext/express-partial-content" || !key.startsWith("@triliumnext"));
}));
// Trigger an npm install to obtain the dependencies.
fs.writeFileSync(path.join(DEST_DIR, "package.json"), JSON.stringify(packageJson));
execSync(`npm install --omit=dev`, {
cwd: DEST_DIR,
stdio: "inherit",
});
}
try {
const clientAssets = [
"./libraries",
`./stylesheets`
];
const serverAssets = [
// copy node_module, to avoid downloading packages a 2nd time during pruning
"./node_modules",
"./assets",
"./translations",
"./db",
"./config-sample.ini",
"./package.json",
"./src/public/icon.png",
"./src/public/manifest.webmanifest",
"./src/public/robots.txt",
"./src/public/fonts",
"./src/public/translations",
`./tpl/`,
"./scripts/cleanupNodeModules.ts",
"./src/views/",
"./src/etapi/etapi.openapi.yaml",
"./src/routes/api/openapi.json",
];
const rootAssets = [
"LICENSE",
"README.md"
];
fs.mkdirpSync(DEST_DIR);
copyNodeModules(path.join(serverDir, "package.json"));
// Copy monorepo assets.
fs.copySync("../../packages/commons/build", path.join(DEST_DIR, "node_modules", "@triliumnext/commons"));
fs.copySync("../../packages/turndown-plugin-gfm", path.join(DEST_DIR, "node_modules", "@triliumnext/turndown-plugin-gfm"));
copyAssets(clientDir, path.join(DEST_DIR, "src", "public"), clientAssets);
copyAssets(serverDir, path.join(DEST_DIR), serverAssets);
copyAssets(rootDir, path.join(DEST_DIR), rootAssets);
/**
* Directories to be copied relative to the project root into <resource_dir>/src/public/app-dist.
*/
const publicDirsToCopy = ["./src/public/app/doc_notes"];
const PUBLIC_DIR = path.join(DEST_DIR, "src", "public", "app-dist");
for (const dir of publicDirsToCopy) {
fs.copySync(dir, path.normalize(path.join(PUBLIC_DIR, path.basename(dir))));
}
fs.copySync(path.join(clientDir, "build"), path.join(DEST_DIR, "src", "public", "app-dist"));
fs.copySync(path.join(rootDir, "packages", "turndown-plugin-gfm", "src"), path.join(DEST_DIR, "src", "public", "app-dist", "turndown-plugin-gfm"));
console.log("Copying complete!")
} catch(err) {
console.error("Error during copy:", err.message)
process.exit(1)
}

Binary file not shown.

View File

@@ -1,23 +0,0 @@
import anonymizationService from "./services/anonymization.js";
import sqlInit from "./services/sql_init.js";
await import("./becca/entity_constructor.js");
sqlInit.dbReady.then(async () => {
try {
console.log("Starting anonymization...");
const resp = await anonymizationService.createAnonymizedCopy("full");
if (resp.success) {
console.log(`Anonymized file has been saved to: ${resp.anonymizedFilePath}`);
process.exit(0);
} else {
console.log("Anonymization failed.");
}
} catch (e: any) {
console.error(e.message, e.stack);
}
process.exit(1);
});

View File

@@ -1,139 +0,0 @@
import express from "express";
import path from "path";
import favicon from "serve-favicon";
import cookieParser from "cookie-parser";
import helmet from "helmet";
import compression from "compression";
import { fileURLToPath } from "url";
import { dirname } from "path";
import sessionParser from "./routes/session_parser.js";
import config from "./services/config.js";
import utils from "./services/utils.js";
import assets from "./routes/assets.js";
import routes from "./routes/routes.js";
import custom from "./routes/custom.js";
import error_handlers from "./routes/error_handlers.js";
import { startScheduledCleanup } from "./services/erase.js";
import sql_init from "./services/sql_init.js";
import { auth } from "express-openid-connect";
import openID from "./services/open_id.js";
import { t } from "i18next";
import eventService from "./services/events.js";
import log from "./services/log.js";
await import("./services/handlers.js");
await import("./becca/becca_loader.js");
const app = express();
const scriptDir = dirname(fileURLToPath(import.meta.url));
// Initialize DB
sql_init.initializeDb();
// Listen for database initialization event
eventService.subscribe(eventService.DB_INITIALIZED, async () => {
try {
log.info("Database initialized, setting up LLM features");
// Initialize embedding providers
const { initializeEmbeddings } = await import("./services/llm/embeddings/init.js");
await initializeEmbeddings();
// Initialize the index service for LLM functionality
const { default: indexService } = await import("./services/llm/index_service.js");
await indexService.initialize().catch(e => console.error("Failed to initialize index service:", e));
log.info("LLM features initialized successfully");
} catch (error) {
console.error("Error initializing LLM features:", error);
}
});
// Initialize LLM features only if database is already initialized
if (sql_init.isDbInitialized()) {
try {
// Initialize embedding providers
const { initializeEmbeddings } = await import("./services/llm/embeddings/init.js");
await initializeEmbeddings();
// Initialize the index service for LLM functionality
const { default: indexService } = await import("./services/llm/index_service.js");
await indexService.initialize().catch(e => console.error("Failed to initialize index service:", e));
} catch (error) {
console.error("Error initializing LLM features:", error);
}
} else {
console.log("Database not initialized yet. LLM features will be initialized after setup.");
}
// view engine setup
app.set("views", path.join(scriptDir, "views"));
app.set("view engine", "ejs");
app.use((req, res, next) => {
// set CORS header
if (config["Network"]["corsAllowOrigin"]) {
res.header("Access-Control-Allow-Origin", config["Network"]["corsAllowOrigin"]);
}
if (config["Network"]["corsAllowMethods"]) {
res.header("Access-Control-Allow-Methods", config["Network"]["corsAllowMethods"]);
}
if (config["Network"]["corsAllowHeaders"]) {
res.header("Access-Control-Allow-Headers", config["Network"]["corsAllowHeaders"]);
}
res.locals.t = t;
return next();
});
if (!utils.isElectron) {
app.use(compression()); // HTTP compression
}
app.use(
helmet({
hidePoweredBy: false, // errors out in electron
contentSecurityPolicy: false,
crossOriginEmbedderPolicy: false
})
);
app.use(express.text({ limit: "500mb" }));
app.use(express.json({ limit: "500mb" }));
app.use(express.raw({ limit: "500mb" }));
app.use(express.urlencoded({ extended: false }));
app.use(cookieParser());
app.use(express.static(path.join(scriptDir, "public/root")));
app.use(`/manifest.webmanifest`, express.static(path.join(scriptDir, "public/manifest.webmanifest")));
app.use(`/robots.txt`, express.static(path.join(scriptDir, "public/robots.txt")));
app.use(`/icon.png`, express.static(path.join(scriptDir, "public/icon.png")));
app.use(sessionParser);
app.use(favicon(`${scriptDir}/../assets/icon.ico`));
if (openID.isOpenIDEnabled())
app.use(auth(openID.generateOAuthConfig()));
await assets.register(app);
routes.register(app);
custom.register(app);
error_handlers.register(app);
// triggers sync timer
await import("./services/sync.js");
// triggers backup timer
await import("./services/backup.js");
// trigger consistency checks timer
await import("./services/consistency_checks.js");
await import("./services/scheduler.js");
startScheduledCleanup();
if (utils.isElectron) {
(await import("@electron/remote/main/index.js")).initialize();
}
export default app;

View File

@@ -1,295 +0,0 @@
import sql from "../services/sql.js";
import NoteSet from "../services/search/note_set.js";
import NotFoundError from "../errors/not_found_error.js";
import type BOption from "./entities/boption.js";
import type BNote from "./entities/bnote.js";
import type BEtapiToken from "./entities/betapi_token.js";
import type BAttribute from "./entities/battribute.js";
import type BBranch from "./entities/bbranch.js";
import BRevision from "./entities/brevision.js";
import BAttachment from "./entities/battachment.js";
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";
interface AttachmentOpts {
includeContentLength?: boolean;
}
/**
* Becca is a backend cache of all notes, branches, and attributes.
* There's a similar frontend cache Froca, and share cache Shaca.
*/
export default class Becca {
loaded!: boolean;
notes!: Record<string, BNote>;
branches!: Record<string, BBranch>;
childParentToBranch!: Record<string, BBranch>;
attributes!: Record<string, BAttribute>;
/** Points from attribute type-name to list of attributes */
attributeIndex!: Record<string, BAttribute[]>;
options!: Record<string, BOption>;
etapiTokens!: Record<string, BEtapiToken>;
allNoteSetCache: NoteSet | null;
constructor() {
this.reset();
this.allNoteSetCache = null;
}
reset() {
this.notes = {};
this.branches = {};
this.childParentToBranch = {};
this.attributes = {};
this.attributeIndex = {};
this.options = {};
this.etapiTokens = {};
this.dirtyNoteSetCache();
this.loaded = false;
}
getRoot() {
return this.getNote("root");
}
findAttributes(type: string, name: string): BAttribute[] {
name = name.trim().toLowerCase();
if (name.startsWith("#") || name.startsWith("~")) {
name = name.substr(1);
}
return this.attributeIndex[`${type}-${name}`] || [];
}
findAttributesWithPrefix(type: string, name: string): BAttribute[] {
const resArr: BAttribute[][] = [];
const key = `${type}-${name}`;
for (const idx in this.attributeIndex) {
if (idx.startsWith(key)) {
resArr.push(this.attributeIndex[idx]);
}
}
return resArr.flat();
}
decryptProtectedNotes() {
for (const note of Object.values(this.notes)) {
note.decrypt();
}
}
addNote(noteId: string, note: BNote) {
this.notes[noteId] = note;
this.dirtyNoteSetCache();
}
getNote(noteId: string): BNote | null {
return this.notes[noteId];
}
getNoteOrThrow(noteId: string): BNote {
const note = this.notes[noteId];
if (!note) {
throw new NotFoundError(`Note '${noteId}' doesn't exist.`);
}
return note;
}
getNotes(noteIds: string[], ignoreMissing: boolean = false): BNote[] {
const filteredNotes: BNote[] = [];
for (const noteId of noteIds) {
const note = this.notes[noteId];
if (!note) {
if (ignoreMissing) {
continue;
}
throw new Error(`Note '${noteId}' was not found in becca.`);
}
filteredNotes.push(note);
}
return filteredNotes;
}
getBranch(branchId: string): BBranch | null {
return this.branches[branchId];
}
getBranchOrThrow(branchId: string): BBranch {
const branch = this.getBranch(branchId);
if (!branch) {
throw new NotFoundError(`Branch '${branchId}' was not found in becca.`);
}
return branch;
}
getAttribute(attributeId: string): BAttribute | null {
return this.attributes[attributeId];
}
getAttributeOrThrow(attributeId: string): BAttribute {
const attribute = this.getAttribute(attributeId);
if (!attribute) {
throw new NotFoundError(`Attribute '${attributeId}' does not exist.`);
}
return attribute;
}
getBranchFromChildAndParent(childNoteId: string, parentNoteId: string): BBranch | null {
return this.childParentToBranch[`${childNoteId}-${parentNoteId}`];
}
getRevision(revisionId: string): BRevision | null {
const row = sql.getRow<RevisionRow | null>("SELECT * FROM revisions WHERE revisionId = ?", [revisionId]);
return row ? new BRevision(row) : null;
}
getRevisionOrThrow(revisionId: string): BRevision {
const revision = this.getRevision(revisionId);
if (!revision) {
throw new NotFoundError(`Revision '${revisionId}' has not been found.`);
}
return revision;
}
getAttachment(attachmentId: string, opts: AttachmentOpts = {}): 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 attachmentId = ? AND isDeleted = 0`
: /*sql*/`SELECT * FROM attachments WHERE attachmentId = ? AND isDeleted = 0`;
return sql.getRows<AttachmentRow>(query, [attachmentId]).map((row) => new BAttachment(row))[0];
}
getAttachmentOrThrow(attachmentId: string, opts: AttachmentOpts = {}): BAttachment {
const attachment = this.getAttachment(attachmentId, opts);
if (!attachment) {
throw new NotFoundError(`Attachment '${attachmentId}' has not been found.`);
}
return attachment;
}
getAttachments(attachmentIds: string[]): BAttachment[] {
return sql.getManyRows<AttachmentRow>("SELECT * FROM attachments WHERE attachmentId IN (???) AND isDeleted = 0", attachmentIds).map((row) => new BAttachment(row));
}
getBlob(entity: { blobId?: string }): BBlob | null {
if (!entity.blobId) {
return null;
}
const row = sql.getRow<BlobRow | null>("SELECT *, LENGTH(content) AS contentLength FROM blobs WHERE blobId = ?", [entity.blobId]);
return row ? new BBlob(row) : null;
}
getOption(name: string): BOption | null {
return this.options[name];
}
getEtapiTokens(): BEtapiToken[] {
return Object.values(this.etapiTokens);
}
getEtapiToken(etapiTokenId: string): BEtapiToken | null {
return this.etapiTokens[etapiTokenId];
}
getEntity<T extends AbstractBeccaEntity<T>>(entityName: string, entityId: string): AbstractBeccaEntity<T> | null {
if (!entityName || !entityId) {
return null;
}
if (entityName === "revisions") {
return this.getRevision(entityId);
} else if (entityName === "attachments") {
return this.getAttachment(entityId);
}
const camelCaseEntityName = entityName.toLowerCase().replace(/(_[a-z])/g, (group) => group.toUpperCase().replace("_", ""));
if (!(camelCaseEntityName in this)) {
throw new Error(`Unknown entity name '${camelCaseEntityName}' (original argument '${entityName}')`);
}
return (this as any)[camelCaseEntityName][entityId];
}
getRecentNotesFromQuery(query: string, params: string[] = []): BRecentNote[] {
const rows = sql.getRows<BRecentNote>(query, params);
return rows.map((row) => new BRecentNote(row));
}
getRevisionsFromQuery(query: string, params: string[] = []): BRevision[] {
const rows = sql.getRows<RevisionRow>(query, params);
return rows.map((row) => new BRevision(row));
}
/** Should be called when the set of all non-skeleton notes changes (added/removed) */
dirtyNoteSetCache() {
this.allNoteSetCache = null;
}
getAllNoteSet() {
// caching this since it takes 10s of milliseconds to fill this initial NoteSet for many notes
if (!this.allNoteSetCache) {
const allNotes = [];
for (const noteId in this.notes) {
const note = this.notes[noteId];
// in the process of loading data sometimes we create "skeleton" note instances which are expected to be filled later
// in case of inconsistent data this might not work and search will then crash on these
if (note.type !== undefined) {
allNotes.push(note);
}
}
this.allNoteSetCache = new NoteSet(allNotes);
}
return this.allNoteSetCache;
}
}
/**
* This interface contains the data that is shared across all the objects of a given derived class of {@link AbstractBeccaEntity}.
* For example, all BAttributes will share their content, but all BBranches will have another set of this data.
*/
export interface ConstructorData<T extends AbstractBeccaEntity<T>> {
primaryKeyName: string;
entityName: string;
hashedProperties: (keyof T)[];
}
export interface NotePojo {
noteId: string;
title?: string;
isProtected?: boolean;
type: string;
mime: string;
blobId?: string;
isDeleted: boolean;
dateCreated?: string;
dateModified?: string;
utcDateCreated: string;
utcDateModified?: string;
}

View File

@@ -1,7 +0,0 @@
"use strict";
import Becca from "./becca-interface.js";
const becca = new Becca();
export default becca;

View File

@@ -1,295 +0,0 @@
"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";
const beccaLoaded = new Promise<void>(async (res, rej) => {
const sqlInit = (await import("../services/sql_init.js")).default;
// We have to import async since options init requires keyboard actions which require translations.
const options_init = (await import("../services/options_init.js")).default;
sqlInit.dbReady.then(() => {
cls.init(() => {
load();
options_init.initStartupOptions();
res();
});
});
});
function load() {
const start = Date.now();
becca.reset();
// we know this is slow and the total becca load time is logged
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
for (const row of sql.getRawRows(/*sql*/`SELECT noteId, title, type, mime, isProtected, blobId, dateCreated, dateModified, utcDateCreated, utcDateModified FROM notes WHERE isDeleted = 0`)) {
new BNote().update(row).init();
}
const branchRows = sql.getRawRows<BranchRow>(/*sql*/`SELECT branchId, noteId, parentNoteId, prefix, notePosition, isExpanded, utcDateModified FROM branches WHERE isDeleted = 0`);
// in-memory sort is faster than in the DB
branchRows.sort((a, b) => (a.notePosition || 0) - (b.notePosition || 0));
for (const row of branchRows) {
new BBranch().update(row).init();
}
for (const row of sql.getRawRows<AttributeRow>(/*sql*/`SELECT attributeId, noteId, type, name, value, isInheritable, position, utcDateModified FROM attributes WHERE isDeleted = 0`)) {
new BAttribute().update(row).init();
}
for (const row of sql.getRows<OptionRow>(/*sql*/`SELECT name, value, isSynced, utcDateModified FROM options`)) {
new BOption(row);
}
for (const row of sql.getRows<EtapiTokenRow>(/*sql*/`SELECT etapiTokenId, name, tokenHash, utcDateCreated, utcDateModified FROM etapi_tokens WHERE isDeleted = 0`)) {
new BEtapiToken(row);
}
});
for (const noteId in becca.notes) {
becca.notes[noteId].sortParents();
}
becca.loaded = true;
log.info(`Becca (note cache) load took ${Date.now() - start}ms`);
}
function reload(reason: string) {
load();
ws.reloadFrontend(reason || "becca reloaded");
}
eventService.subscribeBeccaLoader([eventService.ENTITY_CHANGE_SYNCED], ({ entityName, entityRow }) => {
if (!becca.loaded) {
return;
}
if (["notes", "branches", "attributes", "etapi_tokens", "options"].includes(entityName)) {
const EntityClass = entityConstructor.getEntityFromEntityName(entityName);
const primaryKeyName = EntityClass.primaryKeyName;
let beccaEntity = becca.getEntity(entityName, entityRow[primaryKeyName]);
if (beccaEntity) {
beccaEntity.updateFromRow(entityRow);
} else {
beccaEntity = new EntityClass() as AbstractBeccaEntity<AbstractBeccaEntity<any>>;
beccaEntity.updateFromRow(entityRow);
beccaEntity.init();
}
}
postProcessEntityUpdate(entityName, entityRow);
});
eventService.subscribeBeccaLoader(eventService.ENTITY_CHANGED, ({ entityName, entity }) => {
if (!becca.loaded) {
return;
}
postProcessEntityUpdate(entityName, entity);
});
/**
* This gets run on entity being created or updated.
*
* @param entityName
* @param entityRow - can be a becca entity (change comes from this trilium instance) or just a row (from sync).
* It should be therefore treated as a row.
*/
function postProcessEntityUpdate(entityName: string, entityRow: any) {
if (entityName === "notes") {
noteUpdated(entityRow);
} else if (entityName === "branches") {
branchUpdated(entityRow);
} else if (entityName === "attributes") {
attributeUpdated(entityRow);
} else if (entityName === "note_reordering") {
noteReorderingUpdated(entityRow);
}
}
eventService.subscribeBeccaLoader([eventService.ENTITY_DELETED, eventService.ENTITY_DELETE_SYNCED], ({ entityName, entityId }) => {
if (!becca.loaded) {
return;
}
if (entityName === "notes") {
noteDeleted(entityId);
} else if (entityName === "branches") {
branchDeleted(entityId);
} else if (entityName === "attributes") {
attributeDeleted(entityId);
} else if (entityName === "etapi_tokens") {
etapiTokenDeleted(entityId);
}
});
function noteDeleted(noteId: string) {
delete becca.notes[noteId];
becca.dirtyNoteSetCache();
}
function branchDeleted(branchId: string) {
const branch = becca.branches[branchId];
if (!branch) {
return;
}
const childNote = becca.notes[branch.noteId];
if (childNote) {
childNote.parents = childNote.parents.filter((parent) => parent.noteId !== branch.parentNoteId);
childNote.parentBranches = childNote.parentBranches.filter((parentBranch) => parentBranch.branchId !== branch.branchId);
if (childNote.parents.length > 0) {
// subtree notes might lose some inherited attributes
childNote.invalidateSubTree();
}
}
const parentNote = becca.notes[branch.parentNoteId];
if (parentNote) {
parentNote.children = parentNote.children.filter((child) => child.noteId !== branch.noteId);
}
delete becca.childParentToBranch[`${branch.noteId}-${branch.parentNoteId}`];
if (branch.branchId) {
delete becca.branches[branch.branchId];
}
}
function noteUpdated(entityRow: NoteRow) {
const note = becca.notes[entityRow.noteId];
if (note) {
// TODO, this wouldn't have worked in the original implementation since the variable was named __flatTextCache.
// type / mime could have been changed, and they are present in flatTextCache
note.__flatTextCache = null;
}
}
function branchUpdated(branchRow: BranchRow) {
const childNote = becca.notes[branchRow.noteId];
if (childNote) {
childNote.__flatTextCache = null;
childNote.sortParents();
// notes in the subtree can get new inherited attributes
// this is in theory needed upon branch creation, but there's no "create" event for sync changes
childNote.invalidateSubTree();
}
const parentNote = becca.notes[branchRow.parentNoteId];
if (parentNote) {
parentNote.sortChildren();
}
}
function attributeDeleted(attributeId: string) {
const attribute = becca.attributes[attributeId];
if (!attribute) {
return;
}
const note = becca.notes[attribute.noteId];
if (note) {
// first invalidate and only then remove the attribute (otherwise invalidation wouldn't be complete)
if (attribute.isAffectingSubtree || note.isInherited()) {
note.invalidateSubTree();
} else {
note.invalidateThisCache();
}
note.ownedAttributes = note.ownedAttributes.filter((attr) => attr.attributeId !== attribute.attributeId);
const targetNote = attribute.targetNote;
if (targetNote) {
targetNote.targetRelations = targetNote.targetRelations.filter((rel) => rel.attributeId !== attribute.attributeId);
}
}
delete becca.attributes[attribute.attributeId];
const key = `${attribute.type}-${attribute.name.toLowerCase()}`;
if (key in becca.attributeIndex) {
becca.attributeIndex[key] = becca.attributeIndex[key].filter((attr) => attr.attributeId !== attribute.attributeId);
}
}
function attributeUpdated(attributeRow: BAttribute) {
const attribute = becca.attributes[attributeRow.attributeId];
const note = becca.notes[attributeRow.noteId];
if (note) {
if (attribute.isAffectingSubtree || note.isInherited()) {
note.invalidateSubTree();
} else {
note.invalidateThisCache();
}
}
}
function noteReorderingUpdated(branchIdList: number[]) {
const parentNoteIds = new Set();
for (const branchId in branchIdList) {
const branch = becca.branches[branchId];
if (branch) {
branch.notePosition = branchIdList[branchId];
parentNoteIds.add(branch.parentNoteId);
}
}
}
function etapiTokenDeleted(etapiTokenId: string) {
delete becca.etapiTokens[etapiTokenId];
}
eventService.subscribeBeccaLoader(eventService.ENTER_PROTECTED_SESSION, () => {
try {
becca.decryptProtectedNotes();
} catch (e: any) {
log.error(`Could not decrypt protected notes: ${e.message} ${e.stack}`);
}
});
eventService.subscribeBeccaLoader(eventService.LEAVE_PROTECTED_SESSION, load);
export default {
load,
reload,
beccaLoaded
};

View File

@@ -1,117 +0,0 @@
"use strict";
import becca from "./becca.js";
import cls from "../services/cls.js";
import log from "../services/log.js";
function isNotePathArchived(notePath: string[]) {
const noteId = notePath[notePath.length - 1];
const note = becca.notes[noteId];
if (note.isArchived) {
return true;
}
for (let i = 0; i < notePath.length - 1; i++) {
const note = becca.notes[notePath[i]];
// this is going through parents so archived must be inheritable
if (note.hasInheritableArchivedLabel()) {
return true;
}
}
return false;
}
function getNoteTitle(childNoteId: string, parentNoteId?: string) {
const childNote = becca.notes[childNoteId];
const parentNote = parentNoteId ? becca.notes[parentNoteId] : null;
if (!childNote) {
log.info(`Cannot find note '${childNoteId}'`);
return "[error fetching title]";
}
const title = childNote.getTitleOrProtected();
const branch = parentNote ? becca.getBranchFromChildAndParent(childNote.noteId, parentNote.noteId) : null;
return `${branch && branch.prefix ? `${branch.prefix} - ` : ""}${title}`;
}
/**
* Similar to {@link getNoteTitle}, but also returns the icon class of the note.
*
* @returns An object containing the title and icon class of the note.
*/
function getNoteTitleAndIcon(childNoteId: string, parentNoteId?: string) {
const childNote = becca.notes[childNoteId];
const parentNote = parentNoteId ? becca.notes[parentNoteId] : null;
if (!childNote) {
log.info(`Cannot find note '${childNoteId}'`);
return {
title: "[error fetching title]"
}
}
const title = childNote.getTitleOrProtected();
const icon = childNote.getIcon();
const branch = parentNote ? becca.getBranchFromChildAndParent(childNote.noteId, parentNote.noteId) : null;
return {
icon,
title: `${branch && branch.prefix ? `${branch.prefix} - ` : ""}${title}`
}
}
function getNoteTitleArrayForPath(notePathArray: string[]) {
if (!notePathArray || !Array.isArray(notePathArray)) {
throw new Error(`${notePathArray} is not an array.`);
}
if (notePathArray.length === 1) {
return [getNoteTitle(notePathArray[0])];
}
const titles = [];
let parentNoteId = "root";
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 outsideOfHoistedSubtree = !notePathArray.includes(hoistedNoteId);
for (const noteId of notePathArray) {
// start collecting path segment titles only after hoisted note
if (hoistedNotePassed) {
const title = getNoteTitle(noteId, parentNoteId);
titles.push(title);
}
if (!hoistedNotePassed && (noteId === hoistedNoteId || outsideOfHoistedSubtree)) {
hoistedNotePassed = true;
}
parentNoteId = noteId;
}
return titles;
}
function getNoteTitleForPath(notePathArray: string[]) {
const titles = getNoteTitleArrayForPath(notePathArray);
return titles.join(" / ");
}
export default {
getNoteTitle,
getNoteTitleAndIcon,
getNoteTitleForPath,
isNotePathArchived
};

View File

@@ -1,324 +0,0 @@
"use strict";
import utils from "../../services/utils.js";
import sql from "../../services/sql.js";
import entityChangesService from "../../services/entity_changes.js";
import eventService from "../../services/events.js";
import dateUtils from "../../services/date_utils.js";
import cls from "../../services/cls.js";
import log from "../../services/log.js";
import protectedSessionService from "../../services/protected_session.js";
import blobService from "../../services/blob.js";
import type { default as Becca, ConstructorData } from "../becca-interface.js";
import becca from "../becca.js";
interface ContentOpts {
forceSave?: boolean;
forceFrontendReload?: boolean;
}
/**
* Base class for all backend entities.
*
* @type T the same entity type needed for self-reference in {@link ConstructorData}.
*/
abstract class AbstractBeccaEntity<T extends AbstractBeccaEntity<T>> {
utcDateModified?: string;
dateCreated?: string;
dateModified?: string;
utcDateCreated!: string;
isProtected?: boolean;
isSynced?: boolean;
blobId?: string;
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();
}
}
getUtcDateChanged() {
return this.utcDateModified || this.utcDateCreated;
}
protected get becca(): Becca {
return becca;
}
protected putEntityChange(isDeleted: boolean) {
const constructorData = this.constructor as unknown as ConstructorData<T>;
entityChangesService.putEntityChange({
entityName: constructorData.entityName,
entityId: (this as any)[constructorData.primaryKeyName],
hash: this.generateHash(isDeleted),
isErased: false,
utcDateChanged: this.getUtcDateChanged(),
isSynced: constructorData.entityName !== "options" || !!this.isSynced
});
}
generateHash(isDeleted?: boolean): string {
const constructorData = this.constructor as unknown as ConstructorData<T>;
let contentToHash = "";
for (const propertyName of constructorData.hashedProperties) {
contentToHash += `|${(this as any)[propertyName]}`;
}
if (isDeleted) {
contentToHash += "|deleted";
}
return utils.hash(contentToHash).substr(0, 10);
}
protected getPojoToSave() {
return this.getPojo();
}
hasStringContent(): boolean {
// TODO: Not sure why some entities don't implement it.
return true;
}
abstract getPojo(): {};
init() {
// Do nothing by default, can be overriden in derived classes.
}
abstract updateFromRow(row: unknown): void;
get isDeleted(): boolean {
// TODO: Not sure why some entities don't implement it.
return false;
}
/**
* Saves entity - executes SQL, but doesn't commit the transaction on its own
*/
save(opts?: {}): this {
const constructorData = this.constructor as unknown as ConstructorData<T>;
const entityName = constructorData.entityName;
const primaryKeyName = constructorData.primaryKeyName;
const isNewEntity = !(this as any)[primaryKeyName];
this.beforeSaving(opts);
const pojo = this.getPojoToSave();
sql.transactional(() => {
sql.upsert(entityName, primaryKeyName, pojo);
if (entityName === "recent_notes") {
return;
}
this.putEntityChange(!!this.isDeleted);
if (!cls.isEntityEventsDisabled()) {
const eventPayload = {
entityName,
entity: this
};
if (isNewEntity) {
eventService.emit(eventService.ENTITY_CREATED, eventPayload);
}
eventService.emit(eventService.ENTITY_CHANGED, eventPayload);
}
});
return this;
}
protected _setContent(content: string | Buffer, 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;
if (content === null || content === undefined) {
const constructorData = this.constructor as unknown as ConstructorData<T>;
throw new Error(`Cannot set null content to ${constructorData.primaryKeyName} '${(this as any)[constructorData.primaryKeyName]}'`);
}
if (this.hasStringContent()) {
content = content.toString();
} else {
content = Buffer.isBuffer(content) ? content : Buffer.from(content);
}
const unencryptedContentForHashCalculation = this.getUnencryptedContentForHashCalculation(content);
if (this.isProtected) {
if (protectedSessionService.isProtectedSessionAvailable()) {
const encryptedContent = protectedSessionService.encrypt(content);
if (!encryptedContent) {
throw new Error(`Unable to encrypt the content of the entity.`);
}
content = encryptedContent;
} else {
throw new Error(`Cannot update content of blob since protected session is not available.`);
}
}
sql.transactional(() => {
const newBlobId = this.saveBlob(content, unencryptedContentForHashCalculation, opts);
const oldBlobId = this.blobId;
if (newBlobId !== oldBlobId || opts.forceSave) {
this.blobId = newBlobId;
this.save();
if (oldBlobId && newBlobId !== oldBlobId) {
this.deleteBlobIfNotUsed(oldBlobId);
}
}
});
}
private deleteBlobIfNotUsed(oldBlobId: string) {
if (sql.getValue("SELECT 1 FROM notes WHERE blobId = ? LIMIT 1", [oldBlobId])) {
return;
}
if (sql.getValue("SELECT 1 FROM attachments WHERE blobId = ? LIMIT 1", [oldBlobId])) {
return;
}
if (sql.getValue("SELECT 1 FROM revisions WHERE blobId = ? LIMIT 1", [oldBlobId])) {
return;
}
sql.execute("DELETE FROM blobs WHERE blobId = ?", [oldBlobId]);
// blobs are not marked as erased in entity_changes, they are just purged completely
// this is because technically every keystroke can create a new blob, and there would be just too many
sql.execute("DELETE FROM entity_changes WHERE entityName = 'blobs' AND entityId = ?", [oldBlobId]);
}
private getUnencryptedContentForHashCalculation(unencryptedContent: Buffer | string) {
if (this.isProtected) {
// a "random" prefix makes sure that the calculated hash/blobId is different for a decrypted/encrypted content
const encryptedPrefixSuffix = "t$[nvQg7q)&_ENCRYPTED_?M:Bf&j3jr_";
return Buffer.isBuffer(unencryptedContent) ? Buffer.concat([Buffer.from(encryptedPrefixSuffix), unencryptedContent]) : `${encryptedPrefixSuffix}${unencryptedContent}`;
} else {
return unencryptedContent;
}
}
private saveBlob(content: string | Buffer, unencryptedContentForHashCalculation: string | Buffer, 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 blobNeedsInsert = !sql.getValue("SELECT 1 FROM blobs WHERE blobId = ?", [newBlobId]);
if (!blobNeedsInsert) {
return newBlobId;
}
const pojo = {
blobId: newBlobId,
content: content,
dateModified: dateUtils.localNowDateTime(),
utcDateModified: dateUtils.utcNowDateTime()
};
sql.upsert("blobs", "blobId", pojo);
// we can't reuse blobId as an entity_changes hash, because this one has to be calculatable without having
// access to the decrypted content
const hash = blobService.calculateContentHash(pojo);
entityChangesService.putEntityChange({
entityName: "blobs",
entityId: newBlobId,
hash: hash,
isErased: false,
utcDateChanged: pojo.utcDateModified,
isSynced: true,
// overriding componentId will cause the frontend to think the change is coming from a different component
// and thus reload
componentId: opts.forceFrontendReload ? utils.randomString(10) : null
});
eventService.emit(eventService.ENTITY_CHANGED, {
entityName: "blobs",
entity: this
});
return newBlobId;
}
protected _getContent(): string | Buffer {
const row = sql.getRow<{ content: string | Buffer }>(/*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());
}
/**
* Mark the entity as (soft) deleted. It will be completely erased later.
*
* This is a low-level method, for notes and branches use `note.deleteNote()` and 'branch.deleteBranch()` instead.
*/
markAsDeleted(deleteId: string | null = null) {
const constructorData = this.constructor as unknown as ConstructorData<T>;
const entityId = (this as any)[constructorData.primaryKeyName];
const entityName = constructorData.entityName;
this.utcDateModified = dateUtils.utcNowDateTime();
sql.execute(
/*sql*/`UPDATE ${entityName} SET isDeleted = 1, deleteId = ?, utcDateModified = ?
WHERE ${constructorData.primaryKeyName} = ?`,
[deleteId, this.utcDateModified, entityId]
);
if (this.dateModified) {
this.dateModified = dateUtils.localNowDateTime();
sql.execute(/*sql*/`UPDATE ${entityName} SET dateModified = ? WHERE ${constructorData.primaryKeyName} = ?`, [this.dateModified, entityId]);
}
log.info(`Marking ${entityName} ${entityId} as deleted`);
this.putEntityChange(true);
eventService.emit(eventService.ENTITY_DELETED, { entityName, entityId, entity: this });
}
markAsDeletedSimple() {
const constructorData = this.constructor as unknown as ConstructorData<T>;
const entityId = (this as any)[constructorData.primaryKeyName];
const entityName = constructorData.entityName;
this.utcDateModified = dateUtils.utcNowDateTime();
sql.execute(
/*sql*/`UPDATE ${entityName} SET isDeleted = 1, utcDateModified = ?
WHERE ${constructorData.primaryKeyName} = ?`,
[this.utcDateModified, entityId]
);
log.info(`Marking ${entityName} ${entityId} as deleted`);
this.putEntityChange(true);
eventService.emit(eventService.ENTITY_DELETED, { entityName, entityId, entity: this });
}
}
export default AbstractBeccaEntity;

View File

@@ -1,259 +0,0 @@
"use strict";
import utils from "../../services/utils.js";
import dateUtils from "../../services/date_utils.js";
import AbstractBeccaEntity from "./abstract_becca_entity.js";
import sql from "../../services/sql.js";
import protectedSessionService from "../../services/protected_session.js";
import log from "../../services/log.js";
import type { AttachmentRow } from "@triliumnext/commons";
import type BNote from "./bnote.js";
import type BBranch from "./bbranch.js";
import noteService from "../../services/notes.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<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) */
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);
}
beforeSaving() {
super.beforeSaving();
if (this.position === undefined || this.position === null) {
this.position =
10 +
sql.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
};
}
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;

View File

@@ -1,227 +0,0 @@
"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<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;
}
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;
}
}
get isDeleted() {
return !(this.attributeId in this.becca.attributes);
}
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;

View File

@@ -1,43 +0,0 @@
import AbstractBeccaEntity from "./abstract_becca_entity.js";
import type { BlobRow } from "@triliumnext/commons";
// TODO: Why this does not extend the abstract becca?
class BBlob extends AbstractBeccaEntity<BBlob> {
static get entityName() {
return "blobs";
}
static get primaryKeyName() {
return "blobId";
}
static get hashedProperties() {
return ["blobId", "content"];
}
content!: string | Buffer;
contentLength!: number;
constructor(row: BlobRow) {
super();
this.updateFromRow(row);
}
updateFromRow(row: BlobRow): void {
this.blobId = row.blobId;
this.content = row.content;
this.contentLength = row.contentLength;
this.dateModified = row.dateModified;
this.utcDateModified = row.utcDateModified;
}
getPojo() {
return {
blobId: this.blobId,
content: this.content || null,
contentLength: this.contentLength,
dateModified: this.dateModified,
utcDateModified: this.utcDateModified
};
}
}
export default BBlob;

View File

@@ -1,283 +0,0 @@
"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<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;
}
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];
}
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): boolean {
if (!deleteId) {
deleteId = utils.randomString(10);
}
if (!taskContext) {
taskContext = new TaskContext("no-progress-reporting");
}
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;
}
}
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
});
}
}
}
export default BBranch;

View File

@@ -1,89 +0,0 @@
"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 "<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();
}
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;
}
}
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
};
}
beforeSaving() {
this.utcDateModified = dateUtils.utcNowDateTime();
super.beforeSaving();
if (this.etapiTokenId) {
this.becca.etapiTokens[this.etapiTokenId] = this;
}
}
}
export default BEtapiToken;

File diff suppressed because it is too large Load Diff

View File

@@ -1,73 +0,0 @@
import AbstractBeccaEntity from "./abstract_becca_entity.js";
import dateUtils from "../../services/date_utils.js";
import type { NoteEmbeddingRow } from "@triliumnext/commons";
/**
* Entity representing a note's vector embedding for semantic search and AI features
*/
class BNoteEmbedding extends AbstractBeccaEntity<BNoteEmbedding> {
static get entityName() {
return "note_embeddings";
}
static get primaryKeyName() {
return "embedId";
}
static get hashedProperties() {
return ["embedId", "noteId", "providerId", "modelId", "dimension", "version"];
}
embedId!: string;
noteId!: string;
providerId!: string;
modelId!: string;
dimension!: number;
embedding!: Buffer;
version!: number;
constructor(row?: NoteEmbeddingRow) {
super();
if (row) {
this.updateFromRow(row);
}
}
updateFromRow(row: NoteEmbeddingRow): void {
this.embedId = row.embedId;
this.noteId = row.noteId;
this.providerId = row.providerId;
this.modelId = row.modelId;
this.dimension = row.dimension;
this.embedding = row.embedding;
this.version = row.version;
this.dateCreated = row.dateCreated;
this.dateModified = row.dateModified;
this.utcDateCreated = row.utcDateCreated;
this.utcDateModified = row.utcDateModified;
}
beforeSaving() {
super.beforeSaving();
this.dateModified = dateUtils.localNowDateTime();
this.utcDateModified = dateUtils.utcNowDateTime();
}
getPojo(): NoteEmbeddingRow {
return {
embedId: this.embedId,
noteId: this.noteId,
providerId: this.providerId,
modelId: this.modelId,
dimension: this.dimension,
embedding: this.embedding,
version: this.version,
dateCreated: this.dateCreated!,
dateModified: this.dateModified!,
utcDateCreated: this.utcDateCreated,
utcDateModified: this.utcDateModified!
};
}
}
export default BNoteEmbedding;

View File

@@ -1,56 +0,0 @@
"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<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;
}
beforeSaving() {
super.beforeSaving();
this.utcDateModified = dateUtils.utcNowDateTime();
}
getPojo() {
return {
name: this.name,
value: this.value,
isSynced: this.isSynced,
utcDateModified: this.utcDateModified
};
}
}
export default BOption;

View File

@@ -1,46 +0,0 @@
"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<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;

View File

@@ -1,225 +0,0 @@
"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, 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<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 | 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) */
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<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 sql.getRows<AttachmentRow>(query, [this.revisionId, attachmentId]).map((row) => new BAttachment(row))[0];
}
getAttachmentsByRole(role: string): BAttachment[] {
return sql
.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]);
}
}
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 || undefined,
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
};
}
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) || undefined;
} else {
// updating protected note outside of protected session means we will keep original ciphertexts
delete pojo.title;
}
}
return pojo;
}
}
export default BRevision;

View File

@@ -1,39 +0,0 @@
import type { ConstructorData } from "./becca-interface.js";
import type AbstractBeccaEntity from "./entities/abstract_becca_entity.js";
import BAttachment from "./entities/battachment.js";
import BAttribute from "./entities/battribute.js";
import BBlob from "./entities/bblob.js";
import BBranch from "./entities/bbranch.js";
import BEtapiToken from "./entities/betapi_token.js";
import BNote from "./entities/bnote.js";
import BNoteEmbedding from "./entities/bnote_embedding.js";
import BOption from "./entities/boption.js";
import BRecentNote from "./entities/brecent_note.js";
import BRevision from "./entities/brevision.js";
type EntityClass = new (row?: any) => AbstractBeccaEntity<any>;
const ENTITY_NAME_TO_ENTITY: Record<string, ConstructorData<any> & EntityClass> = {
attachments: BAttachment,
attributes: BAttribute,
blobs: BBlob,
branches: BBranch,
etapi_tokens: BEtapiToken,
notes: BNote,
note_embeddings: BNoteEmbedding,
options: BOption,
recent_notes: BRecentNote,
revisions: BRevision
};
function getEntityFromEntityName(entityName: keyof typeof ENTITY_NAME_TO_ENTITY) {
if (!(entityName in ENTITY_NAME_TO_ENTITY)) {
throw new Error(`Entity for table '${entityName}' not found!`);
}
return ENTITY_NAME_TO_ENTITY[entityName];
}
export default {
getEntityFromEntityName
};

View File

@@ -1,477 +0,0 @@
import becca from "./becca.js";
import log from "../services/log.js";
import beccaService from "./becca_service.js";
import dateUtils from "../services/date_utils.js";
import { JSDOM } from "jsdom";
import type BNote from "./entities/bnote.js";
const DEBUG = false;
const IGNORED_ATTRS = ["datenote", "monthnote", "yearnote"];
const IGNORED_ATTR_NAMES = [
"includenotelink",
"internallink",
"imagelink",
"relationmaplink",
"template",
"disableversioning",
"archived",
"hidepromotedattributes",
"keyboardshortcut",
"noteinfowidgetdisabled",
"linkmapwidgetdisabled",
"revisionswidgetdisabled",
"whatlinksherewidgetdisabled",
"similarnoteswidgetdisabled",
"disableinclusion",
"rendernote",
"pageurl"
];
interface DateLimits {
minDate: string;
minExcludedDate: string;
maxExcludedDate: string;
maxDate: string;
}
interface SimilarNote {
score: number;
notePath: string[];
noteId: string;
}
function filterUrlValue(value: string) {
return value
.replace(/https?:\/\//gi, "")
.replace(/www.js\./gi, "")
.replace(/(\.net|\.com|\.org|\.info|\.edu)/gi, "");
}
function buildRewardMap(note: BNote) {
// Need to use Map instead of object: https://github.com/zadam/trilium/issues/1895
const map = new Map();
function addToRewardMap(text: string | undefined | null, rewardFactor: number) {
if (!text) {
return;
}
for (const word of splitToWords(text)) {
if (word) {
const currentReward = map.get(word) || 0;
// reward grows with the length of matched string
const length = word.length - 0.9; // to penalize specifically very short words - 1 and 2 characters
map.set(word, currentReward + rewardFactor * Math.pow(length, 0.7));
}
}
}
for (const ancestorNote of note.getAncestors()) {
if (ancestorNote.noteId === "root") {
continue;
}
if (ancestorNote.isDecrypted) {
addToRewardMap(ancestorNote.title, 0.3);
}
for (const branch of ancestorNote.getParentBranches()) {
addToRewardMap(branch.prefix, 0.3);
}
}
addToRewardMap(trimMime(note.mime), 0.5);
if (note.isDecrypted) {
addToRewardMap(note.title, 1);
}
for (const branch of note.getParentBranches()) {
addToRewardMap(branch.prefix, 1);
}
for (const attr of note.getAttributes()) {
if (attr.name.startsWith("child:") || attr.name.startsWith("relation:") || attr.name.startsWith("label:")) {
continue;
}
// inherited notes get small penalization
let reward = note.noteId === attr.noteId ? 0.8 : 0.5;
if (IGNORED_ATTRS.includes(attr.name)) {
continue;
}
if (!IGNORED_ATTR_NAMES.includes(attr.name)) {
addToRewardMap(attr.name, reward);
}
if (attr.name === "cliptype") {
reward /= 2;
}
let value = attr.value;
if (value.startsWith("http")) {
value = filterUrlValue(value);
// words in URLs are not that valuable
reward = reward / 2;
}
addToRewardMap(value, reward);
}
if (note.type === "text" && note.isDecrypted) {
const content = note.getContent();
const dom = new JSDOM(content);
const addHeadingsToRewardMap = (elName: string, rewardFactor: number) => {
for (const el of dom.window.document.querySelectorAll(elName)) {
addToRewardMap(el.textContent, rewardFactor);
}
};
// the title is the top with weight 1 so smaller headings will have lower weight
// technically H1 is not supported, but for the case it's present let's weigh it just as H2
addHeadingsToRewardMap("h1", 0.9);
addHeadingsToRewardMap("h2", 0.9);
addHeadingsToRewardMap("h3", 0.8);
addHeadingsToRewardMap("h4", 0.7);
addHeadingsToRewardMap("h5", 0.6);
addHeadingsToRewardMap("h6", 0.5);
}
return map;
}
const mimeCache: Record<string, string> = {};
function trimMime(mime: string) {
if (!mime || mime === "text/html") {
return;
}
if (!(mime in mimeCache)) {
const chunks = mime.split("/");
let str = "";
if (chunks.length >= 2) {
// we're not interested in 'text/' or 'application/' prefix
str = chunks[1];
if (str.startsWith("-x")) {
str = str.substr(2);
}
}
mimeCache[mime] = str;
}
return mimeCache[mime];
}
function buildDateLimits(baseNote: BNote): DateLimits {
const dateCreatedTs = dateUtils.parseDateTime(baseNote.utcDateCreated).getTime();
return {
minDate: dateUtils.utcDateTimeStr(new Date(dateCreatedTs - 3600 * 1000)),
minExcludedDate: dateUtils.utcDateTimeStr(new Date(dateCreatedTs - 5 * 1000)),
maxExcludedDate: dateUtils.utcDateTimeStr(new Date(dateCreatedTs + 5 * 1000)),
maxDate: dateUtils.utcDateTimeStr(new Date(dateCreatedTs + 3600 * 1000))
};
}
// Need to use Map instead of object: https://github.com/zadam/trilium/issues/1895
const wordCache = new Map();
const WORD_BLACKLIST = [
"a",
"the",
"in",
"for",
"from",
"but",
"s",
"so",
"if",
"while",
"until",
"whether",
"after",
"before",
"because",
"since",
"when",
"where",
"how",
"than",
"then",
"and",
"either",
"or",
"neither",
"nor",
"both",
"also"
];
function splitToWords(text: string) {
let words = wordCache.get(text);
if (!words) {
words = text.toLowerCase().split(/[^\p{L}\p{N}]+/u);
wordCache.set(text, words);
for (const idx in words) {
if (WORD_BLACKLIST.includes(words[idx])) {
words[idx] = "";
}
// special case for english plurals
else if (words[idx].length > 2 && words[idx].endsWith("es")) {
words[idx] = words[idx].substr(0, words[idx] - 2);
} else if (words[idx].length > 1 && words[idx].endsWith("s")) {
words[idx] = words[idx].substr(0, words[idx] - 1);
}
}
}
return words;
}
/**
* includeNoteLink and imageLink relation mean that notes are clearly related, but so clearly
* that it doesn't actually need to be shown to the user.
*/
function hasConnectingRelation(sourceNote: BNote, targetNote: BNote) {
return sourceNote.getAttributes().find((attr) => attr.type === "relation" && ["includenotelink", "imagelink"].includes(attr.name) && attr.value === targetNote.noteId);
}
async function findSimilarNotes(noteId: string): Promise<SimilarNote[] | undefined> {
const results = [];
let i = 0;
const baseNote = becca.notes[noteId];
if (!baseNote || !baseNote.utcDateCreated) {
return [];
}
let dateLimits: DateLimits;
try {
dateLimits = buildDateLimits(baseNote);
} catch (e: any) {
throw new Error(`Date limits failed with ${e.message}, entity: ${JSON.stringify(baseNote.getPojo())}`);
}
const rewardMap = buildRewardMap(baseNote);
let ancestorRewardCache: Record<string, number> = {};
const ancestorNoteIds = new Set(baseNote.getAncestors().map((note) => note.noteId));
ancestorNoteIds.add(baseNote.noteId);
let displayRewards = false;
function gatherRewards(text?: string | null, factor: number = 1) {
if (!text) {
return 0;
}
let counter = 0;
// when the title is very long, then weight of each individual word should be lowered,
// also pretty important in e.g. long URLs in label values
const lengthPenalization = 1 / Math.pow(text.length, 0.3);
for (const word of splitToWords(text)) {
const reward = rewardMap.get(word) * factor * lengthPenalization || 0;
if (displayRewards && reward > 0) {
console.log(`Reward ${Math.round(reward * 10) / 10} for word: ${word}`);
console.log(`Before: ${counter}, add ${reward}, res: ${counter + reward}`);
console.log(`${rewardMap.get(word)} * ${factor} * ${lengthPenalization}`);
}
counter += reward;
}
return counter;
}
function gatherAncestorRewards(note?: BNote) {
if (!note || ancestorNoteIds.has(note.noteId)) {
return 0;
}
if (!(note.noteId in ancestorRewardCache)) {
let score = 0;
for (const parentNote of note.parents) {
if (!ancestorNoteIds.has(parentNote.noteId)) {
if (displayRewards) {
console.log("Considering", parentNote.title);
}
if (parentNote.isDecrypted) {
score += gatherRewards(parentNote.title, 0.3);
}
for (const branch of parentNote.getParentBranches()) {
score += gatherRewards(branch.prefix, 0.3) + gatherAncestorRewards(branch.parentNote);
}
}
}
ancestorRewardCache[note.noteId] = score;
}
return ancestorRewardCache[note.noteId];
}
function computeScore(candidateNote: BNote) {
let score = gatherRewards(trimMime(candidateNote.mime)) + gatherAncestorRewards(candidateNote);
if (candidateNote.isDecrypted) {
score += gatherRewards(candidateNote.title);
}
for (const branch of candidateNote.getParentBranches()) {
score += gatherRewards(branch.prefix);
}
for (const attr of candidateNote.getAttributes()) {
if (attr.name.startsWith("child:") || attr.name.startsWith("relation:") || attr.name.startsWith("label:")) {
continue;
}
if (IGNORED_ATTRS.includes(attr.name)) {
continue;
}
if (!IGNORED_ATTR_NAMES.includes(attr.name)) {
score += gatherRewards(attr.name);
}
let value = attr.value;
let factor = 1;
if (!value.startsWith) {
log.info(`Unexpected falsy value for attribute ${JSON.stringify(attr.getPojo())}`);
continue;
} else if (value.startsWith("http")) {
value = filterUrlValue(value);
// words in URLs are not that valuable
factor = 0.5;
}
score += gatherRewards(value, factor);
}
if (candidateNote.type === baseNote.type) {
if (displayRewards) {
console.log("Adding reward for same note type");
}
score += 0.2;
}
/**
* We want to improve the standing of notes which have been created in similar time to each other since
* there's a good chance they are related.
*
* But there's an exception - if they were created really close to each other (within few seconds) then
* they are probably part of the import and not created by hand - these OTOH should not benefit.
*/
const { utcDateCreated } = candidateNote;
if (utcDateCreated < dateLimits.minExcludedDate || utcDateCreated > dateLimits.maxExcludedDate) {
if (utcDateCreated >= dateLimits.minDate && utcDateCreated <= dateLimits.maxDate) {
if (displayRewards) {
console.log("Adding reward for very similar date of creation");
}
score += 1;
} else if (utcDateCreated.substr(0, 10) === dateLimits.minDate.substr(0, 10) || utcDateCreated.substr(0, 10) === dateLimits.maxDate.substr(0, 10)) {
if (displayRewards) {
console.log("Adding reward for same day of creation");
}
// smaller bonus when outside of the window but within the same date
score += 0.5;
}
}
return score;
}
for (const candidateNote of Object.values(becca.notes)) {
if (candidateNote.noteId === baseNote.noteId || hasConnectingRelation(candidateNote, baseNote) || hasConnectingRelation(baseNote, candidateNote)) {
continue;
}
let score = computeScore(candidateNote);
if (score >= 1.5) {
const notePath = candidateNote.getBestNotePath();
// this takes care of note hoisting
if (!notePath) {
// TODO: This return is suspicious, it should probably be continue
return;
}
if (beccaService.isNotePathArchived(notePath)) {
score -= 0.5; // archived penalization
}
results.push({ score, notePath, noteId: candidateNote.noteId });
}
i++;
if (i % 1000 === 0) {
await setImmediatePromise();
}
}
results.sort((a, b) => (a.score > b.score ? -1 : 1));
if (DEBUG) {
console.log("REWARD MAP", rewardMap);
if (results.length >= 1) {
for (const { noteId } of results) {
const note = becca.notes[noteId];
displayRewards = true;
ancestorRewardCache = {}; // reset cache
const totalReward = computeScore(note);
console.log("Total reward:", Math.round(totalReward * 10) / 10);
}
}
}
return results.length > 200 ? results.slice(0, 200) : results;
}
/**
* The point of this is to break up the long-running sync process to avoid blocking
* see https://snyk.io/blog/nodejs-how-even-quick-async-functions-can-block-the-event-loop-starve-io/
*/
function setImmediatePromise() {
return new Promise<void>((resolve) => {
setTimeout(() => resolve(), 0);
});
}
export default {
findSimilarNotes
};

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -1,9 +0,0 @@
class OpenIdError {
message: string;
constructor(message: string) {
this.message = message;
}
}
export default OpenIdError;

View File

@@ -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;

View File

@@ -1,13 +0,0 @@
import type { Router } from "express";
import appInfo from "../services/app_info.js";
import eu from "./etapi_utils.js";
function register(router: Router) {
eu.route(router, "get", "/etapi/app-info", (req, res, next) => {
res.status(200).json(appInfo);
});
}
export default {
register
};

View File

@@ -1,108 +0,0 @@
import becca from "../becca/becca.js";
import eu from "./etapi_utils.js";
import mappers from "./mappers.js";
import v from "./validators.js";
import utils from "../services/utils.js";
import type { Router } from "express";
import type { AttachmentRow } from "@triliumnext/commons";
import type { ValidatorMap } from "./etapi-interface.js";
function register(router: Router) {
const ALLOWED_PROPERTIES_FOR_CREATE_ATTACHMENT: ValidatorMap = {
ownerId: [v.notNull, v.isNoteId],
role: [v.notNull, v.isString],
mime: [v.notNull, v.isString],
title: [v.notNull, v.isString],
position: [v.notNull, v.isInteger],
content: [v.isString]
};
eu.route(router, "post", "/etapi/attachments", (req, res, next) => {
const _params: Partial<AttachmentRow> = {};
eu.validateAndPatch(_params, req.body, ALLOWED_PROPERTIES_FOR_CREATE_ATTACHMENT);
const params = _params as AttachmentRow;
try {
if (!params.ownerId) {
throw new Error("Missing owner ID.");
}
const note = becca.getNoteOrThrow(params.ownerId);
const attachment = note.saveAttachment(params);
res.status(201).json(mappers.mapAttachmentToPojo(attachment));
} catch (e: any) {
throw new eu.EtapiError(500, eu.GENERIC_CODE, e.message);
}
});
eu.route(router, "get", "/etapi/attachments/:attachmentId", (req, res, next) => {
const attachment = eu.getAndCheckAttachment(req.params.attachmentId);
res.json(mappers.mapAttachmentToPojo(attachment));
});
const ALLOWED_PROPERTIES_FOR_PATCH = {
role: [v.notNull, v.isString],
mime: [v.notNull, v.isString],
title: [v.notNull, v.isString],
position: [v.notNull, v.isInteger]
};
eu.route(router, "patch", "/etapi/attachments/:attachmentId", (req, res, next) => {
const attachment = eu.getAndCheckAttachment(req.params.attachmentId);
if (attachment.isProtected) {
throw new eu.EtapiError(400, "ATTACHMENT_IS_PROTECTED", `Attachment '${req.params.attachmentId}' is protected and cannot be modified through ETAPI.`);
}
eu.validateAndPatch(attachment, req.body, ALLOWED_PROPERTIES_FOR_PATCH);
attachment.save();
res.json(mappers.mapAttachmentToPojo(attachment));
});
eu.route(router, "get", "/etapi/attachments/:attachmentId/content", (req, res, next) => {
const attachment = eu.getAndCheckAttachment(req.params.attachmentId);
if (attachment.isProtected) {
throw new eu.EtapiError(400, "ATTACHMENT_IS_PROTECTED", `Attachment '${req.params.attachmentId}' is protected and content cannot be read through ETAPI.`);
}
const filename = utils.formatDownloadTitle(attachment.title, attachment.role, attachment.mime);
res.setHeader("Content-Disposition", utils.getContentDisposition(filename));
res.setHeader("Cache-Control", "no-cache, no-store, must-revalidate");
res.setHeader("Content-Type", attachment.mime);
res.send(attachment.getContent());
});
eu.route(router, "put", "/etapi/attachments/:attachmentId/content", (req, res, next) => {
const attachment = eu.getAndCheckAttachment(req.params.attachmentId);
if (attachment.isProtected) {
throw new eu.EtapiError(400, "ATTACHMENT_IS_PROTECTED", `Attachment '${req.params.attachmentId}' is protected and cannot be modified through ETAPI.`);
}
attachment.setContent(req.body);
return res.sendStatus(204);
});
eu.route(router, "delete", "/etapi/attachments/:attachmentId", (req, res, next) => {
const attachment = becca.getAttachment(req.params.attachmentId);
if (!attachment) {
return res.sendStatus(204);
}
attachment.markAsDeleted();
res.sendStatus(204);
});
}
export default {
register
};

View File

@@ -1,85 +0,0 @@
import becca from "../becca/becca.js";
import eu from "./etapi_utils.js";
import mappers from "./mappers.js";
import attributeService from "../services/attributes.js";
import v from "./validators.js";
import type { Router } from "express";
import type { AttributeRow } from "@triliumnext/commons";
import type { ValidatorMap } from "./etapi-interface.js";
function register(router: Router) {
eu.route(router, "get", "/etapi/attributes/:attributeId", (req, res, next) => {
const attribute = eu.getAndCheckAttribute(req.params.attributeId);
res.json(mappers.mapAttributeToPojo(attribute));
});
const ALLOWED_PROPERTIES_FOR_CREATE_ATTRIBUTE: ValidatorMap = {
attributeId: [v.mandatory, v.notNull, v.isValidEntityId],
noteId: [v.mandatory, v.notNull, v.isNoteId],
type: [v.mandatory, v.notNull, v.isAttributeType],
name: [v.mandatory, v.notNull, v.isString],
value: [v.notNull, v.isString],
isInheritable: [v.notNull, v.isBoolean],
position: [v.notNull, v.isInteger]
};
eu.route(router, "post", "/etapi/attributes", (req, res, next) => {
if (req.body.type === "relation") {
eu.getAndCheckNote(req.body.value);
}
const _params = {};
eu.validateAndPatch(_params, req.body, ALLOWED_PROPERTIES_FOR_CREATE_ATTRIBUTE);
const params: AttributeRow = _params as AttributeRow;
try {
const attr = attributeService.createAttribute(params);
res.status(201).json(mappers.mapAttributeToPojo(attr));
} catch (e: any) {
throw new eu.EtapiError(500, eu.GENERIC_CODE, e.message);
}
});
const ALLOWED_PROPERTIES_FOR_PATCH_LABEL = {
value: [v.notNull, v.isString],
position: [v.notNull, v.isInteger]
};
const ALLOWED_PROPERTIES_FOR_PATCH_RELATION = {
position: [v.notNull, v.isInteger]
};
eu.route(router, "patch", "/etapi/attributes/:attributeId", (req, res, next) => {
const attribute = eu.getAndCheckAttribute(req.params.attributeId);
if (attribute.type === "label") {
eu.validateAndPatch(attribute, req.body, ALLOWED_PROPERTIES_FOR_PATCH_LABEL);
} else if (attribute.type === "relation") {
eu.getAndCheckNote(req.body.value);
eu.validateAndPatch(attribute, req.body, ALLOWED_PROPERTIES_FOR_PATCH_RELATION);
}
attribute.save();
res.json(mappers.mapAttributeToPojo(attribute));
});
eu.route(router, "delete", "/etapi/attributes/:attributeId", (req, res, next) => {
const attribute = becca.getAttribute(req.params.attributeId);
if (!attribute) {
return res.sendStatus(204);
}
attribute.markAsDeleted();
res.sendStatus(204);
});
}
export default {
register
};

View File

@@ -1,44 +0,0 @@
import becca from "../becca/becca.js";
import eu from "./etapi_utils.js";
import passwordEncryptionService from "../services/encryption/password_encryption.js";
import etapiTokenService from "../services/etapi_tokens.js";
import type { RequestHandler, Router } from "express";
function register(router: Router, loginMiddleware: RequestHandler[]) {
eu.NOT_AUTHENTICATED_ROUTE(router, "post", "/etapi/auth/login", loginMiddleware, (req, res, next) => {
const { password, tokenName } = req.body;
if (!passwordEncryptionService.verifyPassword(password)) {
throw new eu.EtapiError(401, "WRONG_PASSWORD", "Wrong password.");
}
const { authToken } = etapiTokenService.createToken(tokenName || "ETAPI login");
res.status(201).json({
authToken
});
});
eu.route(router, "post", "/etapi/auth/logout", (req, res, next) => {
const parsed = etapiTokenService.parseAuthToken(req.headers.authorization);
if (!parsed || !parsed.etapiTokenId) {
throw new eu.EtapiError(400, eu.GENERIC_CODE, "Cannot logout this token.");
}
const etapiToken = becca.getEtapiToken(parsed.etapiTokenId);
if (!etapiToken) {
// shouldn't happen since this already passed auth validation
throw new Error(`Cannot find the token '${parsed.etapiTokenId}'.`);
}
etapiToken.markAsDeletedSimple();
res.sendStatus(204);
});
}
export default {
register
};

View File

@@ -1,16 +0,0 @@
import type { Router } from "express";
import eu from "./etapi_utils.js";
import backupService from "../services/backup.js";
function register(router: Router) {
eu.route(router, "put", "/etapi/backup/:backupName", async (req, res, next) => {
await backupService.backupNow(req.params.backupName);
res.sendStatus(204);
});
}
export default {
register
};

View File

@@ -1,89 +0,0 @@
import type { Router } from "express";
import becca from "../becca/becca.js";
import eu from "./etapi_utils.js";
import mappers from "./mappers.js";
import BBranch from "../becca/entities/bbranch.js";
import entityChangesService from "../services/entity_changes.js";
import v from "./validators.js";
import type { BranchRow } from "@triliumnext/commons";
function register(router: Router) {
eu.route(router, "get", "/etapi/branches/:branchId", (req, res, next) => {
const branch = eu.getAndCheckBranch(req.params.branchId);
res.json(mappers.mapBranchToPojo(branch));
});
const ALLOWED_PROPERTIES_FOR_CREATE_BRANCH = {
noteId: [v.mandatory, v.notNull, v.isNoteId],
parentNoteId: [v.mandatory, v.notNull, v.isNoteId],
notePosition: [v.notNull, v.isInteger],
prefix: [v.isString],
isExpanded: [v.notNull, v.isBoolean]
};
eu.route(router, "post", "/etapi/branches", (req, res, next) => {
const _params = {};
eu.validateAndPatch(_params, req.body, ALLOWED_PROPERTIES_FOR_CREATE_BRANCH);
const params: BranchRow = _params as BranchRow;
const existing = becca.getBranchFromChildAndParent(params.noteId, params.parentNoteId);
if (existing) {
existing.notePosition = params.notePosition as number;
existing.prefix = params.prefix as string;
existing.isExpanded = params.isExpanded as boolean;
existing.save();
return res.status(200).json(mappers.mapBranchToPojo(existing));
} else {
try {
const branch = new BBranch(params).save();
res.status(201).json(mappers.mapBranchToPojo(branch));
} catch (e: any) {
throw new eu.EtapiError(400, eu.GENERIC_CODE, e.message);
}
}
});
const ALLOWED_PROPERTIES_FOR_PATCH = {
notePosition: [v.notNull, v.isInteger],
prefix: [v.isString],
isExpanded: [v.notNull, v.isBoolean]
};
eu.route(router, "patch", "/etapi/branches/:branchId", (req, res, next) => {
const branch = eu.getAndCheckBranch(req.params.branchId);
eu.validateAndPatch(branch, req.body, ALLOWED_PROPERTIES_FOR_PATCH);
branch.save();
res.json(mappers.mapBranchToPojo(branch));
});
eu.route(router, "delete", "/etapi/branches/:branchId", (req, res, next) => {
const branch = becca.getBranch(req.params.branchId);
if (!branch) {
return res.sendStatus(204);
}
branch.deleteBranch();
res.sendStatus(204);
});
eu.route(router, "post", "/etapi/refresh-note-ordering/:parentNoteId", (req, res, next) => {
eu.getAndCheckNote(req.params.parentNoteId);
entityChangesService.putNoteReorderingEntityChange(req.params.parentNoteId, "etapi");
res.sendStatus(204);
});
}
export default {
register
};

View File

@@ -1,3 +0,0 @@
export type ValidatorFunc = (obj: unknown) => string | undefined;
export type ValidatorMap = Record<string, ValidatorFunc[]>;

File diff suppressed because it is too large Load Diff

View File

@@ -1,156 +0,0 @@
import cls from "../services/cls.js";
import sql from "../services/sql.js";
import log from "../services/log.js";
import becca from "../becca/becca.js";
import etapiTokenService from "../services/etapi_tokens.js";
import config from "../services/config.js";
import type { NextFunction, Request, RequestHandler, Response, Router } from "express";
import type { ValidatorMap } from "./etapi-interface.js";
import type { ApiRequestHandler } from "../routes/routes.js";
const GENERIC_CODE = "GENERIC";
type HttpMethod = "all" | "get" | "post" | "put" | "delete" | "patch" | "options" | "head";
const noAuthentication = config.General && config.General.noAuthentication === true;
class EtapiError extends Error {
statusCode: number;
code: string;
constructor(statusCode: number, code: string, message: string) {
super(message);
// Set the prototype explicitly.
Object.setPrototypeOf(this, EtapiError.prototype);
this.statusCode = statusCode;
this.code = code;
}
}
function sendError(res: Response, statusCode: number, code: string, message: string) {
return res
.set("Content-Type", "application/json")
.status(statusCode)
.send(
JSON.stringify({
status: statusCode,
code: code,
message: message
})
);
}
function checkEtapiAuth(req: Request, res: Response, next: NextFunction) {
if (noAuthentication || etapiTokenService.isValidAuthHeader(req.headers.authorization)) {
next();
} else {
sendError(res, 401, "NOT_AUTHENTICATED", "Not authenticated");
}
}
function processRequest(req: Request, res: Response, routeHandler: ApiRequestHandler, next: NextFunction, method: string, path: string) {
try {
cls.namespace.bindEmitter(req);
cls.namespace.bindEmitter(res);
cls.init(() => {
cls.set("componentId", "etapi");
cls.set("localNowDateTime", req.headers["trilium-local-now-datetime"]);
const cb = () => routeHandler(req, res, next);
return sql.transactional(cb);
});
} catch (e: any) {
log.error(`${method} ${path} threw exception ${e.message} with stacktrace: ${e.stack}`);
if (e instanceof EtapiError) {
sendError(res, e.statusCode, e.code, e.message);
} else {
sendError(res, 500, GENERIC_CODE, e.message);
}
}
}
function route(router: Router, method: HttpMethod, path: string, routeHandler: ApiRequestHandler) {
router[method](path, checkEtapiAuth, (req: Request, res: Response, next: NextFunction) => processRequest(req, res, routeHandler, next, method, path));
}
function NOT_AUTHENTICATED_ROUTE(router: Router, method: HttpMethod, path: string, middleware: RequestHandler[], routeHandler: RequestHandler) {
router[method](path, ...middleware, (req: Request, res: Response, next: NextFunction) => processRequest(req, res, routeHandler, next, method, path));
}
function getAndCheckNote(noteId: string) {
const note = becca.getNote(noteId);
if (note) {
return note;
} else {
throw new EtapiError(404, "NOTE_NOT_FOUND", `Note '${noteId}' not found.`);
}
}
function getAndCheckAttachment(attachmentId: string) {
const attachment = becca.getAttachment(attachmentId, { includeContentLength: true });
if (attachment) {
return attachment;
} else {
throw new EtapiError(404, "ATTACHMENT_NOT_FOUND", `Attachment '${attachmentId}' not found.`);
}
}
function getAndCheckBranch(branchId: string) {
const branch = becca.getBranch(branchId);
if (branch) {
return branch;
} else {
throw new EtapiError(404, "BRANCH_NOT_FOUND", `Branch '${branchId}' not found.`);
}
}
function getAndCheckAttribute(attributeId: string) {
const attribute = becca.getAttribute(attributeId);
if (attribute) {
return attribute;
} else {
throw new EtapiError(404, "ATTRIBUTE_NOT_FOUND", `Attribute '${attributeId}' not found.`);
}
}
function validateAndPatch(target: any, source: any, allowedProperties: ValidatorMap) {
for (const key of Object.keys(source)) {
if (!(key in allowedProperties)) {
throw new EtapiError(400, "PROPERTY_NOT_ALLOWED", `Property '${key}' is not allowed for this method.`);
} else {
for (const validator of allowedProperties[key]) {
const validationResult = validator(source[key]);
if (validationResult) {
throw new EtapiError(400, "PROPERTY_VALIDATION_ERROR", `Validation failed on property '${key}': ${validationResult}.`);
}
}
}
}
// validation passed, let's patch
for (const propName of Object.keys(source)) {
target[propName] = source[propName];
}
}
export default {
EtapiError,
sendError,
route,
NOT_AUTHENTICATED_ROUTE,
GENERIC_CODE,
validateAndPatch,
getAndCheckNote,
getAndCheckBranch,
getAndCheckAttribute,
getAndCheckAttachment
};

View File

@@ -1,72 +0,0 @@
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 BNote from "../becca/entities/bnote.js";
function mapNoteToPojo(note: BNote) {
return {
noteId: note.noteId,
isProtected: note.isProtected,
title: note.title,
type: note.type,
mime: note.mime,
blobId: note.blobId,
dateCreated: note.dateCreated,
dateModified: note.dateModified,
utcDateCreated: note.utcDateCreated,
utcDateModified: note.utcDateModified,
parentNoteIds: note.getParentNotes().map((p) => p.noteId),
childNoteIds: note.getChildNotes().map((ch) => ch.noteId),
parentBranchIds: note.getParentBranches().map((p) => p.branchId),
childBranchIds: note.getChildBranches().map((ch) => ch.branchId),
attributes: note.getAttributes().map((attr) => mapAttributeToPojo(attr))
};
}
function mapBranchToPojo(branch: BBranch) {
return {
branchId: branch.branchId,
noteId: branch.noteId,
parentNoteId: branch.parentNoteId,
prefix: branch.prefix,
notePosition: branch.notePosition,
isExpanded: branch.isExpanded,
utcDateModified: branch.utcDateModified
};
}
function mapAttributeToPojo(attr: BAttribute) {
return {
attributeId: attr.attributeId,
noteId: attr.noteId,
type: attr.type,
name: attr.name,
value: attr.value,
position: attr.position,
isInheritable: attr.isInheritable,
utcDateModified: attr.utcDateModified
};
}
function mapAttachmentToPojo(attachment: BAttachment) {
return {
attachmentId: attachment.attachmentId,
ownerId: attachment.ownerId,
role: attachment.role,
mime: attachment.mime,
title: attachment.title,
position: attachment.position,
blobId: attachment.blobId,
dateModified: attachment.dateModified,
utcDateModified: attachment.utcDateModified,
utcDateScheduledForErasureSince: attachment.utcDateScheduledForErasureSince,
contentLength: attachment.contentLength
};
}
export default {
mapNoteToPojo,
mapBranchToPojo,
mapAttributeToPojo,
mapAttachmentToPojo
};

View File

@@ -1,267 +0,0 @@
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 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";
function register(router: Router) {
eu.route(router, "get", "/etapi/notes", (req, res, next) => {
const { search } = req.query;
if (typeof search !== "string" || !search?.trim()) {
throw new eu.EtapiError(400, "SEARCH_QUERY_PARAM_MANDATORY", "'search' query parameter is mandatory.");
}
const searchParams = parseSearchParams(req);
const searchContext = new SearchContext(searchParams);
const searchResults = searchService.findResultsWithQuery(search, searchContext);
const foundNotes = searchResults.map((sr) => becca.notes[sr.noteId]);
const resp: any = {
results: foundNotes.map((note) => mappers.mapNoteToPojo(note))
};
if (searchContext.debugInfo) {
resp.debugInfo = searchContext.debugInfo;
}
res.json(resp);
});
eu.route(router, "get", "/etapi/notes/:noteId", (req, res, next) => {
const note = eu.getAndCheckNote(req.params.noteId);
res.json(mappers.mapNoteToPojo(note));
});
const ALLOWED_PROPERTIES_FOR_CREATE_NOTE: ValidatorMap = {
parentNoteId: [v.mandatory, v.notNull, v.isNoteId],
title: [v.mandatory, v.notNull, v.isString],
type: [v.mandatory, v.notNull, v.isNoteType],
mime: [v.notNull, v.isString],
content: [v.notNull, v.isString],
notePosition: [v.notNull, v.isInteger],
prefix: [v.notNull, v.isString],
isExpanded: [v.notNull, v.isBoolean],
noteId: [v.notNull, v.isValidEntityId],
dateCreated: [v.notNull, v.isString, v.isLocalDateTime],
utcDateCreated: [v.notNull, v.isString, v.isUtcDateTime]
};
eu.route(router, "post", "/etapi/create-note", (req, res, next) => {
const _params = {};
eu.validateAndPatch(_params, req.body, ALLOWED_PROPERTIES_FOR_CREATE_NOTE);
const params = _params as NoteParams;
try {
const resp = noteService.createNewNote(params);
res.status(201).json({
note: mappers.mapNoteToPojo(resp.note),
branch: mappers.mapBranchToPojo(resp.branch)
});
} catch (e: any) {
return eu.sendError(res, 500, eu.GENERIC_CODE, e.message);
}
});
const ALLOWED_PROPERTIES_FOR_PATCH = {
title: [v.notNull, v.isString],
type: [v.notNull, v.isString],
mime: [v.notNull, v.isString],
dateCreated: [v.notNull, v.isString, v.isLocalDateTime],
utcDateCreated: [v.notNull, v.isString, v.isUtcDateTime]
};
eu.route(router, "patch", "/etapi/notes/:noteId", (req, res, next) => {
const note = eu.getAndCheckNote(req.params.noteId);
if (note.isProtected) {
throw new eu.EtapiError(400, "NOTE_IS_PROTECTED", `Note '${req.params.noteId}' is protected and cannot be modified through ETAPI.`);
}
eu.validateAndPatch(note, req.body, ALLOWED_PROPERTIES_FOR_PATCH);
note.save();
res.json(mappers.mapNoteToPojo(note));
});
eu.route(router, "delete", "/etapi/notes/:noteId", (req, res, next) => {
const { noteId } = req.params;
const note = becca.getNote(noteId);
if (!note) {
return res.sendStatus(204);
}
note.deleteNote(null, new TaskContext("no-progress-reporting"));
res.sendStatus(204);
});
eu.route(router, "get", "/etapi/notes/:noteId/content", (req, res, next) => {
const note = eu.getAndCheckNote(req.params.noteId);
if (note.isProtected) {
throw new eu.EtapiError(400, "NOTE_IS_PROTECTED", `Note '${req.params.noteId}' is protected and content cannot be read through ETAPI.`);
}
const filename = utils.formatDownloadTitle(note.title, note.type, note.mime);
res.setHeader("Content-Disposition", utils.getContentDisposition(filename));
res.setHeader("Cache-Control", "no-cache, no-store, must-revalidate");
res.setHeader("Content-Type", note.mime);
res.send(note.getContent());
});
eu.route(router, "put", "/etapi/notes/:noteId/content", (req, res, next) => {
const note = eu.getAndCheckNote(req.params.noteId);
if (note.isProtected) {
throw new eu.EtapiError(400, "NOTE_IS_PROTECTED", `Note '${req.params.noteId}' is protected and cannot be modified through ETAPI.`);
}
note.setContent(req.body);
noteService.asyncPostProcessContent(note, req.body);
return res.sendStatus(204);
});
eu.route(router, "get", "/etapi/notes/:noteId/export", (req, res, next) => {
const note = eu.getAndCheckNote(req.params.noteId);
const format = req.query.format || "html";
if (typeof format !== "string" || !["html", "markdown"].includes(format)) {
throw new eu.EtapiError(400, "UNRECOGNIZED_EXPORT_FORMAT", `Unrecognized export format '${format}', supported values are 'html' (default) or 'markdown'.`);
}
const taskContext = new TaskContext("no-progress-reporting");
// technically a branch is being exported (includes prefix), but it's such a minor difference yet usability pain
// (e.g. branchIds are not seen in UI), that we export "note export" instead.
const branch = note.getParentBranches()[0];
zipExportService.exportToZip(taskContext, branch, format as "html" | "markdown", res);
});
eu.route(router, "post", "/etapi/notes/:noteId/import", (req, res, next) => {
const note = eu.getAndCheckNote(req.params.noteId);
const taskContext = new TaskContext("no-progress-reporting");
zipImportService.importZip(taskContext, req.body, note).then((importedNote) => {
res.status(201).json({
note: mappers.mapNoteToPojo(importedNote),
branch: mappers.mapBranchToPojo(importedNote.getParentBranches()[0])
});
}); // we need better error handling here, async errors won't be properly processed.
});
eu.route(router, "post", "/etapi/notes/:noteId/revision", (req, res, next) => {
const note = eu.getAndCheckNote(req.params.noteId);
note.saveRevision();
return res.sendStatus(204);
});
eu.route(router, "get", "/etapi/notes/:noteId/attachments", (req, res, next) => {
const note = eu.getAndCheckNote(req.params.noteId);
const attachments = note.getAttachments({ includeContentLength: true });
res.json(attachments.map((attachment) => mappers.mapAttachmentToPojo(attachment)));
});
}
function parseSearchParams(req: Request) {
const rawSearchParams: SearchParams = {
fastSearch: parseBoolean(req.query, "fastSearch"),
includeArchivedNotes: parseBoolean(req.query, "includeArchivedNotes"),
ancestorNoteId: parseString(req.query["ancestorNoteId"]),
ancestorDepth: parseString(req.query["ancestorDepth"]), // e.g. "eq5"
orderBy: parseString(req.query["orderBy"]),
// TODO: Check why the order direction was provided as a number, but it's a string everywhere else.
orderDirection: parseOrderDirection(req.query, "orderDirection") as unknown as string,
limit: parseInteger(req.query, "limit"),
debug: parseBoolean(req.query, "debug")
};
const searchParams: SearchParams = {};
for (const paramName of Object.keys(rawSearchParams) as (keyof SearchParams)[]) {
if (rawSearchParams[paramName] !== undefined) {
(searchParams as any)[paramName] = rawSearchParams[paramName];
}
}
return searchParams;
}
const SEARCH_PARAM_ERROR = "SEARCH_PARAM_VALIDATION_ERROR";
function parseString(value: string | ParsedQs | (string | ParsedQs)[] | undefined): string | undefined {
if (typeof value === "string") {
return value;
}
return undefined;
}
function parseBoolean(obj: any, name: string) {
if (!(name in obj)) {
return undefined;
}
if (!["true", "false"].includes(obj[name])) {
throw new eu.EtapiError(400, SEARCH_PARAM_ERROR, `Cannot parse boolean '${name}' value '${obj[name]}, allowed values are 'true' and 'false'.`);
}
return obj[name] === "true";
}
function parseOrderDirection(obj: any, name: string) {
if (!(name in obj)) {
return undefined;
}
const integer = parseInt(obj[name]);
if (!["asc", "desc"].includes(obj[name])) {
throw new eu.EtapiError(400, SEARCH_PARAM_ERROR, `Cannot parse order direction value '${obj[name]}, allowed values are 'asc' and 'desc'.`);
}
return integer;
}
function parseInteger(obj: any, name: string) {
if (!(name in obj)) {
return undefined;
}
const integer = parseInt(obj[name]);
if (Number.isNaN(integer)) {
throw new eu.EtapiError(400, SEARCH_PARAM_ERROR, `Cannot parse integer '${name}' value '${obj[name]}'.`);
}
return integer;
}
export default {
register
};

View File

@@ -1,23 +0,0 @@
import type { Router } from "express";
import fs from "fs";
import path from "path";
import { fileURLToPath } from "url";
const specPath = path.join(path.dirname(fileURLToPath(import.meta.url)), "etapi.openapi.yaml");
let spec: string | null = null;
function register(router: Router) {
router.get("/etapi/etapi.openapi.yaml", (req, res, next) => {
if (!spec) {
spec = fs.readFileSync(specPath, "utf8");
}
res.header("Content-Type", "text/plain"); // so that it displays in browser
res.status(200).send(spec);
});
}
export default {
register
};

View File

@@ -1,91 +0,0 @@
import specialNotesService from "../services/special_notes.js";
import dateNotesService from "../services/date_notes.js";
import eu from "./etapi_utils.js";
import mappers from "./mappers.js";
import type { Router } from "express";
const getDateInvalidError = (date: string) => new eu.EtapiError(400, "DATE_INVALID", `Date "${date}" is not valid.`);
const getWeekInvalidError = (week: string) => new eu.EtapiError(400, "WEEK_INVALID", `Week "${week}" is not valid.`);
const getWeekNotFoundError = (week: string) => new eu.EtapiError(404, "WEEK_NOT_FOUND", `Week "${week}" not found. Check if week note is enabled.`);
const getMonthInvalidError = (month: string) => new eu.EtapiError(400, "MONTH_INVALID", `Month "${month}" is not valid.`);
const getYearInvalidError = (year: string) => new eu.EtapiError(400, "YEAR_INVALID", `Year "${year}" is not valid.`);
function isValidDate(date: string) {
return /[0-9]{4}-[0-9]{2}-[0-9]{2}/.test(date) && !!Date.parse(date);
}
function register(router: Router) {
eu.route(router, "get", "/etapi/inbox/:date", async (req, res, next) => {
const { date } = req.params;
if (!isValidDate(date)) {
throw getDateInvalidError(date);
}
const note = await specialNotesService.getInboxNote(date);
res.json(mappers.mapNoteToPojo(note));
});
eu.route(router, "get", "/etapi/calendar/days/:date", async (req, res, next) => {
const { date } = req.params;
if (!isValidDate(date)) {
throw getDateInvalidError(date);
}
const note = await dateNotesService.getDayNote(date);
res.json(mappers.mapNoteToPojo(note));
});
eu.route(router, "get", "/etapi/calendar/week-first-day/:date", async (req, res, next) => {
const { date } = req.params;
if (!isValidDate(date)) {
throw getDateInvalidError(date);
}
const note = await dateNotesService.getWeekFirstDayNote(date);
res.json(mappers.mapNoteToPojo(note));
});
eu.route(router, "get", "/etapi/calendar/weeks/:week", async (req, res, next) => {
const { week } = req.params;
if (!/[0-9]{4}-W[0-9]{2}/.test(week)) {
throw getWeekInvalidError(week);
}
const note = await dateNotesService.getWeekNote(week);
if (!note) {
throw getWeekNotFoundError(week);
}
res.json(mappers.mapNoteToPojo(note));
});
eu.route(router, "get", "/etapi/calendar/months/:month", async (req, res, next) => {
const { month } = req.params;
if (!/[0-9]{4}-[0-9]{2}/.test(month)) {
throw getMonthInvalidError(month);
}
const note = await dateNotesService.getMonthNote(month);
res.json(mappers.mapNoteToPojo(note));
});
eu.route(router, "get", "/etapi/calendar/years/:year", (req, res, next) => {
const { year } = req.params;
if (!/[0-9]{4}/.test(year)) {
throw getYearInvalidError(year);
}
const note = dateNotesService.getYearNote(year);
res.json(mappers.mapNoteToPojo(note));
});
}
export default {
register
};

View File

@@ -1,121 +0,0 @@
import noteTypeService from "../services/note_types.js";
import dateUtils from "../services/date_utils.js";
import becca from "../becca/becca.js";
function mandatory(obj: unknown) {
if (obj === undefined) {
return `mandatory, but not set`;
}
}
function notNull(obj: unknown) {
if (obj === null) {
return `cannot be null`;
}
}
function isString(obj: unknown) {
if (obj === undefined || obj === null) {
return;
}
if (typeof obj !== "string") {
return `'${obj}' is not a string`;
}
}
function isLocalDateTime(obj: unknown) {
if (typeof obj !== "string") {
return;
}
return dateUtils.validateLocalDateTime(obj);
}
function isUtcDateTime(obj: unknown) {
if (typeof obj !== "string") {
return;
}
return dateUtils.validateUtcDateTime(obj);
}
function isBoolean(obj: unknown) {
if (obj === undefined || obj === null) {
return;
}
if (typeof obj !== "boolean") {
return `'${obj}' is not a boolean`;
}
}
function isInteger(obj: unknown) {
if (obj === undefined || obj === null) {
return;
}
if (!Number.isInteger(obj)) {
return `'${obj}' is not an integer`;
}
}
function isNoteId(obj: unknown) {
if (obj === undefined || obj === null) {
return;
}
if (typeof obj !== "string") {
return `'${obj}' is not a valid noteId`;
}
if (!(obj in becca.notes)) {
return `Note '${obj}' does not exist`;
}
}
function isNoteType(obj: unknown) {
if (obj === undefined || obj === null) {
return;
}
const noteTypes = noteTypeService.getNoteTypeNames();
if (typeof obj !== "string" || !noteTypes.includes(obj)) {
return `'${obj}' is not a valid note type, allowed types are: ${noteTypes.join(", ")}`;
}
}
function isAttributeType(obj: unknown) {
if (obj === undefined || obj === null) {
return;
}
if (typeof obj !== "string" || !["label", "relation"].includes(obj)) {
return `'${obj}' is not a valid attribute type, allowed types are: label, relation`;
}
}
function isValidEntityId(obj: unknown) {
if (obj === undefined || obj === null) {
return;
}
if (typeof obj !== "string" || !/^[A-Za-z0-9_]{4,128}$/.test(obj)) {
return `'${obj}' is not a valid entityId. Only alphanumeric characters are allowed of length 4 to 32.`;
}
}
export default {
mandatory,
notNull,
isString,
isBoolean,
isInteger,
isNoteId,
isNoteType,
isAttributeType,
isValidEntityId,
isLocalDateTime,
isUtcDateTime
};

View File

@@ -1,27 +0,0 @@
import { Session } from "express-session";
export declare module "express-serve-static-core" {
interface Request {
session: Session & {
loggedIn: boolean;
lastAuthState: {
totpEnabled: boolean;
ssoEnabled: boolean;
};
};
headers: {
"x-local-date"?: string;
"x-labels"?: string;
authorization?: string;
"trilium-cred"?: string;
"x-csrf-token"?: string;
"trilium-component-id"?: string;
"trilium-local-now-datetime"?: string;
"trilium-hoisted-note-id"?: string;
"user-agent"?: string;
};
}
}

View File

@@ -1,13 +0,0 @@
/*
* Make sure not to import any modules that depend on localized messages via i18next here, as the initializations
* are loaded later and will result in an empty string.
*/
import { initializeTranslations } from "./services/i18n.js";
async function startApplication() {
await import("./www.js");
}
await initializeTranslations();
await startApplication();

View File

@@ -1,3 +0,0 @@
<p>隐藏树用于记录各种应用层数据,这些数据大部分时间可能对用户不可见。</p>
<p>确保你知道自己在做什么。对这个子树的错误更改可能会导致应用程序崩溃。</p>

View File

@@ -1 +0,0 @@
<p>此启动器操作的键盘快捷键可以在“选项”->“快捷键”中进行配置。</p>

View File

@@ -1,3 +0,0 @@
<p>“后退”和“前进”按钮允许您在导航历史中移动。</p>
<p>这些启动器仅在桌面版本中有效,在服务器版本中将被忽略,您可以使用浏览器的原生导航按钮代替。</p>

View File

@@ -1,11 +0,0 @@
<p>欢迎来到启动栏配置界面。</p>
<p>您可以在此处执行以下操作:</p>
<ul>
<li>通过拖动将可用的启动器移动到可见列表中(从而将它们放入启动栏)</li>
<li>通过拖动将可见的启动器移动到可用列表中(从而将它们从启动栏中隐藏)</li>
<li>您可以通过拖动重新排列列表中的项目</li>
<li>通过右键点击“可见启动器”文件夹来创建新的启动器</li>
<li>如果您想恢复默认设置,可以在右键菜单中找到“重置”选项。</li>
</ul>

View File

@@ -1,9 +0,0 @@
<p>您可以定义以下属性:</p>
<ol>
<li><code>target</code> - 激活启动器时应打开的笔记</li>
<li><code>hoistedNote</code> - 可选,在打开目标笔记之前将更改提升的笔记</li>
<li><code>keyboardShortcut</code> - 可选,按下键盘快捷键将打开该笔记</li>
</ol>
<p>启动栏显示来自启动器的标题/图标,这不一定与目标笔记的标题/图标一致。</p>

View File

@@ -1,12 +0,0 @@
<p>脚本启动器可以执行通过 <code>~script</code> 关系连接的脚本(代码笔记)。</p>
<ol>
<li><code>script</code> - 与应在启动器激活时执行的脚本笔记的关系</li>
<li><code>keyboardShortcut</code> - 可选,按下键盘快捷键将激活启动器</li>
</ol>
<h4>示例脚本</h4>
<pre>
api.showMessage("当前笔记是 " + api.getActiveContextNote().title);
</pre>

View File

@@ -1,6 +0,0 @@
<p>间隔器允许您在视觉上将启动器分组。您可以在提升的属性中进行配置:</p>
<ul>
<li><code>baseSize</code> - 定义以像素为单位的大小(如果有足够的空间)</li>
<li><code>growthFactor</code> - 如果您希望间隔器保持恒定的 <code>baseSize</code>,则设置为 0如果设置为正值它将增长。</li>
</ul>

View File

@@ -1,34 +0,0 @@
<p>请在提升的属性中定义目标小部件笔记。该小部件将用于渲染启动栏图标。</p>
<h4>示例启动栏小部件</h4>
<pre>
const TPL = `&lt;div style="height: 53px; width: 53px;"&gt;&lt;/div&gt;`;
class ExampleLaunchbarWidget extends api.NoteContextAwareWidget {
doRender() {
this.$widget = $(TPL);
}
async refreshWithNote(note) {
this.$widget.css("background-color", this.stringToColor(note.title));
}
stringToColor(str) {
let hash = 0;
for (let i = 0; i < str.length; i++) {
hash = str.charCodeAt(i) + ((hash << 5) - hash);
}
let color = '#';
for (let i = 0; i < 3; i++) {
const value = (hash >> (i * 8)) & 0xFF;
color += ('00' + value.toString(16)).substr(-2);
}
return color;
}
}
module.exports = new ExampleLaunchbarWidget();
</pre>

View File

@@ -1 +0,0 @@
<p>在这里您可以找到所有分享的笔记。</p>

View File

@@ -1 +0,0 @@
<p>此笔记作为一个子树,用于存储由用户脚本生成的数据,这些数据本应避免在隐藏子树中随意创建。</p>

File diff suppressed because one or more lines are too long

Binary file not shown.

Before

Width:  |  Height:  |  Size: 186 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 168 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 172 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 167 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 237 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 202 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 191 KiB

View File

@@ -1,22 +0,0 @@
<p>Currently, we support the following providers:</p>
<ul>
<li><a class="reference-link" href="#root/_help_7EdTxPADv95W">Ollama</a>
</li>
<li><a class="reference-link" href="#root/_help_ZavFigBX9AwP">OpenAI</a>
</li>
<li><a class="reference-link" href="#root/_help_e0lkirXEiSNc">Anthropic</a>
</li>
<li>Voyage AI</li>
</ul>
<p>To set your preferred chat model, you'll want to enter the provider's
name here:</p>
<figure class="image image_resized" style="width:88.38%;">
<img style="aspect-ratio:1884/1267;" src="AI Provider Information_im.png"
width="1884" height="1267">
</figure>
<p>And to set your preferred embedding provider:</p>
<figure class="image image_resized"
style="width:93.47%;">
<img style="aspect-ratio:1907/1002;" src="1_AI Provider Information_im.png"
width="1907" height="1002">
</figure>

Some files were not shown because too many files have changed in this diff Show More