becca entities enriched with functionality from repository entities

This commit is contained in:
zadam 2021-04-25 20:00:42 +02:00
parent 7494491560
commit 8d8d654fe8
7 changed files with 407 additions and 26 deletions

5
.idea/dataSources.xml generated
View File

@ -1,11 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<project version="4"> <project version="4">
<component name="DataSourceManagerImpl" format="xml" multifile-model="true"> <component name="DataSourceManagerImpl" format="xml" multifile-model="true">
<data-source source="LOCAL" name="document.db" uuid="4e69c96a-8a2b-43f5-9b40-d1608f75f7a4"> <data-source source="LOCAL" name="SQLite - document.db" uuid="30cef30d-e704-484d-a4ca-5d3bfc2ece63">
<driver-ref>sqlite.xerial</driver-ref> <driver-ref>sqlite.xerial</driver-ref>
<synchronize>true</synchronize> <synchronize>true</synchronize>
<jdbc-driver>org.sqlite.JDBC</jdbc-driver> <jdbc-driver>org.sqlite.JDBC</jdbc-driver>
<jdbc-url>jdbc:sqlite:$USER_HOME$/trilium-data/document.db</jdbc-url> <jdbc-url>jdbc:sqlite:$PROJECT_DIR$/../trilium-data/document.db</jdbc-url>
<working-dir>$ProjectFileDir$</working-dir>
</data-source> </data-source>
</component> </component>
</project> </project>

2
db/TODO.txt Normal file
View File

@ -0,0 +1,2 @@
- drop branches.utcDateCreated - not used for anything
- drop options.utcDateCreated - not used for anything

View File

@ -17,7 +17,7 @@ function load() {
const start = Date.now(); const start = Date.now();
becca.reset(); becca.reset();
for (const row of sql.iterateRows(`SELECT noteId, title, type, mime, isProtected, dateCreated, dateModified, utcDateCreated, utcDateModified FROM notes WHERE isDeleted = 0`, [])) { for (const row of sql.iterateRows(`SELECT noteId, title, type, mime, isProtected, dateCreated, dateModified, utcDateCreated, utcDateModified FROM notes`, [])) {
new Note(becca, row); new Note(becca, row);
} }

View File

@ -0,0 +1,43 @@
"use strict";
class AbstractEntity {
beforeSaving() {
this.generateIdIfNecessary();
}
generateIdIfNecessary() {
if (!this[this.constructor.primaryKeyName]) {
this[this.constructor.primaryKeyName] = utils.newEntityId();
}
}
generateHash() {
let contentToHash = "";
for (const propertyName of this.constructor.hashedProperties) {
contentToHash += "|" + this[propertyName];
}
return utils.hash(contentToHash).substr(0, 10);
}
getUtcDateChanged() {
return this.utcDateModified || this.utcDateCreated;
}
get repository() {
if (!repo) {
repo = require('../services/repository');
}
return repo;
}
save() {
this.repository.updateEntity(this);
return this;
}
}
module.exports = AbstractEntity;

View File

@ -1,9 +1,19 @@
"use strict"; "use strict";
const Note = require('./note.js'); const Note = require('./note.js');
const AbstractEntity = require("./abstract_entity.js");
const sql = require("../../sql.js");
const dateUtils = require("../../date_utils.js");
const promotedAttributeDefinitionParser = require("../../promoted_attribute_definition_parser");
class Attribute extends AbstractEntity {
static get entityName() { return "attributes"; }
static get primaryKeyName() { return "attributeId"; }
static get hashedProperties() { return ["attributeId", "noteId", "type", "name", "value", "isInheritable"]; }
class Attribute {
constructor(becca, row) { constructor(becca, row) {
super();
/** @param {Becca} */ /** @param {Becca} */
this.becca = becca; this.becca = becca;
/** @param {string} */ /** @param {string} */
@ -60,13 +70,99 @@ class Attribute {
} }
} }
// for logging etc /**
* @returns {Note|null}
*/
getNote() {
return this.repository.getNote(this.noteId);
}
/**
* @returns {Note|null}
*/
getTargetNote() {
if (this.type !== 'relation') {
throw new Error(`Attribute ${this.attributeId} is not relation`);
}
if (!this.value) {
return null;
}
return this.repository.getNote(this.value);
}
/**
* @return {boolean}
*/
isDefinition() {
return this.type === 'label' && (this.name.startsWith('label:') || this.name.startsWith('relation:'));
}
getDefinition() {
return promotedAttributeDefinitionParser.parse(this.value);
}
getDefinedName() {
if (this.type === 'label' && this.name.startsWith('label:')) {
return this.name.substr(6);
} else if (this.type === 'label' && this.name.startsWith('relation:')) {
return this.name.substr(9);
} else {
return this.name;
}
}
get pojo() { get pojo() {
const pojo = {...this}; return {
attributeId: this.attributeId,
noteId: this.noteId,
type: this.type,
name: this.name,
position: this.position,
value: this.value,
isInheritable: this.isInheritable
};
}
delete pojo.becca; beforeSaving() {
if (!this.value) {
if (this.type === 'relation') {
throw new Error(`Cannot save relation ${this.name} since it does not target any note.`);
}
return pojo; // null value isn't allowed
this.value = "";
}
if (this.position === undefined) {
this.position = 1 + sql.getValue(`SELECT COALESCE(MAX(position), 0) FROM attributes WHERE noteId = ?`, [this.noteId]);
}
if (!this.isInheritable) {
this.isInheritable = false;
}
if (!this.isDeleted) {
this.isDeleted = false;
}
super.beforeSaving();
this.utcDateModified = dateUtils.utcNowDateTime();
}
createClone(type, name, value, isInheritable) {
return new Attribute({
noteId: this.noteId,
type: type,
name: name,
value: value,
position: this.position,
isInheritable: isInheritable,
isDeleted: false,
utcDateModified: this.utcDateModified
});
} }
} }

