chore(nx/server): move source code

This commit is contained in:
Elian Doran
2025-04-22 17:16:41 +03:00
parent 9c0d42252e
commit b2af043110
889 changed files with 10 additions and 30 deletions

View File

@@ -2,6 +2,7 @@
"name": "@triliumnext/server",
"version": "0.0.1",
"private": true,
"type": "module",
"nx": {
"targets": {
"serve": {

View File

@@ -0,0 +1,23 @@
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);
});

139
apps/server/src/app.ts Normal file
View File

@@ -0,0 +1,139 @@
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

@@ -0,0 +1,295 @@
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

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

View File

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

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

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

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

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

@@ -0,0 +1,43 @@
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

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

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

@@ -0,0 +1,73 @@
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

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

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

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

@@ -0,0 +1,39 @@
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

@@ -0,0 +1,477 @@
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

@@ -0,0 +1,12 @@
import HttpError from "./http_error.js";
class ForbiddenError extends HttpError {
constructor(message: string) {
super(message, 403);
this.name = "ForbiddenError";
}
}
export default ForbiddenError;

View File

@@ -0,0 +1,13 @@
class HttpError extends Error {
statusCode: number;
constructor(message: string, statusCode: number) {
super(message);
this.name = "HttpError";
this.statusCode = statusCode;
}
}
export default HttpError;

View File

@@ -0,0 +1,12 @@
import HttpError from "./http_error.js";
class NotFoundError extends HttpError {
constructor(message: string) {
super(message, 404);
this.name = "NotFoundError";
}
}
export default NotFoundError;

View File

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

View File

@@ -0,0 +1,12 @@
import HttpError from "./http_error.js";
class ValidationError extends HttpError {
constructor(message: string) {
super(message, 400)
this.name = "ValidationError";
}
}
export default ValidationError;

View File

@@ -0,0 +1,13 @@
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

@@ -0,0 +1,108 @@
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

@@ -0,0 +1,85 @@
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

@@ -0,0 +1,44 @@
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

@@ -0,0 +1,16 @@
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

@@ -0,0 +1,89 @@
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

@@ -0,0 +1,3 @@
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

@@ -0,0 +1,156 @@
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

@@ -0,0 +1,72 @@
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

@@ -0,0 +1,267 @@
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

@@ -0,0 +1,23 @@
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

@@ -0,0 +1,91 @@
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

@@ -0,0 +1,121 @@
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
};

27
apps/server/src/express.d.ts vendored Normal file
View File

