chore(nx/server): move source code
@@ -2,6 +2,7 @@
|
||||
"name": "@triliumnext/server",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"nx": {
|
||||
"targets": {
|
||||
"serve": {
|
||||
|
||||
23
apps/server/src/anonymize.ts
Normal 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
@@ -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;
|
||||
295
apps/server/src/becca/becca-interface.ts
Normal 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;
|
||||
}
|
||||
7
apps/server/src/becca/becca.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
"use strict";
|
||||
|
||||
import Becca from "./becca-interface.js";
|
||||
|
||||
const becca = new Becca();
|
||||
|
||||
export default becca;
|
||||
295
apps/server/src/becca/becca_loader.ts
Normal 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
|
||||
};
|
||||
117
apps/server/src/becca/becca_service.ts
Normal 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
|
||||
};
|
||||
324
apps/server/src/becca/entities/abstract_becca_entity.ts
Normal 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;
|
||||
259
apps/server/src/becca/entities/battachment.ts
Normal 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;
|
||||
227
apps/server/src/becca/entities/battribute.ts
Normal 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;
|
||||
43
apps/server/src/becca/entities/bblob.ts
Normal 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;
|
||||
283
apps/server/src/becca/entities/bbranch.ts
Normal 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;
|
||||
89
apps/server/src/becca/entities/betapi_token.ts
Normal 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;
|
||||
1763
apps/server/src/becca/entities/bnote.ts
Normal file
73
apps/server/src/becca/entities/bnote_embedding.ts
Normal 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;
|
||||
56
apps/server/src/becca/entities/boption.ts
Normal 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;
|
||||
46
apps/server/src/becca/entities/brecent_note.ts
Normal 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;
|
||||
225
apps/server/src/becca/entities/brevision.ts
Normal 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;
|
||||
39
apps/server/src/becca/entity_constructor.ts
Normal 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
|
||||
};
|
||||
477
apps/server/src/becca/similarity.ts
Normal 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
|
||||
};
|
||||
12
apps/server/src/errors/forbidden_error.ts
Normal 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;
|
||||
13
apps/server/src/errors/http_error.ts
Normal 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;
|
||||
12
apps/server/src/errors/not_found_error.ts
Normal 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;
|
||||
9
apps/server/src/errors/open_id_error.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
class OpenIdError {
|
||||
message: string;
|
||||
|
||||
constructor(message: string) {
|
||||
this.message = message;
|
||||
}
|
||||
}
|
||||
|
||||
export default OpenIdError;
|
||||
12
apps/server/src/errors/validation_error.ts
Normal 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;
|
||||
13
apps/server/src/etapi/app_info.ts
Normal 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
|
||||
};
|
||||
108
apps/server/src/etapi/attachments.ts
Normal 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
|
||||
};
|
||||
85
apps/server/src/etapi/attributes.ts
Normal 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
|
||||
};
|
||||
44
apps/server/src/etapi/auth.ts
Normal 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
|
||||
};
|
||||
16
apps/server/src/etapi/backup.ts
Normal 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
|
||||
};
|
||||
89
apps/server/src/etapi/branches.ts
Normal 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
|
||||
};
|
||||
3
apps/server/src/etapi/etapi-interface.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export type ValidatorFunc = (obj: unknown) => string | undefined;
|
||||
|
||||
export type ValidatorMap = Record<string, ValidatorFunc[]>;
|
||||
1168
apps/server/src/etapi/etapi.openapi.yaml
Normal file
156
apps/server/src/etapi/etapi_utils.ts
Normal 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
|
||||
};
|
||||
72
apps/server/src/etapi/mappers.ts
Normal 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
|
||||
};
|
||||
267
apps/server/src/etapi/notes.ts
Normal 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
|
||||
};
|
||||
23
apps/server/src/etapi/spec.ts
Normal 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
|
||||
};
|
||||
91
apps/server/src/etapi/special_notes.ts
Normal 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
|
||||
};
|
||||
121
apps/server/src/etapi/validators.ts
Normal 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
@@ -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;
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
3
apps/server/src/public/app/doc_notes/cn/hidden.html
Normal file
@@ -0,0 +1,3 @@
|
||||
<p>隐藏树用于记录各种应用层数据,这些数据大部分时间可能对用户不可见。</p>
|
||||
|
||||
<p>确保你知道自己在做什么。对这个子树的错误更改可能会导致应用程序崩溃。</p>
|
||||
@@ -0,0 +1 @@
|
||||
<p>此启动器操作的键盘快捷键可以在“选项”->“快捷键”中进行配置。</p>
|
||||
@@ -0,0 +1,3 @@
|
||||
<p>“后退”和“前进”按钮允许您在导航历史中移动。</p>
|
||||
|
||||
<p>这些启动器仅在桌面版本中有效,在服务器版本中将被忽略,您可以使用浏览器的原生导航按钮代替。</p>
|
||||
11
apps/server/src/public/app/doc_notes/cn/launchbar_intro.html
Normal file
@@ -0,0 +1,11 @@
|
||||
<p>欢迎来到启动栏配置界面。</p>
|
||||
|
||||
<p>您可以在此处执行以下操作:</p>
|
||||
|
||||
<ul>
|
||||
<li>通过拖动将可用的启动器移动到可见列表中(从而将它们放入启动栏)</li>
|
||||
<li>通过拖动将可见的启动器移动到可用列表中(从而将它们从启动栏中隐藏)</li>
|
||||
<li>您可以通过拖动重新排列列表中的项目</li>
|
||||
<li>通过右键点击“可见启动器”文件夹来创建新的启动器</li>
|
||||
<li>如果您想恢复默认设置,可以在右键菜单中找到“重置”选项。</li>
|
||||
</ul>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -0,0 +1,6 @@
|
||||
<p>间隔器允许您在视觉上将启动器分组。您可以在提升的属性中进行配置:</p>
|
||||
|
||||
<ul>
|
||||
<li><code>baseSize</code> - 定义以像素为单位的大小(如果有足够的空间)</li>
|
||||
<li><code>growthFactor</code> - 如果您希望间隔器保持恒定的 <code>baseSize</code>,则设置为 0;如果设置为正值,它将增长。</li>
|
||||
</ul>
|
||||
@@ -0,0 +1,34 @@
|
||||
<p>请在提升的属性中定义目标小部件笔记。该小部件将用于渲染启动栏图标。</p>
|
||||
|
||||
<h4>示例启动栏小部件</h4>
|
||||
|
||||
<pre>
|
||||
const TPL = `<div style="height: 53px; width: 53px;"></div>`;
|
||||
|
||||
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>
|
||||
1
apps/server/src/public/app/doc_notes/cn/share.html
Normal file
@@ -0,0 +1 @@
|
||||
<p>在这里您可以找到所有分享的笔记。</p>
|
||||
1
apps/server/src/public/app/doc_notes/cn/user_hidden.html
Normal file
@@ -0,0 +1 @@
|
||||
<p>此笔记作为一个子树,用于存储由用户脚本生成的数据,这些数据本应避免在隐藏子树中随意创建。</p>
|
||||
|
After Width: | Height: | Size: 186 KiB |
|
After Width: | Height: | Size: 168 KiB |
|
After Width: | Height: | Size: 43 KiB |
|
After Width: | Height: | Size: 172 KiB |
|
After Width: | Height: | Size: 167 KiB |
|
After Width: | Height: | Size: 237 KiB |
|
After Width: | Height: | Size: 202 KiB |
|
After Width: | Height: | Size: 49 KiB |
|
After Width: | Height: | Size: 80 KiB |
|
After Width: | Height: | Size: 191 KiB |
@@ -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>
|
||||
|
After Width: | Height: | Size: 47 KiB |
|
After Width: | Height: | Size: 270 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 89 KiB |
@@ -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>
|
||||
|
After Width: | Height: | Size: 5.3 KiB |
|
After Width: | Height: | Size: 198 KiB |
@@ -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. </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 <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. </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>
|
||||
|
After Width: | Height: | Size: 175 KiB |
|
After Width: | Height: | Size: 76 KiB |
@@ -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>
|
||||
|
After Width: | Height: | Size: 23 KiB |
@@ -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. </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>
|
||||
|
After Width: | Height: | Size: 59 KiB |
@@ -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 () => {
|
||||
// creating notes is backend (server) responsibility so we need to pass
|
||||
// the control there
|
||||
const taskNoteId = await api.runOnBackend(async () => {
|
||||
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>
|
||||
|
After Width: | Height: | Size: 158 KiB |
@@ -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 () => {
|
||||
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 && weight) {
|
||||
days.push({ date, weight });
|
||||
}
|
||||
}
|
||||
|
||||
days.sort((a, b) => a.date > b.date ? 1 : -1);
|
||||
|
||||
return days;
|
||||
});
|
||||
|
||||
const datasets = [
|
||||
{
|
||||
label: "Weight (kg)",
|
||||
backgroundColor: 'red',
|
||||
borderColor: 'red',
|
||||
data: days.map(day => day.weight),
|
||||
fill: false,
|
||||
spanGaps: true,
|
||||
datalabels: {
|
||||
display: false
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
return {
|
||||
datasets: datasets,
|
||||
labels: days.map(day => 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>
|
||||
|
After Width: | Height: | Size: 68 KiB |
@@ -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> 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 <a class="reference-link"
|
||||
href="#root/_help_HI6GBBIduIgv">Labels</a>.</p>
|
||||
</li>
|
||||
<li>
|
||||
<p><a class="reference-link" href="#root/_help_Cq5X6iKQop6R">Relations</a> 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
|
||||
<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 <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 <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 <a class="reference-link"
|
||||
href="#root/_help_bwZpz2ajCEwO">Attribute Inheritance</a>.</p>
|
||||
@@ -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
|
||||
<a
|
||||
class="reference-link" href="#root/_help_HI6GBBIduIgv">Labels</a> or <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 <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>
|
||||
@@ -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
|
||||
<a
|
||||
class="reference-link" href="#root/_help_OFXdgB2nNk1F">Promoted Attributes</a> 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 <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 <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 <a class="reference-link" href="#root/_help_bwZpz2ajCEwO">Attribute Inheritance</a> for
|
||||
more information.</li>
|
||||
</ul>
|
||||
<h2>Creating a label manually</h2>
|
||||
<p>In the <em>Owned Attributes</em> section in the <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 <a class="reference-link" href="#root/_help_vZWERwf8U3nx">Note Revisions</a> 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 <a class="reference-link" href="#root/_help_vZWERwf8U3nx">Note Revisions</a> 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 <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 <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 <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 <a class="reference-link" href="#root/_help_OFXdgB2nNk1F">Promoted Attributes</a> 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 <a class="reference-link"
|
||||
href="#root/_help_AlhDUqhENtH7">Custom app-wide CSS</a> 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 <a class="reference-link" href="#root/_help_pKK96zzmvBGf">Theme development</a> 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 <a class="reference-link"
|
||||
href="#root/_help_WFGzWeUK6arS">Customize the Next theme</a> 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 <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 <a class="reference-link"
|
||||
href="#root/_help_0ESUbbAxVnoK">Note List</a>.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>customRequestHandler</code>
|
||||
</td>
|
||||
<td>See <a class="reference-link" href="#root/_help_J5Ex1ZrMbyJ6">Custom Request Handler</a>.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>customResourceProvider</code>
|
||||
</td>
|
||||
<td>See <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 <a class="reference-link" href="#root/_help_MgibgPcfeuGz">Custom Widgets</a> for
|
||||
more information.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>searchHome</code>
|
||||
</td>
|
||||
<td>New search notes will be created as children of this note (see
|
||||
<a
|
||||
class="reference-link" href="#root/_help_m523cpzocqaD">Saved Search</a>).</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>workspace</code> and related attributes</td>
|
||||
<td>See <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 <a class="reference-link" href="#root/_hidden/_help/_help_tC7s2alapj8V/_help_wX4HbRucYSDD/_help_oyIAJ9PvvwHX/_help__help_YKWqdJhzi2VY">SQL Console</a> 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 <a class="reference-link" href="#root/_help_u3YFHC9tQlpm">Bookmarks</a> for
|
||||
more information.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>share*</code>
|
||||
</td>
|
||||
<td>See the attribute reference in <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 <a class="reference-link" href="#root/_help_iRwzGnHPzonm">Relation Map</a> (both
|
||||
the note type and the <a class="reference-link" href="#root/_help_BCkXAVs63Ttv">Note Map (Link map, Tree map)</a> 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 <a class="reference-link" href="#root/_help_47ZrP6FNuoG8">Default Note Title</a> 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 <a class="reference-link" href="#root/_help_KC1HB96bqqHX">Templates</a> for
|
||||
more information.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>toc</code>
|
||||
</td>
|
||||
<td>Controls the display of the <a class="reference-link" href="#root/_help_BFvAtE74rbP6">Table of contents</a> 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 <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 <a class="reference-link" href="#root/_help_AxshuNRegLAv">Highlights list</a> widget</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>hideChildrenOverview</code>
|
||||
</td>
|
||||
<td>Hides the <a class="reference-link" href="#root/_help_0ESUbbAxVnoK">Note List</a> 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
|
||||
<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 <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 <a class="reference-link"
|
||||
href="#root/_help_0ESUbbAxVnoK">Note List</a> for more information.</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</figure>
|
||||
@@ -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>
|
||||
|
After Width: | Height: | Size: 113 KiB |
|
After Width: | Height: | Size: 44 KiB |
@@ -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 <a class="reference-link" href="#root/_help_OFXdgB2nNk1F">Promoted Attributes</a> to
|
||||
make their display more user-friendly.</li>
|
||||
<li><strong>Configuration</strong>: For configuring some notes such as
|
||||
<a
|
||||
class="reference-link" href="#root/_help_HcABDtFCkbFN">Render Note</a>, or configuring <a class="reference-link" href="#root/_help_R9pX4DGra2Vt">Sharing</a> or
|
||||
<a
|
||||
class="reference-link" href="#root/_help_KC1HB96bqqHX">Templates</a> (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 <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 <a class="reference-link" href="#root/_help_bwZpz2ajCEwO">Attribute Inheritance</a> for
|
||||
more information.</li>
|
||||
</ul>
|
||||
<h2>Creating a relation manually</h2>
|
||||
<p>In the <em>Owned Attributes</em> section in the <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 <a class="reference-link" href="#root/_help_oPVyFC7WL2Lp">Note Tree</a> 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 <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 <a class="reference-link" href="#root/_help_KC1HB96bqqHX">Templates</a> for
|
||||
a similar concept. See <a class="reference-link" href="#root/_help_bwZpz2ajCEwO">Attribute Inheritance</a> in
|
||||
the documentation.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>renderNote</code>
|
||||
</td>
|
||||
<td>notes of type <a class="reference-link" href="#root/_help_HcABDtFCkbFN">Render Note</a> 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>
|
||||
|
After Width: | Height: | Size: 22 KiB |
@@ -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 <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 <a class="reference-link" href="#root/_help_yTjUdsOi4CIE">Multiple selection</a> on
|
||||
how to do so.</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li>Right click in the <a class="reference-link" href="#root/_help_oPVyFC7WL2Lp">Note Tree</a> 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 <a class="reference-link" href="#root/_help_HI6GBBIduIgv">Labels</a> 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 <a class="reference-link" href="#root/_help_Cq5X6iKQop6R">Relations</a> 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
|
||||
<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 <a class="reference-link" href="#root/_help_oPVyFC7WL2Lp">Note Tree</a> 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 <a class="reference-link" href="#root/_help_oPVyFC7WL2Lp">Note Tree</a> by
|
||||
selecting them and pressing <kbd>Delete</kbd>.</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li><strong>Delete note revisions</strong>
|
||||
<ul>
|
||||
<li>This will delete all the <a class="reference-link" href="#root/_help_vZWERwf8U3nx">Note Revisions</a> 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>
|
||||
|
After Width: | Height: | Size: 53 KiB |
@@ -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_<SECTION>_<KEY>=<VALUE></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>
|
||||
@@ -0,0 +1,46 @@
|
||||
<p>By default, Trilium cannot be accessed in web browsers by requests coming
|
||||
from other domains/origins than Trilium itself. </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> </td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>Access-Control-Allow-Methods</code>
|
||||
</td>
|
||||
<td><code>TRILIUM_NETWORK_CORS_ALLOW_METHODS</code>
|
||||
</td>
|
||||
<td><code>corsAllowMethods</code> </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>
|
||||
@@ -0,0 +1,17 @@
|
||||
<p>A Trilium instance represents a server. If <a class="reference-link"
|
||||
href="#root/_help_cbkrhQjrkKrh">Synchronization</a> 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>
|
||||
@@ -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' && 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 <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>
|
||||
@@ -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 & 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><protocol>://<host>/custom/hello</code> (where <code><protocol></code> is
|
||||
either <code>http</code> or <code>https</code> based on your setup, and <code><host></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><host>/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>
|
||||
@@ -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 <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 <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 <a href="#root/_help_oyIAJ9PvvwHX">Manually altering the database</a> 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>
|
||||