View File

@ -1,9 +1,19 @@
"use strict"; "use strict";
const Note = require('./note.js'); const Note = require('./note.js');
const AbstractEntity = require("./abstract_entity.js");
const sql = require("../../sql.js");
const dateUtils = require("../../date_utils.js");
class Branch extends AbstractEntity {
static get entityName() { return "branches"; }
static get primaryKeyName() { return "branchId"; }
// notePosition is not part of hash because it would produce a lot of updates in case of reordering
static get hashedProperties() { return ["branchId", "noteId", "parentNoteId", "prefix"]; }
class Branch {
constructor(becca, row) { constructor(becca, row) {
super();
/** @param {Becca} */ /** @param {Becca} */
this.becca = becca; this.becca = becca;
/** @param {string} */ /** @param {string} */
@ -55,13 +65,44 @@ class Branch {
return this.becca.notes[this.parentNoteId]; return this.becca.notes[this.parentNoteId];
} }
// for logging etc
get pojo() { get pojo() {
const pojo = {...this}; return {
branchId: this.branchId,
noteId: this.noteId,
parentNoteId: this.parentNoteId,
prefix: this.prefix,
notePosition: this.notePosition,
isExpanded: this.isExpanded
};
}
delete pojo.becca; createClone(parentNoteId, notePosition) {
return new Branch({
noteId: this.noteId,
parentNoteId: parentNoteId,
notePosition: notePosition,
prefix: this.prefix,
isExpanded: this.isExpanded
});
}
return pojo; beforeSaving() {
if (this.notePosition === undefined || this.notePosition === null) {
const maxNotePos = sql.getValue('SELECT MAX(notePosition) FROM branches WHERE parentNoteId = ? AND isDeleted = 0', [this.parentNoteId]);
this.notePosition = maxNotePos === null ? 0 : maxNotePos + 10;
}
if (!this.isExpanded) {
this.isExpanded = false;
}
if (!this.branchId) {
this.utcDateCreated = dateUtils.utcNowDateTime();
}
super.beforeSaving();
this.utcDateModified = dateUtils.utcNowDateTime();
} }
} }

View File

@ -2,12 +2,23 @@
const protectedSessionService = require('../../protected_session'); const protectedSessionService = require('../../protected_session');
const log = require('../../log'); const log = require('../../log');
const sql = require('../../sql');
const utils = require('../../utils');
const dateUtils = require('../../date_utils');
const entityChangesService = require('../../entity_changes.js');
const AbstractEntity = require("./abstract_entity.js");
const LABEL = 'label'; const LABEL = 'label';
const RELATION = 'relation'; const RELATION = 'relation';
class Note { class Note extends AbstractEntity {
static get entityName() { return "notes"; }
static get primaryKeyName() { return "noteId"; }
static get hashedProperties() { return ["noteId", "title", "isProtected", "type", "mime"]; }
constructor(becca, row) { constructor(becca, row) {
super();
/** @param {Becca} */ /** @param {Becca} */
this.becca = becca; this.becca = becca;
@ -46,10 +57,14 @@ class Note {
} }
update(row) { update(row) {
// ------ Database persisted attributes ------
/** @param {string} */ /** @param {string} */
this.noteId = row.noteId; this.noteId = row.noteId;
/** @param {string} */ /** @param {string} */
this.title = row.title; this.title = row.title;
/** @param {boolean} */
this.isProtected = !!row.isProtected;
/** @param {string} */ /** @param {string} */
this.type = row.type; this.type = row.type;
/** @param {string} */ /** @param {string} */
@ -62,8 +77,9 @@ class Note {
this.utcDateCreated = row.utcDateCreated; this.utcDateCreated = row.utcDateCreated;
/** @param {string} */ /** @param {string} */
this.utcDateModified = row.utcDateModified; this.utcDateModified = row.utcDateModified;
/** @param {boolean} */
this.isProtected = !!row.isProtected; // ------ Derived attributes ------
/** @param {boolean} */ /** @param {boolean} */
this.isDecrypted = !row.isProtected || !!row.isContentAvailable; this.isDecrypted = !row.isProtected || !!row.isContentAvailable;
@ -73,6 +89,162 @@ class Note {
this.flatTextCache = null; this.flatTextCache = null;
} }
/*
* Note content has quite special handling - it's not a separate entity, but a lazily loaded
* part of Note entity with it's own sync. Reasons behind this hybrid design has been:
*
* - content can be quite large and it's not necessary to load it / fill memory for any note access even if we don't need a content, especially for bulk operations like search
* - changes in the note metadata or title should not trigger note content sync (so we keep separate utcDateModified and entity changes records)
* - but to the user note content and title changes are one and the same - single dateModified (so all changes must go through Note and content is not a separate entity)
*/
/** @returns {*} */
getContent(silentNotFoundError = false) {
const row = sql.getRow(`SELECT content FROM note_contents WHERE noteId = ?`, [this.noteId]);
if (!row) {
if (silentNotFoundError) {
return undefined;
}
else {
throw new Error("Cannot find note content for noteId=" + this.noteId);
}
}
let content = row.content;
if (this.isProtected) {
if (protectedSessionService.isProtectedSessionAvailable()) {
content = content === null ? null : protectedSessionService.decrypt(content);
}
else {
content = "";
}
}
if (this.isStringNote()) {
return content === null
? ""
: content.toString("UTF-8");
}
else {
return content;
}
}
/** @returns {{contentLength, dateModified, utcDateModified}} */
getContentMetadata() {
return sql.getRow(`
SELECT
LENGTH(content) AS contentLength,
dateModified,
utcDateModified
FROM note_contents
WHERE noteId = ?`, [this.noteId]);
}
/** @returns {*} */
getJsonContent() {
const content = this.getContent();
if (!content || !content.trim()) {
return null;
}
return JSON.parse(content);
}
setContent(content) {
if (content === null || content === undefined) {
throw new Error(`Cannot set null content to note ${this.noteId}`);
}
if (this.isStringNote()) {
content = content.toString();
}
else {
content = Buffer.isBuffer(content) ? content : Buffer.from(content);
}
const pojo = {
noteId: this.noteId,
content: content,
dateModified: dateUtils.localNowDateTime(),
utcDateModified: dateUtils.utcNowDateTime()
};
if (this.isProtected) {
if (protectedSessionService.isProtectedSessionAvailable()) {
pojo.content = protectedSessionService.encrypt(pojo.content);
}
else {
throw new Error(`Cannot update content of noteId=${this.noteId} since we're out of protected session.`);
}
}
sql.upsert("note_contents", "noteId", pojo);
const hash = utils.hash(this.noteId + "|" + pojo.content.toString());
entityChangesService.addEntityChange({
entityName: 'note_contents',
entityId: this.noteId,
hash: hash,
isErased: false,
utcDateChanged: pojo.utcDateModified
}, null);
}
setJsonContent(content) {
this.setContent(JSON.stringify(content, null, '\t'));
}
/** @returns {boolean} true if this note is the root of the note tree. Root note has "root" noteId */
isRoot() {
return this.noteId === 'root';
}
/** @returns {boolean} true if this note is of application/json content type */
isJson() {
return this.mime === "application/json";
}
/** @returns {boolean} true if this note is JavaScript (code or attachment) */
isJavaScript() {
return (this.type === "code" || this.type === "file")
&& (this.mime.startsWith("application/javascript")
|| this.mime === "application/x-javascript"
|| this.mime === "text/javascript");
}
/** @returns {boolean} true if this note is HTML */
isHtml() {
return ["code", "file", "render"].includes(this.type)
&& this.mime === "text/html";
}
/** @returns {boolean} true if the note has string content (not binary) */
isStringNote() {
return utils.isStringNote(this.type, this.mime);
}
/** @returns {string|null} JS script environment - either "frontend" or "backend" */
getScriptEnv() {
if (this.isHtml() || (this.isJavaScript() && this.mime.endsWith('env=frontend'))) {
return "frontend";
}
if (this.type === 'render') {
return "frontend";
}
if (this.isJavaScript() && this.mime.endsWith('env=backend')) {
return "backend";
}
return null;
}
/** @return {Attribute[]} */ /** @return {Attribute[]} */
get attributes() { get attributes() {
return this.__getAttributes([]); return this.__getAttributes([]);
@ -543,19 +715,45 @@ class Note {
} }
} }
// for logging etc
get pojo() { get pojo() {
const pojo = {...this}; return {
noteId: this.noteId,
title: this.title,
isProtected: this.isProtected,
type: this.type,
mime: this.mime,
dateCreated: this.dateCreated,
dateModified: this.dateModified,
utcDateCreated: this.utcDateCreated,
utcDateModified: this.utcDateModified
};
}
delete pojo.becca; beforeSaving() {
delete pojo.ancestorCache; if (!this.dateCreated) {
delete pojo.attributeCache; this.dateCreated = dateUtils.localNowDateTime();
delete pojo.flatTextCache; }
delete pojo.children;
delete pojo.parents;
delete pojo.parentBranches;
return pojo; if (!this.utcDateCreated) {
this.utcDateCreated = dateUtils.utcNowDateTime();
}
super.beforeSaving();
this.dateModified = dateUtils.localNowDateTime();
this.utcDateModified = dateUtils.utcNowDateTime();
}
updatePojo(pojo) {
if (pojo.isProtected) {
if (this.isDecrypted) {
pojo.title = protectedSessionService.encrypt(pojo.title);
}
else {
// updating protected note outside of protected session means we will keep original ciphertexts
delete pojo.title;
}
}
} }
} }