@@ -0,0 +1,27 @@
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,21 +1,13 @@
/**
* This is not a production server yet!
* This is only a minimal backend to get started.
/*
* 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 express from 'express';
import * as path from 'path';
import { initializeTranslations } from "./services/i18n.js";
const app = express();
async function startApplication() {
await import("./www.js");
}
app.use('/assets', express.static(path.join(__dirname, 'assets')));
app.get('/api', (req, res) => {
res.send({ message: 'Welcome to server!' });
});
const port = process.env.PORT || 3333;
const server = app.listen(port, () => {
console.log(`Listening at http://localhost:${port}/api`);
});
server.on('error', console.error);
await initializeTranslations();
await startApplication();

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,12 @@
<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

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

View File

@@ -0,0 +1,34 @@
<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

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

View File

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

File diff suppressed because one or more lines are too long

Binary file not shown.

After

Width:  |  Height:  |  Size: 186 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 168 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 172 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 167 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 237 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 202 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 191 KiB

View File

@@ -0,0 +1,22 @@
<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>

View File

@@ -0,0 +1,45 @@
<p><a href="https://ollama.com/">Ollama</a> can be installed in a variety
of ways, and even runs <a href="https://hub.docker.com/r/ollama/ollama">within a Docker container</a>.
Ollama will be noticeably quicker when running on a GPU (Nvidia, AMD, Intel),
but it can run on CPU and RAM. To install Ollama without any other prerequisites,
you can follow their <a href="https://ollama.com/download">installer</a>:</p>
<figure
class="image image_resized" style="width:50.49%;">
<img style="aspect-ratio:785/498;" src="3_Installing Ollama_image.png"
width="785" height="498">
</figure>
<figure class="image image_resized" style="width:40.54%;">
<img style="aspect-ratio:467/100;" src="Installing Ollama_image.png" width="467"
height="100">
</figure>
<figure class="image image_resized" style="width:55.73%;">
<img style="aspect-ratio:1296/1011;" src="1_Installing Ollama_image.png"
width="1296" height="1011">
</figure>
<p>After their installer completes, if you're on Windows, you should see
an entry in the start menu to run it:</p>
<figure class="image image_resized"
style="width:66.12%;">
<img style="aspect-ratio:1161/480;" src="2_Installing Ollama_image.png"
width="1161" height="480">
</figure>
<p>Also, you should have access to the <code>ollama</code> CLI via Powershell
or CMD:</p>
<figure class="image image_resized" style="width:86.09%;">
<img style="aspect-ratio:1730/924;" src="5_Installing Ollama_image.png"
width="1730" height="924">
</figure>
<p>After Ollama is installed, you can go ahead and <code>pull</code> the models
you want to use and run. Here's a command to pull my favorite tool-compatible
model and embedding model as of April 2025:</p><pre><code class="language-text-x-trilium-auto">ollama pull llama3.1:8b
ollama pull mxbai-embed-large</code></pre>
<p>Also, you can make sure it's running by going to <a href="http://localhost:11434">http://localhost:11434</a> and
you should get the following response (port 11434 being the “normal” Ollama
port):</p>
<figure class="image">
<img style="aspect-ratio:585/202;" src="4_Installing Ollama_image.png"
width="585" height="202">
</figure>
<p>Now that you have Ollama up and running, have a few models pulled, you're
ready to go to go ahead and start using Ollama as both a chat provider,
and embedding provider!</p>

Binary file not shown.

After

Width:  |  Height:  |  Size: 198 KiB

View File

@@ -0,0 +1,161 @@
<figure class="image image_resized" style="width:63.68%;">
<img style="aspect-ratio:1363/1364;" src="Introduction_image.png" width="1363"
height="1364">
<figcaption>An example chat with an LLM</figcaption>
</figure>
<p>The AI / LLM features within Trilium Notes are designed to allow you to
interact with your Notes in a variety of ways, using as many of the major
providers as we can support.&nbsp;</p>
<p>In addition to being able to send chats to LLM providers such as OpenAI,
Anthropic, and Ollama - we also support agentic tool calling, and embeddings.</p>
<p>The quickest way to get started is to navigate to the “AI/LLM” settings:</p>
<figure
class="image image_resized" style="width:74.04%;">
<img style="aspect-ratio:1916/1906;" src="5_Introduction_image.png" width="1916"
height="1906">
</figure>
<p>Enable the feature:</p>
<figure class="image image_resized" style="width:82.82%;">
<img style="aspect-ratio:1911/997;" src="1_Introduction_image.png" width="1911"
height="997">
</figure>
<h2>Embeddings</h2>
<p><strong>Embeddings</strong> are important as it allows us to have an compact
AI “summary” (it's not human readable text) of each of your Notes, that
we can then perform mathematical functions on (such as cosine similarity)
to smartly figure out which Notes to send as context to the LLM when you're
chatting, among other useful functions.</p>
<p>You will then need to set up the AI “provider” that you wish to use to
create the embeddings for your Notes. Currently OpenAI, Voyage AI, and
Ollama are supported providers for embedding generation.</p>
<p>In the following example, we're going to use our self-hosted Ollama instance
to create the embeddings for our Notes. You can see additional documentation
about installing your own Ollama locally in&nbsp;<a class="reference-link"
href="#root/_help_vvUCN7FDkq7G">Installing Ollama</a>.</p>
<p>To see what embedding models Ollama has available, you can check out
<a
href="https://ollama.com/search?c=embedding">this search</a>on their website, and then <code>pull</code> whichever one
you want to try out. As of 4/15/25, my personal favorite is <code>mxbai-embed-large</code>.</p>
<p>First, we'll need to select the Ollama provider from the tabs of providers,
then we will enter in the Base URL for our Ollama. Since our Ollama is
running on our local machine, our Base URL is <code>http://localhost:11434</code>.
We will then hit the “refresh” button to have it fetch our models:</p>
<figure
class="image image_resized" style="width:82.28%;">
<img style="aspect-ratio:1912/1075;" src="4_Introduction_image.png" width="1912"
height="1075">
</figure>
<p>When selecting the dropdown for the “Embedding Model”, embedding models
should be at the top of the list, separated by regular chat models with
a horizontal line, as seen below:</p>
<figure class="image image_resized"
style="width:61.73%;">
<img style="aspect-ratio:1232/959;" src="8_Introduction_image.png" width="1232"
height="959">
</figure>
<p>After selecting an embedding model, embeddings should automatically begin
to be generated by checking the embedding statistics at the top of the
“AI/LLM” settings panel:</p>
<figure class="image image_resized" style="width:67.06%;">
<img style="aspect-ratio:1333/499;" src="7_Introduction_image.png" width="1333"
height="499">
</figure>
<p>If you don't see any embeddings being created, you will want to scroll
to the bottom of the settings, and hit “Recreate All Embeddings”:</p>
<figure
class="image image_resized" style="width:65.69%;">
<img style="aspect-ratio:1337/1490;" src="3_Introduction_image.png" width="1337"
height="1490">
</figure>
<p>Creating the embeddings will take some time, and will be regenerated when
a Note is created, updated, or deleted (removed).</p>
<p>If for some reason you choose to change your embedding provider, or the
model used, you'll need to recreate all embeddings.</p>
<h2>Tools</h2>
<p>Tools are essentially functions that we provide to the various LLM providers,
and then LLMs can respond in a specific format that tells us what tool
function and parameters they would like to invoke. We then execute these
tools, and provide it as additional context in the Chat conversation.&nbsp;</p>
<p>These are the tools that currently exist, and will certainly be updated
to be more effectively (and even more to be added!):</p>
<ul>
<li><code>search_notes</code>
<ul>
<li>Semantic search</li>
</ul>
</li>
<li><code>keyword_search</code>
<ul>
<li>Keyword-based search</li>
</ul>
</li>
<li><code>attribute_search</code>
<ul>
<li>Attribute-specific search</li>
</ul>
</li>
<li><code>search_suggestion</code>
<ul>
<li>Search syntax helper</li>
</ul>
</li>
<li><code>read_note</code>
<ul>
<li>Read note content (helps the LLM read Notes)</li>
</ul>
</li>
<li><code>create_note</code>
<ul>
<li>Create a Note</li>
</ul>
</li>
<li><code>update_note</code>
<ul>
<li>Update a Note</li>
</ul>
</li>
<li><code>manage_attributes</code>
<ul>
<li>Manage attributes on a Note</li>
</ul>
</li>
<li><code>manage_relationships</code>
<ul>
<li>Manage the various relationships between Notes</li>
</ul>
</li>
<li><code>extract_content</code>
<ul>
<li>Used to smartly extract content from a Note</li>
</ul>
</li>
<li><code>calendar_integration</code>
<ul>
<li>Used to find date notes, create date notes, get the daily note, etc.</li>
</ul>
</li>
</ul>
<p>When Tools are executed within your Chat, you'll see output like the following:</p>
<figure
class="image image_resized" style="width:66.88%;">
<img style="aspect-ratio:1372/1591;" src="6_Introduction_image.png" width="1372"
height="1591">
</figure>
<p>You don't need to tell the LLM to execute a certain tool, it should “smartly”
call tools and automatically execute them as needed.</p>
<h2>Overview</h2>
<p>Now that you know about embeddings and tools, you can just go ahead and
use the “Chat with Notes” button, where you can go ahead and start chatting!:</p>
<figure
class="image image_resized" style="width:60.77%;">
<img style="aspect-ratio:1378/539;" src="2_Introduction_image.png" width="1378"
height="539">
</figure>
<p>If you don't see the “Chat with Notes” button on your side launchbar,
you might need to move it from the “Available Launchers” section to the
“Visible Launchers” section:</p>
<figure class="image image_resized" style="width:69.81%;">
<img style="aspect-ratio:1765/1287;" src="9_Introduction_image.png" width="1765"
height="1287">
</figure>

Binary file not shown.

After

Width:  |  Height:  |  Size: 175 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB

View File

@@ -0,0 +1,17 @@
<p>Trilium offers advanced functionality through <a href="#root/_help_CdNpE2pqjmI6">Scripts</a> and
<a
href="#root/_help_OFXdgB2nNk1F">Promoted Attributes</a>. To illustrate these features, we've prepared
several showcases available in the <a href="#root/_help_wX4HbRucYSDD">demo notes</a>:</p>
<ul>
<li><a href="#root/_help_iRwzGnHPzonm">Relation Map</a>
</li>
<li><a href="#root/_help_l0tKav7yLHGF">Day Notes</a>
</li>
<li><a href="#root/_help_R7abl2fc6Mxi">Weight Tracker</a>
</li>
<li><a href="#root/_help_xYjQUYhpbUEW">Task Manager</a>
</li>
</ul>
<p>It's important to note that these examples are not natively supported
by Trilium out of the box; instead, they demonstrate what you can build
within Trilium.</p>

View File

@@ -0,0 +1,146 @@
<p>A common pattern in note-taking is that a lot of notes will be centered
around a certain date - e.g. you have some tasks which needs to be done
on a certain date, you have meeting minutes from a certain date, you have
your thoughts etc. and it all revolves around a date on which they occurred.
For this reason, it makes sense to create a certain "day workspace" which
will centralize all those notes relevant for a certain date.</p>
<p>For this, Trilium provides a concept of "day note". Trilium semi-automatically
generates a single note for each day. Under this note you can save all
those relevant notes.</p>
<p>Select an existing day note, and the menubar contains a calendar widget.
Select any day to create a note for that day.&nbsp;</p>
<p>
<img src="1_Day Notes_image.png">
</p>
<p>This pattern works well also because of <a href="#root/_help_IakOLONlIfGI">Cloning Notes</a> functionality
- note can appear in multiple places in the note tree, so besides appearing
under day note, it can also be categorized into other notes.</p>
<h2>Demo</h2>
<p>
<img src="Day Notes_image.png">
</p>
<p>You can see the structure of day notes appearing under "Journal" note
- there's a note for the whole year 2025, under it, you have "03 - March"
which then contains "09 - Monday". This is our "day note" which contains
some text in its content and also has some child notes (some of them are
from <a href="#root/_help_xYjQUYhpbUEW">Task manager</a>).</p>
<p>You can also notice how this day note has <a href="#root/_help_OFXdgB2nNk1F">promoted attribute</a> "weight"
where you can track your daily weight. This data is then used in <a href="#root/_help_R7abl2fc6Mxi">Weight tracker</a>.</p>
<h2>Week Note and Quarter Note</h2>
<p>Week and quarter notes are disabled by default, since it might be too
much for some people. To enable them, you need to set <code>#enableWeekNotes</code> and <code>#enableQuarterNotes</code> attributes
on the root calendar note, which is identified by <code>#calendarRoot</code> label.
Week note is affected by the first week of year option. Be careful when
you already have some week notes created, it will not automatically change
the existing week notes and might lead to some duplicates.</p>
<h2>Templates</h2>
<p>Trilium provides <a href="#root/_help_KC1HB96bqqHX">template</a> functionality,
and it could be used together with day notes.</p>
<p>You can define one of the following relations on the root of the journal
(identified by <code>#calendarRoot</code> label):</p>
<ul>
<li>yearTemplate</li>
<li>quarterTemplate (if <code>#enableQuarterNotes</code> is set)</li>
<li>monthTemplate</li>
<li>weekTemplate (if <code>#enableWeekNotes</code> is set)</li>
<li>dateTemplate</li>
</ul>
<p>All of these are relations. When Trilium creates a new note for year or
month or date, it will take a look at the root and attach a corresponding <code>~template</code> relation
to the newly created role. Using this, you can e.g. create your daily template
with e.g. checkboxes for daily routine etc.</p>
<h2>Naming pattern</h2>
<p>You can customize the title of generated journal notes by defining a <code>#datePattern</code>, <code>#weekPattern</code>, <code>#monthPattern</code>, <code>#quarterPattern</code> and <code>#yearPattern</code> attribute
on a root calendar note (identified by <code>#calendarRoot</code> label).
The naming pattern replacements follow a level-up compatibility - each
level can use replacements from itself and all levels above it. For example, <code>#monthPattern</code> can
use month, quarter and year replacements, while <code>#weekPattern</code> can
use week, month, quarter and year replacements. But it is not possible
to use week replacements in <code>#monthPattern</code>.</p>
<h3>Date pattern</h3>
<p>It's possible to customize the title of generated date notes by defining
a <code>#datePattern</code> attribute on a root calendar note (identified
by <code>#calendarRoot</code> label). Following are possible values:</p>
<ul>
<li><code>{isoDate}</code> results in an ISO 8061 formatted date (e.g. "2025-03-09"
for March 9, 2025)</li>
<li><code>{dateNumber}</code> results in a number like <code>9</code> for the
9th day of the month, <code>11</code> for the 11th day of the month</li>
<li><code>{dateNumberPadded}</code> results in a number like <code>09</code> for
the 9th day of the month, <code>11</code> for the 11th day of the month</li>
<li><code>{ordinal}</code> is replaced with the ordinal date (e.g. 1st, 2nd,
3rd) etc.</li>
<li><code>{weekDay}</code> results in the full day name (e.g. <code>Monday</code>)</li>
<li><code>{weekDay3}</code> is replaced with the first 3 letters of the day,
e.g. Mon, Tue, etc.</li>
<li><code>{weekDay2}</code> is replaced with the first 2 letters of the day,
e.g. Mo, Tu, etc.</li>
</ul>
<p>The default is <code>{dateNumberPadded} - {weekDay}</code>
</p>
<h3>Week pattern</h3>
<p>It is also possible to customize the title of generated week notes through
the <code>#weekPattern</code> attribute on the root calendar note. The options
are:</p>
<ul>
<li><code>{weekNumber}</code> results in a number like <code>9</code> for the
9th week of the year, <code>11</code> for the 11th week of the year</li>
<li><code>{weekNumberPadded}</code> results in a number like <code>09</code> for
the 9th week of the year, <code>11</code> for the 11th week of the year</li>
<li><code>{shortWeek}</code> results in a short week string like <code>W9</code> for
the 9th week of the year, <code>W11</code> for the 11th week of the year</li>
<li><code>{shortWeek3}</code> results in a short week string like <code>W09</code> for
the 9th week of the year, <code>W11</code> for the 11th week of the year</li>
</ul>
<p>The default is <code>Week {weekNumber}</code>
</p>
<h3>Month pattern</h3>
<p>It is also possible to customize the title of generated month notes through
the <code>#monthPattern</code> attribute on the root calendar note. The options
are:</p>
<ul>
<li><code>{isoMonth}</code> results in an ISO 8061 formatted month (e.g. "2025-03"
for March 2025)</li>
<li><code>{monthNumber}</code> results in a number like <code>9</code> for September,
and <code>11</code> for November</li>
<li><code>{monthNumberPadded}</code> results in a number like <code>09</code> for
September, and <code>11</code> for November</li>
<li><code>{month}</code> results in the full month name (e.g. <code>September</code> or <code>October</code>)</li>
<li><code>{shortMonth3}</code> is replaced with the first 3 letters of the
month, e.g. Jan, Feb, etc.</li>
<li><code>{shortMonth4}</code> is replaced with the first 4 letters of the
month, e.g. Sept, Octo, etc.</li>
</ul>
<p>The default is <code>{monthNumberPadded} - {month}</code>
</p>
<h3>Quarter pattern</h3>
<p>It is also possible to customize the title of generated quarter notes
through the <code>#quarterPattern</code> attribute on the root calendar note.
The options are:</p>
<ul>
<li><code>{quarterNumber}</code> results in a number like <code>1</code> for
the 1st quarter of the year</li>
<li><code>{shortQuarter}</code> results in a short quarter string like <code>Q1</code> for
the 1st quarter of the year</li>
</ul>
<p>The default is <code>Quarter {quarterNumber}</code>
</p>
<h3>Year pattern</h3>
<p>It is also possible to customize the title of generated year notes through
the <code>#yearPattern</code> attribute on the root calendar note. The options
are:</p>
<ul>
<li><code>{year}</code> results in the full year (e.g. <code>2025</code>)</li>
</ul>
<p>The default is <code>{year}</code>
</p>
<h2>Implementation</h2>
<p>Trilium has some special support for day notes in the form of <a href="https://triliumnext.github.io/Notes/backend_api/BackendScriptApi.html">backend Script API</a> -
see e.g. getDayNote() function.</p>
<p>Day (and year, month) notes are created with a label - e.g. <code>#dateNote="2025-03-09"</code> this
can then be used by other scripts to add new notes to day note etc.</p>
<p>Journal also has relation <code>child:child:child:template=Day template</code> (see
[[attribute inheritance]]) which effectively adds [[template]] to day notes
(grand-grand-grand children of Journal). Please note that, when you enable
week notes or quarter notes, it will not automatically change the relation
for the child level.</p>

View File

@@ -0,0 +1,65 @@
<p>Task Manager is a <a href="#root/_help_OFXdgB2nNk1F">promoted attributes</a> and
<a
href="#root/_help_CdNpE2pqjmI6">scripts</a>showcase present in the <a href="#root/_help_wX4HbRucYSDD">demo notes</a>.</p>
<h2>Demo</h2>
<p>
<img src="Task Manager_task-manager.png">
</p>
<p>Task Manager manages outstanding (TODO) tasks and finished tasks (non-empty
doneDate attribute). Outstanding tasks are further categorized by location
and arbitrary tags - whenever you change tag attribute in the task note,
this task is then automatically moved to appropriate location.</p>
<p>Task Manager also integrates with <a href="#root/_help_l0tKav7yLHGF">day notes</a> -
notes are <a href="#root/_help_IakOLONlIfGI">cloned</a> into day note to
both todoDate note and doneDate note (with <a href="#root/_help_kBrnXNG3Hplm">prefix</a> of
either "TODO" or "DONE").</p>
<h2>Implementation</h2>
<p>New tasks are created in the TODO note which has <code>~child:template</code>
<a
href="#root/_help_zEY4DaJG4YT5">relation</a>(see <a href="#root/_help_bwZpz2ajCEwO">attribute inheritance</a>)
pointing to the task template.</p>
<h3>Attributes</h3>
<p>Task template defines several <a href="#root/_help_OFXdgB2nNk1F">promoted attributes</a> -
todoDate, doneDate, tags, location. Importantly it also defines <code>~runOnAttributeChange</code> relation
- <a href="#root/_help_GPERMystNGTB">event</a> handler which is run on attribute
change. This <a href="#root/_help_CdNpE2pqjmI6">script</a> handles when e.g.
we fill out the doneDate attribute - meaning the task is done and should
be moved to "Done" note and removed from TODO, locations and tags.</p>
<h3>New task button</h3>
<p>There's also "button" note which contains simple script which adds a button
to create new note (task) in the TODO note.</p><pre><code class="language-text-x-trilium-auto">api.addButtonToToolbar({
title: 'New task',
icon: 'check',
shortcut: 'alt+n',
action: async () =&gt; {
// creating notes is backend (server) responsibility so we need to pass
// the control there
const taskNoteId = await api.runOnBackend(async () =&gt; {
const todoRootNote = await api.getNoteWithLabel('taskTodoRoot');
const {note} = await api.createNote(todoRootNote.noteId, 'new task', '');
return note.noteId;
});
// we got an ID of newly created note and we want to immediatelly display it
await api.activateNewNote(taskNoteId);
}
});</code></pre>
<h3>CSS</h3>
<p>In the demo screenshot above you may notice that TODO tasks are in red
color and DONE tasks are green.</p>
<p>This is done by having this CSS <a href="#root/_help_6f9hih2hXXZk">code note</a> which
defines extra CSS classes:</p><pre><code class="language-text-x-trilium-auto">span.fancytree-node.todo .fancytree-title {
color: red !important;
}
span.fancytree-node.done .fancytree-title {
color: green !important;
}</code></pre>
<p>This <a href="#root/_help_6f9hih2hXXZk">code note</a> has <code>#appCss</code>
<a
href="#root/_help_zEY4DaJG4YT5">label</a>which is recognized by Trilium on startup and loaded as CSS into
the application.</p>
<p>Second part of this functionality is based in event handler described
above which assigns <code>#cssClass</code> label to the task to either "done"
or "todo" based on the task status.</p>

View File

@@ -0,0 +1,73 @@
<p>
<img src="Weight Tracker_image.png">
</p>
<p>The <code>Weight Tracker</code> is a <a href="#root/_help_GLks18SNjxmC">Script API</a> showcase
present in the <a href="#root/_help_wX4HbRucYSDD">demo notes</a>.</p>
<p>By adding <code>weight</code> as a <a href="#root/_help_OFXdgB2nNk1F">promoted attribute</a> in
the <a href="#root/_help_KC1HB96bqqHX">template</a> from which <a href="#root/_help_l0tKav7yLHGF">day notes</a> are
created, you can aggregate the data and plot weight change over time.</p>
<h2>Implementation</h2>
<p>The <code>Weight Tracker</code> note in the screenshot above is of the type <code>Render Note</code>.
That type of note doesn't have any useful content itself. Instead it is
a placeholder where a <a href="#root/_help_CdNpE2pqjmI6">script</a> can render
its output.</p>
<p>Scripts for <code>Render Notes</code> are defined in a <a href="#root/_help_zEY4DaJG4YT5">relation</a> called <code>~renderNote</code>.
In this example, it's the <code>Weight Tracker</code>'s child <code>Implementation</code>.
The Implementation consists of two <a href="#root/_help_6f9hih2hXXZk">code notes</a> that
contain some HTML and JavaScript respectively, which load all the notes
with a <code>weight</code> attribute and display their values in a chart.</p>
<p>To actually render the chart, we're using a third party library called
<a
href="https://www.chartjs.org/">chart.js</a>which is imported as an attachment, since it's not built into
Trilium.</p>
<h3>Code</h3>
<p>Here's the content of the script which is placed in a <a href="#root/_help_6f9hih2hXXZk">code note</a> of
type <code>JS Frontend</code>:</p><pre><code class="language-text-x-trilium-auto">async function getChartData() {
const days = await api.runOnBackend(async () =&gt; {
const notes = api.getNotesWithLabel('weight');
const days = [];
for (const note of notes) {
const date = note.getLabelValue('dateNote');
const weight = parseFloat(note.getLabelValue('weight'));
if (date &amp;&amp; weight) {
days.push({ date, weight });
}
}
days.sort((a, b) =&gt; a.date &gt; b.date ? 1 : -1);
return days;
});
const datasets = [
{
label: "Weight (kg)",
backgroundColor: 'red',
borderColor: 'red',
data: days.map(day =&gt; day.weight),
fill: false,
spanGaps: true,
datalabels: {
display: false
}
}
];
return {
datasets: datasets,
labels: days.map(day =&gt; day.date)
};
}
const ctx = $("#canvas")[0].getContext("2d");
new chartjs.Chart(ctx, {
type: 'line',
data: await getChartData()
});</code></pre>
<h2>How to remove the Weight Tracker button from the top bar</h2>
<p>In the link map of the <code>Weight Tracker</code>, there is a note called <code>Button</code>.
Open it and delete or comment out its contents. The <code>Weight Tracker</code> button
will disappear after you restart Trilium.</p>

View File

@@ -0,0 +1,44 @@
<figure class="image">
<img style="aspect-ratio:1071/146;" src="Attributes_image.png" width="1071"
height="146">
</figure>
<p>In Trilium, attributes are key-value pairs assigned to notes, providing
additional metadata or functionality. There are two primary types of attributes:</p>
<ol>
<li>
<p><a class="reference-link" href="#root/_help_HI6GBBIduIgv">Labels</a>&nbsp;can
be used for a variety of purposes, such as storing metadata or configuring
the behaviour of notes. Labels are also searchable, enhancing note retrieval.</p>
<p>For more information, including predefined labels, see&nbsp;<a class="reference-link"
href="#root/_help_HI6GBBIduIgv">Labels</a>.</p>
</li>
<li>
<p><a class="reference-link" href="#root/_help_Cq5X6iKQop6R">Relations</a>&nbsp;define
connections between notes, similar to links. These can be used for metadata
and scripting purposes.</p>
<p>For more information, including a list of predefined relations, see&nbsp;
<a
class="reference-link" href="#root/_help_Cq5X6iKQop6R">Relations</a>.</p>
</li>
</ol>
<p>These attributes play a crucial role in organizing, categorising, and
enhancing the functionality of notes.</p>
<h2>Viewing the list of attributes</h2>
<p>Both the labels and relations for the current note are displayed in the <em>Owned Attributes</em> section
of the&nbsp;<a class="reference-link" href="#root/_help_BlN9DFI679QC">Ribbon</a>,
where they can be viewed and edited. Inherited attributes are displayed
in the <em>Inherited Attributes</em> section of the ribbon, where they can
only be viewed.</p>
<p>In the list of attributes, labels are prefixed with the <code>#</code> character
whereas relations are prefixed with the <code>~</code> character.</p>
<h2>Multiplicity</h2>
<p>Attributes in Trilium can be "multi-valued", meaning multiple attributes
with the same name can co-exist.</p>
<h2>Attribute Definitions and Promoted Attributes</h2>
<p>Special labels create "label/attribute" definitions, enhancing the organization
and management of attributes. For more details, see&nbsp;<a class="reference-link"
href="#root/_help_OFXdgB2nNk1F">Promoted Attributes</a>.</p>
<h2>Attribute Inheritance</h2>
<p>Trilium supports attribute inheritance, allowing child notes to inherit
attributes from their parents. For more information, see&nbsp;<a class="reference-link"
href="#root/_help_bwZpz2ajCEwO">Attribute Inheritance</a>.</p>

View File

@@ -0,0 +1,50 @@
<p>Inheritance refers to the process of having a <a href="#root/_help_HI6GBBIduIgv">label</a> or
a <a href="#root/_help_Cq5X6iKQop6R">relation</a> shared across multiple
notes, generally in parent-child relations (or anywhere if using templates).</p>
<h2>Standard Inheritance</h2>
<p>In Trilium, attributes can be automatically inherited by child notes if
they have the <code>isInheritable</code> flag set to <code>true</code>. This
means the attribute (a key-value pair) is applied to the note and all its
descendants.</p>
<p>To make an attribute inheritable, simply use the visual editor for&nbsp;
<a
class="reference-link" href="#root/_help_HI6GBBIduIgv">Labels</a>&nbsp;or&nbsp;<a class="reference-link" href="#root/_help_Cq5X6iKQop6R">Relations</a>.
Alternatively, the attribute can be manually defined where <code>#myLabel=value</code> becomes <code>#myLabel(inheritable)=value</code> when
inheritable.</p>
<p>As an example, the <code>archived</code> label can be set to be inheritable,
allowing you to hide a whole subtree of notes from searches and other dialogs
by applying this label at the top level.</p>
<p>Standard inheritance forces all the notes that are children (and sub-children)
of a note to have that particular label or relation. If there is a need
to have some notes not inherit one of the labels, then <em>copying inheritance</em> or <em>template inheritance</em> needs
to be used instead.</p>
<h2>Copying Inheritance</h2>
<p>Copying inheritance differs from standard inheritance by using a <code>child:</code> prefix
in the attribute name. This prefix causes new child notes to automatically
receive specific attributes from the parent note. These attributes are
independent of the parent and will persist even if the note is moved elsewhere.</p>
<p>If a parent note has the label <code>#child:exampleAttribute</code>, all
newly created child notes (one level deep) will inherit the <code>#exampleAttribute</code> label.
This can be useful for setting default properties for notes in a specific
section.</p>
<p>Similarly, for relations use <code>~child:myRelation</code>.</p>
<p>Due to the way it's designed, copying inheritance cannot be used to cascade
infinitely within a hierarchy. For that use case, consider using either
standard inheritance or templates.</p>
<h3>Chained inheritance</h3>
<p>It is possible to define labels across multiple levels of depth. For example, <code>#child:child:child:foo</code> applied
to a root note would create:</p>
<ul>
<li><code>#child:child:foo</code> on the first-level children.</li>
<li><code>#child:foo</code> on the second-level children.</li>
<li><code>#foo</code> on the third-level children.</li>
</ul>
<p>Similarly, use <code>~child:child:child:foo</code> if dealing with relations.</p>
<p>Do note that same as simple copying inheritance, the changes will not
apply retroactively to existing notes in the hierarchy, it will only apply
to the newly created notes.</p>
<h2>Template Inheritance</h2>
<p>Attributes can also be inherited from&nbsp;<a class="reference-link" href="#root/_help_KC1HB96bqqHX">Templates</a>.
When a new note is created using a template, it inherits the attributes
defined in that template. This is particularly useful for maintaining consistency
across notes that follow a similar structure or function.</p>

View File

@@ -0,0 +1,385 @@
<p>A label is an <a href="#root/_help_zEY4DaJG4YT5">attribute</a> of a note
which has a name and optionally a value.</p>
<h2>Common use cases</h2>
<ul>
<li><strong>Metadata for personal use</strong>: Assign labels with optional
values for categorization, such as <code>#year=1999</code>, <code>#genre="sci-fi"</code>,
or <code>#author="Neal Stephenson"</code>. This can be combined with&nbsp;
<a
class="reference-link" href="#root/_help_OFXdgB2nNk1F">Promoted Attributes</a>&nbsp;to make their display more user-friendly.</li>
<li><strong>Configuration</strong>: Labels can configure advanced features
or settings (see reference below).</li>
<li><strong>Scripts and Plugins</strong>: Used to tag notes with special metadata,
such as the "weight" attribute in the&nbsp;<a class="reference-link" href="#root/_help_R7abl2fc6Mxi">Weight Tracker</a>.</li>
</ul>
<h2>Creating a label using the visual editor</h2>
<ol>
<li>Go to the <em>Owned Attributes</em> section in the&nbsp;<a class="reference-link"
href="#root/_help_BlN9DFI679QC">Ribbon</a>.</li>
<li>Press the + button (<em>Add new attribute</em>) to the right.</li>
<li>Select <em>Add new label</em> for the relation.</li>
</ol>
<aside class="admonition tip">
<p>If you prefer keyboard shortcuts, press <kbd>Alt</kbd>+<kbd>L</kbd> while
focused on a note or in the <em>Owned Attributes</em> section to display
the visual editor.</p>
</aside>
<p>While in the visual editor:</p>
<ul>
<li>Set the desired name</li>
<li>Optionally, set the value of the label. Labels can exist without a value.</li>
<li>Check <em>Inheritable</em> if the label should be inherited by the child
notes as well. See&nbsp;<a class="reference-link" href="#root/_help_bwZpz2ajCEwO">Attribute Inheritance</a>&nbsp;for
more information.</li>
</ul>
<h2>Creating a label manually</h2>
<p>In the <em>Owned Attributes</em> section in the&nbsp;<a class="reference-link"
href="#root/_help_BlN9DFI679QC">Ribbon</a>:</p>
<ul>
<li>To create a label called <code>myLabel</code> with no value, simply type <code>#myLabel</code>.</li>
<li>To create a label called <code>myLabel</code> with a value <code>value</code>,
simply type <code>#myLabel=value</code>.</li>
<li>If the value contains spaces, then the text must be quoted: <code>#myLabel="Hello world"</code>.</li>
<li>If the string contains quotes (regardless of whether it has spaces), then
the text must be quoted with apostrophes instead: <code>#myLabel='Hello "world"'</code>.</li>
<li>To create an inheritable label called <code>myLabel</code>, simply write <code>#myLabel(inheritable)</code> for
no value or <code>#myLabel(inheritable)=value</code> if there is a value.</li>
</ul>
<h2>Predefined labels</h2>
<p>This is a list of labels that Trilium natively supports.</p>
<aside class="admonition tip">
<p>Some labels presented here end with a <code>*</code>. That means that there
are multiple labels with the same prefix, consult the specific page linked
in the description of that label for more information.</p>
</aside>
<figure class="table" style="width:100%;">
<table class="ck-table-resized">
<colgroup>
<col style="width:33.82%;">
<col style="width:66.18%;">
</colgroup>
<thead>
<tr>
<th>Label</th>
<th>Description</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>disableVersioning</code>
</td>
<td>Disables automatic creation of&nbsp;<a class="reference-link" href="#root/_help_vZWERwf8U3nx">Note Revisions</a>&nbsp;for
a particular note. Useful for e.g. large, but unimportant notes - e.g.
large JS libraries used for scripting.</td>
</tr>
<tr>
<td><code>versioningLimit</code>
</td>
<td>Limits the maximum number of&nbsp;<a class="reference-link" href="#root/_help_vZWERwf8U3nx">Note Revisions</a>&nbsp;for
a particular note, overriding the global settings.</td>
</tr>
<tr>
<td><code>calendarRoot</code>
</td>
<td>Marks the note which should be used as root for&nbsp;<a class="reference-link"
href="#root/_help_l0tKav7yLHGF">Day Notes</a>. Only one should be marked
as such.</td>
</tr>
<tr>
<td><code>archived</code>
</td>
<td>Hides notes from default search results and dialogs. Archived notes can
optionally be hidden in the&nbsp;<a class="reference-link" href="#root/_help_oPVyFC7WL2Lp">Note Tree</a>.</td>
</tr>
<tr>
<td><code>excludeFromExport</code>
</td>
<td>Excludes this note and its children when exporting.</td>
</tr>
<tr>
<td><code>run</code>, <code>runOnInstance</code>, <code>runAtHour</code>
</td>
<td>See&nbsp;<a class="reference-link" href="#root/_help_GPERMystNGTB">Events</a>.</td>
</tr>
<tr>
<td><code>disableInclusion</code>
</td>
<td>Scripts with this label won't be included into parent script execution.</td>
</tr>
<tr>
<td><code>sorted</code>
</td>
<td>
<p>Keeps child notes sorted by title alphabetically.</p>
<p>When given a value, it will sort by the value of another label instead.
If one of the child notes doesn't have the specified label, the title will
be used for them instead.</p>
</td>
</tr>
<tr>
<td><code>sortDirection</code>
</td>
<td>
<p>If <code>sorted</code> is applied, specifies the direction of the sort:</p>
<ul>
<li><code>ASC</code>, ascending (default)</li>
<li><code>DESC</code>, descending</li>
</ul>
</td>
</tr>
<tr>
<td><code>sortFoldersFirst</code>
</td>
<td>If <code>sorted</code> is applied, folders (notes with children) will be
sorted as a group at the top, and the rest will be sorted.</td>
</tr>
<tr>
<td><code>top</code>
</td>
<td>If <code>sorted</code> is applied to the parent note, keeps given note on
top in its parent.</td>
</tr>
<tr>
<td><code>hidePromotedAttributes</code>
</td>
<td>Hide&nbsp;<a class="reference-link" href="#root/_help_OFXdgB2nNk1F">Promoted Attributes</a>&nbsp;on
this note. Generally useful when defining inherited attributes, but the
parent note doesn't need them.</td>
</tr>
<tr>
<td><code>readOnly</code>
</td>
<td>Marks a note to be always be <a href="#root/_help_CoFPLs3dRlXc">read-only</a>,
if it's a supported note (text, code, mermaid).</td>
</tr>
<tr>
<td><code>autoReadOnlyDisabled</code>
</td>
<td>Disables automatic <a href="#root/_help_CoFPLs3dRlXc">read-only mode</a> for
the given note.</td>
</tr>
<tr>
<td><code>appCss</code>
</td>
<td>Marks CSS notes which are loaded into the Trilium application and can
thus be used to modify Trilium's looks. See&nbsp;<a class="reference-link"
href="#root/_help_AlhDUqhENtH7">Custom app-wide CSS</a>&nbsp;for more info.</td>
</tr>
<tr>
<td><code>appTheme</code>
</td>
<td>Marks CSS notes which are full Trilium themes and are thus available in
Trilium options. See&nbsp;<a class="reference-link" href="#root/_help_pKK96zzmvBGf">Theme development</a>&nbsp;for
more information.</td>
</tr>
<tr>
<td><code>appThemeBase</code>
</td>
<td>Set to <code>next</code>, <code>next-light</code>, or <code>next-dark</code> to
use the corresponding TriliumNext theme (auto, light or dark) as the base
for a custom theme, instead of the legacy one. See&nbsp;<a class="reference-link"
href="#root/_help_WFGzWeUK6arS">Customize the Next theme</a>&nbsp;for more
information.</td>
</tr>
<tr>
<td><code>cssClass</code>
</td>
<td>Value of this label is then added as CSS class to the node representing
given note in the&nbsp;<a class="reference-link" href="#root/_help_oPVyFC7WL2Lp">Note Tree</a>.
This can be useful for advanced theming. Can be used in template notes.</td>
</tr>
<tr>
<td><code>iconClass</code>
</td>
<td>value of this label is added as a CSS class to the icon on the tree which
can help visually distinguish the notes in the tree. Example might be bx
bx-home - icons are taken from boxicons. Can be used in template notes.</td>
</tr>
<tr>
<td><code>pageSize</code>
</td>
<td>Specifies the number of items per page in&nbsp;<a class="reference-link"
href="#root/_help_0ESUbbAxVnoK">Note List</a>.</td>
</tr>
<tr>
<td><code>customRequestHandler</code>
</td>
<td>See&nbsp;<a class="reference-link" href="#root/_help_J5Ex1ZrMbyJ6">Custom Request Handler</a>.</td>
</tr>
<tr>
<td><code>customResourceProvider</code>
</td>
<td>See&nbsp;<a class="reference-link" href="#root/_help_d3fAXQ2diepH">Custom Resource Providers</a>.</td>
</tr>
<tr>
<td><code>widget</code>
</td>
<td>Marks this note as a custom widget which will be added to the Trilium
component tree. See&nbsp;<a class="reference-link" href="#root/_help_MgibgPcfeuGz">Custom Widgets</a>&nbsp;for
more information.</td>
</tr>
<tr>
<td><code>searchHome</code>
</td>
<td>New search notes will be created as children of this note (see&nbsp;
<a
class="reference-link" href="#root/_help_m523cpzocqaD">Saved Search</a>).</td>
</tr>
<tr>
<td><code>workspace</code> and related attributes</td>
<td>See&nbsp;<a class="reference-link" href="#root/_help_9sRHySam5fXb">Workspaces</a>.</td>
</tr>
<tr>
<td><code>inbox</code>
</td>
<td>default inbox location for new notes - when you create a note using <em>new note</em> button
in the sidebar, notes will be created as child notes in the note marked
as with <code>#inbox</code> label.</td>
</tr>
<tr>
<td><code>sqlConsoleHome</code>
</td>
<td>Default location of&nbsp;<a class="reference-link" href="#root/_hidden/_help/_help_tC7s2alapj8V/_help_wX4HbRucYSDD/_help_oyIAJ9PvvwHX/_help__help_YKWqdJhzi2VY">SQL Console</a>&nbsp;notes</td>
</tr>
<tr>
<td><code>bookmarked</code>
</td>
<td>Indicates this note is a <a href="#root/_help_u3YFHC9tQlpm">bookmark</a>.</td>
</tr>
<tr>
<td><code>bookmarkFolder</code>
</td>
<td>Note with this label will appear in bookmarks as folder (allowing access
to its children). See&nbsp;<a class="reference-link" href="#root/_help_u3YFHC9tQlpm">Bookmarks</a>&nbsp;for
more information.</td>
</tr>
<tr>
<td><code>share*</code>
</td>
<td>See the attribute reference in&nbsp;<a class="reference-link" href="#root/_help_R9pX4DGra2Vt">Sharing</a>.</td>
</tr>
<tr>
<td><code>displayRelations</code>, <code>hideRelations</code>
</td>
<td>Comma delimited names of relations which should be displayed/hidden in
a&nbsp;<a class="reference-link" href="#root/_help_iRwzGnHPzonm">Relation Map</a>&nbsp;(both
the note type and the&nbsp;<a class="reference-link" href="#root/_help_BCkXAVs63Ttv">Note Map (Link map, Tree map)</a>&nbsp;general
functionality).</td>
</tr>
<tr>
<td><code>titleTemplate</code>
</td>
<td>
<p>Default title of notes created as children of this note. This value is
evaluated as a JavaScript string and thus can be enriched with dynamic
content via the injected <code>now</code> and <code>parentNote</code> variables.</p>
<p>Examples:</p>
<ul>
<li><code>${parentNote.getLabel('authorName')}'s literary works</code>
</li>
<li><code>Log for ${now.format('YYYY-MM-DD HH:mm:ss')}</code>
</li>
<li>to mirror the parent's template.</li>
</ul>
<p>See&nbsp;<a class="reference-link" href="#root/_help_47ZrP6FNuoG8">Default Note Title</a>&nbsp;for
more info.</p>
</td>
</tr>
<tr>
<td><code>template</code>
</td>
<td>This note will appear in the selection of available template when creating
new note. See&nbsp;<a class="reference-link" href="#root/_help_KC1HB96bqqHX">Templates</a>&nbsp;for
more information.</td>
</tr>
<tr>
<td><code>toc</code>
</td>
<td>Controls the display of the&nbsp;<a class="reference-link" href="#root/_help_BFvAtE74rbP6">Table of contents</a>&nbsp;for
a given note. <code>#toc</code> or <code>#toc=show</code> to always display
the table of contents, <code>#toc=false</code> to always hide it.</td>
</tr>
<tr>
<td><code>color</code>
</td>
<td>defines color of the note in note tree, links etc. Use any valid CSS color
value like 'red' or #a13d5f</td>
</tr>
<tr>
<td><code>keyboardShortcut</code>
</td>
<td>Defines a keyboard shortcut which will immediately jump to this note.
Example: 'ctrl+alt+e'. Requires frontend reload for the change to take
effect.</td>
</tr>
<tr>
<td><code>keepCurrentHoisting</code>
</td>
<td>Opening this link won't change hoisting even if the note is not displayable
in the current hoisted subtree.</td>
</tr>
<tr>
<td><code>executeButton</code>
</td>
<td>Title of the button which will execute the current code note</td>
</tr>
<tr>
<td><code>executeDescription</code>
</td>
<td>Longer description of the current code note displayed together with the
execute button</td>
</tr>
<tr>
<td><code>excludeFromNoteMap</code>
</td>
<td>Notes with this label will be hidden from the&nbsp;<a class="reference-link"
href="#root/_help_bdUJEHsAPYQR">Note Map</a>.</td>
</tr>
<tr>
<td><code>newNotesOnTop</code>
</td>
<td>New notes will be created at the top of the parent note, not on the bottom.</td>
</tr>
<tr>
<td><code>hideHighlightWidget</code>
</td>
<td>Hides the&nbsp;<a class="reference-link" href="#root/_help_AxshuNRegLAv">Highlights list</a>&nbsp;widget</td>
</tr>
<tr>
<td><code>hideChildrenOverview</code>
</td>
<td>Hides the&nbsp;<a class="reference-link" href="#root/_help_0ESUbbAxVnoK">Note List</a>&nbsp;for
that particular note.</td>
</tr>
<tr>
<td><code>printLandscape</code>
</td>
<td>When exporting to PDF, changes the orientation of the page to landscape
instead of portrait.</td>
</tr>
<tr>
<td><code>printPageSize</code>
</td>
<td>When exporting to PDF, changes the size of the page. Supported values: <code>A0</code>, <code>A1</code>, <code>A2</code>, <code>A3</code>, <code>A4</code>, <code>A5</code>, <code>A6</code>, <code>Legal</code>, <code>Letter</code>, <code>Tabloid</code>, <code>Ledger</code>.</td>
</tr>
<tr>
<td><code>geolocation</code>
</td>
<td>Indicates the latitude and longitude of a note, to be displayed in a&nbsp;
<a
class="reference-link" href="#root/_help_81SGnPGMk7Xc">Geo Map</a>.</td>
</tr>
<tr>
<td><code>calendar:*</code>
</td>
<td>Defines specific options for the&nbsp;<a class="reference-link" href="#root/_help_xWbu3jpNWapp">Calendar View</a>.</td>
</tr>
<tr>
<td><code>viewType</code>
</td>
<td>Sets the view of child notes (e.g. grid or list). See&nbsp;<a class="reference-link"
href="#root/_help_0ESUbbAxVnoK">Note List</a>&nbsp;for more information.</td>
</tr>
</tbody>
</table>
</figure>

View File

@@ -0,0 +1,49 @@
<p>Promoted attributes are <a href="#root/_help_zEY4DaJG4YT5">attributes</a> which
are considered important and thus are "promoted" onto the main note UI.
See example below:</p>
<p>
<img src="Promoted Attributes_promot.png">
</p>
<p>You can see the note having kind of form with several fields. Each of
these is just regular attribute, the only difference is that they appear
on the note itself.</p>
<p>Attributes can be pretty useful since they allow for querying and script
automation etc. but they are also inconveniently hidden. This allows you
to select few of the important ones and push them to the front of the user.</p>
<p>Now, how do we make attribute to appear on the UI?</p>
<h2>Attribute definition</h2>
<p>Attribute is always name-value pair where both name and value are strings.</p>
<p><em>Attribute definition</em> specifies how should this value be interpreted
- is it just string, or is it a date? Should we allow multiple values or
note? And importantly, should we <em>promote</em> the attribute or not?</p>
<p>
<img src="Promoted Attributes_image.png">
</p>
<p>You can notice tag attribute definition. These "definition" attributes
define how the "value" attributes should behave.</p>
<p>So there's one attribute for value and one for definition. But notice
how definition attribute is <a href="#root/_help_bwZpz2ajCEwO">Inheritable</a>,
meaning that it's also applied to all descendant note. So in a way, this
definition is used for the whole subtree while "value" attributes are applied
only for this note.</p>
<h3>Inverse relation</h3>
<p>Some relations always occur in pairs - my favorite example is on the family.
If you have a note representing husband and note representing wife, then
there might be a relation between those two of <code>isPartnerOf</code>.
This is bidirectional relationship - meaning that if a relation is pointing
from husband to wife then there should be always another relation pointing
from wife to husband.</p>
<p>Another example is with parent - child relationship. Again these always
occur in pairs, but in this case it's not exact same relation - the one
going from parent to child might be called <code>isParentOf</code> and the
other one going from child to parent might be called <code>isChildOf</code>.</p>
<p>Relation definition allows you to specify such "inverse relation" - for
the relation you just define you specify which is the inverse relation.
Note that in the second example we should have two relation definitions
- one for <code>isParentOf</code> which defines <code>isChildOf</code> as inverse
relation and then second relation definition for <code>isChildOf</code> which
defines <code>isParentOf</code> as inverse relation.</p>
<p>What this does internally is that whenever we save a relation which has
defined inverse relation, we check that this inverse relation exists on
the relation target note. Similarly, when we delete relation, we also delete
inverse relation on the target note.</p>

View File

@@ -0,0 +1,139 @@
<p>A relation is similar to a <a href="#root/_help_HI6GBBIduIgv">label</a>,
but instead of having a text value it refers to another note.</p>
<h2>Common use cases</h2>
<ul>
<li><strong>Metadata Relationships for personal use</strong>: For example,
linking a book note to an author note.
<br>This can be combined with&nbsp;<a class="reference-link" href="#root/_help_OFXdgB2nNk1F">Promoted Attributes</a>&nbsp;to
make their display more user-friendly.</li>
<li><strong>Configuration</strong>: For configuring some notes such as&nbsp;
<a
class="reference-link" href="#root/_help_HcABDtFCkbFN">Render Note</a>, or configuring&nbsp;<a class="reference-link" href="#root/_help_R9pX4DGra2Vt">Sharing</a>&nbsp;or&nbsp;
<a
class="reference-link" href="#root/_help_KC1HB96bqqHX">Templates</a>&nbsp;(see the list below).</li>
<li><strong>Scripting</strong>: Attaching scripts to events or conditions
related to the note.</li>
</ul>
<h2>Creating a relation using the visual editor</h2>
<ol>
<li>Go to the <em>Owned Attributes</em> section in the&nbsp;<a class="reference-link"
href="#root/_help_BlN9DFI679QC">Ribbon</a>.</li>
<li>Press the + button (<em>Add new attribute</em>) to the right.</li>
<li>Select <em>Add new relation</em> for the relation.</li>
</ol>
<aside class="admonition tip">
<p>If you prefer keyboard shortcuts, press <kbd>Alt</kbd>+<kbd>L</kbd> while
focused on a note or in the <em>Owned Attributes</em> section to display
the visual editor.</p>
</aside>
<p>While in the visual editor:</p>
<ul>
<li>Set the desired name</li>
<li>Set the Target note (the note to point to). Unlike labels, relations cannot
exist with a target note.</li>
<li>Check <em>Inheritable</em> if the label should be inherited by the child
notes as well. See&nbsp;<a class="reference-link" href="#root/_help_bwZpz2ajCEwO">Attribute Inheritance</a>&nbsp;for
more information.</li>
</ul>
<h2>Creating a relation manually</h2>
<p>In the <em>Owned Attributes</em> section in the&nbsp;<a class="reference-link"
href="#root/_help_BlN9DFI679QC">Ribbon</a>:</p>
<ul>
<li>To create a relation called <code>myRelation</code>:
<ul>
<li>First type <code>~myRelation=@</code>.</li>
<li>After this, an autocompletion box should appear.</li>
<li>Type the title of the note to point to and press <kbd>Enter</kbd> to confirm
(or click the desired note).</li>
<li>Alternatively copy a note from the&nbsp;<a class="reference-link" href="#root/_help_oPVyFC7WL2Lp">Note Tree</a>&nbsp;and
paste it after the <code>=</code> sign (without the <code>@</code>, in this
case).</li>
</ul>
</li>
<li>To create an inheritable relation, follow the same steps as previously
described but instead of <code>~myRelation</code> write <code>~myRelation(inheritable)</code>.</li>
</ul>
<h2>Predefined relations</h2>
<p>These relations are supported and used internally by Trilium.</p>
<aside
class="admonition tip">
<p>Some relations presented here end with a <code>*</code>. That means that
there are multiple relations with the same prefix, consult the specific
page linked in the description of that relation for more information.</p>
</aside>
<figure class="table" style="width:100%;">
<table class="ck-table-resized">
<colgroup>
<col style="width:33.95%;">
<col style="width:66.05%;">
</colgroup>
<thead>
<tr>
<th>Label</th>
<th>Description</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>runOn*</code>
</td>
<td>See&nbsp;<a class="reference-link" href="#root/_help_GPERMystNGTB">Events</a>
</td>
</tr>
<tr>
<td><code>template</code>
</td>
<td>note's attributes will be inherited even without a parent-child relationship,
note's content and subtree will be added to instance notes if empty. See
documentation for details.</td>
</tr>
<tr>
<td><code>inherit</code>
</td>
<td>note's attributes will be inherited even without a parent-child relationship.
See&nbsp;<a class="reference-link" href="#root/_help_KC1HB96bqqHX">Templates</a>&nbsp;for
a similar concept. See&nbsp;<a class="reference-link" href="#root/_help_bwZpz2ajCEwO">Attribute Inheritance</a>&nbsp;in
the documentation.</td>
</tr>
<tr>
<td><code>renderNote</code>
</td>
<td>notes of type&nbsp;<a class="reference-link" href="#root/_help_HcABDtFCkbFN">Render Note</a>&nbsp;will
be rendered using a code note (HTML or script) and it is necessary to point
using this relation to which note should be rendered</td>
</tr>
<tr>
<td><code>widget_relation</code>
</td>
<td>target of this relation will be executed and rendered as a widget in the
sidebar</td>
</tr>
<tr>
<td><code>shareCss</code>
</td>
<td>CSS note which will be injected into the share page. CSS note must be
in the shared sub-tree as well. Consider using <code>share_hidden_from_tree</code> and <code>share_omit_default_css</code> as
well.</td>
</tr>
<tr>
<td><code>shareJs</code>
</td>
<td>JavaScript note which will be injected into the share page. JS note must
be in the shared sub-tree as well. Consider using <code>share_hidden_from_tree</code>.</td>
</tr>
<tr>
<td><code>shareTemplate</code>
</td>
<td>Embedded JavaScript note that will be used as the template for displaying
the shared note. Falls back to the default template. Consider using <code>share_hidden_from_tree</code>.</td>
</tr>
<tr>
<td><code>shareFavicon</code>
</td>
<td>Favicon note to be set in the shared page. Typically you want to set it
to share root and make it inheritable. Favicon note must be in the shared
sub-tree as well. Consider using <code>share_hidden_from_tree</code>.</td>
</tr>
</tbody>
</table>
</figure>

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

View File

@@ -0,0 +1,169 @@
<figure class="image">
<img style="aspect-ratio:1425/654;" src="Bulk Actions_image.png" width="1425"
height="654">
</figure>
<p>The <em>Bulk Actions</em> dialog makes it easy to apply changes to multiple
notes at once, ranging from simple actions such as adding or removing a
label to being executing custom scripts.</p>
<h2>Interaction</h2>
<ul>
<li>The first step is to select the notes in the&nbsp;<a class="reference-link"
href="#root/_help_oPVyFC7WL2Lp">Note Tree</a>. It's possible to apply bulk
actions to:
<ul>
<li>A single note (and potentially its child notes) simply by clicking on
it (with a left click or a right click).</li>
<li>Multiple notes. See&nbsp;<a class="reference-link" href="#root/_help_yTjUdsOi4CIE">Multiple selection</a>&nbsp;on
how to do so.</li>
</ul>
</li>
<li>Right click in the&nbsp;<a class="reference-link" href="#root/_help_oPVyFC7WL2Lp">Note Tree</a>&nbsp;and
select <em>Advanced</em><em>Apply bulk actions</em>.</li>
<li>By default, only the selected notes will be affected. To also include
all the descendants of the notes, check <em>Include descendants of the selected notes</em>.
The number of affected notes at the top of the dialog will update to reflect
the change.</li>
<li>Click on which action to apply from the <em>Available actions</em> section.
A detailed description of each is available in the next section.
<ul>
<li>For each action selected, the <em>Chosen actions</em> section will update
to reveal the entry. Each action will have its own configuration.</li>
<li>To remove an action, simply press the X button to the right of it.</li>
<li>It is possible to apply multiple actions of the same type, such as adding
multiple types.</li>
</ul>
</li>
<li>When all the actions are defined, press <em>Execute bulk actions</em> to
trigger all of them at once.</li>
<li>For convenience, the last bulk action configuration is saved for further
use and will be restored when entering the dialog again.</li>
</ul>
<h2>Actions</h2>
<h3>Labels</h3>
<p>These actions operate the&nbsp;<a class="reference-link" href="#root/_help_HI6GBBIduIgv">Labels</a>&nbsp;of
a note:</p>
<ul>
<li><strong>Add label</strong>
<ul>
<li>For each note, if it doesn't already have a <a href="#root/_help_HI6GBBIduIgv">label</a> of
the given name, it will create it. Keep the <em>New value</em> field empty
to create a label without a value, or complete it to assign a value.</li>
<li>If a note already has this label, its value will be updated.</li>
</ul>
</li>
<li><strong>Update label value</strong>
<ul>
<li>For each note, if it has a <a href="#root/_help_HI6GBBIduIgv">label</a> of
the given name, it will change its value to the specified one. Leave <em>New value</em> field
empty to create a label without a value.</li>
<li>Notes without the label will not be affected.</li>
</ul>
</li>
<li><em><strong>Rename label</strong></em>
<ul>
<li>For each note, if it has a <a href="#root/_help_HI6GBBIduIgv">label</a> of
the given name, it will be renamed/replaced with a label of the new name.
The value of the label (if present) will be kept intact.</li>
<li>Notes without the label will not be affected.</li>
</ul>
</li>
<li><strong>Delete label</strong>
<ul>
<li>For each note, if it has a label of a given name, it will be deleted (regardless
of whether it has a value or not).</li>
<li>Notes without the label will not be affected.</li>
</ul>
</li>
</ul>
<h3>Relations</h3>
<p>These actions operate the&nbsp;<a class="reference-link" href="#root/_help_Cq5X6iKQop6R">Relations</a>&nbsp;of
a note:</p>
<ul>
<li><strong>Add relation</strong>
<ul>
<li>For each note, it will create a relation pointing to the given note.</li>
<li>Notes without this relation will not be affected.</li>
</ul>
</li>
<li><strong>Update relation target</strong>
<ul>
<li>For each note, it will modify a relation to point to the newly given note.</li>
<li>Notes without this relation will not be affected.</li>
</ul>
</li>
<li><strong>Rename relation</strong>
<ul>
<li>For each note, if it has a relation of the given name, it will be renamed/replaced
with a relation of the new name. The target note of the relation will be
kept intact.</li>
<li>Notes without this relation will not be affected.</li>
</ul>
</li>
<li><strong>Delete relation</strong>
<ul>
<li>For each note, if it has a relation of the given name, it will be deleted.</li>
<li>Notes without this relation will not be affected.</li>
</ul>
</li>
</ul>
<h3>Notes</h3>
<ul>
<li><strong>Rename note</strong>
<ul>
<li>For each note, it will change the title of the note to the given one.</li>
<li>As a more advanced use case, the note can be a “template string” which
allows for dynamic values with access to the note information via&nbsp;
<a
class="reference-link" href="#root/_help_habiZ3HU8Kw8">FNote</a>, for example:
<ul>
<li><code>NEW: ${note.title}</code> will prefix all notes with <code>NEW:</code> .</li>
<li><code>${note.dateCreatedObj.format('MM-DD:')}: ${note.title}</code> will
prefix the note titles with each note's creation date (in month-day format).</li>
</ul>
</li>
</ul>
</li>
<li><strong>Move note</strong>
<ul>
<li>For each note, it will be moved to the specified parent note.</li>
<li>As an alternative for less complex situations, the notes can be moved
directly from within the&nbsp;<a class="reference-link" href="#root/_help_oPVyFC7WL2Lp">Note Tree</a>&nbsp;via
cut → paste or via the contextual menu.</li>
</ul>
</li>
<li><strong>Delete note</strong>
<ul>
<li>For each note, it will be deleted.</li>
<li>As an alternative for less complex situations, the notes can be removed
directly from within the&nbsp;<a class="reference-link" href="#root/_help_oPVyFC7WL2Lp">Note Tree</a>&nbsp;by
selecting them and pressing <kbd>Delete</kbd>.</li>
</ul>
</li>
<li><strong>Delete note revisions</strong>
<ul>
<li>This will delete all the&nbsp;<a class="reference-link" href="#root/_help_vZWERwf8U3nx">Note Revisions</a>&nbsp;of
the notes.</li>
</ul>
</li>
</ul>
<h3>Others</h3>
<ul>
<li><strong>Execute script</strong>
<ul>
<li>For more complex scenarios, it is possible to type in a JavaScript expression
in order to apply the necessary changes.</li>
<li>Examples:
<ul>
<li>
<p>To apply a suffix (<code>- suffix</code> in this example), to the note
title:</p><pre><code class="language-application-javascript-env-backend">note.title = note.title + " - suffix";</code></pre>
</li>
<li>
<p>To alter attributes of a note based on another attribute, such as setting
the <code>#shareAlias</code> label to the title of the note:</p><pre><code class="language-application-javascript-env-backend">note.setLabel("shareAlias", note.title)</code></pre>
</li>
</ul>
</li>
</ul>
</li>
</ul>

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

View File

@@ -0,0 +1,27 @@
<p>Trilium supports configuration via a file named <code>config.ini</code> and
environment variables. Please review the file named <a href="https://github.com/TriliumNext/Notes/blob/develop/config-sample.ini">config-sample.ini</a> in
the <a href="https://github.com/TriliumNext/Notes">Notes</a> repository to
see what values are supported.</p>
<p>You can provide the same values via environment variables instead of the <code>config.ini</code> file,
and these environment variables use the following format:</p>
<ol>
<li>Environment variables should be prefixed with <code>TRILIUM_</code> and
use underscores to represent the INI section structure.</li>
<li>The format is: <code>TRILIUM_&lt;SECTION&gt;_&lt;KEY&gt;=&lt;VALUE&gt;</code>
</li>
<li>The environment variables will override any matching values from config.ini</li>
</ol>
<p>For example, if you have this in your config.ini:</p><pre><code class="language-text-x-trilium-auto">[Network]
host=localhost
port=8080</code></pre>
<p>You can override these values using environment variables:</p><pre><code class="language-text-x-trilium-auto">TRILIUM_NETWORK_HOST=0.0.0.0
TRILIUM_NETWORK_PORT=9000</code></pre>
<p>The code will:</p>
<ol>
<li>First load the <code>config.ini</code> file as before</li>
<li>Then scan all environment variables for ones starting with <code>TRILIUM_</code>
</li>
<li>Parse these variables into section/key pairs</li>
<li>Merge them with the config from the file, with environment variables taking
precedence</li>
</ol>

View File

@@ -0,0 +1,46 @@
<p>By default, Trilium cannot be accessed in web browsers by requests coming
from other domains/origins than Trilium itself.&nbsp;</p>
<p>However, it is possible to manually configure <a href="https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/CORS">Cross-Origin Resource Sharing (CORS)</a> since
Trilium v0.93.0 using environment variables or <code>config.ini</code>,
as follows:</p>
<figure class="table" style="width:100%;">
<table class="ck-table-resized">
<colgroup>
<col style="width:26.93%;">
<col style="width:32.46%;">
<col style="width:40.61%;">
</colgroup>
<thead>
<tr>
<th>CORS Header</th>
<th>Corresponding option in <code>config.ini</code>
</th>
<th>Corresponding option in environment variables in the <code>Network</code> section</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>Access-Control-Allow-Origin</code>
</td>
<td><code>TRILIUM_NETWORK_CORS_ALLOW_ORIGIN</code>
</td>
<td><code>corsAllowOrigin</code>&nbsp;</td>
</tr>
<tr>
<td><code>Access-Control-Allow-Methods</code>
</td>
<td><code>TRILIUM_NETWORK_CORS_ALLOW_METHODS</code>
</td>
<td><code>corsAllowMethods</code>&nbsp;</td>
</tr>
<tr>
<td><code>Access-Control-Allow-Headers</code>
</td>
<td><code>TRILIUM_NETWORK_CORS_ALLOW_HEADERS</code>
</td>
<td><code>corsAllowHeaders</code>
</td>
</tr>
</tbody>
</table>
</figure>

View File

@@ -0,0 +1,17 @@
<p>A Trilium instance represents a server. If&nbsp;<a class="reference-link"
href="#root/_help_cbkrhQjrkKrh">Synchronization</a>&nbsp;is set up, since
multiple servers are involved (the one from the desktop client and the
one the synchronisation is set up with), sometimes it can be useful to
distinguish the instance you are running on.</p>
<h2>Setting the instance name</h2>
<p>To set up a name for the instance, modify the <code>config.ini</code>:</p><pre><code class="language-text-x-trilium-auto">[General]
instanceName=Hello</code></pre>
<h2>Distinguishing the instance on back-end</h2>
<p>Use <code>api.getInstanceName()</code> to obtain the instance name of the
current server, as specified in the config file or in environment variables.</p>
<h2>Limiting script runs based on instance</h2>
<p>For a script that is run periodically or on a certain event, it's possible
to limit it to certain instances without having to change the code. Just
add <code>runOnInstance</code> and set as the value the instance name where
the script should run. To run on multiple named instances, simply add the
label multiple times.</p>

View File

@@ -0,0 +1,75 @@
<p>Trilium provides a mechanism for <a href="#root/_help_CdNpE2pqjmI6">scripts</a> to
open a public REST endpoint. This opens a way for various integrations
with other services - a simple example would be creating new note from
Slack by issuing a slash command (e.g. <code>/trilium buy milk</code>).</p>
<h2>Create note from outside Trilium</h2>
<p>Let's take a look at an example. The goal is to provide a REST endpoint
to which we can send title and content and Trilium will create a note.</p>
<p>We'll start with creating a JavaScript backend <a href="#root/_help_6f9hih2hXXZk">code note</a> containing:</p><pre><code class="language-text-x-trilium-auto">const {req, res} = api;
const {secret, title, content} = req.body;
if (req.method == 'POST' &amp;&amp; secret === 'secret-password') {
// notes must be saved somewhere in the tree hierarchy specified by a parent note.
// This is defined by a relation from this code note to the "target" parent note
// alternetively you can just use constant noteId for simplicity (get that from "Note Info" dialog of the desired parent note)
const targetParentNoteId = api.currentNote.getRelationValue('targetNote');
const {note} = api.createTextNote(targetParentNoteId, title, content);
const notePojo = note.getPojo();
res.status(201).json(notePojo);
}
else {
res.send(400);
}</code></pre>
<p>This script note has also following two attributes:</p>
<ul>
<li>label <code>#customRequestHandler</code> with value <code>create-note</code>
</li>
<li>relation <code>~targetNote</code> pointing to a note where new notes should
be saved</li>
</ul>
<h3>Explanation</h3>
<p>Let's test this by using an HTTP client to send a request:</p><pre><code class="language-text-x-trilium-auto">POST http://my.trilium.org/custom/create-note
Content-Type: application/json
{
"secret": "secret-password",
"title": "hello",
"content": "world"
}+++++++++++++++++++++++++++++++++++++++++++++++</code></pre>
<p>Notice the <code>/custom</code> part in the request path - Trilium considers
any request with this prefix as "custom" and tries to find a matching handler
by looking at all notes which have <code>customRequestHandler</code> <a href="#root/_help_zEY4DaJG4YT5">label</a>.
Value of this label then contains a regular expression which will match
the request path (in our case trivial regex "create-note").</p>
<p>Trilium will then find our code note created above and execute it. <code>api.req</code>, <code>api.res</code> are
set to <a href="https://expressjs.com/en/api.html#req">request</a> and
<a
href="https://expressjs.com/en/api.html#res">response</a>objects from which we can get details of the request and also
respond.</p>
<p>In the code note we check the request method and then use trivial authentication
- keep in mind that these endpoints are by default totally unauthenticated,
and you need to take care of this yourself.</p>
<p>Once we pass these checks we will just create the desired note using
<a
href="#root/_help_GLks18SNjxmC">Script API</a>.</p>
<h2>Custom resource provider</h2>
<p>Another common use case is that you want to just expose a file note -
in such case you create label <code>customResourceProvider</code> (value
is again path regex).</p>
<p>For more information, see&nbsp;<a href="#root/_help_d3fAXQ2diepH">Custom Resource Providers</a>.</p>
<h2>Advanced concepts</h2>
<p><code>api.req</code> and <code>api.res</code> are Express.js objects - you
can always look into its <a href="https://expressjs.com/en/api.html">documentation</a> for
details.</p>
<h3>Parameters</h3>
<p>REST request paths often contain parameters in the URL, e.g.:</p><pre><code class="language-text-x-trilium-auto">http://my.trilium.org/custom/notes/123</code></pre>
<p>The last part is dynamic so the matching of the URL must also be dynamic
- for this reason the matching is done with regular expressions. Following <code>customRequestHandler</code> value
would match it:</p><pre><code class="language-text-x-trilium-auto">notes/([0-9]+)</code></pre>
<p>Additionally, this also defines a matching group with the use of parenthesis
which then makes it easier to extract the value. The matched groups are
available in <code>api.pathParams</code>:</p><pre><code class="language-text-x-trilium-auto">const noteId = api.pathParams[0];</code></pre>
<p>Often you also need query params (as in e.g. <code>http://my.trilium.org/custom/notes?noteId=123</code>),
you can get those with standard express <code>req.query.noteId</code>.</p>

View File

@@ -0,0 +1,36 @@
<p>A custom resource provider allows any file imported into Trilium (images,
fonts, stylesheets) to be publicly accessible via a URL.</p>
<p>A potential use case for this is to add embed a custom font alongside
a theme.</p>
<h2>Steps for creating a custom resource provider</h2>
<ol>
<li>Import a file such as an image or a font into Trilium by drag &amp; drop.</li>
<li>Select the file and go to the <em>Owned Attributes</em> section.</li>
<li>Add the label <code>#customResourceProvider=hello</code>.</li>
<li>To test if it is working, use a browser to navigate to <code>&lt;protocol&gt;://&lt;host&gt;/custom/hello</code> (where <code>&lt;protocol&gt;</code> is
either <code>http</code> or <code>https</code> based on your setup, and <code>&lt;host&gt;</code> is
the host or IP to your Trilium server instance). If you are running the
TriliumNext application without a server, use <code>http://localhost:37840</code> as
the base URL.</li>
<li>If everything went well, at the previous step the browser should have
downloaded the file uploaded in the first step.</li>
</ol>
<p>Instead of <code>hello</code>, the name can be:</p>
<ul>
<li>A path, such as <code>fonts/Roboto.ttf</code>, which would be accessible
via <code>&lt;host&gt;/custom/fonts/Roboto.ttf</code>.</li>
<li>As a more advanced use case, a regular expression to match multiple routes,
such as <code>hello/.*</code> which will be accessible via <code>/custom/hello/1</code>, <code>/custom/hello/2</code>, <code>/custom/hello/world</code>,
etc.</li>
</ul>
<h2>Using it in a theme</h2>
<p>For example, if you have a custom font to be imported by the theme, first
upload a font file into Trilium and assign it the <code>#customResourceProvider=fonts/myfont.ttf</code> attribute.</p>
<p>Then modify the theme CSS to point to:</p><pre><code class="language-text-css">@font-face {
font-family: customFont;
src: url("/custom/fonts/myfont.ttf");
}
div {
font-family: customFont;
}</code></pre>

View File

@@ -0,0 +1,27 @@
<p>Your Trilium data is stored in a <a href="https://www.sqlite.org">SQLite</a> database
which contains all notes, tree structure, metadata, and most of the configuration.
The database file is named <code>document.db</code> and is stored in the
application's default&nbsp;<a href="#root/_help_tAassRL4RSQL">Data directory</a>.</p>
<h2>Demo Notes</h2>
<p>When first starting Trilium, it will provide a set of notes to showcase
various features of the application.</p>
<p>For more information see&nbsp;<a class="reference-link" href="#root/_help_6tZeKvSHEUiB">Demo Notes</a>.</p>
<h2>Manually Modifying the Database</h2>
<p>Trilium provides a lot of flexibility, and with it, opportunities for
advanced users to tweak it. If you need to explore or modify the database
directly, you can use a tool such as <a href="https://sqlitebrowser.org/">SQLite Browser</a> to
work directly on the database file.</p>
<p>See&nbsp;<a href="#root/_help_oyIAJ9PvvwHX">Manually altering the database</a>&nbsp;for
more information.</p>
<h2>How to Reset the Database</h2>
<p>If you are experimenting with Trilium and want to return it to its original
state, you can do that by deleting the current database. When you restart
the application, it will generate a new database containing the original
demo notes.</p>
<p>To delete the database, simply go to the <a href="#root/_help_tAassRL4RSQL">data directory</a> and
delete the <code>document.db</code> file (and any other files starting with <code>document.db</code>).</p>
<p>If you do not need to preserve any configurations that might be stored
in the <code>config.ini</code> file, you can just delete all of the <a href="#root/_help_tAassRL4RSQL">data directory's</a> contents
to fully restore the application to its original state. You can also review
the <a href="#root/_help_Gzjqa934BdH4">configuration</a> file to provide
all <code>config.ini</code> values as environment variables instead.</p>

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