+ "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;
+
+
+