From 8d8d654fe8111026bceb33f8c911a5da61e8e219 Mon Sep 17 00:00:00 2001 From: zadam Date: Sun, 25 Apr 2021 20:00:42 +0200 Subject: [PATCH] becca entities enriched with functionality from repository entities --- .idea/dataSources.xml | 5 +- db/TODO.txt | 2 + src/services/becca/becca_loader.js | 2 +- .../becca/entities/abstract_entity.js | 43 ++++ src/services/becca/entities/attribute.js | 106 ++++++++- src/services/becca/entities/branch.js | 51 +++- src/services/becca/entities/note.js | 224 +++++++++++++++++- 7 files changed, 407 insertions(+), 26 deletions(-) create mode 100644 db/TODO.txt create mode 100644 src/services/becca/entities/abstract_entity.js diff --git a/.idea/dataSources.xml b/.idea/dataSources.xml index 3ecf47dd8..4d813bd13 100644 --- a/.idea/dataSources.xml +++ b/.idea/dataSources.xml @@ -1,11 +1,12 @@ - + sqlite.xerial true org.sqlite.JDBC - jdbc:sqlite:$USER_HOME$/trilium-data/document.db + jdbc:sqlite:$PROJECT_DIR$/../trilium-data/document.db + $ProjectFileDir$ \ No newline at end of file diff --git a/db/TODO.txt b/db/TODO.txt new file mode 100644 index 000000000..b5c7b8e1a --- /dev/null +++ b/db/TODO.txt @@ -0,0 +1,2 @@ +- drop branches.utcDateCreated - not used for anything +- drop options.utcDateCreated - not used for anything diff --git a/src/services/becca/becca_loader.js b/src/services/becca/becca_loader.js index 7d2e29882..21f89178e 100644 --- a/src/services/becca/becca_loader.js +++ b/src/services/becca/becca_loader.js @@ -17,7 +17,7 @@ function load() { const start = Date.now(); becca.reset(); - for (const row of sql.iterateRows(`SELECT noteId, title, type, mime, isProtected, dateCreated, dateModified, utcDateCreated, utcDateModified FROM notes WHERE isDeleted = 0`, [])) { + for (const row of sql.iterateRows(`SELECT noteId, title, type, mime, isProtected, dateCreated, dateModified, utcDateCreated, utcDateModified FROM notes`, [])) { new Note(becca, row); } diff --git a/src/services/becca/entities/abstract_entity.js b/src/services/becca/entities/abstract_entity.js new file mode 100644 index 000000000..9797fea9c --- /dev/null +++ b/src/services/becca/entities/abstract_entity.js @@ -0,0 +1,43 @@ +"use strict"; + +class AbstractEntity { + beforeSaving() { + this.generateIdIfNecessary(); + } + + generateIdIfNecessary() { + if (!this[this.constructor.primaryKeyName]) { + this[this.constructor.primaryKeyName] = utils.newEntityId(); + } + } + + generateHash() { + let contentToHash = ""; + + for (const propertyName of this.constructor.hashedProperties) { + contentToHash += "|" + this[propertyName]; + } + + return utils.hash(contentToHash).substr(0, 10); + } + + getUtcDateChanged() { + return this.utcDateModified || this.utcDateCreated; + } + + get repository() { + if (!repo) { + repo = require('../services/repository'); + } + + return repo; + } + + save() { + this.repository.updateEntity(this); + + return this; + } +} + +module.exports = AbstractEntity; diff --git a/src/services/becca/entities/attribute.js b/src/services/becca/entities/attribute.js index a616eab34..01163e439 100644 --- a/src/services/becca/entities/attribute.js +++ b/src/services/becca/entities/attribute.js @@ -1,9 +1,19 @@ "use strict"; const Note = require('./note.js'); +const AbstractEntity = require("./abstract_entity.js"); +const sql = require("../../sql.js"); +const dateUtils = require("../../date_utils.js"); +const promotedAttributeDefinitionParser = require("../../promoted_attribute_definition_parser"); + +class Attribute extends AbstractEntity { + static get entityName() { return "attributes"; } + static get primaryKeyName() { return "attributeId"; } + static get hashedProperties() { return ["attributeId", "noteId", "type", "name", "value", "isInheritable"]; } -class Attribute { constructor(becca, row) { + super(); + /** @param {Becca} */ this.becca = becca; /** @param {string} */ @@ -60,13 +70,99 @@ class Attribute { } } - // for logging etc + /** + * @returns {Note|null} + */ + getNote() { + return this.repository.getNote(this.noteId); + } + + /** + * @returns {Note|null} + */ + getTargetNote() { + if (this.type !== 'relation') { + throw new Error(`Attribute ${this.attributeId} is not relation`); + } + + if (!this.value) { + return null; + } + + return this.repository.getNote(this.value); + } + + /** + * @return {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 pojo() { - const pojo = {...this}; + return { + attributeId: this.attributeId, + noteId: this.noteId, + type: this.type, + name: this.name, + position: this.position, + value: this.value, + isInheritable: this.isInheritable + }; + } - delete pojo.becca; + beforeSaving() { + if (!this.value) { + if (this.type === 'relation') { + throw new Error(`Cannot save relation ${this.name} since it does not target any note.`); + } - return pojo; + // null value isn't allowed + this.value = ""; + } + + if (this.position === undefined) { + this.position = 1 + sql.getValue(`SELECT COALESCE(MAX(position), 0) FROM attributes WHERE noteId = ?`, [this.noteId]); + } + + if (!this.isInheritable) { + this.isInheritable = false; + } + + if (!this.isDeleted) { + this.isDeleted = false; + } + + super.beforeSaving(); + + this.utcDateModified = dateUtils.utcNowDateTime(); + } + + createClone(type, name, value, isInheritable) { + return new Attribute({ + noteId: this.noteId, + type: type, + name: name, + value: value, + position: this.position, + isInheritable: isInheritable, + isDeleted: false, + utcDateModified: this.utcDateModified + }); } } diff --git a/src/services/becca/entities/branch.js b/src/services/becca/entities/branch.js index 409ff53d3..497559a7e 100644 --- a/src/services/becca/entities/branch.js +++ b/src/services/becca/entities/branch.js @@ -1,9 +1,19 @@ "use strict"; const Note = require('./note.js'); +const AbstractEntity = require("./abstract_entity.js"); +const sql = require("../../sql.js"); +const dateUtils = require("../../date_utils.js"); + +class Branch extends AbstractEntity { + 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"]; } -class Branch { constructor(becca, row) { + super(); + /** @param {Becca} */ this.becca = becca; /** @param {string} */ @@ -55,13 +65,44 @@ class Branch { return this.becca.notes[this.parentNoteId]; } - // for logging etc get pojo() { - const pojo = {...this}; + return { + branchId: this.branchId, + noteId: this.noteId, + parentNoteId: this.parentNoteId, + prefix: this.prefix, + notePosition: this.notePosition, + isExpanded: this.isExpanded + }; + } - delete pojo.becca; + createClone(parentNoteId, notePosition) { + return new Branch({ + noteId: this.noteId, + parentNoteId: parentNoteId, + notePosition: notePosition, + prefix: this.prefix, + isExpanded: this.isExpanded + }); + } - return pojo; + beforeSaving() { + if (this.notePosition === undefined || this.notePosition === null) { + const maxNotePos = sql.getValue('SELECT MAX(notePosition) FROM branches WHERE parentNoteId = ? AND isDeleted = 0', [this.parentNoteId]); + this.notePosition = maxNotePos === null ? 0 : maxNotePos + 10; + } + + if (!this.isExpanded) { + this.isExpanded = false; + } + + if (!this.branchId) { + this.utcDateCreated = dateUtils.utcNowDateTime(); + } + + super.beforeSaving(); + + this.utcDateModified = dateUtils.utcNowDateTime(); } } diff --git a/src/services/becca/entities/note.js b/src/services/becca/entities/note.js index 9ca720350..ccd58a29e 100644 --- a/src/services/becca/entities/note.js +++ b/src/services/becca/entities/note.js @@ -2,12 +2,23 @@ const protectedSessionService = require('../../protected_session'); const log = require('../../log'); +const sql = require('../../sql'); +const utils = require('../../utils'); +const dateUtils = require('../../date_utils'); +const entityChangesService = require('../../entity_changes.js'); +const AbstractEntity = require("./abstract_entity.js"); const LABEL = 'label'; const RELATION = 'relation'; -class Note { +class Note extends AbstractEntity { + static get entityName() { return "notes"; } + static get primaryKeyName() { return "noteId"; } + static get hashedProperties() { return ["noteId", "title", "isProtected", "type", "mime"]; } + constructor(becca, row) { + super(); + /** @param {Becca} */ this.becca = becca; @@ -46,10 +57,14 @@ class Note { } update(row) { + // ------ Database persisted attributes ------ + /** @param {string} */ this.noteId = row.noteId; /** @param {string} */ this.title = row.title; + /** @param {boolean} */ + this.isProtected = !!row.isProtected; /** @param {string} */ this.type = row.type; /** @param {string} */ @@ -62,8 +77,9 @@ class Note { this.utcDateCreated = row.utcDateCreated; /** @param {string} */ this.utcDateModified = row.utcDateModified; - /** @param {boolean} */ - this.isProtected = !!row.isProtected; + + // ------ Derived attributes ------ + /** @param {boolean} */ this.isDecrypted = !row.isProtected || !!row.isContentAvailable; @@ -73,6 +89,162 @@ class Note { this.flatTextCache = null; } + /* + * Note content has quite special handling - it's not a separate entity, but a lazily loaded + * part of Note entity with it's 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]); + } + + /** @returns {*} */ + getJsonContent() { + const content = this.getContent(); + + if (!content || !content.trim()) { + return null; + } + + return JSON.parse(content); + } + + setContent(content) { + 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 { + 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 + }, null); + } + + 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.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 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; + } + /** @return {Attribute[]} */ get attributes() { return this.__getAttributes([]); @@ -543,19 +715,45 @@ class Note { } } - // for logging etc get pojo() { - const pojo = {...this}; + return { + noteId: this.noteId, + title: this.title, + isProtected: this.isProtected, + type: this.type, + mime: this.mime, + dateCreated: this.dateCreated, + dateModified: this.dateModified, + utcDateCreated: this.utcDateCreated, + utcDateModified: this.utcDateModified + }; + } - delete pojo.becca; - delete pojo.ancestorCache; - delete pojo.attributeCache; - delete pojo.flatTextCache; - delete pojo.children; - delete pojo.parents; - delete pojo.parentBranches; + beforeSaving() { + if (!this.dateCreated) { + this.dateCreated = dateUtils.localNowDateTime(); + } - return pojo; + if (!this.utcDateCreated) { + this.utcDateCreated = dateUtils.utcNowDateTime(); + } + + super.beforeSaving(); + + this.dateModified = dateUtils.localNowDateTime(); + this.utcDateModified = dateUtils.utcNowDateTime(); + } + + updatePojo(pojo) { + 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; + } + } } }