Merge remote-tracking branch 'origin/next49'

This commit is contained in:
zadam 2021-11-14 13:18:13 +01:00
commit 8996f35cc0
18 changed files with 1320 additions and 34 deletions

49
package-lock.json generated
View File

@ -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",

View File

@ -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"

View File

@ -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();

View File

@ -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();
}

View File

@ -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() {

View File

@ -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: "»";
}
}

View File

@ -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);
}

58
src/share/routes.js Normal file
View File

@ -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
}

View File

@ -0,0 +1,13 @@
let shaca;
class AbstractEntity {
get shaca() {
if (!shaca) {
shaca = require("../shaca");
}
return shaca;
}
}
module.exports = AbstractEntity;

View File

@ -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;

View File

@ -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;

View File

@ -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;

75
src/share/shaca/shaca.js Normal file
View File

@ -0,0 +1,75 @@
"use strict";
class Shaca {
constructor() {
this.reset();
}
reset() {
/** @type {Object.<String, Note>} */
this.notes = {};
/** @type {Object.<String, Branch>} */
this.branches = {};
/** @type {Object.<String, Branch>} */
this.childParentToBranch = {};
/** @type {Object.<String, Attribute>} */
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;

View File

@ -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
};

3
src/share/share_root.js Normal file
View File

@ -0,0 +1,3 @@
module.exports = {
SHARE_ROOT_NOTE_ID: 'root'
}

167
src/share/sql.js Normal file
View File

@ -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
};

View File

@ -0,0 +1,17 @@
<p>
<% if (activeNote.noteId === note.noteId) { %>
<strong><%= note.title %></strong>
<% } else { %>
<a href="./<%= note.noteId %>"><%= note.title %></a>
<% } %>
</p>
<% if (note.hasChildren()) { %>
<ul>
<% note.getChildNotes().forEach(function (childNote) { %>
<li>
<%- include('share-tree-item', {note: childNote}) %>
</li>
<% }) %>
</ul>
<% } %>

90
src/views/share.ejs Normal file
View File

@ -0,0 +1,90 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<link rel="shortcut icon" href="../favicon.ico">
<link href="../stylesheets/share.css" rel="stylesheet">
<% if (note.type === 'text' || note.type === 'book') { %>
<link href="../libraries/ckeditor/ckeditor-content.css" rel="stylesheet">
<% } %>
<title><%= note.title %></title>
</head>
<body>
<div id="layout">
<button id="menuLink"></button>
<div id="menu">
<div class="pure-menu">
<%- include('share-tree-item', {note: subRoot, activeNote: note}) %>
</div>
</div>
<div id="main">
<h1 id="title"><%= note.title %></h1>
<div id="content">
<% if (note.type === 'text') { %>
<div class="ck-content"><%- note.getContent() %></div>
<% } %>
</div>
</div>
</div>
<script>
(function (window, document) {
// we fetch the elements each time because docusaurus removes the previous
// element references on page navigation
function getElements() {
return {
layout: document.getElementById('layout'),
menu: document.getElementById('menu'),
menuLink: document.getElementById('menuLink')
};
}
function toggleClass(element, className) {
var classes = element.className.split(/\s+/);
var length = classes.length;
var i = 0;
for (; i < length; i++) {
if (classes[i] === className) {
classes.splice(i, 1);
break;
}
}
// The className is not found
if (length === classes.length) {
classes.push(className);
}
element.className = classes.join(' ');
}
function toggleAll() {
var active = 'active';
var elements = getElements();
toggleClass(elements.layout, active);
toggleClass(elements.menu, active);
toggleClass(elements.menuLink, active);
}
function handleEvent(e) {
var elements = getElements();
if (e.target.id === elements.menuLink.id) {
toggleAll();
e.preventDefault();
} else if (elements.menu.className.indexOf('active') !== -1) {
toggleAll();
}
}
document.addEventListener('click', handleEvent);
}(this, this.document));
</script>
</body>
</html>