diff --git a/package-lock.json b/package-lock.json index 3e737316c..5cedaffd2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1896,9 +1896,9 @@ "dev": true }, "caniuse-lite": { - "version": "1.0.30001265", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001265.tgz", - "integrity": "sha512-YzBnspggWV5hep1m9Z6sZVLOt7vrju8xWooFAgN6BA5qvy98qPAPb7vNUzypFaoh2pb3vlfzbDO8tB57UPGbtw==", + "version": "1.0.30001269", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001269.tgz", + "integrity": "sha512-UOy8okEVs48MyHYgV+RdW1Oiudl1H6KolybD6ZquD0VcrPSgj25omXO1S7rDydjpqaISCwA8Pyx+jUQKZwWO5w==", "dev": true }, "caseless": { @@ -2188,6 +2188,12 @@ "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", "integrity": "sha1-p9BVi9icQveV3UIyj3QIMcpTvCU=" }, + "colorette": { + "version": "2.0.16", + "resolved": "https://registry.npmjs.org/colorette/-/colorette-2.0.16.tgz", + "integrity": "sha512-hUewv7oMjCp+wkBv5Rm0v87eJhq4woh5rSR+42YSQJKecCqgIqNkZ6lAlQms/BwHPJA5NKMRlpxPRv0n8HQW6g==", + "dev": true + }, "colors": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/colors/-/colors-1.4.0.tgz", @@ -3546,9 +3552,9 @@ } }, "electron-to-chromium": { - "version": "1.3.867", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.3.867.tgz", - "integrity": "sha512-WbTXOv7hsLhjJyl7jBfDkioaY++iVVZomZ4dU6TMe/SzucV6mUAs2VZn/AehBwuZMiNEQDaPuTGn22YK5o+aDw==", + "version": "1.3.872", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.3.872.tgz", + "integrity": "sha512-qG96atLFY0agKyEETiBFNhpRLSXGSXOBuhXWpbkYqrLKKASpRyRBUtfkn0ZjIf/yXfA7FA4nScVOMpXSHFlUCQ==", "dev": true }, "electron-window-state": { @@ -4955,9 +4961,9 @@ "dev": true }, "jest-worker": { - "version": "27.2.5", - "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-27.2.5.tgz", - "integrity": "sha512-HTjEPZtcNKZ4LnhSp02NEH4vE+5OpJ0EsOWYvGQpHgUMLngydESAAMH5Wd/asPf29+XUDQZszxpLg1BkIIA2aw==", + "version": "27.3.1", + "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-27.3.1.tgz", + "integrity": "sha512-ks3WCzsiZaOPJl/oMsDjaf0TRiSv7ctNgs0FqRr2nARsovz6AWWy4oLElwcquGSz692DzgZQrCLScPNs5YlC4g==", "dev": true, "requires": { "@types/node": "*", @@ -7960,12 +7966,6 @@ "resolved": "https://registry.npmjs.org/uuid/-/uuid-3.3.3.tgz", "integrity": "sha512-pW0No1RGHgzlpHJO1nsVrHKpOEIxkGg1xB+v0ZmdNH5OAeAwzAVrCnI2/6Mtx+Uys6iaylxa+D3g4j63IKKjSQ==" }, - "v8-compile-cache": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/v8-compile-cache/-/v8-compile-cache-2.3.0.tgz", - "integrity": "sha512-l8lCEmLcLYZh4nbunNZvQCJc5pv7+RCwa8q/LdUx8u7lsWvPDKmpodJAJNwkAhJC//dFY48KuIEmjtd4RViDrA==", - "dev": true - }, "validate-npm-package-license": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/validate-npm-package-license/-/validate-npm-package-license-3.0.4.tgz", @@ -8032,9 +8032,9 @@ "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==" }, "webpack": { - "version": "5.58.2", - "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.58.2.tgz", - "integrity": "sha512-3S6e9Vo1W2ijk4F4PPWRIu6D/uGgqaPmqw+av3W3jLDujuNkdxX5h5c+RQ6GkjVR+WwIPOfgY8av+j5j4tMqJw==", + "version": "5.59.0", + "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.59.0.tgz", + "integrity": "sha512-2HiFHKnWIb/cBfOfgssQn8XIRvntISXiz//F1q1+hKMs+uzC1zlVCJZEP7XqI1wzrDyc/ZdB4G+MYtz5biJxCA==", "dev": true, "requires": { "@types/eslint-scope": "^3.7.0", @@ -8064,9 +8064,9 @@ } }, "webpack-cli": { - "version": "4.9.0", - "resolved": "https://registry.npmjs.org/webpack-cli/-/webpack-cli-4.9.0.tgz", - "integrity": "sha512-n/jZZBMzVEl4PYIBs+auy2WI0WTQ74EnJDiyD98O2JZY6IVIHJNitkYp/uTXOviIOMfgzrNvC9foKv/8o8KSZw==", + "version": "4.9.1", + "resolved": "https://registry.npmjs.org/webpack-cli/-/webpack-cli-4.9.1.tgz", + "integrity": "sha512-JYRFVuyFpzDxMDB+v/nanUdQYcZtqFPGzmlW4s+UkPMFhSpfRNmf1z4AwYcHJVdvEFAM7FFCQdNTpsBYhDLusQ==", "dev": true, "requires": { "@discoveryjs/json-ext": "^0.5.0", @@ -8080,16 +8080,9 @@ "import-local": "^3.0.2", "interpret": "^2.2.0", "rechoir": "^0.7.0", - "v8-compile-cache": "^2.2.0", "webpack-merge": "^5.7.3" }, "dependencies": { - "colorette": { - "version": "2.0.16", - "resolved": "https://registry.npmjs.org/colorette/-/colorette-2.0.16.tgz", - "integrity": "sha512-hUewv7oMjCp+wkBv5Rm0v87eJhq4woh5rSR+42YSQJKecCqgIqNkZ6lAlQms/BwHPJA5NKMRlpxPRv0n8HQW6g==", - "dev": true - }, "commander": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/commander/-/commander-7.2.0.tgz", diff --git a/package.json b/package.json index b8ebf0c3a..7ec0cc895 100644 --- a/package.json +++ b/package.json @@ -90,8 +90,8 @@ "jsdoc": "3.6.7", "lorem-ipsum": "2.0.4", "rcedit": "3.0.1", - "webpack": "5.58.2", - "webpack-cli": "4.9.0" + "webpack": "5.59.0", + "webpack-cli": "4.9.1" }, "optionalDependencies": { "electron-installer-debian": "3.1.0" diff --git a/src/becca/becca.js b/src/becca/becca.js index 6a3126b14..5fde71c66 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/becca/becca_loader.js b/src/becca/becca_loader.js index 25b4cd04d..68a03779c 100644 --- a/src/becca/becca_loader.js +++ b/src/becca/becca_loader.js @@ -29,15 +29,15 @@ function load() { // 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`, [])) { + 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`, [])) { + 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`, [])) { + 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(); } diff --git a/src/public/app/services/froca.js b/src/public/app/services/froca.js index fd99b7ed2..3cea696db 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/public/stylesheets/share.css b/src/public/stylesheets/share.css new file mode 100644 index 000000000..8320b90ce --- /dev/null +++ b/src/public/stylesheets/share.css @@ -0,0 +1,81 @@ +html { + box-sizing: border-box; + font-size: 16px; +} + +*, *:before, *:after { + box-sizing: inherit; +} + +body, h1, h2, h3, h4, h5, h6, p, ol, ul { + margin: 0; + padding: 0; + font-weight: normal; +} + +ul { + padding-left: 20px; +} + +#layout { + max-width: 1200px; + margin: 0 auto; + display: flex; + flex-direction: row; +} + +#menu { + padding: 20px; + flex-basis: 0; + flex-grow: 1; + background-color: #ccc; + overflow: auto; +} + +#main { + flex-basis: 0; + flex-grow: 3; + background-color:#eee; +} + +#title, #content { + padding: 20px; +} + +#menuLink { + position: fixed; + display: block; + top: 0; + left: 0; + width: 1.4em; + background: #000; + background: rgba(0,0,0,0.7); + font-size: 2rem; + z-index: 10; + height: auto; + color: white; + border: none; + cursor: pointer; +} + +@media (max-width: 48em) { + #layout.active #menu { + display: block; + } + + #layout.active #main { + display: none; + } + + #layout.active #menuLink::after { + content: "«"; + } + + #menu { + display: none; + } + + #menuLink::after { + content: "»"; + } +} diff --git a/src/routes/routes.js b/src/routes/routes.js index 5785e0c60..25cdee7fe 100644 --- a/src/routes/routes.js +++ b/src/routes/routes.js @@ -39,6 +39,7 @@ const keysRoute = require('./api/keys'); const backendLogRoute = require('./api/backend_log'); const statsRoute = require('./api/stats'); const fontsRoute = require('./api/fonts'); +const shareRoutes = require('../share/routes'); const log = require('../services/log'); const express = require('express'); @@ -366,6 +367,8 @@ function register(app) { route(GET, '/api/fonts', [auth.checkApiAuthOrElectron], fontsRoute.getFontCss); + shareRoutes.register(router); + app.use('', router); } diff --git a/src/share/routes.js b/src/share/routes.js new file mode 100644 index 000000000..250639295 --- /dev/null +++ b/src/share/routes.js @@ -0,0 +1,58 @@ +const shaca = require("./shaca/shaca"); +const shacaLoader = require("./shaca/shaca_loader"); +const shareRoot = require("./share_root"); + +function getSubRoot(note) { + if (note.noteId === shareRoot.SHARE_ROOT_NOTE_ID) { + return null; + } + + const parentNote = note.getParentNotes()[0]; + + if (parentNote.noteId === shareRoot.SHARE_ROOT_NOTE_ID) { + return note; + } + + return getSubRoot(parentNote); +} + +function register(router) { + router.get('/share/:noteId', (req, res, next) => { + const {noteId} = req.params; + + shacaLoader.ensureLoad(); + + if (noteId in shaca.notes) { + const note = shaca.notes[noteId]; + + const subRoot = getSubRoot(note); + + res.render("share", { + note, + subRoot + }); + } + else { + res.send("FFF"); + } + }); + + router.get('/share/api/images/:noteId/:filename', (req, res, next) => { + const image = shaca.getNote(req.params.noteId); + + if (!image) { + return res.sendStatus(404); + } + else if (image.type !== 'image') { + return res.sendStatus(400); + } + + res.set('Content-Type', image.mime); + + res.send(image.getContent()); + }); +} + +module.exports = { + register +} diff --git a/src/share/shaca/entities/abstract_entity.js b/src/share/shaca/entities/abstract_entity.js new file mode 100644 index 000000000..2b27d9997 --- /dev/null +++ b/src/share/shaca/entities/abstract_entity.js @@ -0,0 +1,13 @@ +let shaca; + +class AbstractEntity { + get shaca() { + if (!shaca) { + shaca = require("../shaca"); + } + + return shaca; + } +} + +module.exports = AbstractEntity; diff --git a/src/share/shaca/entities/attribute.js b/src/share/shaca/entities/attribute.js new file mode 100644 index 000000000..8aeba46c5 --- /dev/null +++ b/src/share/shaca/entities/attribute.js @@ -0,0 +1,90 @@ +"use strict"; + +const AbstractEntity = require('./abstract_entity'); + +class Attribute extends AbstractEntity { + constructor([attributeId, noteId, type, name, value, isInheritable, position]) { + super(); + + /** @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; + + this.shaca.attributes[this.attributeId] = this; + this.shaca.notes[this.noteId].ownedAttributes.push(this); + + const targetNote = this.targetNote; + + if (targetNote) { + targetNote.targetRelations.push(this); + } + + if (this.type === 'relation' && this.name === 'imageLink') { + const linkedChildNote = this.note.getChildNotes().find(childNote => childNote.noteId === this.value); + + if (linkedChildNote) { + this.note.children = this.note.children.filter(childNote => childNote.noteId !== this.value); + + linkedChildNote.parents = linkedChildNote.parents.filter(parentNote => parentNote.noteId !== this.noteId); + } + } + } + + 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.shaca.notes[this.noteId]; + } + + get targetNote() { + if (this.type === 'relation') { + return this.shaca.notes[this.value]; + } + } + + /** + * @returns {Note|null} + */ + getNote() { + return this.shaca.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.shaca.getNote(this.value); + } +} + +module.exports = Attribute; diff --git a/src/share/shaca/entities/branch.js b/src/share/shaca/entities/branch.js new file mode 100644 index 000000000..bf4ed2070 --- /dev/null +++ b/src/share/shaca/entities/branch.js @@ -0,0 +1,65 @@ +"use strict"; + +const AbstractEntity = require('./abstract_entity'); +const shareRoot = require("../../share_root"); + +class Branch extends AbstractEntity { + constructor([branchId, noteId, parentNoteId, prefix, notePosition, isExpanded]) { + super(); + + /** @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; + + if (this.noteId === shareRoot.SHARE_ROOT_NOTE_ID) { + 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) { + console.log(this); + } + + if (!parentNote.children.includes(childNote)) { + parentNote.children.push(childNote); + } + + this.shaca.branches[this.branchId] = this; + this.shaca.childParentToBranch[`${this.noteId}-${this.parentNoteId}`] = this; + } + + /** @return {Note} */ + get childNote() { + return this.shaca.notes[this.noteId]; + } + + getNote() { + return this.childNote; + } + + /** @return {Note} */ + get parentNote() { + return this.shaca.notes[this.parentNoteId]; + } +} + +module.exports = Branch; diff --git a/src/share/shaca/entities/note.js b/src/share/shaca/entities/note.js new file mode 100644 index 000000000..91268dcee --- /dev/null +++ b/src/share/shaca/entities/note.js @@ -0,0 +1,562 @@ +"use strict"; + +const sql = require('../../sql'); +const utils = require('../../../services/utils'); +const AbstractEntity = require('./abstract_entity'); + +const LABEL = 'label'; +const RELATION = 'relation'; + +class Note extends AbstractEntity { + constructor([noteId, title, type, mime]) { + super(); + + /** @param {string} */ + this.noteId = noteId; + /** @param {string} */ + this.title = title; + /** @param {string} */ + this.type = type; + /** @param {string} */ + this.mime = mime; + + /** @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.shaca.notes[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.shaca.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.shaca.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..8cbb91abb --- /dev/null +++ b/src/share/shaca/shaca_loader.js @@ -0,0 +1,64 @@ +"use strict"; + +const sql = require('../sql'); +const shaca = require('./shaca.js'); +const log = require('../../services/log'); +const Note = require('./entities/note'); +const Branch = require('./entities/branch'); +const Attribute = require('./entities/attribute'); +const shareRoot = require('../share_root'); + +function load() { + const start = Date.now(); + shaca.reset(); + + // using raw query and passing arrays to avoid allocating new objects + + const noteIds = sql.getColumn(` + WITH RECURSIVE + tree(noteId) AS ( + SELECT ? + UNION + SELECT branches.noteId FROM branches + JOIN tree ON branches.parentNoteId = tree.noteId + WHERE branches.isDeleted = 0 + ) + SELECT noteId FROM tree`, [shareRoot.SHARE_ROOT_NOTE_ID]); + + if (noteIds.length === 0) { + shaca.loaded = true; + + return; + } + + const noteIdStr = noteIds.map(noteId => `'${noteId}'`).join(","); + + for (const row of sql.getRawRows(`SELECT noteId, title, type, mime FROM notes WHERE isDeleted = 0 AND noteId IN (${noteIdStr})`)) { + new Note(row); + } + + for (const row of sql.getRawRows(`SELECT branchId, noteId, parentNoteId, prefix, notePosition, isExpanded, utcDateModified FROM branches WHERE isDeleted = 0 AND noteId IN (${noteIdStr})`)) { + new Branch(row); + } + + // TODO: add filter for allowed attributes + for (const row of sql.getRawRows(`SELECT attributeId, noteId, type, name, value, isInheritable, position, utcDateModified FROM attributes WHERE isDeleted = 0 AND noteId IN (${noteIdStr})`, [])) { + new Attribute(row); + } + + shaca.loaded = true; + + log.info(`Shaca load took ${Date.now() - start}ms`); +} + +function ensureLoad() { + if (!shaca.loaded) { + load(); + } +} + + +module.exports = { + load, + ensureLoad +}; diff --git a/src/share/share_root.js b/src/share/share_root.js new file mode 100644 index 000000000..712646572 --- /dev/null +++ b/src/share/share_root.js @@ -0,0 +1,3 @@ +module.exports = { + SHARE_ROOT_NOTE_ID: 'root' +} 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 +}; diff --git a/src/views/share-tree-item.ejs b/src/views/share-tree-item.ejs new file mode 100644 index 000000000..a1285746d --- /dev/null +++ b/src/views/share-tree-item.ejs @@ -0,0 +1,17 @@ +

+ <% if (activeNote.noteId === note.noteId) { %> + <%= note.title %> + <% } else { %> + <%= note.title %> + <% } %> +

+ +<% if (note.hasChildren()) { %> +
    + <% note.getChildNotes().forEach(function (childNote) { %> +
  • + <%- include('share-tree-item', {note: childNote}) %> +
  • + <% }) %> +
+<% } %> diff --git a/src/views/share.ejs b/src/views/share.ejs new file mode 100644 index 000000000..e64d45e70 --- /dev/null +++ b/src/views/share.ejs @@ -0,0 +1,90 @@ + + + + + + + <% if (note.type === 'text' || note.type === 'book') { %> + + <% } %> + <%= note.title %> + + +
+ + + + +
+

<%= note.title %>

+ +
+ <% if (note.type === 'text') { %> +
<%- note.getContent() %>
+ <% } %> +
+
+
+ + + +