From 760c7b73ad11b7516faa8a338cdfa45531c6c9da Mon Sep 17 00:00:00 2001 From: zadam Date: Fri, 17 Feb 2023 16:30:14 +0100 Subject: [PATCH] api docs --- ...cca_entities_abstract_becca_entity.js.html | 221 +++ .../becca_entities_battribute.js.html | 285 +++ .../becca_entities_bbranch.js.html | 328 ++++ .../becca_entities_betapi_token.js.html | 129 ++ docs/backend_api/becca_entities_bnote.js.html | 1559 +++++++++++++++++ .../becca_entities_bnote_revision.js.html | 242 +++ .../becca_entities_boption.js.html | 98 ++ .../becca_entities_brecent_note.js.html | 86 + .../services_backend_script_api.js.html | 593 +++++++ docs/backend_api/services_sql.js.html | 422 +++++ docs/frontend_api/entities_fattribute.js.html | 130 ++ docs/frontend_api/entities_fbranch.js.html | 114 ++ docs/frontend_api/entities_fnote.js.html | 939 ++++++++++ .../entities_fnote_complement.js.html | 91 + .../services_frontend_script_api.js.html | 590 +++++++ package.json | 2 +- 16 files changed, 5828 insertions(+), 1 deletion(-) create mode 100644 docs/backend_api/becca_entities_abstract_becca_entity.js.html create mode 100644 docs/backend_api/becca_entities_battribute.js.html create mode 100644 docs/backend_api/becca_entities_bbranch.js.html create mode 100644 docs/backend_api/becca_entities_betapi_token.js.html create mode 100644 docs/backend_api/becca_entities_bnote.js.html create mode 100644 docs/backend_api/becca_entities_bnote_revision.js.html create mode 100644 docs/backend_api/becca_entities_boption.js.html create mode 100644 docs/backend_api/becca_entities_brecent_note.js.html create mode 100644 docs/backend_api/services_backend_script_api.js.html create mode 100644 docs/backend_api/services_sql.js.html create mode 100644 docs/frontend_api/entities_fattribute.js.html create mode 100644 docs/frontend_api/entities_fbranch.js.html create mode 100644 docs/frontend_api/entities_fnote.js.html create mode 100644 docs/frontend_api/entities_fnote_complement.js.html create mode 100644 docs/frontend_api/services_frontend_script_api.js.html diff --git a/docs/backend_api/becca_entities_abstract_becca_entity.js.html b/docs/backend_api/becca_entities_abstract_becca_entity.js.html new file mode 100644 index 000000000..4036de3f6 --- /dev/null +++ b/docs/backend_api/becca_entities_abstract_becca_entity.js.html @@ -0,0 +1,221 @@ + + + + + JSDoc: Source: becca/entities/abstract_becca_entity.js + + + + + + + + + + +
+ +

Source: becca/entities/abstract_becca_entity.js

+ + + + + + +
+
+
"use strict";
+
+const utils = require('../../services/utils');
+const sql = require('../../services/sql');
+const entityChangesService = require('../../services/entity_changes');
+const eventService = require("../../services/events");
+const dateUtils = require("../../services/date_utils");
+const cls = require("../../services/cls");
+const log = require("../../services/log");
+
+let becca = null;
+
+/**
+ * Base class for all backend entities.
+ */
+class AbstractBeccaEntity {
+    /** @protected */
+    beforeSaving() {
+        this.generateIdIfNecessary();
+    }
+
+    /** @protected */
+    generateIdIfNecessary() {
+        if (!this[this.constructor.primaryKeyName]) {
+            this[this.constructor.primaryKeyName] = utils.newEntityId();
+        }
+    }
+
+    /** @protected */
+    generateHash(isDeleted = false) {
+        let contentToHash = "";
+
+        for (const propertyName of this.constructor.hashedProperties) {
+            contentToHash += `|${this[propertyName]}`;
+        }
+
+        if (isDeleted) {
+            contentToHash += "|deleted";
+        }
+
+        return utils.hash(contentToHash).substr(0, 10);
+    }
+
+    /** @protected */
+    getUtcDateChanged() {
+        return this.utcDateModified || this.utcDateCreated;
+    }
+
+    /**
+     * @protected
+     * @returns {Becca}
+     */
+    get becca() {
+        if (!becca) {
+            becca = require('../becca');
+        }
+
+        return becca;
+    }
+
+    /** @protected */
+    addEntityChange(isDeleted = false) {
+        entityChangesService.addEntityChange({
+            entityName: this.constructor.entityName,
+            entityId: this[this.constructor.primaryKeyName],
+            hash: this.generateHash(isDeleted),
+            isErased: false,
+            utcDateChanged: this.getUtcDateChanged(),
+            isSynced: this.constructor.entityName !== 'options' || !!this.isSynced
+        });
+    }
+
+    /** @protected */
+    getPojoToSave() {
+        return this.getPojo();
+    }
+
+    /**
+     * Saves entity - executes SQL, but doesn't commit the transaction on its own
+     *
+     * @returns {this}
+     */
+    save(opts = {}) {
+        const entityName = this.constructor.entityName;
+        const primaryKeyName = this.constructor.primaryKeyName;
+
+        const isNewEntity = !this[primaryKeyName];
+
+        if (this.beforeSaving) {
+            this.beforeSaving(opts);
+        }
+
+        const pojo = this.getPojoToSave();
+
+        sql.transactional(() => {
+            sql.upsert(entityName, primaryKeyName, pojo);
+
+            if (entityName === 'recent_notes') {
+                return;
+            }
+
+            this.addEntityChange(false);
+
+            if (!cls.isEntityEventsDisabled()) {
+                const eventPayload = {
+                    entityName,
+                    entity: this
+                };
+
+                if (isNewEntity) {
+                    eventService.emit(eventService.ENTITY_CREATED, eventPayload);
+                }
+
+                eventService.emit(eventService.ENTITY_CHANGED, eventPayload);
+            }
+        });
+
+        return this;
+    }
+
+    /**
+     * 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.
+     *
+     * @param [deleteId=null]
+     */
+    markAsDeleted(deleteId = null) {
+        const entityId = this[this.constructor.primaryKeyName];
+        const entityName = this.constructor.entityName;
+
+        this.utcDateModified = dateUtils.utcNowDateTime();
+
+        sql.execute(`UPDATE ${entityName} SET isDeleted = 1, deleteId = ?, utcDateModified = ?
+                           WHERE ${this.constructor.primaryKeyName} = ?`,
+            [deleteId, this.utcDateModified, entityId]);
+
+        if (this.dateModified) {
+            this.dateModified = dateUtils.localNowDateTime();
+
+            sql.execute(`UPDATE ${entityName} SET dateModified = ? WHERE ${this.constructor.primaryKeyName} = ?`,
+                [this.dateModified, entityId]);
+        }
+
+        log.info(`Marking ${entityName} ${entityId} as deleted`);
+
+        this.addEntityChange(true);
+
+        eventService.emit(eventService.ENTITY_DELETED, { entityName, entityId, entity: this });
+    }
+
+    markAsDeletedSimple() {
+        const entityId = this[this.constructor.primaryKeyName];
+        const entityName = this.constructor.entityName;
+
+        this.utcDateModified = dateUtils.utcNowDateTime();
+
+        sql.execute(`UPDATE ${entityName} SET isDeleted = 1, utcDateModified = ?
+                           WHERE ${this.constructor.primaryKeyName} = ?`,
+            [this.utcDateModified, entityId]);
+
+        log.info(`Marking ${entityName} ${entityId} as deleted`);
+
+        this.addEntityChange(true);
+
+        eventService.emit(eventService.ENTITY_DELETED, { entityName, entityId, entity: this });
+    }
+}
+
+module.exports = AbstractBeccaEntity;
+
+
+
+ + + + +
+ + + +
+ + + + + + + diff --git a/docs/backend_api/becca_entities_battribute.js.html b/docs/backend_api/becca_entities_battribute.js.html new file mode 100644 index 000000000..83fff96e5 --- /dev/null +++ b/docs/backend_api/becca_entities_battribute.js.html @@ -0,0 +1,285 @@ + + + + + JSDoc: Source: becca/entities/battribute.js + + + + + + + + + + +
+ +

Source: becca/entities/battribute.js

+ + + + + + +
+
+
"use strict";
+
+const BNote = require('./bnote');
+const AbstractBeccaEntity = require("./abstract_becca_entity");
+const sql = require("../../services/sql");
+const dateUtils = require("../../services/date_utils");
+const promotedAttributeDefinitionParser = require("../../services/promoted_attribute_definition_parser");
+const {sanitizeAttributeName} = require("../../services/sanitize_attribute_name");
+
+/**
+ * Attribute is an abstract concept which has two real uses - label (key - value pair)
+ * and relation (representing named relationship between source and target note)
+ *
+ * @extends AbstractBeccaEntity
+ */
+class BAttribute extends AbstractBeccaEntity {
+    static get entityName() { return "attributes"; }
+    static get primaryKeyName() { return "attributeId"; }
+    static get hashedProperties() { return ["attributeId", "noteId", "type", "name", "value", "isInheritable"]; }
+
+    constructor(row) {
+        super();
+
+        if (!row) {
+            return;
+        }
+
+        this.updateFromRow(row);
+        this.init();
+    }
+
+    updateFromRow(row) {
+        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]) {
+        /** @type {string} */
+        this.attributeId = attributeId;
+        /** @type {string} */
+        this.noteId = noteId;
+        /** @type {string} */
+        this.type = type;
+        /** @type {string} */
+        this.name = name;
+        /** @type {int} */
+        this.position = position;
+        /** @type {string} */
+        this.value = value || "";
+        /** @type {boolean} */
+        this.isInheritable = !!isInheritable;
+        /** @type {string} */
+        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 target 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];
+        }
+    }
+
+    /**
+     * @returns {BNote|null}
+     */
+    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;
+    }
+
+    /**
+     * @returns {BNote|null}
+     */
+    getTargetNote() {
+        if (this.type !== 'relation') {
+            throw new Error(`Attribute ${this.attributeId} is not relation`);
+        }
+
+        if (!this.value) {
+            return null;
+        }
+
+        return this.becca.getNote(this.value);
+    }
+
+    /**
+     * @returns {boolean}
+     */
+    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 = {}) {
+        if (!opts.skipValidation) {
+            this.validate();
+        }
+
+        this.name = sanitizeAttributeName(this.name);
+
+        if (!this.value) {
+            // null value isn't allowed
+            this.value = "";
+        }
+
+        if (this.position === undefined) {
+            // TODO: can be calculated from becca
+            this.position = 1 + sql.getValue(`SELECT COALESCE(MAX(position), 0) FROM attributes WHERE noteId = ?`, [this.noteId]);
+        }
+
+        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, name, value, isInheritable) {
+        return new BAttribute({
+            noteId: this.noteId,
+            type: type,
+            name: name,
+            value: value,
+            position: this.position,
+            isInheritable: isInheritable,
+            utcDateModified: this.utcDateModified
+        });
+    }
+}
+
+module.exports = BAttribute;
+
+
+
+ + + + +
+ + + +
+ + + + + + + diff --git a/docs/backend_api/becca_entities_bbranch.js.html b/docs/backend_api/becca_entities_bbranch.js.html new file mode 100644 index 000000000..b4fa7f567 --- /dev/null +++ b/docs/backend_api/becca_entities_bbranch.js.html @@ -0,0 +1,328 @@ + + + + + JSDoc: Source: becca/entities/bbranch.js + + + + + + + + + + +
+ +

Source: becca/entities/bbranch.js

