Mark the entity as (soft) deleted. It will be completely erased later.
-This is a low level method, for notes and branches use `note.deleteNote()` and 'branch.deleteBranch()` instead.
+This is a low-level method, for notes and branches use `note.deleteNote()` and 'branch.deleteBranch()` instead.
@@ -833,7 +1022,7 @@ This is a low level method, for notes and branches use `note.deleteNote()` and '
@@ -985,7 +1174,7 @@ This is a low level method, for notes and branches use `note.deleteNote()` and '
diff --git a/docs/backend_api/BAttribute.html b/docs/backend_api/BAttribute.html
index 29eb98f90..8c693765f 100644
--- a/docs/backend_api/BAttribute.html
+++ b/docs/backend_api/BAttribute.html
@@ -268,7 +268,7 @@ and relation (representing named relationship between source and target note)Source:
@@ -1628,7 +1827,7 @@ and relation (representing named relationship between source and target note)
Mark the entity as (soft) deleted. It will be completely erased later.
-This is a low level method, for notes and branches use `note.deleteNote()` and 'branch.deleteBranch()` instead.
+This is a low-level method, for notes and branches use `note.deleteNote()` and 'branch.deleteBranch()` instead.
@@ -1741,7 +1940,7 @@ This is a low level method, for notes and branches use `note.deleteNote()` and '
@@ -1898,7 +2097,7 @@ This is a low level method, for notes and branches use `note.deleteNote()` and '
diff --git a/docs/backend_api/BBranch.html b/docs/backend_api/BBranch.html
index f75738ee7..b1aca3eff 100644
--- a/docs/backend_api/BBranch.html
+++ b/docs/backend_api/BBranch.html
@@ -203,7 +203,7 @@ Always check noteId instead.
@@ -1640,7 +1941,7 @@ of deletion should not act as a clone.
Mark the entity as (soft) deleted. It will be completely erased later.
-This is a low level method, for notes and branches use `note.deleteNote()` and 'branch.deleteBranch()` instead.
+This is a low-level method, for notes and branches use `note.deleteNote()` and 'branch.deleteBranch()` instead.
@@ -1753,7 +2054,7 @@ This is a low level method, for notes and branches use `note.deleteNote()` and '
@@ -1910,7 +2211,7 @@ This is a low level method, for notes and branches use `note.deleteNote()` and '
diff --git a/docs/backend_api/BEtapiToken.html b/docs/backend_api/BEtapiToken.html
index 2c6eb8218..b48f600da 100644
--- a/docs/backend_api/BEtapiToken.html
+++ b/docs/backend_api/BEtapiToken.html
@@ -205,7 +205,7 @@ from tokenHash and token.
Mark the entity as (soft) deleted. It will be completely erased later.
-This is a low level method, for notes and branches use `note.deleteNote()` and 'branch.deleteBranch()` instead.
+This is a low-level method, for notes and branches use `note.deleteNote()` and 'branch.deleteBranch()` instead.
@@ -1298,7 +1497,7 @@ This is a low level method, for notes and branches use `note.deleteNote()` and '
@@ -1455,7 +1654,7 @@ This is a low level method, for notes and branches use `note.deleteNote()` and '
diff --git a/docs/backend_api/BNote.html b/docs/backend_api/BNote.html
index b1d3e2b60..5ed76f456 100644
--- a/docs/backend_api/BNote.html
+++ b/docs/backend_api/BNote.html
@@ -154,6 +154,77 @@
+
+ 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.
+
@@ -11149,28 +12180,6 @@ This method can be significantly faster than the getAttribute()
-
Returns:
-
-
-
- true if the note has string content (not binary)
-
-
-
-
-
-
- Type
-
-
-
-boolean
-
-
-
-
-
-
@@ -11192,7 +12201,7 @@ This method can be significantly faster than the getAttribute()
Mark the entity as (soft) deleted. It will be completely erased later.
-This is a low level method, for notes and branches use `note.deleteNote()` and 'branch.deleteBranch()` instead.
+This is a low-level method, for notes and branches use `note.deleteNote()` and 'branch.deleteBranch()` instead.
@@ -11305,7 +12314,7 @@ This is a low level method, for notes and branches use `note.deleteNote()` and '
- true if the note has string content (not binary)
-
-
-
-
-
-
- Type
-
-
-
-boolean
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
markAsDeleted(deleteIdopt)
-
-
-
-
-
-
-
- Mark the entity as (soft) deleted. It will be completely erased later.
-
-This is a low level method, for notes and branches use `note.deleteNote()` and 'branch.deleteBranch()` instead.
-
Mark the entity as (soft) deleted. It will be completely erased later.
-This is a low level method, for notes and branches use `note.deleteNote()` and 'branch.deleteBranch()` instead.
+This is a low-level method, for notes and branches use `note.deleteNote()` and 'branch.deleteBranch()` instead.
@@ -1156,7 +1355,7 @@ This is a low level method, for notes and branches use `note.deleteNote()` and '
@@ -1313,7 +1512,7 @@ This is a low level method, for notes and branches use `note.deleteNote()` and '
diff --git a/docs/backend_api/BRecentNote.html b/docs/backend_api/BRecentNote.html
index cdda77c51..8c25ed62d 100644
--- a/docs/backend_api/BRecentNote.html
+++ b/docs/backend_api/BRecentNote.html
@@ -199,7 +199,7 @@
Mark the entity as (soft) deleted. It will be completely erased later.
-This is a low level method, for notes and branches use `note.deleteNote()` and 'branch.deleteBranch()` instead.
+This is a low-level method, for notes and branches use `note.deleteNote()` and 'branch.deleteBranch()` instead.
@@ -1088,7 +1287,7 @@ This is a low level method, for notes and branches use `note.deleteNote()` and '
@@ -1245,7 +1444,7 @@ This is a low level method, for notes and branches use `note.deleteNote()` and '
diff --git a/docs/backend_api/BackendScriptApi.html b/docs/backend_api/BackendScriptApi.html
index ba6ffbc2b..383ba8d7a 100644
--- a/docs/backend_api/BackendScriptApi.html
+++ b/docs/backend_api/BackendScriptApi.html
@@ -1623,7 +1623,7 @@ JSON MIME type. See also createNewNote() for more options.
-int
+integer
@@ -6272,7 +6272,7 @@ if some action needs to happen on only one specific instance.
-number
+integer
@@ -7883,7 +7883,7 @@ exists, then we'll use that transaction.
diff --git a/docs/backend_api/becca_entities_abstract_becca_entity.js.html b/docs/backend_api/becca_entities_abstract_becca_entity.js.html
index b64387b2d..f06d1e202 100644
--- a/docs/backend_api/becca_entities_abstract_becca_entity.js.html
+++ b/docs/backend_api/becca_entities_abstract_becca_entity.js.html
@@ -35,6 +35,8 @@ const eventService = require("../../services/events");
const dateUtils = require("../../services/date_utils");
const cls = require("../../services/cls");
const log = require("../../services/log");
+const protectedSessionService = require("../../services/protected_session");
+const blobService = require("../../services/blob");
let becca = null;
@@ -146,10 +148,140 @@ class AbstractBeccaEntity {
return this;
}
+ /** @protected */
+ _setContent(content, opts = {}) {
+ // client code asks to save entity even if blobId didn't change (something else was changed)
+ opts.forceSave = !!opts.forceSave;
+ opts.forceFrontendReload = !!opts.forceFrontendReload;
+
+ if (content === null || content === undefined) {
+ throw new Error(`Cannot set null content to ${this.constructor.primaryKeyName} '${this[this.constructor.primaryKeyName]}'`);
+ }
+
+ if (this.hasStringContent()) {
+ content = content.toString();
+ }
+ else {
+ content = Buffer.isBuffer(content) ? content : Buffer.from(content);
+ }
+
+ const unencryptedContentForHashCalculation = this.#getUnencryptedContentForHashCalculation(content);
+
+ if (this.isProtected) {
+ if (protectedSessionService.isProtectedSessionAvailable()) {
+ content = protectedSessionService.encrypt(content);
+ } else {
+ throw new Error(`Cannot update content of blob since protected session is not available.`);
+ }
+ }
+
+ sql.transactional(() => {
+ const newBlobId = this.#saveBlob(content, unencryptedContentForHashCalculation, opts);
+ const oldBlobId = this.blobId;
+
+ if (newBlobId !== oldBlobId || opts.forceSave) {
+ this.blobId = newBlobId;
+ this.save();
+
+ if (newBlobId !== oldBlobId) {
+ this.#deleteBlobIfNoteUsed(oldBlobId);
+ }
+ }
+ });
+ }
+
+ #deleteBlobIfNoteUsed(blobId) {
+ if (sql.getValue("SELECT 1 FROM notes WHERE blobId = ? LIMIT 1", [blobId])) {
+ return;
+ }
+
+ if (sql.getValue("SELECT 1 FROM attachments WHERE blobId = ? LIMIT 1", [blobId])) {
+ return;
+ }
+
+ if (sql.getValue("SELECT 1 FROM revisions WHERE blobId = ? LIMIT 1", [blobId])) {
+ return;
+ }
+
+ sql.execute("DELETE FROM blobs WHERE blobId = ?", [blobId]);
+ sql.execute("DELETE FROM entity_changes WHERE entityName = 'blobs' AND entityId = ?", [blobId]);
+ }
+
+ #getUnencryptedContentForHashCalculation(unencryptedContent) {
+ if (this.isProtected) {
+ // a "random" prefix make sure that the calculated hash/blobId is different for an encrypted note and decrypted
+ const encryptedPrefixSuffix = "t$[nvQg7q)&_ENCRYPTED_?M:Bf&j3jr_";
+ return Buffer.isBuffer(unencryptedContent)
+ ? Buffer.concat([Buffer.from(encryptedPrefixSuffix), unencryptedContent])
+ : `${encryptedPrefixSuffix}${unencryptedContent}`;
+ } else {
+ return unencryptedContent;
+ }
+ }
+
+ #saveBlob(content, unencryptedContentForHashCalculation, opts = {}) {
+ /*
+ * We're using the unencrypted blob for the hash calculation, because otherwise the random IV would
+ * cause every content blob to be unique which would balloon the database size (esp. with revisioning).
+ * This has minor security implications (it's easy to infer that given content is shared between different
+ * notes/attachments, but the trade-off comes out clearly positive).
+ */
+ const newBlobId = utils.hashedBlobId(unencryptedContentForHashCalculation);
+ const blobNeedsInsert = !sql.getValue('SELECT 1 FROM blobs WHERE blobId = ?', [newBlobId]);
+
+ if (!blobNeedsInsert) {
+ return newBlobId;
+ }
+
+ const pojo = {
+ blobId: newBlobId,
+ content: content,
+ dateModified: dateUtils.localNowDateTime(),
+ utcDateModified: dateUtils.utcNowDateTime()
+ };
+
+ sql.upsert("blobs", "blobId", pojo);
+
+ const hash = utils.hash(`${newBlobId}|${pojo.content.toString()}`);
+
+ entityChangesService.addEntityChange({
+ entityName: 'blobs',
+ entityId: newBlobId,
+ hash: hash,
+ isErased: false,
+ utcDateChanged: pojo.utcDateModified,
+ isSynced: true,
+ // overriding componentId will cause frontend to think the change is coming from a different component
+ // and thus reload
+ componentId: opts.forceFrontendReload ? utils.randomString(10) : null
+ });
+
+ eventService.emit(eventService.ENTITY_CHANGED, {
+ entityName: 'blobs',
+ entity: this
+ });
+
+ return newBlobId;
+ }
+
+ /**
+ * @protected
+ * @returns {string|Buffer}
+ */
+ _getContent() {
+ const row = sql.getRow(`SELECT content FROM blobs WHERE blobId = ?`, [this.blobId]);
+
+ if (!row) {
+ throw new Error(`Cannot find content for ${this.constructor.primaryKeyName} '${this[this.constructor.primaryKeyName]}', blobId '${this.blobId}'`);
+ }
+
+ return blobService.processContent(row.content, this.isProtected, this.hasStringContent());
+ }
+
/**
* Mark the entity as (soft) deleted. It will be completely erased later.
*
- * This is a low level method, for notes and branches use `note.deleteNote()` and 'branch.deleteBranch()` instead.
+ * This is a low-level method, for notes and branches use `note.deleteNote()` and 'branch.deleteBranch()` instead.
*
* @param [deleteId=null]
*/
@@ -206,7 +338,7 @@ module.exports = AbstractBeccaEntity;
diff --git a/docs/backend_api/becca_entities_battribute.js.html b/docs/backend_api/becca_entities_battribute.js.html
index d9715c60b..8890189fa 100644
--- a/docs/backend_api/becca_entities_battribute.js.html
+++ b/docs/backend_api/becca_entities_battribute.js.html
@@ -79,7 +79,7 @@ class BAttribute extends AbstractBeccaEntity {
this.type = type;
/** @type {string} */
this.name = name;
- /** @type {int} */
+ /** @type {integer} */
this.position = position;
/** @type {string} */
this.value = value || "";
@@ -169,7 +169,7 @@ class BAttribute extends AbstractBeccaEntity {
*/
getTargetNote() {
if (this.type !== 'relation') {
- throw new Error(`Attribute ${this.attributeId} is not relation`);
+ throw new Error(`Attribute '${this.attributeId}' is not a relation.`);
}
if (!this.value) {
@@ -216,9 +216,11 @@ class BAttribute extends AbstractBeccaEntity {
this.value = "";
}
- if (this.position === undefined) {
- // TODO: can be calculated from becca
- this.position = 1 + sql.getValue(`SELECT COALESCE(MAX(position), 0) FROM attributes WHERE noteId = ?`, [this.noteId]);
+ if (this.position === undefined || this.position === null) {
+ const maxExistingPosition = this.getNote().getAttributes()
+ .reduce((maxPosition, attr) => Math.max(maxPosition, attr.position || 0), 0);
+
+ this.position = maxExistingPosition + 10;
}
if (!this.isInheritable) {
@@ -270,7 +272,7 @@ module.exports = BAttribute;
diff --git a/docs/backend_api/becca_entities_bbranch.js.html b/docs/backend_api/becca_entities_bbranch.js.html
index b2261711d..8e8282cc0 100644
--- a/docs/backend_api/becca_entities_bbranch.js.html
+++ b/docs/backend_api/becca_entities_bbranch.js.html
@@ -83,7 +83,7 @@ class BBranch extends AbstractBeccaEntity {
this.parentNoteId = parentNoteId;
/** @type {string|null} */
this.prefix = prefix;
- /** @type {int} */
+ /** @type {integer} */
this.notePosition = notePosition;
/** @type {boolean} */
this.isExpanded = !!isExpanded;
@@ -131,6 +131,7 @@ class BBranch extends AbstractBeccaEntity {
return this.becca.notes[this.noteId];
}
+ /** @returns {BNote} */
getNote() {
return this.childNote;
}
@@ -214,7 +215,7 @@ class BBranch extends AbstractBeccaEntity {
// first delete children and then parent - this will show up better in recent changes
- log.info(`Deleting note ${note.noteId}`);
+ log.info(`Deleting note '${note.noteId}'`);
this.becca.notes[note.noteId].isBeingDeleted = true;
@@ -226,6 +227,10 @@ class BBranch extends AbstractBeccaEntity {
relation.markAsDeleted(deleteId);
}
+ for (const attachment of note.getAttachments()) {
+ attachment.markAsDeleted(deleteId);
+ }
+
note.markAsDeleted(deleteId);
return true;
@@ -247,7 +252,7 @@ class BBranch extends AbstractBeccaEntity {
for (const childBranch of this.parentNote.getChildBranches()) {
if (maxNotePos < childBranch.notePosition
- && childBranch.noteId !== '_hidden' // hidden has very large notePosition to always stay last
+ && childBranch.noteId !== '_hidden' // hidden has a very large notePosition to always stay last
) {
maxNotePos = childBranch.notePosition;
}
@@ -313,7 +318,7 @@ module.exports = BBranch;
diff --git a/docs/backend_api/becca_entities_betapi_token.js.html b/docs/backend_api/becca_entities_betapi_token.js.html
index 329843522..16fbdc9ed 100644
--- a/docs/backend_api/becca_entities_betapi_token.js.html
+++ b/docs/backend_api/becca_entities_betapi_token.js.html
@@ -114,7 +114,7 @@ module.exports = BEtapiToken;
diff --git a/docs/backend_api/becca_entities_bnote.js.html b/docs/backend_api/becca_entities_bnote.js.html
index 0c729d838..6c7d41cb2 100644
--- a/docs/backend_api/becca_entities_bnote.js.html
+++ b/docs/backend_api/becca_entities_bnote.js.html
@@ -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;
diff --git a/docs/backend_api/becca_entities_bnote_revision.js.html b/docs/backend_api/becca_entities_bnote_revision.js.html
deleted file mode 100644
index ea663e51f..000000000
--- a/docs/backend_api/becca_entities_bnote_revision.js.html
+++ /dev/null
@@ -1,244 +0,0 @@
-
-
-
-
- JSDoc: Source: becca/entities/bnote_revision.js
-
-
-
-
-
-
-
-
-
-
-
-
-
Source: becca/entities/bnote_revision.js
-
-
-
-
-
-
-
-
-
"use strict";
-
-const protectedSessionService = require('../../services/protected_session');
-const utils = require('../../services/utils');
-const sql = require('../../services/sql');
-const dateUtils = require('../../services/date_utils');
-const becca = require('../becca');
-const entityChangesService = require('../../services/entity_changes');
-const AbstractBeccaEntity = require("./abstract_becca_entity");
-
-/**
- * NoteRevision represents snapshot of note's title and content at some point in the past.
- * It's used for seamless note versioning.
- *
- * @extends AbstractBeccaEntity
- */
-class BNoteRevision extends AbstractBeccaEntity {
- static get entityName() { return "note_revisions"; }
- static get primaryKeyName() { return "noteRevisionId"; }
- static get hashedProperties() { return ["noteRevisionId", "noteId", "title", "isProtected", "dateLastEdited", "dateCreated", "utcDateLastEdited", "utcDateCreated", "utcDateModified"]; }
-
- constructor(row, titleDecrypted = false) {
- super();
-
- /** @type {string} */
- this.noteRevisionId = row.noteRevisionId;
- /** @type {string} */
- this.noteId = row.noteId;
- /** @type {string} */
- this.type = row.type;
- /** @type {string} */
- this.mime = row.mime;
- /** @type {boolean} */
- this.isProtected = !!row.isProtected;
- /** @type {string} */
- this.title = row.title;
- /** @type {string} */
- this.dateLastEdited = row.dateLastEdited;
- /** @type {string} */
- this.dateCreated = row.dateCreated;
- /** @type {string} */
- this.utcDateLastEdited = row.utcDateLastEdited;
- /** @type {string} */
- this.utcDateCreated = row.utcDateCreated;
- /** @type {string} */
- this.utcDateModified = row.utcDateModified;
- /** @type {number} */
- this.contentLength = row.contentLength;
-
- if (this.isProtected && !titleDecrypted) {
- this.title = protectedSessionService.isProtectedSessionAvailable()
- ? protectedSessionService.decryptString(this.title)
- : "[protected]";
- }
- }
-
- getNote() {
- return becca.notes[this.noteId];
- }
-
- /** @returns {boolean} true if the note has string content (not binary) */
- isStringNote() {
- return utils.isStringNote(this.type, this.mime);
- }
-
- /*
- * Note revision content has quite special handling - it's not a separate entity, but a lazily loaded
- * part of NoteRevision entity with its own sync. Reason behind this hybrid design is that
- * 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.
- *
- * This is the same approach as is used for Note's content.
- */
-
- /** @returns {*} */
- getContent(silentNotFoundError = false) {
- const res = sql.getRow(`SELECT content FROM note_revision_contents WHERE noteRevisionId = ?`, [this.noteRevisionId]);
-
- if (!res) {
- if (silentNotFoundError) {
- return undefined;
- }
- else {
- throw new Error(`Cannot find note revision content for noteRevisionId=${this.noteRevisionId}`);
- }
- }
-
- let content = res.content;
-
- if (this.isProtected) {
- if (protectedSessionService.isProtectedSessionAvailable()) {
- content = protectedSessionService.decrypt(content);
- }
- else {
- content = "";
- }
- }
-
- if (this.isStringNote()) {
- return content === null
- ? ""
- : content.toString("UTF-8");
- }
- else {
- return content;
- }
- }
-
- setContent(content) {
- const pojo = {
- noteRevisionId: this.noteRevisionId,
- content: content,
- utcDateModified: dateUtils.utcNowDateTime()
- };
-
- if (this.isProtected) {
- if (protectedSessionService.isProtectedSessionAvailable()) {
- pojo.content = protectedSessionService.encrypt(pojo.content);
- }
- else {
- throw new Error(`Cannot update content of noteRevisionId=${this.noteRevisionId} since we're out of protected session.`);
- }
- }
-
- sql.upsert("note_revision_contents", "noteRevisionId", pojo);
-
- const hash = utils.hash(`${this.noteRevisionId}|${pojo.content.toString()}`);
-
- entityChangesService.addEntityChange({
- entityName: 'note_revision_contents',
- entityId: this.noteRevisionId,
- hash: hash,
- isErased: false,
- utcDateChanged: this.getUtcDateChanged(),
- isSynced: true
- });
- }
-
- /** @returns {{contentLength, dateModified, utcDateModified}} */
- getContentMetadata() {
- return sql.getRow(`
- SELECT
- LENGTH(content) AS contentLength,
- dateModified,
- utcDateModified
- FROM note_revision_contents
- WHERE noteRevisionId = ?`, [this.noteRevisionId]);
- }
-
- beforeSaving() {
- super.beforeSaving();
-
- this.utcDateModified = dateUtils.utcNowDateTime();
- }
-
- getPojo() {
- return {
- noteRevisionId: this.noteRevisionId,
- noteId: this.noteId,
- type: this.type,
- mime: this.mime,
- isProtected: this.isProtected,
- title: this.title,
- dateLastEdited: this.dateLastEdited,
- dateCreated: this.dateCreated,
- utcDateLastEdited: this.utcDateLastEdited,
- utcDateCreated: this.utcDateCreated,
- utcDateModified: this.utcDateModified,
- content: this.content, // used when retrieving full note revision to frontend
- contentLength: this.contentLength
- };
- }
-
- getPojoToSave() {
- const pojo = this.getPojo();
- delete pojo.content; // not getting persisted
- delete pojo.contentLength; // not getting persisted
-
- if (pojo.isProtected) {
- if (protectedSessionService.isProtectedSessionAvailable()) {
- pojo.title = protectedSessionService.encrypt(this.title);
- }
- else {
- // updating protected note outside of protected session means we will keep original ciphertexts
- delete pojo.title;
- }
- }
-
- return pojo;
- }
-}
-
-module.exports = BNoteRevision;
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/docs/backend_api/becca_entities_boption.js.html b/docs/backend_api/becca_entities_boption.js.html
index 0f2856fdc..a6dfc490a 100644
--- a/docs/backend_api/becca_entities_boption.js.html
+++ b/docs/backend_api/becca_entities_boption.js.html
@@ -86,7 +86,7 @@ module.exports = BOption;
diff --git a/docs/backend_api/becca_entities_brecent_note.js.html b/docs/backend_api/becca_entities_brecent_note.js.html
index 02f6858fb..f4cb341dc 100644
--- a/docs/backend_api/becca_entities_brecent_note.js.html
+++ b/docs/backend_api/becca_entities_brecent_note.js.html
@@ -71,7 +71,7 @@ module.exports = BRecentNote;
diff --git a/docs/backend_api/index.html b/docs/backend_api/index.html
index f9f7919b5..d3d940f88 100644
--- a/docs/backend_api/index.html
+++ b/docs/backend_api/index.html
@@ -50,7 +50,7 @@
diff --git a/docs/backend_api/module-sql.html b/docs/backend_api/module-sql.html
index 2605d6530..aa0b94eda 100644
--- a/docs/backend_api/module-sql.html
+++ b/docs/backend_api/module-sql.html
@@ -1294,7 +1294,7 @@
diff --git a/docs/backend_api/services_backend_script_api.js.html b/docs/backend_api/services_backend_script_api.js.html
index bffce4bcd..ad5eb02f4 100644
--- a/docs/backend_api/services_backend_script_api.js.html
+++ b/docs/backend_api/services_backend_script_api.js.html
@@ -243,7 +243,7 @@ function BackendScriptApi(currentNote, apiParams) {
* @property {boolean} [params.isProtected=false]
* @property {boolean} [params.isExpanded=false]
* @property {string} [params.prefix='']
- * @property {int} [params.notePosition] - default is last existing notePosition in a parent + 10
+ * @property {integer} [params.notePosition] - default is last existing notePosition in a parent + 10
* @returns {{note: BNote, branch: BBranch}} object contains newly created entities note and branch
*/
this.createNewNote = noteService.createNewNote;
@@ -440,7 +440,7 @@ function BackendScriptApi(currentNote, apiParams) {
* Return randomly generated string of given length. This random string generation is NOT cryptographically secure.
*
* @method
- * @param {number} length of the string
+ * @param {integer} length of the string
* @returns {string} random string
*/
this.randomString = utils.randomString;
@@ -578,7 +578,7 @@ module.exports = BackendScriptApi;
diff --git a/docs/backend_api/services_sql.js.html b/docs/backend_api/services_sql.js.html
index 783bf8301..29891f533 100644
--- a/docs/backend_api/services_sql.js.html
+++ b/docs/backend_api/services_sql.js.html
@@ -407,7 +407,7 @@ module.exports = {
diff --git a/docs/frontend_api/FAttribute.html b/docs/frontend_api/FAttribute.html
index 901dc598c..1ef4a4eca 100644
--- a/docs/frontend_api/FAttribute.html
+++ b/docs/frontend_api/FAttribute.html
@@ -416,7 +416,7 @@ and relation (representing named relationship between source and target note)position :int
+
position :integer
@@ -427,7 +427,7 @@ and relation (representing named relationship between source and target note)
-int
+integer
@@ -844,7 +844,7 @@ and relation (representing named relationship between source and target note)
diff --git a/docs/frontend_api/FBranch.html b/docs/frontend_api/FBranch.html
index 4ec45ea97..ded0f398c 100644
--- a/docs/frontend_api/FBranch.html
+++ b/docs/frontend_api/FBranch.html
@@ -420,7 +420,7 @@ parents.
-
- 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.
+ attribute of the given type and name. If there are more such attributes, first is returned. Returns null if there's no such attribute belonging to this note.
- attribute value of given type and name or null if no such attribute exists.
+ attribute value of the given type and name or null if no such attribute exists.
- 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.
+ attribute of the given type and name. If there are more such attributes, first is returned. Returns null if there's no such attribute belonging to this note.
- attribute value of given type and name or null if no such attribute exists.
+ attribute value of the given type and name or null if no such attribute exists.
-
-
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/docs/frontend_api/FrontendScriptApi.html b/docs/frontend_api/FrontendScriptApi.html
index 5e94a32c2..a61c1a6bb 100644
--- a/docs/frontend_api/FrontendScriptApi.html
+++ b/docs/frontend_api/FrontendScriptApi.html
@@ -625,7 +625,7 @@ available in the JS frontend notes. You can use e.g. api.showMessage(api.s
-
where script is currently executing
+
where the script is currently executing
@@ -958,7 +958,7 @@ available in the JS frontend notes. You can use e.g. api.showMessage(api.s
-
where script started executing
+
where the script started executing
@@ -2021,7 +2021,7 @@ available in the JS frontend notes. You can use e.g. api.showMessage(api.s
-
createNoteLink(notePath, paramsopt)
+
createLink(notePath, paramsopt)
@@ -2029,7 +2029,7 @@ available in the JS frontend notes. You can use e.g. api.showMessage(api.s
- Create note link (jQuery object) for given note.
+ Create a note link (jQuery object) for given note.
@@ -2540,6 +2540,161 @@ available in the JS frontend notes. You can use e.g. api.showMessage(api.s
+
@@ -2870,7 +3025,7 @@ available in the JS frontend notes. You can use e.g. api.showMessage(api.s
- See https://ckeditor.com/docs/ckeditor5/latest/api/module_core_editor_editor-Editor.html for a documentation on the returned instance.
+ See https://ckeditor.com/docs/ckeditor5/latest/api/module_core_editor_editor-Editor.html for documentation on the returned instance.
@@ -2954,7 +3109,7 @@ available in the JS frontend notes. You can use e.g. api.showMessage(api.s
-Promise.<CKEditor>
+Promise.<BalloonEditor>
@@ -3087,7 +3242,7 @@ implementation of actual widget type.
- Returns component which owns given DOM element (the nearest parent component in DOM tree)
+ Returns component which owns the given DOM element (the nearest parent component in DOM tree)
@@ -3659,7 +3814,7 @@ if some action needs to happen on only one specific instance.
- Returns note by given noteId. If note is missing from cache, it's loaded.
+ Returns note by given noteId. If note is missing from the cache, it's loaded.
*
@@ -3815,10 +3970,10 @@ if some action needs to happen on only one specific instance.
- Returns list of notes. If note is missing from cache, it's loaded.
+ Returns list of notes. If note is missing from the cache, it's loaded.
This is often used to bulk-fill the cache with notes which would have to be picked one by one
-otherwise (by e.g. createNoteLink())
+otherwise (by e.g. createLink())
@@ -4520,7 +4675,7 @@ otherwise (by e.g. createNoteLink())
@@ -5483,7 +5638,7 @@ otherwise (by e.g. createNoteLink())
-number
+integer
@@ -6688,7 +6843,7 @@ Internally this serializes the anonymous function into string and sends it to ba
- Show error message to the user.
+ Show an error message to the user.
@@ -6825,7 +6980,7 @@ Internally this serializes the anonymous function into string and sends it to ba
- Show info message to the user.
+ Show an info message to the user.
@@ -6962,7 +7117,7 @@ Internally this serializes the anonymous function into string and sends it to ba
- Trigger command.
+ Trigger command. This is a very low-level API which should be avoided if possible.
@@ -7122,7 +7277,7 @@ Internally this serializes the anonymous function into string and sends it to ba
- Trigger event.
+ Trigger event. This is a very low-level API which should be avoided if possible.
@@ -7282,11 +7437,11 @@ Internally this serializes the anonymous function into string and sends it to ba
- Trilium runs in backend and frontend process, when something is changed on the backend from script,
+ Trilium runs in a backend and frontend process, when something is changed on the backend from a script,
frontend will get asynchronously synchronized.
This method returns a promise which resolves once all the backend -> frontend synchronization is finished.
-Typical use case is when new note has been created, we should wait until it is synced into frontend and only then activate it.
+Typical use case is when a new note has been created, we should wait until it is synced into frontend and only then activate it.
@@ -7394,7 +7549,7 @@ Typical use case is when new note has been created, we should wait until it is s
diff --git a/docs/frontend_api/entities_fattribute.js.html b/docs/frontend_api/entities_fattribute.js.html
index a9cb49a5f..690ae0b49 100644
--- a/docs/frontend_api/entities_fattribute.js.html
+++ b/docs/frontend_api/entities_fattribute.js.html
@@ -50,7 +50,7 @@ class FAttribute {
this.name = row.name;
/** @type {string} */
this.value = row.value;
- /** @type {int} */
+ /** @type {integer} */
this.position = row.position;
/** @type {boolean} */
this.isInheritable = !!row.isInheritable;
@@ -115,7 +115,7 @@ export default FAttribute;
diff --git a/docs/frontend_api/entities_fbranch.js.html b/docs/frontend_api/entities_fbranch.js.html
index deae06846..6a51a59a8 100644
--- a/docs/frontend_api/entities_fbranch.js.html
+++ b/docs/frontend_api/entities_fbranch.js.html
@@ -47,7 +47,7 @@ class FBranch {
this.noteId = row.noteId;
/** @type {string} */
this.parentNoteId = row.parentNoteId;
- /** @type {int} */
+ /** @type {integer} */
this.notePosition = row.notePosition;
/** @type {string} */
this.prefix = row.prefix;
@@ -99,7 +99,7 @@ export default FBranch;
diff --git a/docs/frontend_api/entities_fnote.js.html b/docs/frontend_api/entities_fnote.js.html
index 84616e2cb..db2d8c859 100644
--- a/docs/frontend_api/entities_fnote.js.html
+++ b/docs/frontend_api/entities_fnote.js.html
@@ -29,10 +29,10 @@
import server from '../services/server.js';
import noteAttributeCache from "../services/note_attribute_cache.js";
import ws from "../services/ws.js";
-import options from "../services/options.js";
import froca from "../services/froca.js";
import protectedSessionHolder from "../services/protected_session_holder.js";
import cssClassManager from "../services/css_class_manager.js";
+import FAttachment from "./fattachment.js";
const LABEL = 'label';
const RELATION = 'relation';
@@ -79,6 +79,9 @@ class FNote {
/** @type {Object.<string, string>} */
this.childToBranch = {};
+ /** @type {FAttachment[]|null} */
+ this.attachments = null; // lazy loaded
+
this.update(row);
}
@@ -220,7 +223,7 @@ class FNote {
}
// 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
+ // this is done so that non-search & non-archived paths are always explored as first when looking for a note path
sortParents() {
this.parents.sort((aNoteId, bNoteId) => {
const aBranchId = this.parentToBranch[aNoteId];
@@ -253,6 +256,45 @@ class FNote {
return await this.froca.getNotes(this.children);
}
+ /** @returns {Promise<FAttachment[]>} */
+ async getAttachments() {
+ if (!this.attachments) {
+ this.attachments = await this.froca.getAttachmentsForNote(this.noteId);
+ }
+
+ return this.attachments;
+ }
+
+ /** @returns {Promise<FAttachment>} */
+ async getAttachmentById(attachmentId) {
+ const attachments = await this.getAttachments();
+
+ return attachments.find(att => att.attachmentId === attachmentId);
+ }
+
+ 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 && referencingNote !== parentNote) {
+ return false;
+ } else if (parentNote.type !== 'text' || !parentNote.isContentAvailable()) {
+ return false;
+ }
+
+ return true;
+ }
+
/**
* @param {string} [type] - (optional) attribute type to filter
* @param {string} [name] - (optional) attribute name to filter
@@ -343,13 +385,10 @@ class FNote {
}
const parentNotes = this.getParentNotes().filter(note => note.type !== 'search');
- 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 the most common case
+ : parentNotes.flatMap(parentNote => parentNote.getAllNotePaths());
for (const notePath of notePaths) {
notePath.push(this.noteId);
@@ -388,7 +427,7 @@ class FNote {
}
/**
- * Returns note path considered to be the "best"
+ * Returns the note path considered to be the "best"
*
* @param {string} [hoistedNoteId='root']
* @return {string[]} array of noteIds constituting the particular note path
@@ -398,7 +437,7 @@ class FNote {
}
/**
- * Returns note path considered to be the "best"
+ * Returns the note path considered to be the "best"
*
* @param {string} [hoistedNoteId='root']
* @return {string} serialized note path (e.g. 'root/a1h315/js725h')
@@ -538,17 +577,10 @@ class FNote {
return;
}
- if (options.is("hideIncludedImages_main")) {
- const imageLinks = this.getRelations('imageLink');
-
- // image is already visible in the parent note so no need to display it separately in the book
- childBranches = childBranches.filter(branch => !imageLinks.find(rel => rel.value === branch.noteId));
- }
-
// we're not checking hideArchivedNotes since that would mean we need to lazy load the child notes
// which would seriously slow down everything.
// we check this flag only once user chooses to expand the parent. This has the negative consequence that
- // note may appear as folder but not contain any children when all of them are archived
+ // note may appear as a folder but not contain any children when all of them are archived
return childBranches;
}
@@ -592,7 +624,7 @@ class FNote {
/**
* @param {string} type - attribute type (label, relation, etc.)
* @param {string} name - attribute name
- * @returns {FAttribute} 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.
+ * @returns {FAttribute} attribute of the given type and name. If there are more such attributes, first is returned. Returns null if there's no such attribute belonging to this note.
*/
getOwnedAttribute(type, name) {
const attributes = this.getOwnedAttributes();
@@ -603,7 +635,7 @@ class FNote {
/**
* @param {string} type - attribute type (label, relation, etc.)
* @param {string} name - attribute name
- * @returns {FAttribute} 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.
+ * @returns {FAttribute} attribute of the given type and name. If there are 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();
@@ -614,7 +646,7 @@ class FNote {
/**
* @param {string} type - attribute type (label, relation, etc.)
* @param {string} name - attribute name
- * @returns {string} attribute value of given type and name or null if no such attribute exists.
+ * @returns {string} attribute value of the given type and name or null if no such attribute exists.
*/
getOwnedAttributeValue(type, name) {
const attr = this.getOwnedAttribute(type, name);
@@ -625,7 +657,7 @@ class FNote {
/**
* @param {string} type - attribute type (label, relation, etc.)
* @param {string} name - attribute name
- * @returns {string} attribute value of given type and name or null if no such attribute exists.
+ * @returns {string} attribute value of the given type and name or null if no such attribute exists.
*/
getAttributeValue(type, name) {
const attr = this.getAttribute(type, name);
@@ -757,7 +789,7 @@ class FNote {
}
getPromotedDefinitionAttributes() {
- if (this.hasLabel('hidePromotedAttributes')) {
+ if (this.isLabelTruthy('hidePromotedAttributes')) {
return [];
}
@@ -769,7 +801,7 @@ class FNote {
return def && def.isPromoted;
});
- // attrs are not resorted if position changes after initial load
+ // attrs are not resorted if position changes after the initial load
promotedAttrs.sort((a, b) => {
if (a.noteId === b.noteId) {
return a.position < b.position ? -1 : 1;
@@ -835,7 +867,7 @@ class FNote {
/**
* Get relations which target this note
*
- * @returns {FNote[]}
+ * @returns {Promise<FNote[]>}
*/
async getTargetRelationSourceNotes() {
const targetRelations = this.getTargetRelations();
@@ -844,12 +876,19 @@ class FNote {
}
/**
- * Return note complement which is most importantly note's content
- *
- * @returns {Promise<FNoteComplement>}
+ * @deprecated use getBlob() instead
+ * @return {Promise<FBlob>}
*/
async getNoteComplement() {
- return await this.froca.getNoteComplement(this.noteId);
+ return this.getBlob();
+ }
+
+ /**
+ * @param [opts.preview=false] - retrieve only first 10 000 characters for a preview
+ * @return {Promise<FBlob>}
+ */
+ async getBlob(opts = {}) {
+ return await this.froca.getBlob('notes', this.noteId, opts);
}
toString() {
@@ -971,7 +1010,7 @@ export default FNote;
diff --git a/docs/frontend_api/entities_fnote_complement.js.html b/docs/frontend_api/entities_fnote_complement.js.html
deleted file mode 100644
index 252ea6a2f..000000000
--- a/docs/frontend_api/entities_fnote_complement.js.html
+++ /dev/null
@@ -1,91 +0,0 @@
-
-
-
-
- JSDoc: Source: entities/fnote_complement.js
-
-
-
-
-
-
-
-
-
-
-
-
-
Source: entities/fnote_complement.js
-
-
-
-
-
-
-
-
-
/**
- * Complements the FNote with the main note content and other extra attributes
- */
-class FNoteComplement {
- constructor(row) {
- /** @type {string} */
- this.noteId = row.noteId;
-
- /**
- * can either contain the whole content (in e.g. string notes), only part (large text notes) or nothing at all (binary notes, images)
- * @type {string}
- */
- this.content = row.content;
-
- /** @type {int} */
- this.contentLength = row.contentLength;
-
- /** @type {string} */
- this.dateCreated = row.dateCreated;
-
- /** @type {string} */
- this.dateModified = row.dateModified;
-
- /** @type {string} */
- this.utcDateCreated = row.utcDateCreated;
-
- /** @type {string} */
- this.utcDateModified = row.utcDateModified;
-
- // "combined" date modified give larger out of note's and note_content's dateModified
-
- /** @type {string} */
- this.combinedDateModified = row.combinedDateModified;
-
- /** @type {string} */
- this.combinedUtcDateModified = row.combinedUtcDateModified;
- }
-}
-
-export default FNoteComplement;
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/docs/frontend_api/index.html b/docs/frontend_api/index.html
index 6465f193a..46ccdc9a8 100644
--- a/docs/frontend_api/index.html
+++ b/docs/frontend_api/index.html
@@ -50,7 +50,7 @@
diff --git a/docs/frontend_api/services_frontend_script_api.js.html b/docs/frontend_api/services_frontend_script_api.js.html
index 679f2e5eb..76970d3e0 100644
--- a/docs/frontend_api/services_frontend_script_api.js.html
+++ b/docs/frontend_api/services_frontend_script_api.js.html
@@ -53,9 +53,9 @@ function FrontendScriptApi(startNote, currentNote, originEntity = null, $contain
/** @property {jQuery} container of all the rendered script content */
this.$container = $container;
- /** @property {object} note where script started executing */
+ /** @property {object} note where the script started executing */
this.startNote = startNote;
- /** @property {object} note where script is currently executing */
+ /** @property {object} note where the script is currently executing */
this.currentNote = currentNote;
/** @property {object|null} entity whose event triggered this execution */
this.originEntity = originEntity;
@@ -192,7 +192,7 @@ function FrontendScriptApi(startNote, currentNote, originEntity = null, $contain
params: prepareParams(params),
startNoteId: startNote.noteId,
currentNoteId: currentNote.noteId,
- originEntityName: "notes", // currently there's no other entity on frontend which can trigger event
+ originEntityName: "notes", // currently there's no other entity on the frontend which can trigger event
originEntityId: originEntity ? originEntity.noteId : null
}, "script");
@@ -233,7 +233,7 @@ function FrontendScriptApi(startNote, currentNote, originEntity = null, $contain
};
/**
- * Returns note by given noteId. If note is missing from cache, it's loaded.
+ * Returns note by given noteId. If note is missing from the cache, it's loaded.
**
* @method
* @param {string} noteId
@@ -242,10 +242,10 @@ function FrontendScriptApi(startNote, currentNote, originEntity = null, $contain
this.getNote = async noteId => await froca.getNote(noteId);
/**
- * Returns list of notes. If note is missing from cache, it's loaded.
+ * Returns list of notes. If note is missing from the cache, it's loaded.
*
* This is often used to bulk-fill the cache with notes which would have to be picked one by one
- * otherwise (by e.g. createNoteLink())
+ * otherwise (by e.g. createLink())
*
* @method
* @param {string[]} noteIds
@@ -286,7 +286,7 @@ function FrontendScriptApi(startNote, currentNote, originEntity = null, $contain
this.parseDate = utils.parseDate;
/**
- * Show info message to the user.
+ * Show an info message to the user.
*
* @method
* @param {string} message
@@ -294,7 +294,7 @@ function FrontendScriptApi(startNote, currentNote, originEntity = null, $contain
this.showMessage = toastService.showMessage;
/**
- * Show error message to the user.
+ * Show an error message to the user.
*
* @method
* @param {string} message
@@ -302,7 +302,7 @@ function FrontendScriptApi(startNote, currentNote, originEntity = null, $contain
this.showError = toastService.showError;
/**
- * Trigger command.
+ * Trigger command. This is a very low-level API which should be avoided if possible.
*
* @method
* @param {string} name
@@ -311,7 +311,7 @@ function FrontendScriptApi(startNote, currentNote, originEntity = null, $contain
this.triggerCommand = (name, data) => appContext.triggerCommand(name, data);
/**
- * Trigger event.
+ * Trigger event. This is a very low-level API which should be avoided if possible.
*
* @method
* @param {string} name
@@ -320,7 +320,7 @@ function FrontendScriptApi(startNote, currentNote, originEntity = null, $contain
this.triggerEvent = (name, data) => appContext.triggerEvent(name, data);
/**
- * Create note link (jQuery object) for given note.
+ * Create a note link (jQuery object) for given note.
*
* @method
* @param {string} notePath (or noteId)
@@ -330,7 +330,7 @@ function FrontendScriptApi(startNote, currentNote, originEntity = null, $contain
* @param {boolean} [params.showNoteIcon=false] - show also note icon before the title
* @param {string} [params.title=] - custom link tile with note's title as default
*/
- this.createNoteLink = linkService.createNoteLink;
+ this.createLink = linkService.createLink;
/**
* Adds given text to the editor cursor
@@ -347,10 +347,10 @@ function FrontendScriptApi(startNote, currentNote, originEntity = null, $contain
this.getActiveContextNote = () => appContext.tabManager.getActiveContextNote();
/**
- * See https://ckeditor.com/docs/ckeditor5/latest/api/module_core_editor_editor-Editor.html for a documentation on the returned instance.
+ * See https://ckeditor.com/docs/ckeditor5/latest/api/module_core_editor_editor-Editor.html for documentation on the returned instance.
*
* @method
- * @returns {Promise<CKEditor>} instance of CKEditor
+ * @returns {Promise<BalloonEditor>} instance of CKEditor
*/
this.getActiveContextTextEditor = () => appContext.tabManager.getActiveContext()?.getTextEditor();
@@ -373,12 +373,12 @@ function FrontendScriptApi(startNote, currentNote, originEntity = null, $contain
/**
* @method
- * @returns {Promise<string|null>} returns note path of active note or null if there isn't active note
+ * @returns {Promise<string|null>} returns a note path of active note or null if there isn't active note
*/
this.getActiveContextNotePath = () => appContext.tabManager.getActiveContextNotePath();
/**
- * Returns component which owns given DOM element (the nearest parent component in DOM tree)
+ * Returns component which owns the given DOM element (the nearest parent component in DOM tree)
*
* @method
* @param {Element} el - DOM element
@@ -483,11 +483,11 @@ function FrontendScriptApi(startNote, currentNote, originEntity = null, $contain
this.bindGlobalShortcut = shortcutService.bindGlobalShortcut;
/**
- * Trilium runs in backend and frontend process, when something is changed on the backend from script,
+ * Trilium runs in a backend and frontend process, when something is changed on the backend from a script,
* frontend will get asynchronously synchronized.
*
* This method returns a promise which resolves once all the backend -> frontend synchronization is finished.
- * Typical use case is when new note has been created, we should wait until it is synced into frontend and only then activate it.
+ * Typical use case is when a new note has been created, we should wait until it is synced into frontend and only then activate it.
*
* @method
* @returns {Promise<void>}
@@ -506,11 +506,18 @@ function FrontendScriptApi(startNote, currentNote, originEntity = null, $contain
* Return randomly generated string of given length. This random string generation is NOT cryptographically secure.
*
* @method
- * @param {number} length of the string
+ * @param {integer} length of the string
* @returns {string} random string
*/
this.randomString = utils.randomString;
+ /**
+ * @method
+ * @param {int} size in bytes
+ * @return {string} formatted string
+ */
+ this.formatNoteSize = utils.formatNoteSize;
+
this.logMessages = {};
this.logSpacedUpdates = {};
@@ -551,7 +558,7 @@ export default FrontendScriptApi;