This commit is contained in:
zadam
2023-06-29 20:54:58 +02:00
parent 788841d256
commit faa402fcda
32 changed files with 4286 additions and 4054 deletions

View File

@@ -33,9 +33,9 @@ const log = require('../../services/log');
const sql = require('../../services/sql');
const utils = require('../../services/utils');
const dateUtils = require('../../services/date_utils');
const entityChangesService = require('../../services/entity_changes');
const AbstractBeccaEntity = require("./abstract_becca_entity");
const BNoteRevision = require("./bnote_revision");
const BRevision = require("./brevision");
const BAttachment = require("./battachment");
const TaskContext = require("../../services/task_context");
const dayjs = require("dayjs");
const utc = require('dayjs/plugin/utc');
@@ -53,7 +53,7 @@ const RELATION = 'relation';
class BNote extends AbstractBeccaEntity {
static get entityName() { return "notes"; }
static get primaryKeyName() { return "noteId"; }
static get hashedProperties() { return ["noteId", "title", "isProtected", "type", "mime"]; }
static get hashedProperties() { return ["noteId", "title", "isProtected", "type", "mime", "blobId"]; }
constructor(row) {
super();
@@ -73,6 +73,7 @@ class BNote extends AbstractBeccaEntity {
row.type,
row.mime,
row.isProtected,
row.blobId,
row.dateCreated,
row.dateModified,
row.utcDateCreated,
@@ -80,19 +81,21 @@ class BNote extends AbstractBeccaEntity {
]);
}
update([noteId, title, type, mime, isProtected, dateCreated, dateModified, utcDateCreated, utcDateModified]) {
update([noteId, title, type, mime, isProtected, blobId, dateCreated, dateModified, utcDateCreated, utcDateModified]) {
// ------ Database persisted attributes ------
/** @type {string} */
this.noteId = noteId;
/** @type {string} */
this.title = title;
/** @type {boolean} */
this.isProtected = !!isProtected;
/** @type {string} */
this.type = type;
/** @type {string} */
this.mime = mime;
/** @type {boolean} */
this.isProtected = !!isProtected;
/** @type {string} */
this.blobId = blobId;
/** @type {string} */
this.dateCreated = dateCreated || dateUtils.localNowDateTime();
/** @type {string} */
@@ -112,7 +115,7 @@ class BNote extends AbstractBeccaEntity {
this.decrypt();
/** @type {string|null} */
this.flatTextCache = null;
this.__flatTextCache = null;
return this;
}
@@ -136,7 +139,7 @@ class BNote extends AbstractBeccaEntity {
this.__attributeCache = null;
/** @type {BAttribute[]|null}
* @private */
this.inheritableAttributeCache = null;
this.__inheritableAttributeCache = null;
/** @type {BAttribute[]}
* @private */
@@ -146,7 +149,7 @@ class BNote extends AbstractBeccaEntity {
/** @type {BNote[]|null}
* @private */
this.ancestorCache = null;
this.__ancestorCache = null;
// following attributes are filled during searching from database
@@ -231,49 +234,41 @@ class BNote extends AbstractBeccaEntity {
* - 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 {string|Buffer} */
getContent() {
return this._getContent();
}
/** @returns {{contentLength, dateModified, utcDateModified}} */
/** @returns {{dateModified, utcDateModified}} */
getContentMetadata() {
return sql.getRow(`
SELECT
LENGTH(content) AS contentLength,
dateModified,
utcDateModified
FROM note_contents
WHERE noteId = ?`, [this.noteId]);
return sql.getRow(`SELECT dateModified, utcDateModified FROM blobs WHERE blobId = ?`, [this.blobId]);
}
/** @returns {*} */
getJsonContent() {
const content = this.getContent();
if (!content || !content.trim()) {
return null;
}
return JSON.parse(content);
}
/**
* @param content
* @param {object} [opts]
* @param {object} [opts.forceSave=false] - will also save this BNote entity
* @param {object} [opts.forceFrontendReload=false] - override frontend heuristics on when to reload, instruct to reload
*/
setContent(content, opts) {
this._setContent(content, opts);
eventService.emit(eventService.NOTE_CONTENT_CHANGE, { entity: this });
}
setJsonContent(content) {
this.setContent(JSON.stringify(content, null, '\t'));
}
get dateCreatedObj() {
@@ -292,68 +287,6 @@ class BNote extends AbstractBeccaEntity {
return this.utcDateModified === null ? null : dayjs.utc(this.utcDateModified);
}
/** @returns {*} */
getJsonContent() {
const content = this.getContent();
if (!content || !content.trim()) {
return null;
}
return JSON.parse(content);
}
setContent(content, ignoreMissingProtectedSession = false) {
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 if (!ignoreMissingProtectedSession) {
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,
isSynced: true
});
eventService.emit(eventService.ENTITY_CHANGED, {
entityName: 'note_contents',
entity: this
});
}
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';
@@ -384,8 +317,13 @@ class BNote extends AbstractBeccaEntity {
|| (this.type === 'file' && this.mime?.startsWith('image/'));
}
/** @returns {boolean} true if the note has string content (not binary) */
/** @deprecated use hasStringContent() instead */
isStringNote() {
return this.hasStringContent();
}
/** @returns {boolean} true if the note has string content (not binary) */
hasStringContent() {
return utils.isStringNote(this.type, this.mime);
}
@@ -482,11 +420,11 @@ class BNote extends AbstractBeccaEntity {
}
}
this.inheritableAttributeCache = [];
this.__inheritableAttributeCache = [];
for (const attr of this.__attributeCache) {
if (attr.isInheritable) {
this.inheritableAttributeCache.push(attr);
this.__inheritableAttributeCache.push(attr);
}
}
}
@@ -503,11 +441,11 @@ class BNote extends AbstractBeccaEntity {
return [];
}
if (!this.inheritableAttributeCache) {
this.__getAttributes(path); // will refresh also this.inheritableAttributeCache
if (!this.__inheritableAttributeCache) {
this.__getAttributes(path); // will refresh also this.__inheritableAttributeCache
}
return this.inheritableAttributeCache;
return this.__inheritableAttributeCache;
}
__validateTypeName(type, name) {
@@ -841,40 +779,40 @@ class BNote extends AbstractBeccaEntity {
* @returns {string} - returns flattened textual representation of note, prefixes and attributes
*/
getFlatText() {
if (!this.flatTextCache) {
this.flatTextCache = `${this.noteId} ${this.type} ${this.mime} `;
if (!this.__flatTextCache) {
this.__flatTextCache = `${this.noteId} ${this.type} ${this.mime} `;
for (const branch of this.parentBranches) {
if (branch.prefix) {
this.flatTextCache += `${branch.prefix} `;
this.__flatTextCache += `${branch.prefix} `;
}
}
this.flatTextCache += `${this.title} `;
this.__flatTextCache += `${this.title} `;
for (const attr of this.getAttributes()) {
// it's best to use space as separator since spaces are filtered from the search string by the tokenization into words
this.flatTextCache += `${attr.type === 'label' ? '#' : '~'}${attr.name}`;
this.__flatTextCache += `${attr.type === 'label' ? '#' : '~'}${attr.name}`;
if (attr.value) {
this.flatTextCache += `=${attr.value}`;
this.__flatTextCache += `=${attr.value}`;
}
this.flatTextCache += ' ';
this.__flatTextCache += ' ';
}
this.flatTextCache = utils.normalize(this.flatTextCache);
this.__flatTextCache = utils.normalize(this.__flatTextCache);
}
return this.flatTextCache;
return this.__flatTextCache;
}
invalidateThisCache() {
this.flatTextCache = null;
this.__flatTextCache = null;
this.__attributeCache = null;
this.inheritableAttributeCache = null;
this.ancestorCache = null;
this.__inheritableAttributeCache = null;
this.__ancestorCache = null;
}
invalidateSubTree(path = []) {
@@ -903,24 +841,6 @@ class BNote extends AbstractBeccaEntity {
}
}
invalidateSubtreeFlatText() {
this.flatTextCache = null;
for (const childNote of this.children) {
childNote.invalidateSubtreeFlatText();
}
for (const targetRelation of this.targetRelations) {
if (targetRelation.name === 'template' || targetRelation.name === 'inherit') {
const note = targetRelation.note;
if (note) {
note.invalidateSubtreeFlatText();
}
}
}
}
getRelationDefinitions() {
return this.getLabels()
.filter(l => l.name.startsWith("relation:"));
@@ -1049,7 +969,7 @@ class BNote extends AbstractBeccaEntity {
};
}
/** @returns {String[]} - includes the subtree node as well */
/** @returns {string[]} - includes the subtree root note as well */
getSubtreeNoteIds({includeArchived = true, includeHidden = false, resolveSearch = false} = {}) {
return this.getSubtree({includeArchived, includeHidden, resolveSearch})
.notes
@@ -1111,28 +1031,33 @@ class BNote extends AbstractBeccaEntity {
/** @returns {BNote[]} */
getAncestors() {
if (!this.ancestorCache) {
if (!this.__ancestorCache) {
const noteIds = new Set();
this.ancestorCache = [];
this.__ancestorCache = [];
for (const parent of this.parents) {
if (noteIds.has(parent.noteId)) {
continue;
}
this.ancestorCache.push(parent);
this.__ancestorCache.push(parent);
noteIds.add(parent.noteId);
for (const ancestorNote of parent.getAncestors()) {
if (!noteIds.has(ancestorNote.noteId)) {
this.ancestorCache.push(ancestorNote);
this.__ancestorCache.push(ancestorNote);
noteIds.add(ancestorNote.noteId);
}
}
}
}
return this.ancestorCache;
return this.__ancestorCache;
}
/** @returns {string[]} */
getAncestorNoteIds() {
return this.getAncestors().map(note => note.noteId);
}
/** @returns {boolean} */
@@ -1150,6 +1075,7 @@ class BNote extends AbstractBeccaEntity {
return this.noteId === '_hidden' || this.hasAncestor('_hidden');
}
/** @returns {BAttribute[]} */
getTargetRelations() {
return this.targetRelations;
}
@@ -1186,10 +1112,55 @@ class BNote extends AbstractBeccaEntity {
return minDistance;
}
/** @returns {BNoteRevision[]} */
getNoteRevisions() {
return sql.getRows("SELECT * FROM note_revisions WHERE noteId = ?", [this.noteId])
.map(row => new BNoteRevision(row));
/** @returns {BRevision[]} */
getRevisions() {
return sql.getRows("SELECT * FROM revisions WHERE noteId = ?", [this.noteId])
.map(row => new BRevision(row));
}
/** @returns {BAttachment[]} */
getAttachments(opts = {}) {
opts.includeContentLength = !!opts.includeContentLength;
// from testing it looks like calculating length does not make a difference in performance even on large-ish DB
// given that we're always fetching attachments only for a specific note, we might just do it always
const query = opts.includeContentLength
? `SELECT attachments.*, LENGTH(blobs.content) AS contentLength
FROM attachments
JOIN blobs USING (blobId)
WHERE parentId = ? AND isDeleted = 0
ORDER BY position`
: `SELECT * FROM attachments WHERE parentId = ? AND isDeleted = 0 ORDER BY position`;
return sql.getRows(query, [this.noteId])
.map(row => new BAttachment(row));
}
/** @returns {BAttachment|null} */
getAttachmentById(attachmentId, opts = {}) {
opts.includeContentLength = !!opts.includeContentLength;
const query = opts.includeContentLength
? `SELECT attachments.*, LENGTH(blobs.content) AS contentLength
FROM attachments
JOIN blobs USING (blobId)
WHERE parentId = ? AND attachmentId = ? AND isDeleted = 0`
: `SELECT * FROM attachments WHERE parentId = ? AND attachmentId = ? AND isDeleted = 0`;
return sql.getRows(query, [this.noteId, attachmentId])
.map(row => new BAttachment(row))[0];
}
/** @returns {BAttachment[]} */
getAttachmentByRole(role) {
return sql.getRows(`
SELECT attachments.*
FROM attachments
WHERE parentId = ?
AND role = ?
AND isDeleted = 0
ORDER BY position`, [this.noteId, role])
.map(row => new BAttachment(row));
}
/**
@@ -1203,13 +1174,10 @@ class BNote extends AbstractBeccaEntity {
}
const parentNotes = this.getParentNotes();
let notePaths = [];
if (parentNotes.length === 1) { // optimization for most common case
notePaths = parentNotes[0].getAllNotePaths();
} else {
notePaths = parentNotes.flatMap(parentNote => parentNote.getAllNotePaths());
}
const notePaths = parentNotes.length === 1
? parentNotes[0].getAllNotePaths() // optimization for most common case
: parentNotes.flatMap(parentNote => parentNote.getAllNotePaths());
for (const notePath of notePaths) {
notePath.push(this.noteId);
@@ -1356,10 +1324,10 @@ class BNote extends AbstractBeccaEntity {
* @param {string} name - name of the attribute, not including the leading ~/#
* @param {string} [value] - value of the attribute - text for labels, target note ID for relations; optional.
* @param {boolean} [isInheritable=false]
* @param {int} [position]
* @param {int|null} [position]
* @returns {BAttribute}
*/
addAttribute(type, name, value = "", isInheritable = false, position = 1000) {
addAttribute(type, name, value = "", isInheritable = false, position = null) {
const BAttribute = require("./battribute");
return new BAttribute({
@@ -1486,13 +1454,84 @@ class BNote extends AbstractBeccaEntity {
return cloningService.cloneNoteToBranch(this.noteId, branch.branchId);
}
isEligibleForConversionToAttachment() {
if (this.type !== 'image' || !this.isContentAvailable() || this.hasChildren() || this.getParentBranches().length !== 1) {
return false;
}
const targetRelations = this.getTargetRelations().filter(relation => relation.name === 'imageLink');
if (targetRelations.length > 1) {
return false;
}
const parentNote = this.getParentNotes()[0]; // at this point note can have only one parent
const referencingNote = targetRelations[0]?.getNote();
if (referencingNote && parentNote !== referencingNote) {
return false;
} else if (parentNote.type !== 'text' || !parentNote.isContentAvailable()) {
return false;
}
return true;
}
/**
* Some notes are eligible for conversion into an attachment of its parent, note must have these properties:
* - it has exactly one target relation
* - it has a relation from its parent note
* - it has no children
* - it has no clones
* - parent is of type text
* - both notes are either unprotected or user is in protected session
*
* Currently, works only for image notes.
*
* In future this functionality might get more generic and some of the requirements relaxed.
*
* @params {Object} [opts]
* @params {bolean} [opts.force=false} it is envisioned that user can force the conversion even if some conditions
* are not satisfied (e.g. relation to parent doesn't exist).
*
* @returns {BAttachment|null} - null if note is not eligible for conversion
*/
convertToParentAttachment(opts = {force: false}) {
if (!this.isEligibleForConversionToAttachment()) {
return null;
}
const content = this.getContent();
const parentNote = this.getParentNotes()[0];
const attachment = parentNote.saveAttachment({
role: 'image',
mime: this.mime,
title: this.title,
content: content
});
let parentContent = parentNote.getContent();
const oldNoteUrl = `api/images/${this.noteId}/`;
const newAttachmentUrl = `api/attachments/${attachment.attachmentId}/image/`;
const fixedContent = utils.replaceAll(parentContent, oldNoteUrl, newAttachmentUrl);
parentNote.setContent(fixedContent);
this.deleteNote();
return attachment;
}
/**
* (Soft) delete a note and all its descendants.
*
* @param {string} [deleteId] - optional delete identified
* @param {string} [deleteId=null] - optional delete identified
* @param {TaskContext} [taskContext]
*/
deleteNote(deleteId, taskContext) {
deleteNote(deleteId = null, taskContext = null) {
if (this.isDeleted) {
return;
}
@@ -1519,7 +1558,7 @@ class BNote extends AbstractBeccaEntity {
if (this.isProtected && !this.isDecrypted && protectedSessionService.isProtectedSessionAvailable()) {
try {
this.title = protectedSessionService.decryptString(this.title);
this.flatTextCache = null;
this.__flatTextCache = null;
this.isDecrypted = true;
}
@@ -1538,42 +1577,87 @@ class BNote extends AbstractBeccaEntity {
}
get isDeleted() {
// isBeingDeleted is relevant only in the transition period when the deletion process have begun, but not yet
// finished (note is still in becca)
return !(this.noteId in this.becca.notes) || this.isBeingDeleted;
}
/**
* @returns {BNoteRevision|null}
* @returns {BRevision|null}
*/
saveNoteRevision() {
const content = this.getContent();
saveRevision() {
return sql.transactional(() => {
let noteContent = this.getContent();
const contentMetadata = this.getContentMetadata();
if (!content || (Buffer.isBuffer(content) && content.byteLength === 0)) {
return null;
const revision = new BRevision({
noteId: this.noteId,
// title and text should be decrypted now
title: this.title,
type: this.type,
mime: this.mime,
isProtected: this.isProtected,
utcDateLastEdited: this.utcDateModified > contentMetadata.utcDateModified
? this.utcDateModified
: contentMetadata.utcDateModified,
utcDateCreated: dateUtils.utcNowDateTime(),
utcDateModified: dateUtils.utcNowDateTime(),
dateLastEdited: this.dateModified > contentMetadata.dateModified
? this.dateModified
: contentMetadata.dateModified,
dateCreated: dateUtils.localNowDateTime()
}, true);
revision.save(); // to generate revisionId, which is then used to save attachments
if (this.type === 'text') {
for (const noteAttachment of this.getAttachments()) {
if (noteAttachment.utcDateScheduledForErasureSince) {
continue;
}
const revisionAttachment = noteAttachment.copy();
revisionAttachment.parentId = revision.revisionId;
revisionAttachment.setContent(noteAttachment.getContent(), {forceSave: true});
// content is rewritten to point to the revision attachments
noteContent = noteContent.replaceAll(`attachments/${noteAttachment.attachmentId}`, `attachments/${revisionAttachment.attachmentId}`);
}
revision.setContent(noteContent, {forceSave: true});
}
return revision;
});
}
/**
* @returns {BAttachment}
*/
saveAttachment({attachmentId, role, mime, title, content, position}) {
let attachment;
if (attachmentId) {
attachment = this.becca.getAttachmentOrThrow(attachmentId);
} else {
attachment = new BAttachment({
parentId: this.noteId,
title,
role,
mime,
isProtected: this.isProtected,
position
});
}
const contentMetadata = this.getContentMetadata();
content = content || "";
attachment.setContent(content, {forceSave: true});
const noteRevision = new BNoteRevision({
noteId: this.noteId,
// title and text should be decrypted now
title: this.title,
type: this.type,
mime: this.mime,
isProtected: this.isProtected,
utcDateLastEdited: this.utcDateModified > contentMetadata.utcDateModified
? this.utcDateModified
: contentMetadata.utcDateModified,
utcDateCreated: dateUtils.utcNowDateTime(),
utcDateModified: dateUtils.utcNowDateTime(),
dateLastEdited: this.dateModified > contentMetadata.dateModified
? this.dateModified
: contentMetadata.dateModified,
dateCreated: dateUtils.localNowDateTime()
}, true).save();
return attachment;
}
noteRevision.setContent(content);
return noteRevision;
getFileName() {
return utils.formatDownloadTitle(this.title, this.type, this.mime);
}
beforeSaving() {
@@ -1592,6 +1676,7 @@ class BNote extends AbstractBeccaEntity {
isProtected: this.isProtected,
type: this.type,
mime: this.mime,
blobId: this.blobId,
isDeleted: false,
dateCreated: this.dateCreated,
dateModified: this.dateModified,
@@ -1628,7 +1713,7 @@ module.exports = BNote;
</div>
<nav>
<h2><a href="index.html">Home</a></h2><h3>Modules</h3><ul><li><a href="module-sql.html">sql</a></li></ul><h3>Classes</h3><ul><li><a href="AbstractBeccaEntity.html">AbstractBeccaEntity</a></li><li><a href="BAttribute.html">BAttribute</a></li><li><a href="BBranch.html">BBranch</a></li><li><a href="BEtapiToken.html">BEtapiToken</a></li><li><a href="BNote.html">BNote</a></li><li><a href="BNoteRevision.html">BNoteRevision</a></li><li><a href="BOption.html">BOption</a></li><li><a href="BRecentNote.html">BRecentNote</a></li><li><a href="BackendScriptApi.html">BackendScriptApi</a></li></ul>
<h2><a href="index.html">Home</a></h2><h3>Modules</h3><ul><li><a href="module-sql.html">sql</a></li></ul><h3>Classes</h3><ul><li><a href="AbstractBeccaEntity.html">AbstractBeccaEntity</a></li><li><a href="BAttachment.html">BAttachment</a></li><li><a href="BAttribute.html">BAttribute</a></li><li><a href="BBranch.html">BBranch</a></li><li><a href="BEtapiToken.html">BEtapiToken</a></li><li><a href="BNote.html">BNote</a></li><li><a href="BOption.html">BOption</a></li><li><a href="BRecentNote.html">BRecentNote</a></li><li><a href="BRevision.html">BRevision</a></li><li><a href="BackendScriptApi.html">BackendScriptApi</a></li></ul>
</nav>
<br class="clear">