+ + + + + + +
+
+
"use strict";
+
+const BNote = require('./bnote');
+const AbstractBeccaEntity = require("./abstract_becca_entity");
+const dateUtils = require("../../services/date_utils");
+const utils = require("../../services/utils");
+const TaskContext = require("../../services/task_context");
+const cls = require("../../services/cls");
+const log = require("../../services/log");
+
+/**
+ * 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.
+ *
+ * @extends AbstractBeccaEntity
+ */
+class BBranch extends AbstractBeccaEntity {
+    static get entityName() { return "branches"; }
+    static get primaryKeyName() { return "branchId"; }
+    // notePosition is not part of hash because it would produce a lot of updates in case of reordering
+    static get hashedProperties() { return ["branchId", "noteId", "parentNoteId", "prefix"]; }
+
+    constructor(row) {
+        super();
+
+        if (!row) {
+            return;
+        }
+
+        this.updateFromRow(row);
+        this.init();
+    }
+
+    updateFromRow(row) {
+        this.update([
+            row.branchId,
+            row.noteId,
+            row.parentNoteId,
+            row.prefix,
+            row.notePosition,
+            row.isExpanded,
+            row.utcDateModified
+        ]);
+    }
+
+    update([branchId, noteId, parentNoteId, prefix, notePosition, isExpanded, utcDateModified]) {
+        /** @type {string} */
+        this.branchId = branchId;
+        /** @type {string} */
+        this.noteId = noteId;
+        /** @type {string} */
+        this.parentNoteId = parentNoteId;
+        /** @type {string|null} */
+        this.prefix = prefix;
+        /** @type {int} */
+        this.notePosition = notePosition;
+        /** @type {boolean} */
+        this.isExpanded = !!isExpanded;
+        /** @type {string} */
+        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 (!childNote.parents.includes(parentNote)) {
+            childNote.parents.push(parentNote);
+        }
+
+        if (!parentNote.children.includes(childNote)) {
+            parentNote.children.push(childNote);
+        }
+    }
+
+    /** @returns {BNote} */
+    get childNote() {
+        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() {
+        return this.childNote;
+    }
+
+    /** @returns {BNote|undefined} - root branch will have undefined parent, all other branches have to have a parent note */
+    get parentNote() {
+        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 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.
+     *
+     * @returns {boolean}
+     */
+    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 {string} [deleteId] - optional delete identified
+     * @param {TaskContext} [taskContext]
+     *
+     * @returns {boolean} - true if note has been deleted, false otherwise
+     */
+    deleteBranch(deleteId, taskContext) {
+        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
+                const handlers = require("../../services/handlers");
+                handlers.runAttachedRelations(note, 'runOnNoteDeletion', note);
+            }
+        }
+
+        if (this.noteId === 'root'
+            || this.noteId === cls.getHoistedNoteId()) {
+
+            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()) {
+                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()) {
+                attribute.markAsDeleted(deleteId);
+            }
+
+            for (const relation of note.getTargetRelations()) {
+                relation.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;
+
+            for (const childBranch of this.parentNote.getChildBranches()) {
+                if (maxNotePos < childBranch.notePosition
+                    && childBranch.noteId !== '_hidden' // hidden has 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, notePosition) {
+        const existingBranch = this.becca.getBranchFromChildAndParent(this.noteId, parentNoteId);
+
+        if (existingBranch) {
+            existingBranch.notePosition = notePosition;
+            return existingBranch;
+        } else {
+            return new BBranch({
+                noteId: this.noteId,
+                parentNoteId: parentNoteId,
+                notePosition: notePosition,
+                prefix: this.prefix,
+                isExpanded: this.isExpanded
+            });
+        }
+    }
+}
+
+module.exports = BBranch;
+
+
+
+ + + + +
+ + + +
+ + + + + + + diff --git a/docs/backend_api/becca_entities_betapi_token.js.html b/docs/backend_api/becca_entities_betapi_token.js.html new file mode 100644 index 000000000..92ab19328 --- /dev/null +++ b/docs/backend_api/becca_entities_betapi_token.js.html @@ -0,0 +1,129 @@ + + + + + JSDoc: Source: becca/entities/betapi_token.js + + + + + + + + + + +
+ +

Source: becca/entities/betapi_token.js

+ + + + + + +
+
+
"use strict";
+
+const dateUtils = require('../../services/date_utils');
+const AbstractBeccaEntity = require("./abstract_becca_entity");
+
+/**
+ * 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.
+ *
+ * @extends AbstractBeccaEntity
+ */
+class BEtapiToken extends AbstractBeccaEntity {
+    static get entityName() { return "etapi_tokens"; }
+    static get primaryKeyName() { return "etapiTokenId"; }
+    static get hashedProperties() { return ["etapiTokenId", "name", "tokenHash", "utcDateCreated", "utcDateModified", "isDeleted"]; }
+
+    constructor(row) {
+        super();
+
+        if (!row) {
+            return;
+        }
+
+        this.updateFromRow(row);
+        this.init();
+    }
+
+    updateFromRow(row) {
+        /** @type {string} */
+        this.etapiTokenId = row.etapiTokenId;
+        /** @type {string} */
+        this.name = row.name;
+        /** @type {string} */
+        this.tokenHash = row.tokenHash;
+        /** @type {string} */
+        this.utcDateCreated = row.utcDateCreated || dateUtils.utcNowDateTime();
+        /** @type {string} */
+        this.utcDateModified = row.utcDateModified || this.utcDateCreated;
+        /** @type {boolean} */
+        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();
+
+        this.becca.etapiTokens[this.etapiTokenId] = this;
+    }
+}
+
+module.exports = BEtapiToken;
+
+
+
+ + + + +
+ + + +
+ + + + + + + diff --git a/docs/backend_api/becca_entities_bnote.js.html b/docs/backend_api/becca_entities_bnote.js.html new file mode 100644 index 000000000..97889071a --- /dev/null +++ b/docs/backend_api/becca_entities_bnote.js.html @@ -0,0 +1,1559 @@ + + + + + JSDoc: Source: becca/entities/bnote.js + + + + + + + + + + +
+ +

Source: becca/entities/bnote.js

+ + + + + + +
+
+
"use strict";
+
+const protectedSessionService = require('../../services/protected_session');
+const log = require('../../services/log');
+const sql = require('../../services/sql');
+const utils = require('../../services/utils');
+const dateUtils = require('../../services/date_utils');
+const entityChangesService = require('../../services/entity_changes');
+const AbstractBeccaEntity = require("./abstract_becca_entity");
+const BNoteRevision = require("./bnote_revision");
+const TaskContext = require("../../services/task_context");
+const dayjs = require("dayjs");
+const utc = require('dayjs/plugin/utc');
+const eventService = require("../../services/events");
+dayjs.extend(utc);
+
+const LABEL = 'label';
+const RELATION = 'relation';
+
+/**
+ * Trilium's main entity which can represent text note, image, code note, file attachment etc.
+ *
+ * @extends AbstractBeccaEntity
+ */
+class BNote extends AbstractBeccaEntity {
+    static get entityName() { return "notes"; }
+    static get primaryKeyName() { return "noteId"; }
+    static get hashedProperties() { return ["noteId", "title", "isProtected", "type", "mime"]; }
+
+    constructor(row) {
+        super();
+
+        if (!row) {
+            return;
+        }
+
+        this.updateFromRow(row);
+        this.init();
+    }
+
+    updateFromRow(row) {
+        this.update([
+            row.noteId,
+            row.title,
+            row.type,
+            row.mime,
+            row.isProtected,
+            row.dateCreated,
+            row.dateModified,
+            row.utcDateCreated,
+            row.utcDateModified
+        ]);
+    }
+
+    update([noteId, title, type, mime, isProtected, dateCreated, dateModified, utcDateCreated, utcDateModified]) {
+        // ------ Database persisted attributes ------
+
+        /** @type {string} */
+        this.noteId = noteId;
+        /** @type {string} */
+        this.title = title;
+        /** @type {boolean} */
+        this.isProtected = !!isProtected;
+        /** @type {string} */
+        this.type = type;
+        /** @type {string} */
+        this.mime = mime;
+        /** @type {string} */
+        this.dateCreated = dateCreated || dateUtils.localNowDateTime();
+        /** @type {string} */
+        this.dateModified = dateModified;
+        /** @type {string} */
+        this.utcDateCreated = utcDateCreated || dateUtils.utcNowDateTime();
+        /** @type {string} */
+        this.utcDateModified = utcDateModified;
+        /** @type {boolean} - set during the deletion operation, before it is completed (removed from becca completely) */
+        this.isBeingDeleted = false;
+
+        // ------ Derived attributes ------
+
+        /** @type {boolean} */
+        this.isDecrypted = !this.noteId || !this.isProtected;
+
+        this.decrypt();
+
+        /** @type {string|null} */
+        this.flatTextCache = null;
+
+        return this;
+    }
+
+    init() {
+        /** @type {BBranch[]}
+         * @private */
+        this.parentBranches = [];
+        /** @type {BNote[]}
+         * @private */
+        this.parents = [];
+        /** @type {BNote[]}
+         * @private*/
+        this.children = [];
+        /** @type {BAttribute[]}
+         * @private */
+        this.ownedAttributes = [];
+
+        /** @type {BAttribute[]|null}
+         * @private */
+        this.__attributeCache = null;
+        /** @type {BAttribute[]|null}
+         * @private*/
+        this.inheritableAttributeCache = null;
+
+        /** @type {BAttribute[]}
+         * @private*/
+        this.targetRelations = [];
+
+        this.becca.addNote(this.noteId, this);
+
+        /** @type {BNote[]|null}
+         * @private */
+        this.ancestorCache = null;
+
+        // following attributes are filled during searching from database
+
+        /**
+         * size of the content in bytes
+         * @type {int|null}
+         * @private
+         */
+        this.contentSize = null;
+        /**
+         * size of the content and note revision contents in bytes
+         * @type {int|null}
+         * @private
+         */
+        this.noteSize = null;
+        /**
+         * number of note revisions for this note
+         * @type {int|null}
+         * @private
+         */
+        this.revisionCount = null;
+    }
+
+    isContentAvailable() {
+        return !this.noteId // new note which was not encrypted yet
+            || !this.isProtected
+            || protectedSessionService.isProtectedSessionAvailable()
+    }
+
+    getTitleOrProtected() {
+        return this.isContentAvailable() ? this.title : '[protected]';
+    }
+
+    /** @returns {BBranch[]} */
+    getParentBranches() {
+        return this.parentBranches;
+    }
+
+    /**
+     * Returns <i>strong</i> (as opposed to <i>weak</i>) parent branches. See isWeak for details.
+     *
+     * @returns {BBranch[]}
+     */
+    getStrongParentBranches() {
+        return this.getParentBranches().filter(branch => !branch.isWeak);
+    }
+
+    /**
+     * @returns {BBranch[]}
+     * @deprecated use getParentBranches() instead
+     */
+    getBranches() {
+        return this.parentBranches;
+    }
+
+    /** @returns {BNote[]} */
+    getParentNotes() {
+        return this.parents;
+    }
+
+    /** @returns {BNote[]} */
+    getChildNotes() {
+        return this.children;
+    }
+
+    /** @returns {boolean} */
+    hasChildren() {
+        return this.children && this.children.length > 0;
+    }
+
+    /** @returns {BBranch[]} */
+    getChildBranches() {
+        return this.children.map(childNote => this.becca.getBranchFromChildAndParent(childNote.noteId, this.noteId));
+    }
+
+    /*
+     * Note content has quite special handling - it's not a separate entity, but a lazily loaded
+     * part of Note entity with its own sync. Reasons behind this hybrid design has been:
+     *
+     * - content can be quite large, and it's not necessary to load it / fill memory for any note access even if we don't need a content, especially for bulk operations like search
+     * - changes in the note metadata or title should not trigger note content sync (so we keep separate utcDateModified and entity changes records)
+     * - but to the user note content and title changes are one and the same - single dateModified (so all changes must go through Note and content is not a separate entity)
+     */
+
+    /** @returns {*} */
+    getContent(silentNotFoundError = false) {
+        const row = sql.getRow(`SELECT content FROM note_contents WHERE noteId = ?`, [this.noteId]);
+
+        if (!row) {
+            if (silentNotFoundError) {
+                return undefined;
+            }
+            else {
+                throw new Error(`Cannot find note content for noteId=${this.noteId}`);
+            }
+        }
+
+        let content = row.content;
+
+        if (this.isProtected) {
+            if (protectedSessionService.isProtectedSessionAvailable()) {
+                content = content === null ? null : protectedSessionService.decrypt(content);
+            }
+            else {
+                content = "";
+            }
+        }
+
+        if (this.isStringNote()) {
+            return content === null
+                ? ""
+                : content.toString("UTF-8");
+        }
+        else {
+            return content;
+        }
+    }
+
+    /** @returns {{contentLength, dateModified, utcDateModified}} */
+    getContentMetadata() {
+        return sql.getRow(`
+            SELECT 
+                LENGTH(content) AS contentLength, 
+                dateModified,
+                utcDateModified 
+            FROM note_contents 
+            WHERE noteId = ?`, [this.noteId]);
+    }
+
+    get dateCreatedObj() {
+        return this.dateCreated === null ? null : dayjs(this.dateCreated);
+    }
+
+    get utcDateCreatedObj() {
+        return this.utcDateCreated === null ? null : dayjs.utc(this.utcDateCreated);
+    }
+
+    get dateModifiedObj() {
+        return this.dateModified === null ? null : dayjs(this.dateModified);
+    }
+
+    get utcDateModifiedObj() {
+        return this.utcDateModified === null ? null : dayjs.utc(this.utcDateModified);
+    }
+
+    /** @returns {*} */
+    getJsonContent() {
+        const content = this.getContent();
+
+        if (!content || !content.trim()) {
+            return null;
+        }
+
+        return JSON.parse(content);
+    }
+
+    setContent(content, ignoreMissingProtectedSession = false) {
+        if (content === null || content === undefined) {
+            throw new Error(`Cannot set null content to note '${this.noteId}'`);
+        }
+
+        if (this.isStringNote()) {
+            content = content.toString();
+        }
+        else {
+            content = Buffer.isBuffer(content) ? content : Buffer.from(content);
+        }
+
+        const pojo = {
+            noteId: this.noteId,
+            content: content,
+            dateModified: dateUtils.localNowDateTime(),
+            utcDateModified: dateUtils.utcNowDateTime()
+        };
+
+        if (this.isProtected) {
+            if (protectedSessionService.isProtectedSessionAvailable()) {
+                pojo.content = protectedSessionService.encrypt(pojo.content);
+            }
+            else if (!ignoreMissingProtectedSession) {
+                throw new Error(`Cannot update content of noteId '${this.noteId}' since we're out of protected session.`);
+            }
+        }
+
+        sql.upsert("note_contents", "noteId", pojo);
+
+        const hash = utils.hash(`${this.noteId}|${pojo.content.toString()}`);
+
+        entityChangesService.addEntityChange({
+            entityName: 'note_contents',
+            entityId: this.noteId,
+            hash: hash,
+            isErased: false,
+            utcDateChanged: pojo.utcDateModified,
+            isSynced: true
+        });
+
+        eventService.emit(eventService.ENTITY_CHANGED, {
+            entityName: 'note_contents',
+            entity: this
+        });
+    }
+
+    setJsonContent(content) {
+        this.setContent(JSON.stringify(content, null, '\t'));
+    }
+
+    /** @returns {boolean} true if this note is the root of the note tree. Root note has "root" noteId */
+    isRoot() {
+        return this.noteId === 'root';
+    }
+
+    /** @returns {boolean} true if this note is of application/json content type */
+    isJson() {
+        return this.mime === "application/json";
+    }
+
+    /** @returns {boolean} true if this note is JavaScript (code or attachment) */
+    isJavaScript() {
+        return (this.type === "code" || this.type === "file" || this.type === 'launcher')
+            && (this.mime.startsWith("application/javascript")
+                || this.mime === "application/x-javascript"
+                || this.mime === "text/javascript");
+    }
+
+    /** @returns {boolean} true if this note is HTML */
+    isHtml() {
+        return ["code", "file", "render"].includes(this.type)
+            && this.mime === "text/html";
+    }
+
+    /** @returns {boolean} true if this note is an image */
+    isImage() {
+        return this.type === 'image'
+            || (this.type === 'file' && this.mime?.startsWith('image/'));
+    }
+
+    /** @returns {boolean} true if the note has string content (not binary) */
+    isStringNote() {
+        return utils.isStringNote(this.type, this.mime);
+    }
+
+    /** @returns {string|null} JS script environment - either "frontend" or "backend" */
+    getScriptEnv() {
+        if (this.isHtml() || (this.isJavaScript() && this.mime.endsWith('env=frontend'))) {
+            return "frontend";
+        }
+
+        if (this.type === 'render') {
+            return "frontend";
+        }
+
+        if (this.isJavaScript() && this.mime.endsWith('env=backend')) {
+            return "backend";
+        }
+
+        return null;
+    }
+
+    /**
+     * @param {string} [type] - (optional) attribute type to filter
+     * @param {string} [name] - (optional) attribute name to filter
+     * @returns {BAttribute[]} all note's attributes, including inherited ones
+     */
+    getAttributes(type, name) {
+        this.__validateTypeName(type, name);
+        this.__ensureAttributeCacheIsAvailable();
+
+        if (type && name) {
+            return this.__attributeCache.filter(attr => attr.name === name && attr.type === type);
+        }
+        else if (type) {
+            return this.__attributeCache.filter(attr => attr.type === type);
+        }
+        else if (name) {
+            return this.__attributeCache.filter(attr => attr.name === name);
+        }
+        else {
+            // a bit unsafe to return the original array, but defensive copy would be costly
+            return this.__attributeCache;
+        }
+    }
+
+    /** @private */
+    __ensureAttributeCacheIsAvailable() {
+        if (!this.__attributeCache) {
+            this.__getAttributes([]);
+        }
+    }
+
+    /** @private */
+    __getAttributes(path) {
+        if (path.includes(this.noteId)) {
+            return [];
+        }
+
+        if (!this.__attributeCache) {
+            const parentAttributes = this.ownedAttributes.slice();
+            const newPath = [...path, this.noteId];
+
+            // inheritable attrs on root are typically not intended to be applied to hidden subtree #3537
+            if (this.noteId !== 'root' && this.noteId !== '_hidden') {
+                for (const parentNote of this.parents) {
+                    parentAttributes.push(...parentNote.__getInheritableAttributes(newPath));
+                }
+            }
+
+            const templateAttributes = [];
+
+            for (const ownedAttr of parentAttributes) { // parentAttributes so we process also inherited templates
+                if (ownedAttr.type === 'relation' && ['template', 'inherit'].includes(ownedAttr.name)) {
+                    const templateNote = this.becca.notes[ownedAttr.value];
+
+                    if (templateNote) {
+                        templateAttributes.push(
+                            ...templateNote.__getAttributes(newPath)
+                                // template attr is used as a marker for templates, but it's not meant to be inherited
+                                .filter(attr => !(attr.type === 'label' && (attr.name === 'template' || attr.name === 'workspacetemplate')))
+                        );
+                    }
+                }
+            }
+
+            this.__attributeCache = [];
+
+            const addedAttributeIds = new Set();
+
+            for (const attr of parentAttributes.concat(templateAttributes)) {
+                if (!addedAttributeIds.has(attr.attributeId)) {
+                    addedAttributeIds.add(attr.attributeId);
+
+                    this.__attributeCache.push(attr);
+                }
+            }
+
+            this.inheritableAttributeCache = [];
+
+            for (const attr of this.__attributeCache) {
+                if (attr.isInheritable) {
+                    this.inheritableAttributeCache.push(attr);
+                }
+            }
+        }
+
+        return this.__attributeCache;
+    }
+
+    /**
+     * @private
+     * @returns {BAttribute[]}
+     */
+    __getInheritableAttributes(path) {
+        if (path.includes(this.noteId)) {
+            return [];
+        }
+
+        if (!this.inheritableAttributeCache) {
+            this.__getAttributes(path); // will refresh also this.inheritableAttributeCache
+        }
+
+        return this.inheritableAttributeCache;
+    }
+
+    __validateTypeName(type, name) {
+        if (type && type !== 'label' && type !== 'relation') {
+            throw new Error(`Unrecognized attribute type '${type}'. Only 'label' and 'relation' are possible values.`);
+        }
+
+        if (name) {
+            const firstLetter = name.charAt(0);
+            if (firstLetter === '#' || firstLetter === '~') {
+                throw new Error(`Detect '#' or '~' in the attribute's name. In the API, attribute names should be set without these characters.`);
+            }
+        }
+    }
+
+    /**
+     * @param type
+     * @param name
+     * @param [value]
+     * @returns {boolean}
+     */
+    hasAttribute(type, name, value = null) {
+        return !!this.getAttributes().find(attr =>
+            attr.name === name
+            && (value === undefined || value === null || attr.value === value)
+            && attr.type === type
+        );
+    }
+
+    getAttributeCaseInsensitive(type, name, value) {
+        name = name.toLowerCase();
+        value = value ? value.toLowerCase() : null;
+
+        return this.getAttributes().find(
+            attr => attr.name.toLowerCase() === name
+            && (!value || attr.value.toLowerCase() === value)
+            && attr.type === type);
+    }
+
+    getRelationTarget(name) {
+        const relation = this.getAttributes().find(attr => attr.name === name && attr.type === 'relation');
+
+        return relation ? relation.targetNote : null;
+    }
+
+    /**
+     * @param {string} name - label name
+     * @param {string} [value] - label value
+     * @returns {boolean} true if label exists (including inherited)
+     */
+    hasLabel(name, value) { return this.hasAttribute(LABEL, name, value); }
+
+    /**
+     * @param {string} name - label name
+     * @param {string} [value] - label value
+     * @returns {boolean} true if label exists (excluding inherited)
+     */
+    hasOwnedLabel(name, value) { return this.hasOwnedAttribute(LABEL, name, value); }
+
+    /**
+     * @param {string} name - relation name
+     * @param {string} [value] - relation value
+     * @returns {boolean} true if relation exists (including inherited)
+     */
+    hasRelation(name, value) { return this.hasAttribute(RELATION, name, value); }
+
+    /**
+     * @param {string} name - relation name
+     * @param {string} [value] - relation value
+     * @returns {boolean} true if relation exists (excluding inherited)
+     */
+    hasOwnedRelation(name, value) { return this.hasOwnedAttribute(RELATION, name, value); }
+
+    /**
+     * @param {string} name - label name
+     * @returns {BAttribute|null} label if it exists, null otherwise
+     */
+    getLabel(name) { return this.getAttribute(LABEL, name); }
+
+    /**
+     * @param {string} name - label name
+     * @returns {BAttribute|null} label if it exists, null otherwise
+     */
+    getOwnedLabel(name) { return this.getOwnedAttribute(LABEL, name); }
+
+    /**
+     * @param {string} name - relation name
+     * @returns {BAttribute|null} relation if it exists, null otherwise
+     */
+    getRelation(name) { return this.getAttribute(RELATION, name); }
+
+    /**
+     * @param {string} name - relation name
+     * @returns {BAttribute|null} relation if it exists, null otherwise
+     */
+    getOwnedRelation(name) { return this.getOwnedAttribute(RELATION, name); }
+
+    /**
+     * @param {string} name - label name
+     * @returns {string|null} label value if label exists, null otherwise
+     */
+    getLabelValue(name) { return this.getAttributeValue(LABEL, name); }
+
+    /**
+     * @param {string} name - label name
+     * @returns {string|null} label value if label exists, null otherwise
+     */
+    getOwnedLabelValue(name) { return this.getOwnedAttributeValue(LABEL, name); }
+
+    /**
+     * @param {string} name - relation name
+     * @returns {string|null} relation value if relation exists, null otherwise
+     */
+    getRelationValue(name) { return this.getAttributeValue(RELATION, name); }
+
+    /**
+     * @param {string} name - relation name
+     * @returns {string|null} relation value if relation exists, null otherwise
+     */
+    getOwnedRelationValue(name) { return this.getOwnedAttributeValue(RELATION, name); }
+
+    /**
+     * @param {string} type - attribute type (label, relation, etc.)
+     * @param {string} name - attribute name
+     * @param {string} [value] - attribute value
+     * @returns {boolean} true if note has an attribute with given type and name (excluding inherited)
+     */
+    hasOwnedAttribute(type, name, value) {
+        return !!this.getOwnedAttribute(type, name, value);
+    }
+
+    /**
+     * @param {string} type - attribute type (label, relation, etc.)
+     * @param {string} name - attribute name
+     * @returns {BAttribute} attribute of given type and name. If there's more such attributes, first is  returned. Returns null if there's no such attribute belonging to this note.
+     */
+    getAttribute(type, name) {
+        const attributes = this.getAttributes();
+
+        return attributes.find(attr => attr.name === name && attr.type === type);
+    }
+
+    /**
+     * @param {string} type - attribute type (label, relation, etc.)
+     * @param {string} name - attribute name
+     * @returns {string|null} attribute value of given type and name or null if no such attribute exists.
+     */
+    getAttributeValue(type, name) {
+        const attr = this.getAttribute(type, name);
+
+        return attr ? attr.value : null;
+    }
+
+    /**
+     * @param {string} type - attribute type (label, relation, etc.)
+     * @param {string} name - attribute name
+     * @returns {string|null} attribute value of given type and name or null if no such attribute exists.
+     */
+    getOwnedAttributeValue(type, name) {
+        const attr = this.getOwnedAttribute(type, name);
+
+        return attr ? attr.value : null;
+    }
+
+    /**
+     * @param {string} [name] - label name to filter
+     * @returns {BAttribute[]} all note's labels (attributes with type label), including inherited ones
+     */
+    getLabels(name) {
+        return this.getAttributes(LABEL, name);
+    }
+
+    /**
+     * @param {string} [name] - label name to filter
+     * @returns {string[]} all note's label values, including inherited ones
+     */
+    getLabelValues(name) {
+        return this.getLabels(name).map(l => l.value);
+    }
+
+    /**
+     * @param {string} [name] - label name to filter
+     * @returns {BAttribute[]} all note's labels (attributes with type label), excluding inherited ones
+     */
+    getOwnedLabels(name) {
+        return this.getOwnedAttributes(LABEL, name);
+    }
+
+    /**
+     * @param {string} [name] - label name to filter
+     * @returns {string[]} all note's label values, excluding inherited ones
+     */
+    getOwnedLabelValues(name) {
+        return this.getOwnedAttributes(LABEL, name).map(l => l.value);
+    }
+
+    /**
+     * @param {string} [name] - relation name to filter
+     * @returns {BAttribute[]} all note's relations (attributes with type relation), including inherited ones
+     */
+    getRelations(name) {
+        return this.getAttributes(RELATION, name);
+    }
+
+    /**
+     * @param {string} [name] - relation name to filter
+     * @returns {BAttribute[]} all note's relations (attributes with type relation), excluding inherited ones
+     */
+    getOwnedRelations(name) {
+        return this.getOwnedAttributes(RELATION, name);
+    }
+
+    /**
+     * @param {string|null} [type] - (optional) attribute type to filter
+     * @param {string|null} [name] - (optional) attribute name to filter
+     * @param {string|null} [value] - (optional) attribute value to filter
+     * @returns {BAttribute[]} note's "owned" attributes - excluding inherited ones
+     */
+    getOwnedAttributes(type = null, name = null, value = null) {
+        this.__validateTypeName(type, name);
+
+        if (type && name && value !== undefined && value !== null) {
+            return this.ownedAttributes.filter(attr => attr.name === name && attr.value === value && attr.type === type);
+        }
+        else if (type && name) {
+            return this.ownedAttributes.filter(attr => attr.name === name && attr.type === type);
+        }
+        else if (type) {
+            return this.ownedAttributes.filter(attr => attr.type === type);
+        }
+        else if (name) {
+            return this.ownedAttributes.filter(attr => attr.name === name);
+        }
+        else {
+            return this.ownedAttributes.slice();
+        }
+    }
+
+    /**
+     * @returns {BAttribute} attribute belonging to this specific note (excludes inherited attributes)
+     *
+     * This method can be significantly faster than the getAttribute()
+     */
+    getOwnedAttribute(type, name, value = null) {
+        const attrs = this.getOwnedAttributes(type, name, value);
+
+        return attrs.length > 0 ? attrs[0] : null;
+    }
+
+    get isArchived() {
+        return this.hasAttribute('label', 'archived');
+    }
+
+    hasInheritableArchivedLabel() {
+        for (const attr of this.getAttributes()) {
+            if (attr.name === 'archived' && attr.type === LABEL && attr.isInheritable) {
+                return true;
+            }
+        }
+
+        return false;
+    }
+
+    // will sort the parents so that the non-archived are first and archived at the end
+    // this is done so that the non-archived paths are always explored as first when looking for note path
+    sortParents() {
+        this.parentBranches.sort((a, b) => {
+            if (a.parentNote?.isArchived) {
+                return 1;
+            } else if (a.parentNote?.isHiddenCompletely()) {
+                return 1;
+            } else {
+                return -1;
+            }
+        });
+
+        this.parents = this.parentBranches
+            .map(branch => branch.parentNote)
+            .filter(note => !!note);
+    }
+
+    sortChildren() {
+        if (this.children.length === 0) {
+            return;
+        }
+
+        const becca = this.becca;
+
+        this.children.sort((a, b) => {
+            const aBranch = becca.getBranchFromChildAndParent(a.noteId, this.noteId);
+            const bBranch = becca.getBranchFromChildAndParent(b.noteId, this.noteId);
+
+            return aBranch?.notePosition < bBranch?.notePosition ? -1 : 1;
+        });
+    }
+
+    /**
+     * This is used for:
+     * - fast searching
+     * - note similarity evaluation
+     *
+     * @returns {string} - returns flattened textual representation of note, prefixes and attributes
+     */
+    getFlatText() {
+        if (!this.flatTextCache) {
+            this.flatTextCache = `${this.noteId} ${this.type} ${this.mime} `;
+
+            for (const branch of this.parentBranches) {
+                if (branch.prefix) {
+                    this.flatTextCache += `${branch.prefix} `;
+                }
+            }
+
+            this.flatTextCache += `${this.title} `;
+
+            for (const attr of this.getAttributes()) {
+                // it's best to use space as separator since spaces are filtered from the search string by the tokenization into words
+                this.flatTextCache += `${attr.type === 'label' ? '#' : '~'}${attr.name}`;
+
+                if (attr.value) {
+                    this.flatTextCache += `=${attr.value}`;
+                }
+
+                this.flatTextCache += ' ';
+            }
+
+            this.flatTextCache = utils.normalize(this.flatTextCache);
+        }
+
+        return this.flatTextCache;
+    }
+
+    invalidateThisCache() {
+        this.flatTextCache = null;
+
+        this.__attributeCache = null;
+        this.inheritableAttributeCache = null;
+        this.ancestorCache = null;
+    }
+
+    invalidateSubTree(path = []) {
+        if (path.includes(this.noteId)) {
+            return;
+        }
+
+        this.invalidateThisCache();
+
+        if (this.children.length || this.targetRelations.length) {
+            path = [...path, this.noteId];
+        }
+
+        for (const childNote of this.children) {
+            childNote.invalidateSubTree(path);
+        }
+
+        for (const targetRelation of this.targetRelations) {
+            if (targetRelation.name === 'template' || targetRelation.name === 'inherit') {
+                const note = targetRelation.note;
+
+                if (note) {
+                    note.invalidateSubTree(path);
+                }
+            }
+        }
+    }
+
+    invalidateSubtreeFlatText() {
+        this.flatTextCache = null;
+
+        for (const childNote of this.children) {
+            childNote.invalidateSubtreeFlatText();
+        }
+
+        for (const targetRelation of this.targetRelations) {
+            if (targetRelation.name === 'template' || targetRelation.name === 'inherit') {
+                const note = targetRelation.note;
+
+                if (note) {
+                    note.invalidateSubtreeFlatText();
+                }
+            }
+        }
+    }
+
+    getRelationDefinitions() {
+        return this.getLabels()
+            .filter(l => l.name.startsWith("relation:"));
+    }
+
+    getLabelDefinitions() {
+        return this.getLabels()
+            .filter(l => l.name.startsWith("relation:"));
+    }
+
+    isInherited() {
+        return !!this.targetRelations.find(rel => rel.name === 'template' || rel.name === 'inherit');
+    }
+
+    /** @returns {BNote[]} */
+    getSubtreeNotesIncludingTemplated() {
+        const set = new Set();
+
+        function inner(note) {
+            // _hidden is not counted as subtree for the purpose of inheritance
+            if (set.has(note) || note.noteId === '_hidden') {
+                return;
+            }
+
+            set.add(note);
+
+            for (const childNote of note.children) {
+                inner(childNote);
+            }
+
+            for (const targetRelation of note.targetRelations) {
+                if (targetRelation.name === 'template' || targetRelation.name === 'inherit') {
+                    const targetNote = targetRelation.note;
+
+                    if (targetNote) {
+                        inner(targetNote);
+                    }
+                }
+            }
+        }
+
+        inner(this);
+
+        return Array.from(set);
+    }
+
+    /** @returns {BNote[]} */
+    getSearchResultNotes() {
+        if (this.type !== 'search') {
+            return [];
+        }
+
+        try {
+            const searchService = require("../../services/search/services/search");
+            const {searchResultNoteIds} = searchService.searchFromNote(this);
+
+            const becca = this.becca;
+            return searchResultNoteIds
+                .map(resultNoteId => becca.notes[resultNoteId])
+                .filter(note => !!note);
+        }
+        catch (e) {
+            log.error(`Could not resolve search note ${this.noteId}: ${e.message}`);
+            return [];
+        }
+    }
+
+    /**
+     * @returns {{notes: BNote[], relationships: Array.<{parentNoteId: string, childNoteId: string}>}}
+     */
+    getSubtree({includeArchived = true, includeHidden = false, resolveSearch = false} = {}) {
+        const noteSet = new Set();
+        const relationships = []; // list of tuples parentNoteId -> childNoteId
+
+        function resolveSearchNote(searchNote) {
+            try {
+                for (const resultNote of searchNote.getSearchResultNotes()) {
+                    addSubtreeNotesInner(resultNote, searchNote);
+                }
+            }
+            catch (e) {
+                log.error(`Could not resolve search note ${searchNote?.noteId}: ${e.message}`);
+            }
+        }
+
+        function addSubtreeNotesInner(note, parentNote = null) {
+            if (note.noteId === '_hidden' && !includeHidden) {
+                return;
+            }
+
+            if (parentNote) {
+                // this needs to happen first before noteSet check to include all clone relationships
+                relationships.push({
+                    parentNoteId: parentNote.noteId,
+                    childNoteId: note.noteId
+                });
+            }
+
+            if (noteSet.has(note)) {
+                return;
+            }
+
+            if (!includeArchived && note.isArchived) {
+                return;
+            }
+
+            noteSet.add(note);
+
+            if (note.type === 'search') {
+                if (resolveSearch) {
+                    resolveSearchNote(note);
+                }
+            }
+            else {
+                for (const childNote of note.children) {
+                    addSubtreeNotesInner(childNote, note);
+                }
+            }
+        }
+
+        addSubtreeNotesInner(this);
+
+        return {
+            notes: Array.from(noteSet),
+            relationships
+        };
+    }
+
+    /** @returns {String[]} - includes the subtree node as well */
+    getSubtreeNoteIds({includeArchived = true, includeHidden = false, resolveSearch = false} = {}) {
+        return this.getSubtree({includeArchived, includeHidden, resolveSearch})
+            .notes
+            .map(note => note.noteId);
+    }
+
+    /** @deprecated use getSubtreeNoteIds() instead */
+    getDescendantNoteIds() {
+        return this.getSubtreeNoteIds();
+    }
+
+    get parentCount() {
+        return this.parents.length;
+    }
+
+    get childrenCount() {
+        return this.children.length;
+    }
+
+    get labelCount() {
+        return this.getAttributes().filter(attr => attr.type === 'label').length;
+    }
+
+    get ownedLabelCount() {
+        return this.ownedAttributes.filter(attr => attr.type === 'label').length;
+    }
+
+    get relationCount() {
+        return this.getAttributes().filter(attr => attr.type === 'relation' && !attr.isAutoLink()).length;
+    }
+
+    get relationCountIncludingLinks() {
+        return this.getAttributes().filter(attr => attr.type === 'relation').length;
+    }
+
+    get ownedRelationCount() {
+        return this.ownedAttributes.filter(attr => attr.type === 'relation' && !attr.isAutoLink()).length;
+    }
+
+    get ownedRelationCountIncludingLinks() {
+        return this.ownedAttributes.filter(attr => attr.type === 'relation').length;
+    }
+
+    get targetRelationCount() {
+        return this.targetRelations.filter(attr => !attr.isAutoLink()).length;
+    }
+
+    get targetRelationCountIncludingLinks() {
+        return this.targetRelations.length;
+    }
+
+    get attributeCount() {
+        return this.getAttributes().length;
+    }
+
+    get ownedAttributeCount() {
+        return this.getOwnedAttributes().length;
+    }
+
+    /** @returns {BNote[]} */
+    getAncestors() {
+        if (!this.ancestorCache) {
+            const noteIds = new Set();
+            this.ancestorCache = [];
+
+            for (const parent of this.parents) {
+                if (noteIds.has(parent.noteId)) {
+                    continue;
+                }
+
+                this.ancestorCache.push(parent);
+                noteIds.add(parent.noteId);
+
+                for (const ancestorNote of parent.getAncestors()) {
+                    if (!noteIds.has(ancestorNote.noteId)) {
+                        this.ancestorCache.push(ancestorNote);
+                        noteIds.add(ancestorNote.noteId);
+                    }
+                }
+            }
+        }
+
+        return this.ancestorCache;
+    }
+
+    /** @returns {boolean} */
+    hasAncestor(ancestorNoteId) {
+        for (const ancestorNote of this.getAncestors()) {
+            if (ancestorNote.noteId === ancestorNoteId) {
+                return true;
+            }
+        }
+
+        return false;
+    }
+
+    isInHiddenSubtree() {
+        return this.noteId === '_hidden' || this.hasAncestor('_hidden');
+    }
+
+    getTargetRelations() {
+        return this.targetRelations;
+    }
+
+    /** @returns {BNote[]} - returns only notes which are templated, does not include their subtrees
+     *                     in effect returns notes which are influenced by note's non-inheritable attributes */
+    getInheritingNotes() {
+        const arr = [this];
+
+        for (const targetRelation of this.targetRelations) {
+            if (targetRelation.name === 'template' || targetRelation.name === 'inherit') {
+                const note = targetRelation.note;
+
+                if (note) {
+                    arr.push(note);
+                }
+            }
+        }
+
+        return arr;
+    }
+
+    getDistanceToAncestor(ancestorNoteId) {
+        if (this.noteId === ancestorNoteId) {
+            return 0;
+        }
+
+        let minDistance = 999999;
+
+        for (const parent of this.parents) {
+            minDistance = Math.min(minDistance, parent.getDistanceToAncestor(ancestorNoteId) + 1);
+        }
+
+        return minDistance;
+    }
+
+    /** @returns {BNoteRevision[]} */
+    getNoteRevisions() {
+        return sql.getRows("SELECT * FROM note_revisions WHERE noteId = ?", [this.noteId])
+            .map(row => new BNoteRevision(row));
+    }
+
+    /**
+     * @returns {string[][]} - array of notePaths (each represented by array of noteIds constituting the particular note path)
+     */
+    getAllNotePaths() {
+        if (this.noteId === 'root') {
+            return [['root']];
+        }
+
+        const notePaths = [];
+
+        for (const parentNote of this.getParentNotes()) {
+            for (const parentPath of parentNote.getAllNotePaths()) {
+                parentPath.push(this.noteId);
+                notePaths.push(parentPath);
+            }
+        }
+
+        return notePaths;
+    }
+
+    /**
+     * @return boolean - true if there's no non-hidden path, note is not cloned to the visible tree
+     */
+    isHiddenCompletely() {
+        if (this.noteId === 'root') {
+            return false;
+        }
+
+        for (const parentNote of this.parents) {
+            if (parentNote.noteId === 'root') {
+                return false;
+            } else if (parentNote.noteId === '_hidden') {
+                continue;
+            }
+
+            if (!parentNote.isHiddenCompletely()) {
+                return false;
+            }
+        }
+
+        return true;
+    }
+
+    /**
+     * @param ancestorNoteId
+     * @returns {boolean} - true if ancestorNoteId occurs in at least one of the note's paths
+     */
+    isDescendantOfNote(ancestorNoteId) {
+        const notePaths = this.getAllNotePaths();
+
+        return notePaths.some(path => path.includes(ancestorNoteId));
+    }
+
+    /**
+     * Update's given attribute's value or creates it if it doesn't exist
+     *
+     * @param {string} type - attribute type (label, relation, etc.)
+     * @param {string} name - attribute name
+     * @param {string} [value] - attribute value (optional)
+     */
+    setAttribute(type, name, value) {
+        const attributes = this.getOwnedAttributes();
+        const attr = attributes.find(attr => attr.type === type && attr.name === name);
+
+        value = value?.toString() || "";
+
+        if (attr) {
+            if (attr.value !== value) {
+                attr.value = value;
+                attr.save();
+            }
+        }
+        else {
+            const BAttribute = require("./battribute");
+
+            new BAttribute({
+                noteId: this.noteId,
+                type: type,
+                name: name,
+                value: value
+            }).save();
+        }
+    }
+
+    /**
+     * Removes given attribute name-value pair if it exists.
+     *
+     * @param {string} type - attribute type (label, relation, etc.)
+     * @param {string} name - attribute name
+     * @param {string} [value] - attribute value (optional)
+     */
+    removeAttribute(type, name, value) {
+        const attributes = this.getOwnedAttributes();
+
+        for (const attribute of attributes) {
+            if (attribute.type === type && attribute.name === name && (value === undefined || value === attribute.value)) {
+                attribute.markAsDeleted();
+            }
+        }
+    }
+
+    /**
+     * Adds a new attribute to this note. The attribute is saved and returned.
+     * See addLabel, addRelation for more specific methods.
+     *
+     * @param {string} type - attribute type (label / relation)
+     * @param {string} name - name of the attribute, not including the leading ~/#
+     * @param {string} [value] - value of the attribute - text for labels, target note ID for relations; optional.
+     * @param {boolean} [isInheritable=false]
+     * @param {int} [position]
+     * @returns {BAttribute}
+     */
+    addAttribute(type, name, value = "", isInheritable = false, position = 1000) {
+        const BAttribute = require("./battribute");
+
+        return new BAttribute({
+            noteId: this.noteId,
+            type: type,
+            name: name,
+            value: value,
+            isInheritable: isInheritable,
+            position: position
+        }).save();
+    }
+
+    /**
+     * Adds a new label to this note. The label attribute is saved and returned.
+     *
+     * @param {string} name - name of the label, not including the leading #
+     * @param {string} [value] - text value of the label; optional
+     * @param {boolean} [isInheritable=false]
+     * @returns {BAttribute}
+     */
+    addLabel(name, value = "", isInheritable = false) {
+        return this.addAttribute(LABEL, name, value, isInheritable);
+    }
+
+    /**
+     * Adds a new relation to this note. The relation attribute is saved and
+     * returned.
+     *
+     * @param {string} name - name of the relation, not including the leading ~
+     * @param {string} targetNoteId
+     * @param {boolean} [isInheritable=false]
+     * @returns {BAttribute}
+     */
+    addRelation(name, targetNoteId, isInheritable = false) {
+        return this.addAttribute(RELATION, name, targetNoteId, isInheritable);
+    }
+
+    /**
+     * Based on enabled, attribute is either set or removed.
+     *
+     * @param {string} type - attribute type ('relation', 'label' etc.)
+     * @param {boolean} enabled - toggle On or Off
+     * @param {string} name - attribute name
+     * @param {string} [value] - attribute value (optional)
+     */
+    toggleAttribute(type, enabled, name, value) {
+        if (enabled) {
+            this.setAttribute(type, name, value);
+        }
+        else {
+            this.removeAttribute(type, name, value);
+        }
+    }
+
+    /**
+     * Based on enabled, label is either set or removed.
+     *
+     * @param {boolean} enabled - toggle On or Off
+     * @param {string} name - label name
+     * @param {string} [value] - label value (optional)
+     */
+    toggleLabel(enabled, name, value) { return this.toggleAttribute(LABEL, enabled, name, value); }
+
+    /**
+     * Based on enabled, relation is either set or removed.
+     *
+     * @param {boolean} enabled - toggle On or Off
+     * @param {string} name - relation name
+     * @param {string} [value] - relation value (noteId)
+     */
+    toggleRelation(enabled, name, value) { return this.toggleAttribute(RELATION, enabled, name, value); }
+
+    /**
+     * Update's given label's value or creates it if it doesn't exist
+     *
+     * @param {string} name - label name
+     * @param {string} [value] - label value
+     */
+    setLabel(name, value) { return this.setAttribute(LABEL, name, value); }
+
+    /**
+     * Update's given relation's value or creates it if it doesn't exist
+     *
+     * @param {string} name - relation name
+     * @param {string} value - relation value (noteId)
+     */
+    setRelation(name, value) { return this.setAttribute(RELATION, name, value); }
+
+    /**
+     * Remove label name-value pair, if it exists.
+     *
+     * @param {string} name - label name
+     * @param {string} [value] - label value
+     */
+    removeLabel(name, value) { return this.removeAttribute(LABEL, name, value); }
+
+    /**
+     * Remove relation name-value pair, if it exists.
+     *
+     * @param {string} name - relation name
+     * @param {string} [value] - relation value (noteId)
+     */
+    removeRelation(name, value) { return this.removeAttribute(RELATION, name, value); }
+
+    searchNotesInSubtree(searchString) {
+        const searchService = require("../../services/search/services/search");
+
+        return searchService.searchNotes(searchString);
+    }
+
+    searchNoteInSubtree(searchString) {
+        return this.searchNotesInSubtree(searchString)[0];
+    }
+
+    /**
+     * @param parentNoteId
+     * @returns {{success: boolean, message: string}}
+     */
+    cloneTo(parentNoteId) {
+        const cloningService = require("../../services/cloning");
+
+        const branch = this.becca.getNote(parentNoteId).getParentBranches()[0];
+
+        return cloningService.cloneNoteToBranch(this.noteId, branch.branchId);
+    }
+
+    /**
+     * (Soft) delete a note and all its descendants.
+     *
+     * @param {string} [deleteId] - optional delete identified
+     * @param {TaskContext} [taskContext]
+     */
+    deleteNote(deleteId, taskContext) {
+        if (this.isDeleted) {
+            return;
+        }
+
+        if (!deleteId) {
+            deleteId = utils.randomString(10);
+        }
+
+        if (!taskContext) {
+            taskContext = new TaskContext('no-progress-reporting');
+        }
+
+        // needs to be run before branches and attributes are deleted and thus attached relations disappear
+        const handlers = require("../../services/handlers");
+        handlers.runAttachedRelations(this, 'runOnNoteDeletion', this);
+        taskContext.noteDeletionHandlerTriggered = true;
+
+        for (const branch of this.getParentBranches()) {
+            branch.deleteBranch(deleteId, taskContext);
+        }
+    }
+
+    decrypt() {
+        if (this.isProtected && !this.isDecrypted && protectedSessionService.isProtectedSessionAvailable()) {
+            try {
+                this.title = protectedSessionService.decryptString(this.title);
+                this.flatTextCache = null;
+
+                this.isDecrypted = true;
+            }
+            catch (e) {
+                log.error(`Could not decrypt note ${this.noteId}: ${e.message} ${e.stack}`);
+            }
+        }
+    }
+
+    isLaunchBarConfig() {
+        return this.type === 'launcher' || ['_lbRoot', '_lbAvailableLaunchers', '_lbVisibleLaunchers'].includes(this.noteId);
+    }
+
+    isOptions() {
+        return this.noteId.startsWith("_options");
+    }
+
+    get isDeleted() {
+        return !(this.noteId in this.becca.notes) || this.isBeingDeleted;
+    }
+
+    /**
+     * @returns {BNoteRevision|null}
+     */
+    saveNoteRevision() {
+        const content = this.getContent();
+
+        if (!content || (Buffer.isBuffer(content) && content.byteLength === 0)) {
+            return null;
+        }
+
+        const contentMetadata = this.getContentMetadata();
+
+        const noteRevision = new BNoteRevision({
+            noteId: this.noteId,
+            // title and text should be decrypted now
+            title: this.title,
+            type: this.type,
+            mime: this.mime,
+            isProtected: this.isProtected,
+            utcDateLastEdited: this.utcDateModified > contentMetadata.utcDateModified
+                ? this.utcDateModified
+                : contentMetadata.utcDateModified,
+            utcDateCreated: dateUtils.utcNowDateTime(),
+            utcDateModified: dateUtils.utcNowDateTime(),
+            dateLastEdited: this.dateModified > contentMetadata.dateModified
+                ? this.dateModified
+                : contentMetadata.dateModified,
+            dateCreated: dateUtils.localNowDateTime()
+        }, true).save();
+
+        noteRevision.setContent(content);
+
+        return noteRevision;
+    }
+
+    beforeSaving() {
+        super.beforeSaving();
+
+        this.becca.addNote(this.noteId, this);
+
+        this.dateModified = dateUtils.localNowDateTime();
+        this.utcDateModified = dateUtils.utcNowDateTime();
+    }
+
+    getPojo() {
+        return {
+            noteId: this.noteId,
+            title: this.title,
+            isProtected: this.isProtected,
+            type: this.type,
+            mime: this.mime,
+            isDeleted: false,
+            dateCreated: this.dateCreated,
+            dateModified: this.dateModified,
+            utcDateCreated: this.utcDateCreated,
+            utcDateModified: this.utcDateModified
+        };
+    }
+
+    getPojoToSave() {
+        const pojo = this.getPojo();
+
+        if (pojo.isProtected) {
+            if (this.isDecrypted) {
+                pojo.title = protectedSessionService.encrypt(pojo.title);
+            }
+            else {
+                // updating protected note outside of protected session means we will keep original ciphertexts
+                delete pojo.title;
+            }
+        }
+
+        return pojo;
+    }
+}
+
+module.exports = BNote;
+
+
+
+ + + + +
+ + + +
+ + + + + + + diff --git a/docs/backend_api/becca_entities_bnote_revision.js.html b/docs/backend_api/becca_entities_bnote_revision.js.html new file mode 100644 index 000000000..c140f1d10 --- /dev/null +++ b/docs/backend_api/becca_entities_bnote_revision.js.html @@ -0,0 +1,242 @@ + + + + + JSDoc: Source: becca/entities/bnote_revision.js + + + + + + + + + + +
+ +

Source: becca/entities/bnote_revision.js

+ + + + + + +
+
+
"use strict";
+
+const protectedSessionService = require('../../services/protected_session');
+const utils = require('../../services/utils');
+const sql = require('../../services/sql');
+const dateUtils = require('../../services/date_utils');
+const becca = require('../becca');
+const entityChangesService = require('../../services/entity_changes');
+const AbstractBeccaEntity = require("./abstract_becca_entity");
+
+/**
+ * NoteRevision represents snapshot of note's title and content at some point in the past.
+ * It's used for seamless note versioning.
+ *
+ * @extends AbstractBeccaEntity
+ */
+class BNoteRevision extends AbstractBeccaEntity {
+    static get entityName() { return "note_revisions"; }
+    static get primaryKeyName() { return "noteRevisionId"; }
+    static get hashedProperties() { return ["noteRevisionId", "noteId", "title", "isProtected", "dateLastEdited", "dateCreated", "utcDateLastEdited", "utcDateCreated", "utcDateModified"]; }
+
+    constructor(row, titleDecrypted = false) {
+        super();
+
+        /** @type {string} */
+        this.noteRevisionId = row.noteRevisionId;
+        /** @type {string} */
+        this.noteId = row.noteId;
+        /** @type {string} */
+        this.type = row.type;
+        /** @type {string} */
+        this.mime = row.mime;
+        /** @type {boolean} */
+        this.isProtected = !!row.isProtected;
+        /** @type {string} */
+        this.title = row.title;
+        /** @type {string} */
+        this.dateLastEdited = row.dateLastEdited;
+        /** @type {string} */
+        this.dateCreated = row.dateCreated;
+        /** @type {string} */
+        this.utcDateLastEdited = row.utcDateLastEdited;
+        /** @type {string} */
+        this.utcDateCreated = row.utcDateCreated;
+        /** @type {string} */
+        this.utcDateModified = row.utcDateModified;
+        /** @type {number} */
+        this.contentLength = row.contentLength;
+
+        if (this.isProtected && !titleDecrypted) {
+            this.title = protectedSessionService.isProtectedSessionAvailable()
+                ? protectedSessionService.decryptString(this.title)
+                : "[protected]";
+        }
+    }
+
+    getNote() {
+        return becca.notes[this.noteId];
+    }
+
+    /** @returns {boolean} true if the note has string content (not binary) */
+    isStringNote() {
+        return utils.isStringNote(this.type, this.mime);
+    }
+
+    /*
+     * Note revision content has quite special handling - it's not a separate entity, but a lazily loaded
+     * part of NoteRevision entity with its own sync. 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.
+     */
+
+    /** @returns {*} */
+    getContent(silentNotFoundError = false) {
+        const res = sql.getRow(`SELECT content FROM note_revision_contents WHERE noteRevisionId = ?`, [this.noteRevisionId]);
+
+        if (!res) {
+            if (silentNotFoundError) {
+                return undefined;
+            }
+            else {
+                throw new Error(`Cannot find note revision content for noteRevisionId=${this.noteRevisionId}`);
+            }
+        }
+
+        let content = res.content;
+
+        if (this.isProtected) {
+            if (protectedSessionService.isProtectedSessionAvailable()) {
+                content = protectedSessionService.decrypt(content);
+            }
+            else {
+                content = "";
+            }
+        }
+
+        if (this.isStringNote()) {
+            return content === null
+                ? ""
+                : content.toString("UTF-8");
+        }
+        else {
+            return content;
+        }
+    }
+
+    setContent(content) {
+        const pojo = {
+            noteRevisionId: this.noteRevisionId,
+            content: content,
+            utcDateModified: dateUtils.utcNowDateTime()
+        };
+
+        if (this.isProtected) {
+            if (protectedSessionService.isProtectedSessionAvailable()) {
+                pojo.content = protectedSessionService.encrypt(pojo.content);
+            }
+            else {
+                throw new Error(`Cannot update content of noteRevisionId=${this.noteRevisionId} since we're out of protected session.`);
+            }
+        }
+
+        sql.upsert("note_revision_contents", "noteRevisionId", pojo);
+
+        const hash = utils.hash(`${this.noteRevisionId}|${pojo.content.toString()}`);
+
+        entityChangesService.addEntityChange({
+            entityName: 'note_revision_contents',
+            entityId: this.noteRevisionId,
+            hash: hash,
+            isErased: false,
+            utcDateChanged: this.getUtcDateChanged(),
+            isSynced: true
+        });
+    }
+
+    /** @returns {{contentLength, dateModified, utcDateModified}} */
+    getContentMetadata() {
+        return sql.getRow(`
+            SELECT 
+                LENGTH(content) AS contentLength, 
+                dateModified,
+                utcDateModified 
+            FROM note_revision_contents 
+            WHERE noteRevisionId = ?`, [this.noteRevisionId]);
+    }
+
+    beforeSaving() {
+        super.beforeSaving();
+
+        this.utcDateModified = dateUtils.utcNowDateTime();
+    }
+
+    getPojo() {
+        return {
+            noteRevisionId: this.noteRevisionId,
+            noteId: this.noteId,
+            type: this.type,
+            mime: this.mime,
+            isProtected: this.isProtected,
+            title: this.title,
+            dateLastEdited: this.dateLastEdited,
+            dateCreated: this.dateCreated,
+            utcDateLastEdited: this.utcDateLastEdited,
+            utcDateCreated: this.utcDateCreated,
+            utcDateModified: this.utcDateModified,
+            contentLength: this.contentLength
+        };
+    }
+
+    getPojoToSave() {
+        const pojo = this.getPojo();
+        delete pojo.contentLength; // not getting persisted
+
+        if (pojo.isProtected) {
+            if (protectedSessionService.isProtectedSessionAvailable()) {
+                pojo.title = protectedSessionService.encrypt(this.title);
+            }
+            else {
+                // updating protected note outside of protected session means we will keep original ciphertexts
+                delete pojo.title;
+            }
+        }
+
+        return pojo;
+    }
+}
+
+module.exports = BNoteRevision;
+
+
+
+ + + + +
+ + + +
+ + + + + + + diff --git a/docs/backend_api/becca_entities_boption.js.html b/docs/backend_api/becca_entities_boption.js.html new file mode 100644 index 000000000..ab2c22f51 --- /dev/null +++ b/docs/backend_api/becca_entities_boption.js.html @@ -0,0 +1,98 @@ + + + + + JSDoc: Source: becca/entities/boption.js + + + + + + + + + + +
+ +

Source: becca/entities/boption.js

+ + + + + + +
+
+
"use strict";
+
+const dateUtils = require('../../services/date_utils');
+const AbstractBeccaEntity = require("./abstract_becca_entity");
+
+/**
+ * Option represents name-value pair, either directly configurable by the user or some system property.
+ *
+ * @extends AbstractBeccaEntity
+ */
+class BOption extends AbstractBeccaEntity {
+    static get entityName() { return "options"; }
+    static get primaryKeyName() { return "name"; }
+    static get hashedProperties() { return ["name", "value"]; }
+
+    constructor(row) {
+        super();
+
+        /** @type {string} */
+        this.name = row.name;
+        /** @type {string} */
+        this.value = row.value;
+        /** @type {boolean} */
+        this.isSynced = !!row.isSynced;
+        /** @type {string} */
+        this.utcDateModified = row.utcDateModified;
+
+        this.becca.options[this.name] = this;
+    }
+
+    beforeSaving() {
+        super.beforeSaving();
+
+        this.utcDateModified = dateUtils.utcNowDateTime();
+    }
+
+    getPojo() {
+        return {
+            name: this.name,
+            value: this.value,
+            isSynced: this.isSynced,
+            utcDateModified: this.utcDateModified
+        }
+    }
+}
+
+module.exports = BOption;
+
+
+
+ + + + +
+ + + +
+ + + + + + + diff --git a/docs/backend_api/becca_entities_brecent_note.js.html b/docs/backend_api/becca_entities_brecent_note.js.html new file mode 100644 index 000000000..2e2f5ccb1 --- /dev/null +++ b/docs/backend_api/becca_entities_brecent_note.js.html @@ -0,0 +1,86 @@ + + + + + JSDoc: Source: becca/entities/brecent_note.js + + + + + + + + + + +
+ +

Source: becca/entities/brecent_note.js

+ + + + + + +
+
+
"use strict";
+
+const dateUtils = require('../../services/date_utils');
+const AbstractBeccaEntity = require("./abstract_becca_entity");
+
+/**
+ * RecentNote represents recently visited note.
+ *
+ * @extends AbstractBeccaEntity
+ */
+class BRecentNote extends AbstractBeccaEntity {
+    static get entityName() { return "recent_notes"; }
+    static get primaryKeyName() { return "noteId"; }
+
+    constructor(row) {
+        super();
+
+        /** @type {string} */
+        this.noteId = row.noteId;
+        /** @type {string} */
+        this.notePath = row.notePath;
+        /** @type {string} */
+        this.utcDateCreated = row.utcDateCreated || dateUtils.utcNowDateTime();
+    }
+
+    getPojo() {
+        return {
+            noteId: this.noteId,
+            notePath: this.notePath,
+            utcDateCreated: this.utcDateCreated
+        }
+    }
+}
+
+module.exports = BRecentNote;
+
+
+
+ + + + +
+ + + +
+ + + + + + + diff --git a/docs/backend_api/services_backend_script_api.js.html b/docs/backend_api/services_backend_script_api.js.html new file mode 100644 index 000000000..44f40bed6 --- /dev/null +++ b/docs/backend_api/services_backend_script_api.js.html @@ -0,0 +1,593 @@ + + + + + JSDoc: Source: services/backend_script_api.js + + + + + + + + + + +
+ +

Source: services/backend_script_api.js

+ + + + + + +
+
+
const log = require('./log');
+const noteService = require('./notes');
+const sql = require('./sql');
+const utils = require('./utils');
+const attributeService = require('./attributes');
+const dateNoteService = require('./date_notes');
+const treeService = require('./tree');
+const config = require('./config');
+const axios = require('axios');
+const dayjs = require('dayjs');
+const xml2js = require('xml2js');
+const cloningService = require('./cloning');
+const appInfo = require('./app_info');
+const searchService = require('./search/services/search');
+const SearchContext = require("./search/search_context");
+const becca = require("../becca/becca");
+const ws = require("./ws");
+const SpacedUpdate = require("./spaced_update");
+const specialNotesService = require("./special_notes");
+const branchService = require("./branches");
+const exportService = require("./export/zip");
+
+/**
+ * <p>This is the main backend API interface for scripts. All the properties and methods are published in the "api" object
+ * available in the JS backend notes. You can use e.g. <code>api.log(api.startNote.title);</code></p>
+ *
+ * @constructor
+ */
+function BackendScriptApi(currentNote, apiParams) {
+    /** @property {BNote} note where script started executing */
+    this.startNote = apiParams.startNote;
+    /** @property {BNote} note where script is currently executing. Don't mix this up with concept of active note */
+    this.currentNote = currentNote;
+    /** @property {AbstractBeccaEntity} entity whose event triggered this executions */
+    this.originEntity = apiParams.originEntity;
+
+    for (const key in apiParams) {
+        this[key] = apiParams[key];
+    }
+
+    /**
+     * @property {axios} Axios library for HTTP requests. See {@link https://axios-http.com} for documentation
+     * @deprecated use native (browser compatible) fetch() instead
+     */
+    this.axios = axios;
+    /** @property {dayjs} day.js library for date manipulation. See {@link https://day.js.org} for documentation */
+    this.dayjs = dayjs;
+    /** @property {axios} xml2js library for XML parsing. See {@link https://github.com/Leonidas-from-XIV/node-xml2js} for documentation */
+    this.xml2js = xml2js;
+
+    /**
+     * Instance name identifies particular Trilium instance. It can be useful for scripts
+     * if some action needs to happen on only one specific instance.
+     *
+     * @returns {string|null}
+     */
+    this.getInstanceName = () => config.General ? config.General.instanceName : null;
+
+    /**
+     * @method
+     * @param {string} noteId
+     * @returns {BNote|null}
+     */
+    this.getNote = noteId => becca.getNote(noteId);
+
+    /**
+     * @method
+     * @param {string} branchId
+     * @returns {BBranch|null}
+     */
+    this.getBranch = branchId => becca.getBranch(branchId);
+
+    /**
+     * @method
+     * @param {string} attributeId
+     * @returns {BAttribute|null}
+     */
+    this.getAttribute = attributeId => becca.getAttribute(attributeId);
+
+    /**
+     * This is a powerful search method - you can search by attributes and their values, e.g.:
+     * "#dateModified =* MONTH AND #log". See {@link https://github.com/zadam/trilium/wiki/Search} for full documentation for all options
+     *
+     * @method
+     * @param {string} query
+     * @param {Object} [searchParams]
+     * @returns {BNote[]}
+     */
+    this.searchForNotes = (query, searchParams = {}) => {
+        if (searchParams.includeArchivedNotes === undefined) {
+            searchParams.includeArchivedNotes = true;
+        }
+
+        if (searchParams.ignoreHoistedNote === undefined) {
+            searchParams.ignoreHoistedNote = true;
+        }
+
+        const noteIds = searchService.findResultsWithQuery(query, new SearchContext(searchParams))
+            .map(sr => sr.noteId);
+
+        return becca.getNotes(noteIds);
+    };
+
+    /**
+     * This is a powerful search method - you can search by attributes and their values, e.g.:
+     * "#dateModified =* MONTH AND #log". See {@link https://github.com/zadam/trilium/wiki/Search} for full documentation for all options
+     *
+     * @method
+     * @param {string} query
+     * @param {Object} [searchParams]
+     * @returns {BNote|null}
+     */
+    this.searchForNote = (query, searchParams = {}) => {
+        const notes = this.searchForNotes(query, searchParams);
+
+        return notes.length > 0 ? notes[0] : null;
+    };
+
+    /**
+     * Retrieves notes with given label name & value
+     *
+     * @method
+     * @param {string} name - attribute name
+     * @param {string} [value] - attribute value
+     * @returns {BNote[]}
+     */
+    this.getNotesWithLabel = attributeService.getNotesWithLabel;
+
+    /**
+     * Retrieves first note with given label name & value
+     *
+     * @method
+     * @param {string} name - attribute name
+     * @param {string} [value] - attribute value
+     * @returns {BNote|null}
+     */
+    this.getNoteWithLabel = attributeService.getNoteWithLabel;
+
+    /**
+     * If there's no branch between note and parent note, create one. Otherwise, do nothing.
+     *
+     * @method
+     * @param {string} noteId
+     * @param {string} parentNoteId
+     * @param {string} prefix - if branch will be created between note and parent note, set this prefix
+     * @returns {void}
+     */
+    this.ensureNoteIsPresentInParent = cloningService.ensureNoteIsPresentInParent;
+
+    /**
+     * If there's a branch between note and parent note, remove it. Otherwise, do nothing.
+     *
+     * @method
+     * @param {string} noteId
+     * @param {string} parentNoteId
+     * @returns {void}
+     */
+    this.ensureNoteIsAbsentFromParent = cloningService.ensureNoteIsAbsentFromParent;
+
+    /**
+     * Based on the value, either create or remove branch between note and parent note.
+     *
+     * @method
+     * @param {boolean} present - true if we want the branch to exist, false if we want it gone
+     * @param {string} noteId
+     * @param {string} parentNoteId
+     * @param {string} prefix - if branch will be created between note and parent note, set this prefix
+     * @returns {void}
+     */
+    this.toggleNoteInParent = cloningService.toggleNoteInParent;
+
+    /**
+     * Create text note. See also createNewNote() for more options.
+     *
+     * @method
+     * @param {string} parentNoteId
+     * @param {string} title
+     * @param {string} content
+     * @returns {{note: BNote, branch: BBranch}} - object having "note" and "branch" keys representing respective objects
+     */
+    this.createTextNote = (parentNoteId, title, content = '') => noteService.createNewNote({
+        parentNoteId,
+        title,
+        content,
+        type: 'text'
+    });
+
+    /**
+     * Create data note - data in this context means object serializable to JSON. Created note will be of type 'code' and
+     * JSON MIME type. See also createNewNote() for more options.
+     *
+     * @method
+     * @param {string} parentNoteId
+     * @param {string} title
+     * @param {object} content
+     * @returns {{note: BNote, branch: BBranch}} object having "note" and "branch" keys representing respective objects
+     */
+    this.createDataNote = (parentNoteId, title, content = {}) => noteService.createNewNote({
+        parentNoteId,
+        title,
+        content: JSON.stringify(content, null, '\t'),
+        type: 'code',
+        mime: 'application/json'
+    });
+
+    /**
+     * @method
+     *
+     * @property {object} params
+     * @property {string} params.parentNoteId
+     * @property {string} params.title
+     * @property {string|buffer} params.content
+     * @property {string} params.type - text, code, file, image, search, book, relationMap, canvas
+     * @property {string} [params.mime] - value is derived from default mimes for type
+     * @property {boolean} [params.isProtected=false]
+     * @property {boolean} [params.isExpanded=false]
+     * @property {string} [params.prefix='']
+     * @property {int} [params.notePosition] - default is last existing notePosition in a parent + 10
+     * @returns {{note: BNote, branch: BBranch}} object contains newly created entities note and branch
+     */
+    this.createNewNote = noteService.createNewNote;
+
+    /**
+     * @method
+     * @deprecated please use createTextNote() with similar API for simpler use cases or createNewNote() for more complex needs
+     *
+     * @param {string} parentNoteId - create new note under this parent
+     * @param {string} title
+     * @param {string} [content=""]
+     * @param {object} [extraOptions={}]
+     * @property {boolean} [extraOptions.json=false] - should the note be JSON
+     * @property {boolean} [extraOptions.isProtected=false] - should the note be protected
+     * @property {string} [extraOptions.type='text'] - note type
+     * @property {string} [extraOptions.mime='text/html'] - MIME type of the note
+     * @property {object[]} [extraOptions.attributes=[]] - attributes to be created for this note
+     * @property {string} extraOptions.attributes.type - attribute type - label, relation etc.
+     * @property {string} extraOptions.attributes.name - attribute name
+     * @property {string} [extraOptions.attributes.value] - attribute value
+     * @returns {{note: BNote, branch: BBranch}} object contains newly created entities note and branch
+     */
+    this.createNote = (parentNoteId, title, content = "", extraOptions= {}) => {
+        extraOptions.parentNoteId = parentNoteId;
+        extraOptions.title = title;
+
+        const parentNote = becca.getNote(parentNoteId);
+
+        // code note type can be inherited, otherwise text is default
+        extraOptions.type = parentNote.type === 'code' ? 'code' : 'text';
+        extraOptions.mime = parentNote.type === 'code' ? parentNote.mime : 'text/html';
+
+        if (extraOptions.json) {
+            extraOptions.content = JSON.stringify(content || {}, null, '\t');
+            extraOptions.type = 'code';
+            extraOptions.mime = 'application/json';
+        }
+        else {
+            extraOptions.content = content;
+        }
+
+        return sql.transactional(() => {
+            const {note, branch} = noteService.createNewNote(extraOptions);
+
+            for (const attr of extraOptions.attributes || []) {
+                attributeService.createAttribute({
+                    noteId: note.noteId,
+                    type: attr.type,
+                    name: attr.name,
+                    value: attr.value,
+                    isInheritable: !!attr.isInheritable
+                });
+            }
+
+            return {note, branch};
+        });
+    };
+
+    this.logMessages = {};
+    this.logSpacedUpdates = {};
+
+    /**
+     * Log given message to trilium logs and log pane in UI
+     *
+     * @method
+     * @param message
+     * @returns {void}
+     */
+    this.log = message => {
+        log.info(message);
+
+        const {noteId} = this.startNote;
+
+        this.logMessages[noteId] = this.logMessages[noteId] || [];
+        this.logSpacedUpdates[noteId] = this.logSpacedUpdates[noteId] || new SpacedUpdate(() => {
+            const messages = this.logMessages[noteId];
+            this.logMessages[noteId] = [];
+
+            ws.sendMessageToAllClients({
+                type: 'api-log-messages',
+                noteId,
+                messages
+            });
+        }, 100);
+
+        this.logMessages[noteId].push(message);
+        this.logSpacedUpdates[noteId].scheduleUpdate();
+    };
+
+    /**
+     * Returns root note of the calendar.
+     *
+     * @method
+     * @returns {BNote|null}
+     */
+    this.getRootCalendarNote = dateNoteService.getRootCalendarNote;
+
+    /**
+     * Returns day note for given date. If such note doesn't exist, it is created.
+     *
+     * @method
+     * @param {string} date in YYYY-MM-DD format
+     * @param {BNote} [rootNote] - specify calendar root note, normally leave empty to use the default calendar
+     * @returns {BNote|null}
+     */
+    this.getDayNote = dateNoteService.getDayNote;
+
+    /**
+     * Returns today's day note. If such note doesn't exist, it is created.
+     *
+     * @method
+     * @param {BNote} [rootNote] - specify calendar root note, normally leave empty to use the default calendar
+     * @returns {BNote|null}
+     */
+    this.getTodayNote = dateNoteService.getTodayNote;
+
+    /**
+     * Returns note for the first date of the week of the given date.
+     *
+     * @method
+     * @param {string} date in YYYY-MM-DD format
+     * @param {object} [options]
+     * @param {string} [options.startOfTheWeek=monday] - either "monday" (default) or "sunday"
+     * @param {BNote} [rootNote] - specify calendar root note, normally leave empty to use the default calendar
+     * @returns {BNote|null}
+     */
+    this.getWeekNote = dateNoteService.getWeekNote;
+
+    /**
+     * Returns month note for given date. If such note doesn't exist, it is created.
+     *
+     * @method
+     * @param {string} date in YYYY-MM format
+     * @param {BNote} [rootNote] - specify calendar root note, normally leave empty to use the default calendar
+     * @returns {BNote|null}
+     */
+    this.getMonthNote = dateNoteService.getMonthNote;
+
+    /**
+     * Returns year note for given year. If such note doesn't exist, it is created.
+     *
+     * @method
+     * @param {string} year in YYYY format
+     * @param {BNote} [rootNote] - specify calendar root note, normally leave empty to use the default calendar
+     * @returns {BNote|null}
+     */
+    this.getYearNote = dateNoteService.getYearNote;
+
+    /**
+     * Sort child notes of a given note.
+     *
+     * @method
+     * @param {string} parentNoteId - this note's child notes will be sorted
+     * @param {object} [sortConfig]
+     * @property {string} [sortConfig.sortBy=title] - 'title', 'dateCreated', 'dateModified' or a label name
+     *                                See {@link https://github.com/zadam/trilium/wiki/Sorting} for details.
+     * @property {boolean} [sortConfig.reverse=false]
+     * @property {boolean} [sortConfig.foldersFirst=false]
+     * @returns {void}
+     */
+    this.sortNotes = (parentNoteId, sortConfig = {}) => treeService.sortNotes(
+        parentNoteId,
+        sortConfig.sortBy || "title",
+        !!sortConfig.reverse,
+        !!sortConfig.foldersFirst
+    );
+
+    /**
+     * This method finds note by its noteId and prefix and either sets it to the given parentNoteId
+     * or removes the branch (if parentNoteId is not given).
+     *
+     * This method looks similar to toggleNoteInParent() but differs because we're looking up branch by prefix.
+     *
+     * @method
+     * @deprecated this method is pretty confusing and serves specialized purpose only
+     * @param {string} noteId
+     * @param {string} prefix
+     * @param {string|null} parentNoteId
+     * @returns {void}
+     */
+    this.setNoteToParent = treeService.setNoteToParent;
+
+    /**
+     * This functions wraps code which is supposed to be running in transaction. If transaction already
+     * exists, then we'll use that transaction.
+     *
+     * @method
+     * @param {function} func
+     * @returns {?} result of func callback
+     */
+    this.transactional = sql.transactional;
+
+    /**
+     * Return randomly generated string of given length. This random string generation is NOT cryptographically secure.
+     *
+     * @method
+     * @param {number} length of the string
+     * @returns {string} random string
+     */
+    this.randomString = utils.randomString;
+
+    /**
+     * @method
+     * @param {string} string to escape
+     * @returns {string} escaped string
+     */
+    this.escapeHtml = utils.escapeHtml;
+
+    /**
+     * @method
+     * @param {string} string to unescape
+     * @returns {string} unescaped string
+     */
+    this.unescapeHtml = utils.unescapeHtml;
+
+    /**
+     * @property {module:sql} sql
+     */
+    this.sql = sql;
+
+    /**
+     * @method
+     * @returns {{syncVersion, appVersion, buildRevision, dbVersion, dataDirectory, buildDate}|*} - object representing basic info about running Trilium version
+     */
+    this.getAppInfo = () => appInfo
+
+    /**
+     * Creates a new launcher to the launchbar. If the launcher (id) already exists, it will be updated.
+     *
+     * @method
+     * @param {object} opts
+     * @property {string} opts.id - id of the launcher, only alphanumeric at least 6 characters long
+     * @property {string} opts.type - one of
+     *                          * "note" - activating the launcher will navigate to the target note (specified in targetNoteId param)
+     *                          * "script" -  activating the launcher will execute the script (specified in scriptNoteId param)
+     *                          * "customWidget" - the launcher will be rendered with a custom widget (specified in widgetNoteId param)
+     * @property {string} opts.title
+     * @property {boolean} [opts.isVisible=false] - if true, will be created in the "Visible launchers", otherwise in "Available launchers"
+     * @property {string} [opts.icon] - name of the boxicon to be used (e.g. "bx-time")
+     * @property {string} [opts.keyboardShortcut] - will activate the target note/script upon pressing, e.g. "ctrl+e"
+     * @property {string} [opts.targetNoteId] - for type "note"
+     * @property {string} [opts.scriptNoteId] - for type "script"
+     * @property {string} [opts.widgetNoteId] - for type "customWidget"
+     * @returns {{note: BNote}}
+     */
+    this.createOrUpdateLauncher = opts => {
+        if (!opts.id) { throw new Error("ID is a mandatory parameter for api.createOrUpdateLauncher(opts)"); }
+        if (!opts.id.match(/[a-z0-9]{6,1000}/i)) { throw new Error(`ID must be an alphanumeric string at least 6 characters long.`); }
+        if (!opts.type) { throw new Error("Launcher Type is a mandatory parameter for api.createOrUpdateLauncher(opts)"); }
+        if (!["note", "script", "customWidget"].includes(opts.type)) { throw new Error(`Given launcher type '${opts.type}'`); }
+        if (!opts.title?.trim()) { throw new Error("Title is a mandatory parameter for api.createOrUpdateLauncher(opts)"); }
+        if (opts.type === 'note' && !opts.targetNoteId) { throw new Error("targetNoteId is mandatory for launchers of type 'note'"); }
+        if (opts.type === 'script' && !opts.scriptNoteId) { throw new Error("scriptNoteId is mandatory for launchers of type 'script'"); }
+        if (opts.type === 'customWidget' && !opts.widgetNoteId) { throw new Error("widgetNoteId is mandatory for launchers of type 'customWidget'"); }
+
+        const parentNoteId = !!opts.isVisible ? '_lbVisibleLaunchers' : '_lbAvailableLaunchers';
+        const noteId = 'al_' + opts.id;
+
+        const launcherNote =
+            becca.getNote(opts.id) ||
+            specialNotesService.createLauncher({
+                noteId: noteId,
+                parentNoteId: parentNoteId,
+                launcherType: opts.type,
+            }).note;
+
+        if (launcherNote.title !== opts.title) {
+            launcherNote.title = opts.title;
+            launcherNote.save();
+        }
+
+        if (launcherNote.getParentBranches().length === 1) {
+            const branch = launcherNote.getParentBranches()[0];
+
+            if (branch.parentNoteId !== parentNoteId) {
+                branchService.moveBranchToNote(branch, parentNoteId);
+            }
+        }
+
+        if (opts.type === 'note') {
+            launcherNote.setRelation('target', opts.targetNoteId);
+        } else if (opts.type === 'script') {
+            launcherNote.setRelation('script', opts.scriptNoteId);
+        } else if (opts.type === 'customWidget') {
+            launcherNote.setRelation('widget', opts.widgetNoteId);
+        } else {
+            throw new Error(`Unrecognized launcher type '${opts.type}'`);
+        }
+
+        if (opts.keyboardShortcut) {
+            launcherNote.setLabel('keyboardShortcut', opts.keyboardShortcut);
+        } else {
+            launcherNote.removeLabel('keyboardShortcut');
+        }
+
+        if (opts.icon) {
+            launcherNote.setLabel('iconClass', `bx ${opts.icon}`);
+        } else {
+            launcherNote.removeLabel('keyboardShortcut');
+        }
+
+        return {note: launcherNote};
+    };
+
+    /**
+     * @method
+     * @param {string} noteId
+     * @param {string} format - either 'html' or 'markdown'
+     * @param {string} zipFilePath
+     * @returns {Promise<void>}
+     */
+    this.exportSubtreeToZipFile = async (noteId, format, zipFilePath) => await exportService.exportToZipFile(noteId, format, zipFilePath);
+
+    /**
+     * This object contains "at your risk" and "no BC guarantees" objects for advanced use cases.
+     *
+     * @property {Becca} becca - provides access to the backend in-memory object graph, see {@link https://github.com/zadam/trilium/blob/master/src/becca/becca.js}
+     */
+    this.__private = {
+        becca
+    }
+}
+
+module.exports = BackendScriptApi;
+
+
+
+ + + + +
+ + + +
+ + + + + + + diff --git a/docs/backend_api/services_sql.js.html b/docs/backend_api/services_sql.js.html new file mode 100644 index 000000000..ec2ac56e6 --- /dev/null +++ b/docs/backend_api/services_sql.js.html @@ -0,0 +1,422 @@ + + + + + JSDoc: Source: services/sql.js + + + + + + + + + + +
+ +

Source: services/sql.js

+ + + + + + +
+
+
"use strict";
+
+/**
+ * @module sql
+ */
+
+const log = require('./log');
+const Database = require('better-sqlite3');
+const dataDir = require('./data_dir');
+const cls = require('./cls');
+const fs = require("fs-extra");
+
+const dbConnection = new Database(dataDir.DOCUMENT_PATH);
+dbConnection.pragma('journal_mode = WAL');
+
+const LOG_ALL_QUERIES = false;
+
+[`exit`, `SIGINT`, `SIGUSR1`, `SIGUSR2`, `SIGTERM`].forEach(eventType => {
+    process.on(eventType, () => {
+        if (dbConnection) {
+            // closing connection is especially important to fold -wal file into the main DB file
+            // (see https://sqlite.org/tempfiles.html for details)
+            dbConnection.close();
+        }
+    });
+});
+
+function insert(tableName, rec, replace = false) {
+    const keys = Object.keys(rec);
+    if (keys.length === 0) {
+        log.error(`Can't insert empty object into table ${tableName}`);
+        return;
+    }
+
+    const columns = keys.join(", ");
+    const questionMarks = keys.map(p => "?").join(", ");
+
+    const query = `INSERT
+    ${replace ? "OR REPLACE" : ""} INTO
+    ${tableName}
+    (
+    ${columns}
+    )
+    VALUES (${questionMarks})`;
+
+    const res = execute(query, Object.values(rec));
+
+    return res ? res.lastInsertRowid : null;
+}
+
+function replace(tableName, rec) {
+    return insert(tableName, rec, true);
+}
+
+function upsert(tableName, primaryKey, rec) {
+    const keys = Object.keys(rec);
+    if (keys.length === 0) {
+        log.error(`Can't upsert empty object into table ${tableName}`);
+        return;
+    }
+
+    const columns = keys.join(", ");
+
+    const questionMarks = keys.map(colName => `@${colName}`).join(", ");
+
+    const updateMarks = keys.map(colName => `${colName} = @${colName}`).join(", ");
+
+    const query = `INSERT INTO ${tableName} (${columns}) VALUES (${questionMarks}) 
+                   ON CONFLICT (${primaryKey}) DO UPDATE SET ${updateMarks}`;
+
+    for (const idx in rec) {
+        if (rec[idx] === true || rec[idx] === false) {
+            rec[idx] = rec[idx] ? 1 : 0;
+        }
+    }
+
+    execute(query, rec);
+}
+
+const statementCache = {};
+
+function stmt(sql) {
+    if (!(sql in statementCache)) {
+        statementCache[sql] = dbConnection.prepare(sql);
+    }
+
+    return statementCache[sql];
+}
+
+function getRow(query, params = []) {
+    return wrap(query, s => s.get(params));
+}
+
+function getRowOrNull(query, params = []) {
+    const all = getRows(query, params);
+
+    return all.length > 0 ? all[0] : null;
+}
+
+function getValue(query, params = []) {
+    return wrap(query, s => s.pluck().get(params));
+}
+
+// smaller values can result in better performance due to better usage of statement cache
+const PARAM_LIMIT = 100;
+
+function getManyRows(query, params) {
+    let results = [];
+
+    while (params.length > 0) {
+        const curParams = params.slice(0, Math.min(params.length, PARAM_LIMIT));
+        params = params.slice(curParams.length);
+
+        const curParamsObj = {};
+
+        let j = 1;
+        for (const param of curParams) {
+            curParamsObj['param' + j++] = param;
+        }
+
+        let i = 1;
+        const questionMarks = curParams.map(() => ":param" + i++).join(",");
+        const curQuery = query.replace(/\?\?\?/g, questionMarks);
+
+        const statement = curParams.length === PARAM_LIMIT
+            ? stmt(curQuery)
+            : dbConnection.prepare(curQuery);
+
+        const subResults = statement.all(curParamsObj);
+        results = results.concat(subResults);
+    }
+
+    return results;
+}
+
+function getRows(query, params = []) {
+    return wrap(query, s => s.all(params));
+}
+
+function getRawRows(query, params = []) {
+    return wrap(query, s => s.raw().all(params));
+}
+
+function iterateRows(query, params = []) {
+    if (LOG_ALL_QUERIES) {
+        console.log(query);
+    }
+
+    return stmt(query).iterate(params);
+}
+
+function getMap(query, params = []) {
+    const map = {};
+    const results = getRawRows(query, params);
+
+    for (const row of results) {
+        map[row[0]] = row[1];
+    }
+
+    return map;
+}
+
+function getColumn(query, params = []) {
+    return wrap(query, s => s.pluck().all(params));
+}
+
+function execute(query, params = []) {
+    return wrap(query, s => s.run(params));
+}
+
+function executeMany(query, params) {
+    if (LOG_ALL_QUERIES) {
+        console.log(query);
+    }
+
+    while (params.length > 0) {
+        const curParams = params.slice(0, Math.min(params.length, PARAM_LIMIT));
+        params = params.slice(curParams.length);
+
+        const curParamsObj = {};
+
+        let j = 1;
+        for (const param of curParams) {
+            curParamsObj['param' + j++] = param;
+        }
+
+        let i = 1;
+        const questionMarks = curParams.map(() => ":param" + i++).join(",");
+        const curQuery = query.replace(/\?\?\?/g, questionMarks);
+
+        dbConnection.prepare(curQuery).run(curParamsObj);
+    }
+}
+
+function executeScript(query) {
+    if (LOG_ALL_QUERIES) {
+        console.log(query);
+    }
+
+    return dbConnection.exec(query);
+}
+
+function wrap(query, func) {
+    const startTimestamp = Date.now();
+    let result;
+
+    if (LOG_ALL_QUERIES) {
+        console.log(query);
+    }
+
+    try {
+        result = func(stmt(query));
+    }
+    catch (e) {
+        if (e.message.includes("The database connection is not open")) {
+            // this often happens on killing the app which puts these alerts in front of user
+            // in these cases error should be simply ignored.
+            console.log(e.message);
+
+            return null
+        }
+
+        throw e;
+    }
+
+    const milliseconds = Date.now() - startTimestamp;
+
+    if (milliseconds >= 20) {
+        if (query.includes("WITH RECURSIVE")) {
+            log.info(`Slow recursive query took ${milliseconds}ms.`);
+        }
+        else {
+            log.info(`Slow query took ${milliseconds}ms: ${query.trim().replace(/\s+/g, " ")}`);
+        }
+    }
+
+    return result;
+}
+
+function transactional(func) {
+    try {
+        const ret = dbConnection.transaction(func).deferred();
+
+        if (!dbConnection.inTransaction) { // i.e. transaction was really committed (and not just savepoint released)
+            require('./ws').sendTransactionEntityChangesToAllClients();
+        }
+
+        return ret;
+    }
+    catch (e) {
+        const entityChangeIds = cls.getAndClearEntityChangeIds();
+
+        if (entityChangeIds.length > 0) {
+            log.info("Transaction rollback dirtied the becca, forcing reload.");
+
+            require('../becca/becca_loader').load();
+        }
+
+        // the maxEntityChangeId has been incremented during failed transaction, need to recalculate
+        require('./entity_changes').recalculateMaxEntityChangeId();
+
+        throw e;
+    }
+}
+
+function fillParamList(paramIds, truncate = true) {
+    if (paramIds.length === 0) {
+        return;
+    }
+
+    if (truncate) {
+        execute("DELETE FROM param_list");
+    }
+
+    paramIds = Array.from(new Set(paramIds));
+
+    if (paramIds.length > 30000) {
+        fillParamList(paramIds.slice(30000), false);
+
+        paramIds = paramIds.slice(0, 30000);
+    }
+
+    // doing it manually to avoid this showing up on the sloq query list
+    const s = stmt(`INSERT INTO param_list VALUES ${paramIds.map(paramId => `(?)`).join(',')}`, paramIds);
+
+    s.run(paramIds);
+}
+
+async function copyDatabase(targetFilePath) {
+    try {
+        fs.unlinkSync(targetFilePath);
+    } catch (e) {
+    } // unlink throws exception if the file did not exist
+
+    await dbConnection.backup(targetFilePath);
+}
+
+module.exports = {
+    dbConnection,
+    insert,
+    replace,
+
+    /**
+     * Get single value from the given query - first column from first returned row.
+     *
+     * @method
+     * @param {string} query - SQL query with ? used as parameter placeholder
+     * @param {object[]} [params] - array of params if needed
+     * @returns [object] - single value
+     */
+    getValue,
+
+    /**
+     * Get first returned row.
+     *
+     * @method
+     * @param {string} query - SQL query with ? used as parameter placeholder
+     * @param {object[]} [params] - array of params if needed
+     * @returns {object} - map of column name to column value
+     */
+    getRow,
+    getRowOrNull,
+
+    /**
+     * Get all returned rows.
+     *
+     * @method
+     * @param {string} query - SQL query with ? used as parameter placeholder
+     * @param {object[]} [params] - array of params if needed
+     * @returns {object[]} - array of all rows, each row is a map of column name to column value
+     */
+    getRows,
+    getRawRows,
+    iterateRows,
+    getManyRows,
+
+    /**
+     * Get a map of first column mapping to second column.
+     *
+     * @method
+     * @param {string} query - SQL query with ? used as parameter placeholder
+     * @param {object[]} [params] - array of params if needed
+     * @returns {object} - map of first column to second column
+     */
+    getMap,
+
+    /**
+     * Get a first column in an array.
+     *
+     * @method
+     * @param {string} query - SQL query with ? used as parameter placeholder
+     * @param {object[]} [params] - array of params if needed
+     * @returns {object[]} - array of first column of all returned rows
+     */
+    getColumn,
+
+    /**
+     * Execute SQL
+     *
+     * @method
+     * @param {string} query - SQL query with ? used as parameter placeholder
+     * @param {object[]} [params] - array of params if needed
+     */
+    execute,
+    executeMany,
+    executeScript,
+    transactional,
+    upsert,
+    fillParamList,
+    copyDatabase
+};
+
+
+
+ + + + +
+ + + +
+ + + + + + + diff --git a/docs/frontend_api/entities_fattribute.js.html b/docs/frontend_api/entities_fattribute.js.html new file mode 100644 index 000000000..dffe972bc --- /dev/null +++ b/docs/frontend_api/entities_fattribute.js.html @@ -0,0 +1,130 @@ + + + + + JSDoc: Source: entities/fattribute.js + + + + + + + + + + +
+ +

Source: entities/fattribute.js

+ + + + + + +
+
+
import promotedAttributeDefinitionParser from '../services/promoted_attribute_definition_parser.js';
+
+/**
+ * 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 FAttribute {
+    constructor(froca, row) {
+        this.froca = froca;
+
+        this.update(row);
+    }
+
+    update(row) {
+        /** @type {string} */
+        this.attributeId = row.attributeId;
+        /** @type {string} */
+        this.noteId = row.noteId;
+        /** @type {string} */
+        this.type = row.type;
+        /** @type {string} */
+        this.name = row.name;
+        /** @type {string} */
+        this.value = row.value;
+        /** @type {int} */
+        this.position = row.position;
+        /** @type {boolean} */
+        this.isInheritable = !!row.isInheritable;
+    }
+
+    /** @returns {FNote} */
+    getNote() {
+        return this.froca.notes[this.noteId];
+    }
+
+    /** @returns {Promise<FNote>} */
+    async getTargetNote() {
+        const targetNoteId = this.targetNoteId;
+
+        return await this.froca.getNote(targetNoteId, true);
+    }
+
+    get targetNoteId() { // alias
+        if (this.type !== 'relation') {
+            throw new Error(`Attribute ${this.attributeId} is not a relation`);
+        }
+
+        return this.value;
+    }
+
+    get isAutoLink() {
+        return this.type === 'relation' && ['internalLink', 'imageLink', 'relationMapLink', 'includeNoteLink'].includes(this.name);
+    }
+
+    get toString() {
+        return `FAttribute(attributeId=${this.attributeId}, type=${this.type}, name=${this.name}, value=${this.value})`;
+    }
+
+    isDefinition() {
+        return this.type === 'label' && (this.name.startsWith('label:') || this.name.startsWith('relation:'));
+    }
+
+    getDefinition() {
+        return promotedAttributeDefinitionParser.parse(this.value);
+    }
+
+    isDefinitionFor(attr) {
+        return this.type === 'label' && this.name === `${attr.type}:${attr.name}`;
+    }
+
+    get dto() {
+        const dto = Object.assign({}, this);
+        delete dto.froca;
+
+        return dto;
+    }
+}
+
+export default FAttribute;
+
+
+
+ + + + +
+ + + +
+ + + + + + + diff --git a/docs/frontend_api/entities_fbranch.js.html b/docs/frontend_api/entities_fbranch.js.html new file mode 100644 index 000000000..2b7ced98d --- /dev/null +++ b/docs/frontend_api/entities_fbranch.js.html @@ -0,0 +1,114 @@ + + + + + JSDoc: Source: entities/fbranch.js + + + + + + + + + + +
+ +

Source: entities/fbranch.js

+ + + + + + +
+
+
/**
+ * Branch represents a relationship between a child note and its parent note. Trilium allows a note to have multiple
+ * parents.
+ */
+class FBranch {
+    constructor(froca, row) {
+        this.froca = froca;
+
+        this.update(row);
+    }
+
+    update(row) {
+        /**
+         * primary key
+         * @type {string}
+         */
+        this.branchId = row.branchId;
+        /** @type {string} */
+        this.noteId = row.noteId;
+        /** @type {string} */
+        this.parentNoteId = row.parentNoteId;
+        /** @type {int} */
+        this.notePosition = row.notePosition;
+        /** @type {string} */
+        this.prefix = row.prefix;
+        /** @type {boolean} */
+        this.isExpanded = !!row.isExpanded;
+        /** @type {boolean} */
+        this.fromSearchNote = !!row.fromSearchNote;
+    }
+
+    /** @returns {FNote} */
+    async getNote() {
+        return this.froca.getNote(this.noteId);
+    }
+
+    /** @returns {FNote} */
+    getNoteFromCache() {
+        return this.froca.getNoteFromCache(this.noteId);
+    }
+
+    /** @returns {FNote} */
+    async getParentNote() {
+        return this.froca.getNote(this.parentNoteId);
+    }
+
+    /** @returns {boolean} true if it's top level, meaning its parent is root note */
+    isTopLevel() {
+        return this.parentNoteId === 'root';
+    }
+
+    get toString() {
+        return `FBranch(branchId=${this.branchId})`;
+    }
+
+    get pojo() {
+        const pojo = {...this};
+        delete pojo.froca;
+        return pojo;
+    }
+}
+
+export default FBranch;
+
+
+
+ + + + +
+ + + +
+ + + + + + + diff --git a/docs/frontend_api/entities_fnote.js.html b/docs/frontend_api/entities_fnote.js.html new file mode 100644 index 000000000..a31f615a2 --- /dev/null +++ b/docs/frontend_api/entities_fnote.js.html @@ -0,0 +1,939 @@ + + + + + JSDoc: Source: entities/fnote.js + + + + + + + + + + +
+ +

Source: entities/fnote.js

+ + + + + + +
+
+
import server from '../services/server.js';
+import noteAttributeCache from "../services/note_attribute_cache.js";
+import ws from "../services/ws.js";
+import options from "../services/options.js";
+import froca from "../services/froca.js";
+import protectedSessionHolder from "../services/protected_session_holder.js";
+import cssClassManager from "../services/css_class_manager.js";
+
+const LABEL = 'label';
+const RELATION = 'relation';
+
+const NOTE_TYPE_ICONS = {
+    "file": "bx bx-file",
+    "image": "bx bx-image",
+    "code": "bx bx-code",
+    "render": "bx bx-extension",
+    "search": "bx bx-file-find",
+    "relationMap": "bx bx-map-alt",
+    "book": "bx bx-book",
+    "noteMap": "bx bx-map-alt",
+    "mermaid": "bx bx-selection",
+    "canvas": "bx bx-pen",
+    "webView": "bx bx-globe-alt",
+    "launcher": "bx bx-link",
+    "doc": "bx bxs-file-doc",
+    "contentWidget": "bx bxs-widget"
+};
+
+class FNote {
+    /**
+     * @param {Froca} froca
+     * @param {Object.<string, Object>} row
+     */
+    constructor(froca, row) {
+        this.froca = froca;
+
+        /** @type {string[]} */
+        this.attributes = [];
+
+        /** @type {string[]} */
+        this.targetRelations = [];
+
+        /** @type {string[]} */
+        this.parents = [];
+        /** @type {string[]} */
+        this.children = [];
+
+        /** @type {Object.<string, string>} */
+        this.parentToBranch = {};
+
+        /** @type {Object.<string, string>} */
+        this.childToBranch = {};
+
+        this.update(row);
+    }
+
+    update(row) {
+        /** @type {string} */
+        this.noteId = row.noteId;
+        /** @type {string} */
+        this.title = row.title;
+        /** @type {boolean} */
+        this.isProtected = !!row.isProtected;
+        /**
+         * one of 'text', 'code', 'file' or 'render'
+         * @type {string}
+         */
+        this.type = row.type;
+        /**
+         * content-type, e.g. "application/json"
+         * @type {string}
+         */
+        this.mime = row.mime;
+    }
+
+    addParent(parentNoteId, branchId) {
+        if (parentNoteId === 'none') {
+            return;
+        }
+
+        if (!this.parents.includes(parentNoteId)) {
+            this.parents.push(parentNoteId);
+        }
+
+        this.parentToBranch[parentNoteId] = branchId;
+    }
+
+    addChild(childNoteId, branchId, sort = true) {
+        if (!(childNoteId in this.childToBranch)) {
+            this.children.push(childNoteId);
+        }
+
+        this.childToBranch[childNoteId] = branchId;
+
+        if (sort) {
+            this.sortChildren();
+        }
+    }
+
+    sortChildren() {
+        const branchIdPos = {};
+
+        for (const branchId of Object.values(this.childToBranch)) {
+            branchIdPos[branchId] = this.froca.getBranch(branchId).notePosition;
+        }
+
+        this.children.sort((a, b) => branchIdPos[this.childToBranch[a]] < branchIdPos[this.childToBranch[b]] ? -1 : 1);
+    }
+
+    /** @returns {boolean} */
+    isJson() {
+        return this.mime === "application/json";
+    }
+
+    async getContent() {
+        // we're not caching content since these objects are in froca and as such pretty long-lived
+        const note = await server.get(`notes/${this.noteId}`);
+
+        return note.content;
+    }
+
+    async getJsonContent() {
+        const content = await this.getContent();
+
+        try {
+            return JSON.parse(content);
+        }
+        catch (e) {
+            console.log(`Cannot parse content of note '${this.noteId}': `, e.message);
+
+            return null;
+        }
+    }
+
+    /**
+     * @returns {string[]}
+     */
+    getParentBranchIds() {
+        return Object.values(this.parentToBranch);
+    }
+
+    /**
+     * @returns {string[]}
+     * @deprecated use getParentBranchIds() instead
+     */
+    getBranchIds() {
+        return this.getParentBranchIds();
+    }
+
+    /**
+     * @returns {FBranch[]}
+     */
+    getParentBranches() {
+        const branchIds = Object.values(this.parentToBranch);
+
+        return this.froca.getBranches(branchIds);
+    }
+
+    /**
+     * @returns {FBranch[]}
+     * @deprecated use getParentBranches() instead
+     */
+    getBranches() {
+        return this.getParentBranches();
+    }
+
+    /** @returns {boolean} */
+    hasChildren() {
+        return this.children.length > 0;
+    }
+
+    /** @returns {FBranch[]} */
+    getChildBranches() {
+        // don't use Object.values() to guarantee order
+        const branchIds = this.children.map(childNoteId => this.childToBranch[childNoteId]);
+
+        return this.froca.getBranches(branchIds);
+    }
+
+    /** @returns {string[]} */
+    getParentNoteIds() {
+        return this.parents;
+    }
+
+    /** @returns {FNote[]} */
+    getParentNotes() {
+        return this.froca.getNotesFromCache(this.parents);
+    }
+
+    // will sort the parents so that non-search & non-archived are first and archived at the end
+    // this is done so that non-search & non-archived paths are always explored as first when looking for note path
+    resortParents() {
+        this.parents.sort((aNoteId, bNoteId) => {
+            const aBranchId = this.parentToBranch[aNoteId];
+
+            if (aBranchId && aBranchId.startsWith('virt-')) {
+                return 1;
+            }
+
+            const aNote = this.froca.getNoteFromCache([aNoteId]);
+
+            if (aNote.isArchived || aNote.isHiddenCompletely()) {
+                return 1;
+            }
+
+            return -1;
+        });
+    }
+
+    get isArchived() {
+        return this.hasAttribute('label', 'archived');
+    }
+
+    /** @returns {string[]} */
+    getChildNoteIds() {
+        return this.children;
+    }
+
+    /** @returns {Promise<FNote[]>} */
+    async getChildNotes() {
+        return await this.froca.getNotes(this.children);
+    }
+
+    /**
+     * @param {string} [type] - (optional) attribute type to filter
+     * @param {string} [name] - (optional) attribute name to filter
+     * @returns {FAttribute[]} all note's attributes, including inherited ones
+     */
+    getOwnedAttributes(type, name) {
+        const attrs = this.attributes
+            .map(attributeId => this.froca.attributes[attributeId])
+            .filter(Boolean); // filter out nulls;
+
+        return this.__filterAttrs(attrs, type, name);
+    }
+
+    /**
+     * @param {string} [type] - (optional) attribute type to filter
+     * @param {string} [name] - (optional) attribute name to filter
+     * @returns {FAttribute[]} all note's attributes, including inherited ones
+     */
+    getAttributes(type, name) {
+        return this.__filterAttrs(this.__getCachedAttributes([]), type, name);
+    }
+
+    __getCachedAttributes(path) {
+        // notes/clones cannot form tree cycles, it is possible to create attribute inheritance cycle via templates
+        // when template instance is a parent of template itself
+        if (path.includes(this.noteId)) {
+            return [];
+        }
+
+        if (!(this.noteId in noteAttributeCache.attributes)) {
+            const newPath = [...path, this.noteId];
+            const attrArrs = [ this.getOwnedAttributes() ];
+
+            // inheritable attrs on root are typically not intended to be applied to hidden subtree #3537
+            if (this.noteId !== 'root' && this.noteId !== '_hidden') {
+                for (const parentNote of this.getParentNotes()) {
+                    // these virtual parent-child relationships are also loaded into froca
+                    if (parentNote.type !== 'search') {
+                        attrArrs.push(parentNote.__getInheritableAttributes(newPath));
+                    }
+                }
+            }
+
+            for (const templateAttr of attrArrs.flat().filter(attr => attr.type === 'relation' && ['template', 'inherit'].includes(attr.name))) {
+                const templateNote = this.froca.notes[templateAttr.value];
+
+                if (templateNote && templateNote.noteId !== this.noteId) {
+                    attrArrs.push(
+                        templateNote.__getCachedAttributes(newPath)
+                            // template attr is used as a marker for templates, but it's not meant to be inherited
+                            .filter(attr => !(attr.type === 'label' && (attr.name === 'template' || attr.name === 'workspacetemplate')))
+                    );
+                }
+            }
+
+            noteAttributeCache.attributes[this.noteId] = [];
+            const addedAttributeIds = new Set();
+
+            for (const attr of attrArrs.flat()) {
+                if (!addedAttributeIds.has(attr.attributeId)) {
+                    addedAttributeIds.add(attr.attributeId);
+
+                    noteAttributeCache.attributes[this.noteId].push(attr);
+                }
+            }
+        }
+
+        return noteAttributeCache.attributes[this.noteId];
+    }
+
+    isRoot() {
+        return this.noteId === 'root';
+    }
+
+    getAllNotePaths(encounteredNoteIds = null) {
+        if (this.noteId === 'root') {
+            return [['root']];
+        }
+
+        if (!encounteredNoteIds) {
+            encounteredNoteIds = new Set();
+        }
+
+        encounteredNoteIds.add(this.noteId);
+
+        const parentNotes = this.getParentNotes();
+        let paths;
+
+        if (parentNotes.length === 1) { // optimization for the most common case
+            if (encounteredNoteIds.has(parentNotes[0].noteId)) {
+                return [];
+            }
+            else {
+                paths = parentNotes[0].getAllNotePaths(encounteredNoteIds);
+            }
+        }
+        else {
+            paths = [];
+
+            for (const parentNote of parentNotes) {
+                if (encounteredNoteIds.has(parentNote.noteId)) {
+                    continue;
+                }
+
+                const newSet = new Set(encounteredNoteIds);
+
+                paths.push(...parentNote.getAllNotePaths(newSet));
+            }
+        }
+
+        for (const path of paths) {
+            path.push(this.noteId);
+        }
+
+        return paths;
+    }
+
+    getSortedNotePaths(hoistedNotePath = 'root') {
+        const notePaths = this.getAllNotePaths().map(path => ({
+            notePath: path,
+            isInHoistedSubTree: path.includes(hoistedNotePath),
+            isArchived: path.find(noteId => froca.notes[noteId].isArchived),
+            isSearch: path.find(noteId => froca.notes[noteId].type === 'search'),
+            isHidden: path.includes('_hidden')
+        }));
+
+        notePaths.sort((a, b) => {
+            if (a.isInHoistedSubTree !== b.isInHoistedSubTree) {
+                return a.isInHoistedSubTree ? -1 : 1;
+            } else if (a.isSearch !== b.isSearch) {
+                return a.isSearch ? 1 : -1;
+            } else if (a.isArchived !== b.isArchived) {
+                return a.isArchived ? 1 : -1;
+            } else if (a.isHidden !== b.isHidden) {
+                return a.isHidden ? 1 : -1;
+            } else {
+                return a.notePath.length - b.notePath.length;
+            }
+        });
+
+        return notePaths;
+    }
+
+    /**
+     * @return boolean - true if there's no non-hidden path, note is not cloned to the visible tree
+     */
+    isHiddenCompletely() {
+        if (this.noteId === 'root') {
+            return false;
+        }
+
+        for (const parentNote of this.getParentNotes()) {
+            if (parentNote.noteId === 'root') {
+                return false;
+            } else if (parentNote.noteId === '_hidden') {
+                continue;
+            }
+
+            if (!parentNote.isHiddenCompletely()) {
+                return false;
+            }
+        }
+
+        return true;
+    }
+
+    __filterAttrs(attributes, type, name) {
+        this.__validateTypeName(type, name);
+
+        if (!type && !name) {
+            return attributes;
+        } else if (type && name) {
+            return attributes.filter(attr => attr.name === name && attr.type === type);
+        } else if (type) {
+            return attributes.filter(attr => attr.type === type);
+        } else if (name) {
+            return attributes.filter(attr => attr.name === name);
+        }
+    }
+
+    __getInheritableAttributes(path) {
+        const attrs = this.__getCachedAttributes(path);
+
+        return attrs.filter(attr => attr.isInheritable);
+    }
+
+    __validateTypeName(type, name) {
+        if (type && type !== 'label' && type !== 'relation') {
+            throw new Error(`Unrecognized attribute type '${type}'. Only 'label' and 'relation' are possible values.`);
+        }
+
+        if (name) {
+            const firstLetter = name.charAt(0);
+            if (firstLetter === '#' || firstLetter === '~') {
+                throw new Error(`Detect '#' or '~' in the attribute's name. In the API, attribute names should be set without these characters.`);
+            }
+        }
+    }
+
+    /**
+     * @param {string} [name] - label name to filter
+     * @returns {FAttribute[]} all note's labels (attributes with type label), including inherited ones
+     */
+    getOwnedLabels(name) {
+        return this.getOwnedAttributes(LABEL, name);
+    }
+
+    /**
+     * @param {string} [name] - label name to filter
+     * @returns {FAttribute[]} all note's labels (attributes with type label), including inherited ones
+     */
+    getLabels(name) {
+        return this.getAttributes(LABEL, name);
+    }
+
+    getIcon() {
+        const iconClassLabels = this.getLabels('iconClass');
+        const workspaceIconClass = this.getWorkspaceIconClass();
+
+        if (iconClassLabels.length > 0) {
+            return iconClassLabels[0].value;
+        }
+        else if (workspaceIconClass) {
+            return workspaceIconClass;
+        }
+        else if (this.noteId === 'root') {
+            return "bx bx-chevrons-right";
+        }
+        if (this.noteId === '_share') {
+            return "bx bx-share-alt";
+        }
+        else if (this.type === 'text') {
+            if (this.isFolder()) {
+                return "bx bx-folder";
+            }
+            else {
+                return "bx bx-note";
+            }
+        }
+        else if (this.type === 'code' && this.mime.startsWith('text/x-sql')) {
+            return "bx bx-data";
+        }
+        else {
+            return NOTE_TYPE_ICONS[this.type];
+        }
+    }
+
+    getColorClass() {
+        const color = this.getLabelValue("color");
+        return cssClassManager.createClassForColor(color);
+    }
+
+    isFolder() {
+        return this.type === 'search'
+            || this.getFilteredChildBranches().length > 0;
+    }
+
+    getFilteredChildBranches() {
+        let childBranches = this.getChildBranches();
+
+        if (!childBranches) {
+            ws.logError(`No children for '${this.noteId}'. This shouldn't happen.`);
+            return;
+        }
+
+        if (options.is("hideIncludedImages_main")) {
+            const imageLinks = this.getRelations('imageLink');
+
+            // image is already visible in the parent note so no need to display it separately in the book
+            childBranches = childBranches.filter(branch => !imageLinks.find(rel => rel.value === branch.noteId));
+        }
+
+        // we're not checking hideArchivedNotes since that would mean we need to lazy load the child notes
+        // which would seriously slow down everything.
+        // we check this flag only once user chooses to expand the parent. This has the negative consequence that
+        // note may appear as folder but not contain any children when all of them are archived
+
+        return childBranches;
+    }
+
+    /**
+     * @param {string} [name] - relation name to filter
+     * @returns {FAttribute[]} all note's relations (attributes with type relation), including inherited ones
+     */
+    getOwnedRelations(name) {
+        return this.getOwnedAttributes(RELATION, name);
+    }
+
+    /**
+     * @param {string} [name] - relation name to filter
+     * @returns {FAttribute[]} all note's relations (attributes with type relation), including inherited ones
+     */
+    getRelations(name) {
+        return this.getAttributes(RELATION, name);
+    }
+
+    /**
+     * @param {string} type - attribute type (label, relation, etc.)
+     * @param {string} name - attribute name
+     * @returns {boolean} true if note has an attribute with given type and name (including inherited)
+     */
+    hasAttribute(type, name) {
+        return !!this.getAttribute(type, name);
+    }
+
+    /**
+     * @param {string} type - attribute type (label, relation, etc.)
+     * @param {string} name - attribute name
+     * @returns {boolean} true if note has an attribute with given type and name (including inherited)
+     */
+    hasOwnedAttribute(type, name) {
+        return !!this.getOwnedAttribute(type, name);
+    }
+
+    /**
+     * @param {string} type - attribute type (label, relation, etc.)
+     * @param {string} name - attribute name
+     * @returns {FAttribute} attribute of given type and name. If there's more such attributes, first is  returned. Returns null if there's no such attribute belonging to this note.
+     */
+    getOwnedAttribute(type, name) {
+        const attributes = this.getOwnedAttributes();
+
+        return attributes.find(attr => attr.name === name && attr.type === type);
+    }
+
+    /**
+     * @param {string} type - attribute type (label, relation, etc.)
+     * @param {string} name - attribute name
+     * @returns {FAttribute} attribute of given type and name. If there's more such attributes, first is  returned. Returns null if there's no such attribute belonging to this note.
+     */
+    getAttribute(type, name) {
+        const attributes = this.getAttributes();
+
+        return attributes.find(attr => attr.name === name && attr.type === type);
+    }
+
+    /**
+     * @param {string} type - attribute type (label, relation, etc.)
+     * @param {string} name - attribute name
+     * @returns {string} attribute value of given type and name or null if no such attribute exists.
+     */
+    getOwnedAttributeValue(type, name) {
+        const attr = this.getOwnedAttribute(type, name);
+
+        return attr ? attr.value : null;
+    }
+
+    /**
+     * @param {string} type - attribute type (label, relation, etc.)
+     * @param {string} name - attribute name
+     * @returns {string} attribute value of given type and name or null if no such attribute exists.
+     */
+    getAttributeValue(type, name) {
+        const attr = this.getAttribute(type, name);
+
+        return attr ? attr.value : null;
+    }
+
+    /**
+     * @param {string} name - label name
+     * @returns {boolean} true if label exists (excluding inherited)
+     */
+    hasOwnedLabel(name) { return this.hasOwnedAttribute(LABEL, name); }
+
+    /**
+     * @param {string} name - label name
+     * @returns {boolean} true if label exists (including inherited)
+     */
+    hasLabel(name) { return this.hasAttribute(LABEL, name); }
+
+    /**
+     * @param {string} name - relation name
+     * @returns {boolean} true if relation exists (excluding inherited)
+     */
+    hasOwnedRelation(name) { return this.hasOwnedAttribute(RELATION, name); }
+
+    /**
+     * @param {string} name - relation name
+     * @returns {boolean} true if relation exists (including inherited)
+     */
+    hasRelation(name) { return this.hasAttribute(RELATION, name); }
+
+    /**
+     * @param {string} name - label name
+     * @returns {FAttribute} label if it exists, null otherwise
+     */
+    getOwnedLabel(name) { return this.getOwnedAttribute(LABEL, name); }
+
+    /**
+     * @param {string} name - label name
+     * @returns {FAttribute} label if it exists, null otherwise
+     */
+    getLabel(name) { return this.getAttribute(LABEL, name); }
+
+    /**
+     * @param {string} name - relation name
+     * @returns {FAttribute} relation if it exists, null otherwise
+     */
+    getOwnedRelation(name) { return this.getOwnedAttribute(RELATION, name); }
+
+    /**
+     * @param {string} name - relation name
+     * @returns {FAttribute} relation if it exists, null otherwise
+     */
+    getRelation(name) { return this.getAttribute(RELATION, name); }
+
+    /**
+     * @param {string} name - label name
+     * @returns {string} label value if label exists, null otherwise
+     */
+    getOwnedLabelValue(name) { return this.getOwnedAttributeValue(LABEL, name); }
+
+    /**
+     * @param {string} name - label name
+     * @returns {string} label value if label exists, null otherwise
+     */
+    getLabelValue(name) { return this.getAttributeValue(LABEL, name); }
+
+    /**
+     * @param {string} name - relation name
+     * @returns {string} relation value if relation exists, null otherwise
+     */
+    getOwnedRelationValue(name) { return this.getOwnedAttributeValue(RELATION, name); }
+
+    /**
+     * @param {string} name - relation name
+     * @returns {string} relation value if relation exists, null otherwise
+     */
+    getRelationValue(name) { return this.getAttributeValue(RELATION, name); }
+
+    /**
+     * @param {string} name
+     * @returns {Promise<FNote>|null} target note of the relation or null (if target is empty or note was not found)
+     */
+    async getRelationTarget(name) {
+        const targets = await this.getRelationTargets(name);
+
+        return targets.length > 0 ? targets[0] : null;
+    }
+
+    /**
+     * @param {string} [name] - relation name to filter
+     * @returns {Promise<FNote[]>}
+     */
+    async getRelationTargets(name) {
+        const relations = this.getRelations(name);
+        const targets = [];
+
+        for (const relation of relations) {
+            targets.push(await this.froca.getNote(relation.value));
+        }
+
+        return targets;
+    }
+
+    /**
+     * @returns {FNote[]}
+     */
+    getNotesToInheritAttributesFrom() {
+        const relations = [
+            ...this.getRelations('template'),
+            ...this.getRelations('inherit')
+        ];
+
+        return relations.map(rel => this.froca.notes[rel.value]);
+    }
+
+    getPromotedDefinitionAttributes() {
+        if (this.hasLabel('hidePromotedAttributes')) {
+            return [];
+        }
+
+        const promotedAttrs = this.getAttributes()
+            .filter(attr => attr.isDefinition())
+            .filter(attr => {
+                const def = attr.getDefinition();
+
+                return def && def.isPromoted;
+            });
+
+        // attrs are not resorted if position changes after initial load
+        promotedAttrs.sort((a, b) => a.position < b.position ? -1 : 1);
+
+        return promotedAttrs;
+    }
+
+    hasAncestor(ancestorNoteId, followTemplates = false, visitedNoteIds = null) {
+        if (this.noteId === ancestorNoteId) {
+            return true;
+        }
+
+        if (!visitedNoteIds) {
+            visitedNoteIds = new Set();
+        } else if (visitedNoteIds.has(this.noteId)) {
+            // to avoid infinite cycle when template is descendent of the instance
+            return false;
+        }
+
+        visitedNoteIds.add(this.noteId);
+
+        if (followTemplates) {
+            for (const templateNote of this.getNotesToInheritAttributesFrom()) {
+                if (templateNote.hasAncestor(ancestorNoteId, followTemplates, visitedNoteIds)) {
+                    return true;
+                }
+            }
+        }
+
+        for (const parentNote of this.getParentNotes()) {
+            if (parentNote.hasAncestor(ancestorNoteId, followTemplates, visitedNoteIds)) {
+                return true;
+            }
+        }
+
+        return false;
+    }
+
+    isInHiddenSubtree() {
+        return this.noteId === '_hidden' || this.hasAncestor('_hidden');
+    }
+
+    /**
+     * @deprecated NOOP
+     */
+    invalidateAttributeCache() {}
+
+    /**
+     * Get relations which target this note
+     *
+     * @returns {FAttribute[]}
+     */
+    getTargetRelations() {
+        return this.targetRelations
+            .map(attributeId => this.froca.attributes[attributeId]);
+    }
+
+    /**
+     * Get relations which target this note
+     *
+     * @returns {FNote[]}
+     */
+    async getTargetRelationSourceNotes() {
+        const targetRelations = this.getTargetRelations();
+
+        return await this.froca.getNotes(targetRelations.map(tr => tr.noteId));
+    }
+
+    /**
+     * Return note complement which is most importantly note's content
+     *
+     * @returns {Promise<FNoteComplement>}
+     */
+    async getNoteComplement() {
+        return await this.froca.getNoteComplement(this.noteId);
+    }
+
+    toString() {
+        return `Note(noteId=${this.noteId}, title=${this.title})`;
+    }
+
+    get dto() {
+        const dto = Object.assign({}, this);
+        delete dto.froca;
+
+        return dto;
+    }
+
+    getCssClass() {
+        const labels = this.getLabels('cssClass');
+        return labels.map(l => l.value).join(' ');
+    }
+
+    getWorkspaceIconClass() {
+        const labels = this.getLabels('workspaceIconClass');
+        return labels.length > 0 ? labels[0].value : "";
+    }
+
+    getWorkspaceTabBackgroundColor() {
+        const labels = this.getLabels('workspaceTabBackgroundColor');
+        return labels.length > 0 ? labels[0].value : "";
+    }
+
+    /** @returns {boolean} true if this note is JavaScript (code or file) */
+    isJavaScript() {
+        return (this.type === "code" || this.type === "file" || this.type === 'launcher')
+            && (this.mime.startsWith("application/javascript")
+                || this.mime === "application/x-javascript"
+                || this.mime === "text/javascript");
+    }
+
+    /** @returns {boolean} true if this note is HTML */
+    isHtml() {
+        return (this.type === "code" || this.type === "file" || this.type === "render") && this.mime === "text/html";
+    }
+
+    /** @returns {string|null} JS script environment - either "frontend" or "backend" */
+    getScriptEnv() {
+        if (this.isHtml() || (this.isJavaScript() && this.mime.endsWith('env=frontend'))) {
+            return "frontend";
+        }
+
+        if (this.type === 'render') {
+            return "frontend";
+        }
+
+        if (this.isJavaScript() && this.mime.endsWith('env=backend')) {
+            return "backend";
+        }
+
+        return null;
+    }
+
+    async executeScript() {
+        if (!this.isJavaScript()) {
+            throw new Error(`Note ${this.noteId} is of type ${this.type} and mime ${this.mime} and thus cannot be executed`);
+        }
+
+        const env = this.getScriptEnv();
+
+        if (env === "frontend") {
+            const bundleService = (await import("../services/bundle.js")).default;
+            return await bundleService.getAndExecuteBundle(this.noteId);
+        }
+        else if (env === "backend") {
+            const resp = await server.post(`script/run/${this.noteId}`);
+        }
+        else {
+            throw new Error(`Unrecognized env type ${env} for note ${this.noteId}`);
+        }
+    }
+
+    isShared() {
+        for (const parentNoteId of this.parents) {
+            if (parentNoteId === 'root' || parentNoteId === 'none') {
+                continue;
+            }
+
+            const parentNote = froca.notes[parentNoteId];
+
+            if (!parentNote || parentNote.type === 'search') {
+                continue;
+            }
+
+            if (parentNote.noteId === '_share' || parentNote.isShared()) {
+                return true;
+            }
+        }
+
+        return false;
+    }
+
+    isContentAvailable() {
+        return !this.isProtected || protectedSessionHolder.isProtectedSessionAvailable()
+    }
+
+    isLaunchBarConfig() {
+        return this.type === 'launcher' || ['_lbRoot', '_lbAvailableLaunchers', '_lbVisibleLaunchers'].includes(this.noteId);
+    }
+
+    isOptions() {
+        return this.noteId.startsWith("_options");
+    }
+}
+
+export default FNote;
+
+
+
+ + + + +
+ + + +
+ + + + + + + diff --git a/docs/frontend_api/entities_fnote_complement.js.html b/docs/frontend_api/entities_fnote_complement.js.html new file mode 100644 index 000000000..6ea35e95a --- /dev/null +++ b/docs/frontend_api/entities_fnote_complement.js.html @@ -0,0 +1,91 @@ + + + + + JSDoc: Source: entities/fnote_complement.js + + + + + + + + + + +
+ +

Source: entities/fnote_complement.js

+ + + + + + +
+
+
/**
+ * Complements the FNote with the main note content and other extra attributes
+ */
+class FNoteComplement {
+    constructor(row) {
+        /** @type {string} */
+        this.noteId = row.noteId;
+
+        /**
+         * can either contain the whole content (in e.g. string notes), only part (large text notes) or nothing at all (binary notes, images)
+         * @type {string}
+         */
+        this.content = row.content;
+
+        /** @type {int} */
+        this.contentLength = row.contentLength;
+
+        /** @type {string} */
+        this.dateCreated = row.dateCreated;
+
+        /** @type {string} */
+        this.dateModified = row.dateModified;
+
+        /** @type {string} */
+        this.utcDateCreated = row.utcDateCreated;
+
+        /** @type {string} */
+        this.utcDateModified = row.utcDateModified;
+
+        // "combined" date modified give larger out of note's and note_content's dateModified
+
+        /** @type {string} */
+        this.combinedDateModified = row.combinedDateModified;
+
+        /** @type {string} */
+        this.combinedUtcDateModified = row.combinedUtcDateModified;
+    }
+}
+
+export default FNoteComplement;
+
+
+
+ + + + +
+ + + +
+ + + + + + + diff --git a/docs/frontend_api/services_frontend_script_api.js.html b/docs/frontend_api/services_frontend_script_api.js.html new file mode 100644 index 000000000..a326810c3 --- /dev/null +++ b/docs/frontend_api/services_frontend_script_api.js.html @@ -0,0 +1,590 @@ + + + + + JSDoc: Source: services/frontend_script_api.js + + + + + + + + + + +
+ +

Source: services/frontend_script_api.js

+ + + + + + +
+
+
import server from './server.js';
+import utils from './utils.js';
+import toastService from './toast.js';
+import linkService from './link.js';
+import froca from './froca.js';
+import noteTooltipService from './note_tooltip.js';
+import protectedSessionService from './protected_session.js';
+import dateNotesService from './date_notes.js';
+import searchService from './search.js';
+import RightPanelWidget from '../widgets/right_panel_widget.js';
+import ws from "./ws.js";
+import appContext from "../components/app_context.js";
+import NoteContextAwareWidget from "../widgets/note_context_aware_widget.js";
+import BasicWidget from "../widgets/basic_widget.js";
+import SpacedUpdate from "./spaced_update.js";
+import shortcutService from "./shortcuts.js";
+
+/**
+ * <p>This is the main frontend API interface for scripts. All the properties and methods are published in the "api" object
+ * available in the JS frontend notes. You can use e.g. <code>api.showMessage(api.startNote.title);</code></p>
+ *
+ * @constructor
+ */
+function FrontendScriptApi(startNote, currentNote, originEntity = null, $container = null) {
+    /** @property {jQuery} container of all the rendered script content */
+    this.$container = $container;
+
+    /** @property {object} note where script started executing */
+    this.startNote = startNote;
+    /** @property {object} note where script is currently executing */
+    this.currentNote = currentNote;
+    /** @property {object|null} entity whose event triggered this execution */
+    this.originEntity = originEntity;
+
+    /** @property {dayjs} day.js library for date manipulation. See {@link https://day.js.org} for documentation */
+    this.dayjs = dayjs;
+
+    /**
+     * @property {RightPanelWidget}
+     * @deprecated use api.RightPanelWidget instead
+     */
+    this.CollapsibleWidget = RightPanelWidget;
+
+    /** @property {RightPanelWidget} */
+    this.RightPanelWidget = RightPanelWidget;
+
+    /** @property {NoteContextAwareWidget} */
+    this.NoteContextAwareWidget = NoteContextAwareWidget;
+
+    /**
+     * @property {NoteContextAwareWidget}
+     * @deprecated use NoteContextAwareWidget instead
+     */
+    this.TabAwareWidget = NoteContextAwareWidget;
+
+    /**
+     * @property {NoteContextAwareWidget}
+     * @deprecated use NoteContextAwareWidget instead
+     */
+    this.TabCachingWidget = NoteContextAwareWidget;
+
+    /**
+     * @property {NoteContextAwareWidget}
+     * @deprecated use NoteContextAwareWidget instead
+     */
+    this.NoteContextCachingWidget = NoteContextAwareWidget;
+
+    /** @property {BasicWidget} */
+    this.BasicWidget = BasicWidget;
+
+    /**
+     * Activates note in the tree and in the note detail.
+     *
+     * @method
+     * @param {string} notePath (or noteId)
+     * @returns {Promise<void>}
+     */
+    this.activateNote = async notePath => {
+        await appContext.tabManager.getActiveContext().setNote(notePath);
+    };
+
+    /**
+     * Activates newly created note. Compared to this.activateNote() also makes sure that frontend has been fully synced.
+     *
+     * @param {string} notePath (or noteId)
+     * @returns {Promise<void>}
+     */
+    this.activateNewNote = async notePath => {
+        await ws.waitForMaxKnownEntityChangeId();
+
+        await appContext.tabManager.getActiveContext().setNote(notePath);
+        appContext.triggerEvent('focusAndSelectTitle');
+    };
+
+    /**
+     * Open a note in a new tab.
+     *
+     * @method
+     * @param {string} notePath (or noteId)
+     * @param {boolean} activate - set to true to activate the new tab, false to stay on the current tab
+     * @returns {Promise<void>}
+     */
+    this.openTabWithNote = async (notePath, activate) => {
+        await ws.waitForMaxKnownEntityChangeId();
+
+        await appContext.tabManager.openContextWithNote(notePath, { activate });
+
+        if (activate) {
+            appContext.triggerEvent('focusAndSelectTitle');
+        }
+    };
+
+    /**
+     * Open a note in a new split.
+     *
+     * @method
+     * @param {string} notePath (or noteId)
+     * @param {boolean} activate - set to true to activate the new split, false to stay on the current split
+     * @returns {Promise<void>}
+     */
+    this.openSplitWithNote = async (notePath, activate) => {
+        await ws.waitForMaxKnownEntityChangeId();
+
+        const subContexts = appContext.tabManager.getActiveContext().getSubContexts();
+        const {ntxId} = subContexts[subContexts.length - 1];
+
+        appContext.triggerCommand("openNewNoteSplit", {ntxId, notePath});
+
+        if (activate) {
+            appContext.triggerEvent('focusAndSelectTitle');
+        }
+    };
+
+    /**
+     * Adds a new launcher to the launchbar. If the launcher (id) already exists, it will be updated.
+     *
+     * @method
+     * @deprecated you can now create/modify launchers in the top-left Menu -> Configure Launchbar
+     *             for special needs there's also backend API's createOrUpdateLauncher()
+     * @param {object} opts
+     * @property {string} [opts.id] - id of the button, used to identify the old instances of this button to be replaced
+     *                          ID is optional because of BC, but not specifying it is deprecated. ID can be alphanumeric only.
+     * @property {string} opts.title
+     * @property {string} [opts.icon] - name of the boxicon to be used (e.g. "time" for "bx-time" icon)
+     * @property {function} opts.action - callback handling the click on the button
+     * @property {string} [opts.shortcut] - keyboard shortcut for the button, e.g. "alt+t"
+     */
+    this.addButtonToToolbar = async opts => {
+        console.warn("api.addButtonToToolbar() has been deprecated since v0.58 and may be removed in the future. Use  Menu -> Configure Launchbar to create/update launchers instead.");
+
+        const {action, ...reqBody} = opts;
+        reqBody.action = action.toString();
+
+        await server.put('special-notes/api-script-launcher', reqBody);
+    };
+
+    function prepareParams(params) {
+        if (!params) {
+            return params;
+        }
+
+        return params.map(p => {
+            if (typeof p === "function") {
+                return `!@#Function: ${p.toString()}`;
+            }
+            else {
+                return p;
+            }
+        });
+    }
+
+    /**
+     * Executes given anonymous function on the backend.
+     * Internally this serializes the anonymous function into string and sends it to backend via AJAX.
+     *
+     * @method
+     * @param {string} script - script to be executed on the backend
+     * @param {Array.<?>} params - list of parameters to the anonymous function to be sent to backend
+     * @returns {Promise<*>} return value of the executed function on the backend
+     */
+    this.runOnBackend = async (script, params = []) => {
+        if (typeof script === "function") {
+            script = script.toString();
+        }
+
+        const ret = await server.post('script/exec', {
+            script: script,
+            params: prepareParams(params),
+            startNoteId: startNote.noteId,
+            currentNoteId: currentNote.noteId,
+            originEntityName: "notes", // currently there's no other entity on frontend which can trigger event
+            originEntityId: originEntity ? originEntity.noteId : null
+        }, "script");
+
+        if (ret.success) {
+            await ws.waitForMaxKnownEntityChangeId();
+
+            return ret.executionResult;
+        }
+        else {
+            throw new Error(`server error: ${ret.error}`);
+        }
+    };
+
+    /**
+     * This is a powerful search method - you can search by attributes and their values, e.g.:
+     * "#dateModified =* MONTH AND #log". See full documentation for all options at: https://github.com/zadam/trilium/wiki/Search
+     *
+     * @method
+     * @param {string} searchString
+     * @returns {Promise<FNote[]>}
+     */
+    this.searchForNotes = async searchString => {
+        return await searchService.searchForNotes(searchString);
+    };
+
+    /**
+     * This is a powerful search method - you can search by attributes and their values, e.g.:
+     * "#dateModified =* MONTH AND #log". See full documentation for all options at: https://github.com/zadam/trilium/wiki/Search
+     *
+     * @method
+     * @param {string} searchString
+     * @returns {Promise<FNote|null>}
+     */
+    this.searchForNote = async searchString => {
+        const notes = await this.searchForNotes(searchString);
+
+        return notes.length > 0 ? notes[0] : null;
+    };
+
+    /**
+     * Returns note by given noteId. If note is missing from cache, it's loaded.
+     **
+     * @method
+     * @param {string} noteId
+     * @returns {Promise<FNote>}
+     */
+    this.getNote = async noteId => await froca.getNote(noteId);
+
+    /**
+     * Returns list of notes. If note is missing from cache, it's loaded.
+     *
+     * This is often used to bulk-fill the cache with notes which would have to be picked one by one
+     * otherwise (by e.g. createNoteLink())
+     *
+     * @method
+     * @param {string[]} noteIds
+     * @param {boolean} [silentNotFoundError] - don't report error if the note is not found
+     * @returns {Promise<FNote[]>}
+     */
+    this.getNotes = async (noteIds, silentNotFoundError = false) => await froca.getNotes(noteIds, silentNotFoundError);
+
+    /**
+     * Update frontend tree (note) cache from the backend.
+     *
+     * @method
+     * @param {string[]} noteIds
+     */
+    this.reloadNotes = async noteIds => await froca.reloadNotes(noteIds);
+
+    /**
+     * Instance name identifies particular Trilium instance. It can be useful for scripts
+     * if some action needs to happen on only one specific instance.
+     *
+     * @method
+     * @returns {string}
+     */
+    this.getInstanceName = () => window.glob.instanceName;
+
+    /**
+     * @method
+     * @param {Date} date
+     * @returns {string} date in YYYY-MM-DD format
+     */
+    this.formatDateISO = utils.formatDateISO;
+
+    /**
+     * @method
+     * @param {string} str
+     * @returns {Date} parsed object
+     */
+    this.parseDate = utils.parseDate;
+
+    /**
+     * Show info message to the user.
+     *
+     * @method
+     * @param {string} message
+     */
+    this.showMessage = toastService.showMessage;
+
+    /**
+     * Show error message to the user.
+     *
+     * @method
+     * @param {string} message
+     */
+    this.showError = toastService.showError;
+
+    /**
+     * Trigger command.
+     *
+     * @method
+     * @param {string} name
+     * @param {object} data
+     */
+    this.triggerCommand = (name, data) => appContext.triggerCommand(name, data);
+
+    /**
+     * Trigger event.
+     *
+     * @method
+     * @param {string} name
+     * @param {object} data
+     */
+    this.triggerEvent = (name, data) => appContext.triggerEvent(name, data);
+
+    /**
+     * Create note link (jQuery object) for given note.
+     *
+     * @method
+     * @param {string} notePath (or noteId)
+     * @param {object} [params]
+     * @param {boolean} [params.showTooltip=true] - enable/disable tooltip on the link
+     * @param {boolean} [params.showNotePath=false] - show also whole note's path as part of the link
+     * @param {boolean} [params.showNoteIcon=false] - show also note icon before the title
+     * @param {string} [params.title=] - custom link tile with note's title as default
+     */
+    this.createNoteLink = linkService.createNoteLink;
+
+    /**
+     * Adds given text to the editor cursor
+     *
+     * @method
+     * @param {string} text - this must be clear text, HTML is not supported.
+     */
+    this.addTextToActiveContextEditor = text => appContext.triggerCommand('addTextToActiveEditor', {text});
+
+    /**
+     * @method
+     * @returns {FNote} active note (loaded into right pane)
+     */
+    this.getActiveContextNote = () => appContext.tabManager.getActiveContextNote();
+
+    /**
+     * See https://ckeditor.com/docs/ckeditor5/latest/api/module_core_editor_editor-Editor.html for a documentation on the returned instance.
+     *
+     * @method
+     * @returns {Promise<CKEditor>} instance of CKEditor
+     */
+    this.getActiveContextTextEditor = () => appContext.tabManager.getActiveContext()?.getTextEditor();
+
+    /**
+     * See https://codemirror.net/doc/manual.html#api
+     *
+     * @method
+     * @returns {Promise<CodeMirror>} instance of CodeMirror
+     */
+    this.getActiveContextCodeEditor = () => appContext.tabManager.getActiveContext()?.getCodeEditor();
+
+    /**
+     * Get access to the widget handling note detail. Methods like `getWidgetType()` and `getTypeWidget()` to get to the
+     * implementation of actual widget type.
+     *
+     * @method
+     * @returns {Promise<NoteDetailWidget>}
+     */
+    this.getActiveNoteDetailWidget = () => new Promise(resolve => appContext.triggerCommand('executeInActiveNoteDetailWidget', {callback: resolve}));
+
+    /**
+     * @method
+     * @returns {Promise<string|null>} returns note path of active note or null if there isn't active note
+     */
+    this.getActiveContextNotePath = () => appContext.tabManager.getActiveContextNotePath();
+
+    /**
+     * Returns component which owns given DOM element (the nearest parent component in DOM tree)
+     *
+     * @method
+     * @param {Element} el - DOM element
+     * @returns {Component}
+     */
+    this.getComponentByEl = el => appContext.getComponentByEl(el);
+
+    /**
+     * @method
+     * @param {object} $el - jquery object on which to set up the tooltip
+     * @returns {Promise<void>}
+     */
+    this.setupElementTooltip = noteTooltipService.setupElementTooltip;
+
+    /**
+     * @method
+     * @param {string} noteId
+     * @param {boolean} protect - true to protect note, false to unprotect
+     * @returns {Promise<void>}
+     */
+    this.protectNote = async (noteId, protect) => {
+        await protectedSessionService.protectNote(noteId, protect, false);
+    };
+
+    /**
+     * @method
+     * @param {string} noteId
+     * @param {boolean} protect - true to protect subtree, false to unprotect
+     * @returns {Promise<void>}
+     */
+    this.protectSubTree = async (noteId, protect) => {
+        await protectedSessionService.protectNote(noteId, protect, true);
+    };
+
+    /**
+     * Returns date-note for today. If it doesn't exist, it is automatically created.
+     *
+     * @method
+     * @returns {Promise<FNote>}
+     */
+    this.getTodayNote = dateNotesService.getTodayNote;
+
+    /**
+     * Returns day note for a given date. If it doesn't exist, it is automatically created.
+     *
+     * @method
+     * @param {string} date - e.g. "2019-04-29"
+     * @returns {Promise<FNote>}
+     */
+    this.getDayNote = dateNotesService.getDayNote;
+
+    /**
+     * Returns day note for the first date of the week of the given date. If it doesn't exist, it is automatically created.
+     *
+     * @method
+     * @param {string} date - e.g. "2019-04-29"
+     * @returns {Promise<FNote>}
+     */
+     this.getWeekNote = dateNotesService.getWeekNote;
+
+    /**
+     * Returns month-note. If it doesn't exist, it is automatically created.
+     *
+     * @method
+     * @param {string} month - e.g. "2019-04"
+     * @returns {Promise<FNote>}
+     */
+    this.getMonthNote = dateNotesService.getMonthNote;
+
+    /**
+     * Returns year-note. If it doesn't exist, it is automatically created.
+     *
+     * @method
+     * @param {string} year - e.g. "2019"
+     * @returns {Promise<FNote>}
+     */
+    this.getYearNote = dateNotesService.getYearNote;
+
+    /**
+     * Hoist note in the current tab. See https://github.com/zadam/trilium/wiki/Note-hoisting
+     *
+     * @method
+     * @param {string} noteId - set hoisted note. 'root' will effectively unhoist
+     * @returns {Promise<void>}
+     */
+    this.setHoistedNoteId = (noteId) => {
+        const activeNoteContext = appContext.tabManager.getActiveContext();
+
+        if (activeNoteContext) {
+            activeNoteContext.setHoistedNoteId(noteId);
+        }
+    };
+
+    /**
+     * @method
+     * @param {string} keyboardShortcut - e.g. "ctrl+shift+a"
+     * @param {function} handler
+     * @param {string} [namespace] - specify namespace of the handler for the cases where call for bind may be repeated.
+     *                               If a handler with this ID exists, it's replaced by the new handler.
+     * @returns {Promise<void>}
+     */
+    this.bindGlobalShortcut = shortcutService.bindGlobalShortcut;
+
+    /**
+     * Trilium runs in backend and frontend process, when something is changed on the backend from script,
+     * frontend will get asynchronously synchronized.
+     *
+     * This method returns a promise which resolves once all the backend -> frontend synchronization is finished.
+     * Typical use case is when new note has been created, we should wait until it is synced into frontend and only then activate it.
+     *
+     * @method
+     * @returns {Promise<void>}
+     */
+    this.waitUntilSynced = ws.waitForMaxKnownEntityChangeId;
+
+    /**
+     * This will refresh all currently opened notes which have included note specified in the parameter
+     *
+     * @param includedNoteId - noteId of the included note
+     * @returns {Promise<void>}
+     */
+    this.refreshIncludedNote = includedNoteId => appContext.triggerEvent('refreshIncludedNote', {noteId: includedNoteId});
+
+    /**
+     * Return randomly generated string of given length. This random string generation is NOT cryptographically secure.
+     *
+     * @method
+     * @param {number} length of the string
+     * @returns {string} random string
+     */
+    this.randomString = utils.randomString;
+
+    this.logMessages = {};
+    this.logSpacedUpdates = {};
+
+    /**
+     * Log given message to the log pane in UI
+     *
+     * @param message
+     * @returns {void}
+     */
+    this.log = message => {
+        const {noteId} = this.startNote;
+
+        message = `${utils.now()}: ${message}`;
+
+        console.log(`Script ${noteId}: ${message}`);
+
+        this.logMessages[noteId] = this.logMessages[noteId] || [];
+        this.logSpacedUpdates[noteId] = this.logSpacedUpdates[noteId] || new SpacedUpdate(() => {
+            const messages = this.logMessages[noteId];
+            this.logMessages[noteId] = [];
+
+            appContext.triggerEvent("apiLogMessages", {noteId, messages});
+        }, 100);
+
+        this.logMessages[noteId].push(message);
+        this.logSpacedUpdates[noteId].scheduleUpdate();
+    };
+}
+
+export default FrontendScriptApi;
+
+
+
+ + + + +
+ + + +
+ + + + + + + diff --git a/package.json b/package.json index 451d2cf99..01f949ae7 100644 --- a/package.json +++ b/package.json @@ -20,7 +20,7 @@ "switch-server": "rm -rf ./node_modules/better-sqlite3 && npm install", "switch-electron": "rm -rf ./node_modules/better-sqlite3 && npm install && ./node_modules/.bin/electron-rebuild", "build-backend-docs": "rm -rf ./docs/backend_api && ./node_modules/.bin/jsdoc -c jsdoc-conf.json -d ./docs/backend_api src/becca/entities/*.js src/services/backend_script_api.js src/services/sql.js", - "build-frontend-docs": "rm -rf ./docs/frontend_api && ./node_modules/.bin/jsdoc -c jsdoc-conf.json -d ./docs/frontend_api src/public/app/entities/*.js src/public/app/services/frontend_script_api.js", + "build-frontend-docs": "rm -rf ./docs/frontend_api && ./node_modules/.bin/jsdoc -c jsdoc-conf.json -d ./docs/frontend_api src/public/app/entities/*.js src/public/app/services/frontend_script_api.js src/public/app/widgets/right_panel_widget.js", "build-docs": "npm run build-backend-docs && npm run build-frontend-docs", "webpack": "npx webpack -c webpack-desktop.config.js && npx webpack -c webpack-mobile.config.js && npx webpack -c webpack-setup.config.js", "test-jasmine": "jasmine",