From a14aa461cabf8403fd37d2cb32a38c07686d0e73 Mon Sep 17 00:00:00 2001 From: zadam Date: Sat, 16 Oct 2021 22:13:34 +0200 Subject: [PATCH] sharing WIP --- src/becca/becca.js | 3 + src/public/app/services/froca.js | 4 +- src/share/entities/attribute.js | 112 ++++++ src/share/entities/branch.js | 90 +++++ src/share/entities/note.js | 577 +++++++++++++++++++++++++++++++ src/share/shaca/shaca.js | 75 ++++ src/share/shaca/shaca_loader.js | 207 +++++++++++ src/share/sql.js | 167 +++++++++ 8 files changed, 1234 insertions(+), 1 deletion(-) create mode 100644 src/share/entities/attribute.js create mode 100644 src/share/entities/branch.js create mode 100644 src/share/entities/note.js create mode 100644 src/share/shaca/shaca.js create mode 100644 src/share/shaca/shaca_loader.js create mode 100644 src/share/sql.js diff --git a/src/becca/becca.js b/src/becca/becca.js index ecd6babce..48256aa74 100644 --- a/src/becca/becca.js +++ b/src/becca/becca.js @@ -3,6 +3,9 @@ const sql = require("../services/sql.js"); const NoteSet = require("../services/search/note_set"); +/** + * Becca is a backend cache of all notes, branches and attributes. There's a similar frontend cache Froca. + */ class Becca { constructor() { this.reset(); diff --git a/src/public/app/services/froca.js b/src/public/app/services/froca.js index 87476432b..1ba9b04d8 100644 --- a/src/public/app/services/froca.js +++ b/src/public/app/services/froca.js @@ -6,12 +6,14 @@ import appContext from "./app_context.js"; import NoteComplement from "../entities/note_complement.js"; /** - * Froca keeps a read only cache of note tree structure in frontend's memory. + * Froca (FROntend CAche) keeps a read only cache of note tree structure in frontend's memory. * - notes are loaded lazily when unknown noteId is requested * - when note is loaded, all its parent and child branches are loaded as well. For a branch to be used, it's not must be loaded before * - deleted notes are present in the cache as well, but they don't have any branches. As a result check for deleted branch is done by presence check - if the branch is not there even though the corresponding note has been loaded, we can infer it is deleted. * * Note and branch deletions are corner cases and usually not needed. + * + * Backend has a similar cache called Becca */ class Froca { constructor() { diff --git a/src/share/entities/attribute.js b/src/share/entities/attribute.js new file mode 100644 index 000000000..eb7ce2ebd --- /dev/null +++ b/src/share/entities/attribute.js @@ -0,0 +1,112 @@ +"use strict"; + +const Note = require('./note.js'); +const sql = require("../sql.js"); + +class Attribute { + constructor(row) { + this.updateFromRow(row); + this.init(); + } + + updateFromRow(row) { + this.update([ + row.attributeId, + row.noteId, + row.type, + row.name, + row.value, + row.isInheritable, + row.position + ]); + } + + update([attributeId, noteId, type, name, value, isInheritable, position]) { + /** @param {string} */ + this.attributeId = attributeId; + /** @param {string} */ + this.noteId = noteId; + /** @param {string} */ + this.type = type; + /** @param {string} */ + this.name = name; + /** @param {int} */ + this.position = position; + /** @param {string} */ + this.value = value; + /** @param {boolean} */ + this.isInheritable = !!isInheritable; + + 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 Note({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); + } + } + + get isAffectingSubtree() { + return this.isInheritable + || (this.type === 'relation' && this.name === 'template'); + } + + 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 {Note|null} + */ + getNote() { + return this.becca.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.becca.getNote(this.value); + } +} + +module.exports = Attribute; diff --git a/src/share/entities/branch.js b/src/share/entities/branch.js new file mode 100644 index 000000000..24af4d858 --- /dev/null +++ b/src/share/entities/branch.js @@ -0,0 +1,90 @@ +"use strict"; + +const Note = require('./note.js'); +const sql = require("../sql.js"); + +class Branch { + constructor(row) { + 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]) { + /** @param {string} */ + this.branchId = branchId; + /** @param {string} */ + this.noteId = noteId; + /** @param {string} */ + this.parentNoteId = parentNoteId; + /** @param {string} */ + this.prefix = prefix; + /** @param {int} */ + this.notePosition = notePosition; + /** @param {boolean} */ + this.isExpanded = !!isExpanded; + + return this; + } + + init() { + if (this.branchId === 'root') { + return; + } + + const childNote = this.childNote; + const parentNote = this.parentNote; + + if (!childNote.parents.includes(parentNote)) { + childNote.parents.push(parentNote); + } + + if (!childNote.parentBranches.includes(this)) { + childNote.parentBranches.push(this); + } + + if (!parentNote.children.includes(childNote)) { + parentNote.children.push(childNote); + } + + this.becca.branches[this.branchId] = this; + this.becca.childParentToBranch[`${this.noteId}-${this.parentNoteId}`] = this; + } + + /** @return {Note} */ + get childNote() { + 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 Note({noteId: this.noteId})); + } + + return this.becca.notes[this.noteId]; + } + + getNote() { + return this.childNote; + } + + /** @return {Note} */ + get parentNote() { + if (!(this.parentNoteId in this.becca.notes)) { + // entities can come out of order in sync, create skeleton which will be filled later + this.becca.addNote(this.parentNoteId, new Note({noteId: this.parentNoteId})); + } + + return this.becca.notes[this.parentNoteId]; + } +} + +module.exports = Branch; diff --git a/src/share/entities/note.js b/src/share/entities/note.js new file mode 100644 index 000000000..070f9481b --- /dev/null +++ b/src/share/entities/note.js @@ -0,0 +1,577 @@ +"use strict"; + +const sql = require('../sql'); +const utils = require('../../services/utils'); + +const LABEL = 'label'; +const RELATION = 'relation'; + +class Note { + constructor(row) { + this.updateFromRow(row); + this.init(); + } + + updateFromRow(row) { + this.update([ + row.noteId, + row.title, + row.type, + row.mime + ]); + } + + update([noteId, title, type, mime]) { + /** @param {string} */ + this.noteId = noteId; + /** @param {string} */ + this.title = title; + /** @param {string} */ + this.type = type; + /** @param {string} */ + this.mime = mime; + + return this; + } + + init() { + /** @param {Branch[]} */ + this.parentBranches = []; + /** @param {Note[]} */ + this.parents = []; + /** @param {Note[]} */ + this.children = []; + /** @param {Attribute[]} */ + this.ownedAttributes = []; + + /** @param {Attribute[]|null} */ + this.__attributeCache = null; + /** @param {Attribute[]|null} */ + this.inheritableAttributeCache = null; + + /** @param {Attribute[]} */ + this.targetRelations = []; + + this.becca.addNote(this.noteId, this); + + /** @param {Note[]|null} */ + this.ancestorCache = null; + } + + getParentBranches() { + return this.parentBranches; + } + + getBranches() { + return this.parentBranches; + } + + getParentNotes() { + return this.parents; + } + + getChildNotes() { + return this.children; + } + + hasChildren() { + return this.children && this.children.length > 0; + } + + getChildBranches() { + return this.children.map(childNote => this.becca.getBranchFromChildAndParent(childNote.noteId, this.noteId)); + } + + 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.isStringNote()) { + return content === null + ? "" + : content.toString("UTF-8"); + } + else { + return content; + } + } + + /** @returns {*} */ + getJsonContent() { + const content = this.getContent(); + + if (!content || !content.trim()) { + return null; + } + + return JSON.parse(content); + } + + /** @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); + } + + /** + * @param {string} [type] - (optional) attribute type to filter + * @param {string} [name] - (optional) attribute name to filter + * @returns {Attribute[]} all note's attributes, including inherited ones + */ + getAttributes(type, name) { + this.__getAttributes([]); + + if (type && name) { + return this.__attributeCache.filter(attr => attr.type === type && attr.name === name); + } + else if (type) { + return this.__attributeCache.filter(attr => attr.type === type); + } + else if (name) { + return this.__attributeCache.filter(attr => attr.name === name); + } + else { + return this.__attributeCache.slice(); + } + } + + __getAttributes(path) { + if (path.includes(this.noteId)) { + return []; + } + + if (!this.__attributeCache) { + const parentAttributes = this.ownedAttributes.slice(); + const newPath = [...path, this.noteId]; + + if (this.noteId !== 'root') { + 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' && ownedAttr.name === 'template') { + const templateNote = this.becca.notes[ownedAttr.value]; + + if (templateNote) { + templateAttributes.push(...templateNote.__getAttributes(newPath)); + } + } + } + + 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; + } + + /** @return {Attribute[]} */ + __getInheritableAttributes(path) { + if (path.includes(this.noteId)) { + return []; + } + + if (!this.inheritableAttributeCache) { + this.__getAttributes(path); // will refresh also this.inheritableAttributeCache + } + + return this.inheritableAttributeCache; + } + + hasAttribute(type, name) { + return !!this.getAttributes().find(attr => attr.type === type && attr.name === name); + } + + getAttributeCaseInsensitive(type, name, value) { + name = name.toLowerCase(); + value = value ? value.toLowerCase() : null; + + return this.getAttributes().find( + attr => attr.type === type + && attr.name.toLowerCase() === name + && (!value || attr.value.toLowerCase() === value)); + } + + getRelationTarget(name) { + const relation = this.getAttributes().find(attr => attr.type === 'relation' && attr.name === name); + + return relation ? relation.targetNote : null; + } + + /** + * @param {string} name - label name + * @returns {boolean} true if label exists (including inherited) + */ + hasLabel(name) { return this.hasAttribute(LABEL, name); } + + /** + * @param {string} name - label name + * @returns {boolean} true if label exists (excluding inherited) + */ + hasOwnedLabel(name) { return this.hasOwnedAttribute(LABEL, name); } + + /** + * @param {string} name - relation name + * @returns {boolean} true if relation exists (including inherited) + */ + hasRelation(name) { return this.hasAttribute(RELATION, name); } + + /** + * @param {string} name - relation name + * @returns {boolean} true if relation exists (excluding inherited) + */ + hasOwnedRelation(name) { return this.hasOwnedAttribute(RELATION, name); } + + /** + * @param {string} name - label name + * @returns {Attribute|null} label if it exists, null otherwise + */ + getLabel(name) { return this.getAttribute(LABEL, name); } + + /** + * @param {string} name - label name + * @returns {Attribute|null} label if it exists, null otherwise + */ + getOwnedLabel(name) { return this.getOwnedAttribute(LABEL, name); } + + /** + * @param {string} name - relation name + * @returns {Attribute|null} relation if it exists, null otherwise + */ + getRelation(name) { return this.getAttribute(RELATION, name); } + + /** + * @param {string} name - relation name + * @returns {Attribute|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 + * @returns {boolean} true if note has an attribute with given type and name (excluding inherited) + */ + hasOwnedAttribute(type, name) { + return !!this.getOwnedAttribute(type, name); + } + + /** + * @param {string} type - attribute type (label, relation, etc.) + * @param {string} name - attribute name + * @returns {Attribute} 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.type === type && attr.name === name); + } + + /** + * @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 {Attribute[]} 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 {Attribute[]} 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 {Attribute[]} 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 {Attribute[]} all note's relations (attributes with type relation), excluding inherited ones + */ + getOwnedRelations(name) { + return this.getOwnedAttributes(RELATION, name); + } + + /** + * @param {string} [type] - (optional) attribute type to filter + * @param {string} [name] - (optional) attribute name to filter + * @returns {Attribute[]} note's "owned" attributes - excluding inherited ones + */ + getOwnedAttributes(type, name) { + // it's a common mistake to include # or ~ into attribute name + if (name && ["#", "~"].includes(name[0])) { + name = name.substr(1); + } + + if (type && name) { + return this.ownedAttributes.filter(attr => attr.type === type && attr.name === name); + } + 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 {Attribute} attribute belonging to this specific note (excludes inherited attributes) + * + * This method can be significantly faster than the getAttribute() + */ + getOwnedAttribute(type, name) { + const attrs = this.getOwnedAttributes(type, name); + + return attrs.length > 0 ? attrs[0] : null; + } + + get isArchived() { + return this.hasAttribute('label', 'archived'); + } + + hasInheritableOwnedArchivedLabel() { + return !!this.ownedAttributes.find(attr => attr.type === 'label' && attr.name === 'archived' && attr.isInheritable); + } + + // 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.parentBranches.sort((a, b) => + a.branchId.startsWith('virt-') + || a.parentNote.hasInheritableOwnedArchivedLabel() ? 1 : -1); + + this.parents = this.parentBranches.map(branch => branch.parentNote); + } + + isTemplate() { + return !!this.targetRelations.find(rel => rel.name === 'template'); + } + + /** @return {Note[]} */ + getSubtreeNotesIncludingTemplated() { + const arr = [[this]]; + + for (const childNote of this.children) { + arr.push(childNote.getSubtreeNotesIncludingTemplated()); + } + + for (const targetRelation of this.targetRelations) { + if (targetRelation.name === 'template') { + const note = targetRelation.note; + + if (note) { + arr.push(note.getSubtreeNotesIncludingTemplated()); + } + } + } + + return arr.flat(); + } + + /** @return {Note[]} */ + getSubtreeNotes(includeArchived = true) { + const noteSet = new Set(); + + function addSubtreeNotesInner(note) { + if (!includeArchived && note.isArchived) { + return; + } + + noteSet.add(note); + + for (const childNote of note.children) { + addSubtreeNotesInner(childNote); + } + } + + addSubtreeNotesInner(this); + + return Array.from(noteSet); + } + + /** @return {String[]} */ + getSubtreeNoteIds() { + return this.getSubtreeNotes().map(note => note.noteId); + } + + getDescendantNoteIds() { + return this.getSubtreeNoteIds(); + } + + getAncestors() { + if (!this.ancestorCache) { + const noteIds = new Set(); + this.ancestorCache = []; + + for (const parent of this.parents) { + if (!noteIds.has(parent.noteId)) { + 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; + } + + getTargetRelations() { + return this.targetRelations; + } + + /** @return {Note[]} - returns only notes which are templated, does not include their subtrees + * in effect returns notes which are influenced by note's non-inheritable attributes */ + getTemplatedNotes() { + const arr = [this]; + + for (const targetRelation of this.targetRelations) { + if (targetRelation.name === 'template') { + const note = targetRelation.note; + + if (note) { + arr.push(note); + } + } + } + + return arr; + } + + /** + * @param ancestorNoteId + * @return {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)); + } +} + +module.exports = Note; diff --git a/src/share/shaca/shaca.js b/src/share/shaca/shaca.js new file mode 100644 index 000000000..6ffaeb936 --- /dev/null +++ b/src/share/shaca/shaca.js @@ -0,0 +1,75 @@ +"use strict"; + +class Shaca { + constructor() { + this.reset(); + } + + reset() { + /** @type {Object.} */ + this.notes = {}; + /** @type {Object.} */ + this.branches = {}; + /** @type {Object.} */ + this.childParentToBranch = {}; + /** @type {Object.} */ + this.attributes = {}; + + this.loaded = false; + } + + getNote(noteId) { + return this.notes[noteId]; + } + + getNotes(noteIds, ignoreMissing = false) { + const filteredNotes = []; + + for (const noteId of noteIds) { + const note = this.notes[noteId]; + + if (!note) { + if (ignoreMissing) { + continue; + } + + throw new Error(`Note '${noteId}' was not found in becca.`); + } + + filteredNotes.push(note); + } + + return filteredNotes; + } + + getBranch(branchId) { + return this.branches[branchId]; + } + + getAttribute(attributeId) { + return this.attributes[attributeId]; + } + + getBranchFromChildAndParent(childNoteId, parentNoteId) { + return this.childParentToBranch[`${childNoteId}-${parentNoteId}`]; + } + + getEntity(entityName, entityId) { + if (!entityName || !entityId) { + return null; + } + + const camelCaseEntityName = entityName.toLowerCase().replace(/(_[a-z])/g, + group => + group + .toUpperCase() + .replace('_', '') + ); + + return this[camelCaseEntityName][entityId]; + } +} + +const shaca = new Shaca(); + +module.exports = shaca; diff --git a/src/share/shaca/shaca_loader.js b/src/share/shaca/shaca_loader.js new file mode 100644 index 000000000..c5904ef3c --- /dev/null +++ b/src/share/shaca/shaca_loader.js @@ -0,0 +1,207 @@ +"use strict"; + +const sql = require('../services/sql'); +const eventService = require('../services/events'); +const shaca = require('./shaca.js'); +const sqlInit = require('../services/sql_init'); +const log = require('../services/log'); +const Note = require('./entities/note'); +const Branch = require('./entities/branch'); +const Attribute = require('./entities/attribute'); +const Option = require('./entities/option'); +const entityConstructor = require("../becca/entity_constructor"); + +function load() { + const start = Date.now(); + shaca.reset(); + + // using raw query and passing arrays to avoid allocating new objects + // this is worth it for becca load since it happens every run and blocks the app until finished + + for (const row of sql.getRawRows(`SELECT noteId, title, type, mime, isProtected, dateCreated, dateModified, utcDateCreated, utcDateModified FROM notes WHERE isDeleted = 0`, [])) { + new Note().update(row).init(); + } + + for (const row of sql.getRawRows(`SELECT branchId, noteId, parentNoteId, prefix, notePosition, isExpanded, utcDateModified FROM branches WHERE isDeleted = 0`, [])) { + new Branch().update(row).init(); + } + + for (const row of sql.getRawRows(`SELECT attributeId, noteId, type, name, value, isInheritable, position, utcDateModified FROM attributes WHERE isDeleted = 0`, [])) { + new Attribute().update(row).init(); + } + + for (const row of sql.getRows(`SELECT name, value, isSynced, utcDateModified FROM options`)) { + new Option(row); + } + + shaca.loaded = true; + + log.info(`Shaca load took ${Date.now() - start}ms`); +} + +eventService.subscribe([eventService.ENTITY_CHANGE_SYNCED], ({entityName, entityRow}) => { + if (!becca.loaded) { + return; + } + + if (["notes", "branches", "attributes"].includes(entityName)) { + const EntityClass = entityConstructor.getEntityFromEntityName(entityName); + const primaryKeyName = EntityClass.primaryKeyName; + + let beccaEntity = becca.getEntity(entityName, entityRow[primaryKeyName]); + + if (beccaEntity) { + beccaEntity.updateFromRow(entityRow); + } else { + beccaEntity = new EntityClass(); + beccaEntity.updateFromRow(entityRow); + beccaEntity.init(); + } + } + + postProcessEntityUpdate(entityName, entityRow); +}); + +eventService.subscribe(eventService.ENTITY_CHANGED, ({entityName, entity}) => { + if (!becca.loaded) { + return; + } + + postProcessEntityUpdate(entityName, entity); +}); + +eventService.subscribe([eventService.ENTITY_DELETED, eventService.ENTITY_DELETE_SYNCED], ({entityName, entityId}) => { + if (!becca.loaded) { + return; + } + + if (entityName === 'notes') { + noteDeleted(entityId); + } else if (entityName === 'branches') { + branchDeleted(entityId); + } else if (entityName === 'attributes') { + attributeDeleted(entityId); + } +}); + +function noteDeleted(noteId) { + delete becca.notes[noteId]; + + becca.dirtyNoteSetCache(); +} + +function branchDeleted(branchId) { + const branch = becca.branches[branchId]; + + if (!branch) { + return; + } + + const childNote = becca.notes[branch.noteId]; + + if (childNote) { + childNote.parents = childNote.parents.filter(parent => parent.noteId !== branch.parentNoteId); + childNote.parentBranches = childNote.parentBranches + .filter(parentBranch => parentBranch.branchId !== branch.branchId); + + if (childNote.parents.length > 0) { + childNote.invalidateSubTree(); + } + } + + const parentNote = becca.notes[branch.parentNoteId]; + + if (parentNote) { + parentNote.children = parentNote.children.filter(child => child.noteId !== branch.noteId); + } + + delete becca.childParentToBranch[`${branch.noteId}-${branch.parentNoteId}`]; + delete becca.branches[branch.branchId]; +} + +function branchUpdated(branch) { + const childNote = becca.notes[branch.noteId]; + + if (childNote) { + childNote.flatTextCache = null; + childNote.resortParents(); + } +} + +function attributeDeleted(attributeId) { + const attribute = becca.attributes[attributeId]; + + if (!attribute) { + return; + } + + const note = becca.notes[attribute.noteId]; + + if (note) { + // first invalidate and only then remove the attribute (otherwise invalidation wouldn't be complete) + if (attribute.isAffectingSubtree || note.isTemplate()) { + note.invalidateSubTree(); + } else { + note.invalidateThisCache(); + } + + note.ownedAttributes = note.ownedAttributes.filter(attr => attr.attributeId !== attribute.attributeId); + + const targetNote = attribute.targetNote; + + if (targetNote) { + targetNote.targetRelations = targetNote.targetRelations.filter(rel => rel.attributeId !== attribute.attributeId); + } + } + + delete becca.attributes[attribute.attributeId]; + + const key = `${attribute.type}-${attribute.name.toLowerCase()}`; + + if (key in becca.attributeIndex) { + becca.attributeIndex[key] = becca.attributeIndex[key].filter(attr => attr.attributeId !== attribute.attributeId); + } +} + +function attributeUpdated(attribute) { + const note = becca.notes[attribute.noteId]; + + if (note) { + if (attribute.isAffectingSubtree || note.isTemplate()) { + note.invalidateSubTree(); + } else { + note.invalidateThisCache(); + } + } +} + +function noteReorderingUpdated(branchIdList) { + const parentNoteIds = new Set(); + + for (const branchId in branchIdList) { + const branch = becca.branches[branchId]; + + if (branch) { + branch.notePosition = branchIdList[branchId]; + + parentNoteIds.add(branch.parentNoteId); + } + } +} + +eventService.subscribe(eventService.ENTER_PROTECTED_SESSION, () => { + try { + becca.decryptProtectedNotes(); + } + catch (e) { + log.error(`Could not decrypt protected notes: ${e.message} ${e.stack}`); + } +}); + +eventService.subscribe(eventService.LEAVE_PROTECTED_SESSION, load); + +module.exports = { + load, + reload, + beccaLoaded +}; diff --git a/src/share/sql.js b/src/share/sql.js new file mode 100644 index 000000000..49849da76 --- /dev/null +++ b/src/share/sql.js @@ -0,0 +1,167 @@ +"use strict"; + +const log = require('../services/log'); +const Database = require('better-sqlite3'); +const dataDir = require('../services/data_dir'); + +const dbConnection = new Database(dataDir.DOCUMENT_PATH, { readonly: true }); + +[`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(); + } + }); +}); + +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 = []) { + const row = getRowOrNull(query, params); + + if (!row) { + return null; + } + + return row[Object.keys(row)[0]]; +} + +// 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 = []) { + return stmt(query).iterate(params); +} + +function getMap(query, params = []) { + const map = {}; + const results = getRows(query, params); + + for (const row of results) { + const keys = Object.keys(row); + + map[row[keys[0]]] = row[keys[1]]; + } + + return map; +} + +function getColumn(query, params = []) { + const list = []; + const result = getRows(query, params); + + if (result.length === 0) { + return list; + } + + const key = Object.keys(result[0])[0]; + + for (const row of result) { + list.push(row[key]); + } + + return list; +} + +function wrap(query, func) { + const startTimestamp = Date.now(); + let result; + + 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; +} + +module.exports = { + dbConnection, + getValue, + getRow, + getRowOrNull, + getRows, + getRawRows, + iterateRows, + getManyRows, + getMap, + getColumn +};