diff --git a/docs/backend_api/Note.html b/docs/backend_api/Note.html index 068be13d0..5f8d01cc5 100644 --- a/docs/backend_api/Note.html +++ b/docs/backend_api/Note.html @@ -270,6 +270,29 @@ + + + isErased + + + + + +boolean + + + + + + + + + + true if note's content is erased after it has been deleted + + + + dateCreated @@ -396,7 +419,7 @@
Source:
@@ -583,7 +606,7 @@
Source:
@@ -750,7 +773,7 @@
Source:
@@ -928,7 +951,7 @@
Source:
@@ -1034,7 +1057,7 @@
Source:
@@ -1136,7 +1159,7 @@
Source:
@@ -1242,7 +1265,7 @@
Source:
@@ -1348,7 +1371,7 @@
Source:
@@ -1450,7 +1473,7 @@
Source:
@@ -1683,7 +1706,7 @@
Source:
@@ -1881,7 +1904,7 @@
Source:
@@ -2079,7 +2102,7 @@
Source:
@@ -2181,7 +2204,7 @@
Source:
@@ -2332,7 +2355,7 @@
Source:
@@ -2499,7 +2522,7 @@
Source:
@@ -2666,7 +2689,7 @@
Source:
@@ -2821,7 +2844,7 @@
Source:
@@ -2933,7 +2956,7 @@
Source:
@@ -3035,7 +3058,7 @@
Source:
@@ -3143,7 +3166,7 @@ This method can be significantly faster than the getAttribute()
Source:
@@ -3251,7 +3274,7 @@ This method can be significantly faster than the getAttributes()
Source:
@@ -3406,7 +3429,7 @@ This method can be significantly faster than the getAttributes()
Source:
@@ -3573,7 +3596,7 @@ This method can be significantly faster than the getAttributes()
Source:
@@ -3740,7 +3763,7 @@ This method can be significantly faster than the getAttributes()
Source:
@@ -3895,7 +3918,7 @@ This method can be significantly faster than the getAttributes()
Source:
@@ -4065,7 +4088,7 @@ This method can be significantly faster than the getAttributes()
Source:
@@ -4216,7 +4239,7 @@ This method can be significantly faster than the getAttributes()
Source:
@@ -4326,7 +4349,7 @@ This method can be significantly faster than the getAttributes()
Source:
@@ -4428,7 +4451,7 @@ This method can be significantly faster than the getAttributes()
Source:
@@ -4534,7 +4557,7 @@ This method can be significantly faster than the getAttributes()
Source:
@@ -4712,7 +4735,7 @@ This method can be significantly faster than the getAttributes()
Source:
@@ -4818,7 +4841,7 @@ This method can be significantly faster than the getAttributes()
Source:
@@ -4973,7 +4996,7 @@ This method can be significantly faster than the getAttributes()
Source:
@@ -5128,7 +5151,7 @@ This method can be significantly faster than the getAttributes()
Source:
@@ -5239,7 +5262,7 @@ Cache is note instance scoped.
Source:
@@ -5323,7 +5346,7 @@ Cache is note instance scoped.
Source:
@@ -5429,7 +5452,7 @@ Cache is note instance scoped.
Source:
@@ -5535,7 +5558,7 @@ Cache is note instance scoped.
Source:
@@ -5641,7 +5664,7 @@ Cache is note instance scoped.
Source:
@@ -5747,7 +5770,7 @@ Cache is note instance scoped.
Source:
@@ -5853,7 +5876,7 @@ Cache is note instance scoped.
Source:
@@ -6082,7 +6105,7 @@ Cache is note instance scoped.
Source:
@@ -6280,7 +6303,7 @@ Cache is note instance scoped.
Source:
@@ -6478,7 +6501,7 @@ Cache is note instance scoped.
Source:
@@ -6540,7 +6563,7 @@ Cache is note instance scoped.
- Creates given attribute name-value pair if it doesn't exist. + Update's given attribute's value or creates it if it doesn't exist
@@ -6707,7 +6730,7 @@ Cache is note instance scoped.
Source:
@@ -6809,7 +6832,7 @@ Cache is note instance scoped.
Source:
@@ -6911,7 +6934,7 @@ Cache is note instance scoped.
Source:
@@ -6973,7 +6996,7 @@ Cache is note instance scoped.
- Create label name-value pair if it doesn't exist yet. + Update's given label's value or creates it if it doesn't exist
@@ -7109,7 +7132,7 @@ Cache is note instance scoped.
Source:
@@ -7171,7 +7194,7 @@ Cache is note instance scoped.
- Create relation name-value pair if it doesn't exist yet. + Update's given relation's value or creates it if it doesn't exist
@@ -7307,7 +7330,7 @@ Cache is note instance scoped.
Source:
@@ -7567,7 +7590,7 @@ Cache is note instance scoped.
Source:
@@ -7796,7 +7819,7 @@ Cache is note instance scoped.
Source:
@@ -8025,7 +8048,7 @@ Cache is note instance scoped.
Source:
diff --git a/docs/backend_api/NoteRevision.html b/docs/backend_api/NoteRevision.html index 8a6568176..7143be4fb 100644 --- a/docs/backend_api/NoteRevision.html +++ b/docs/backend_api/NoteRevision.html @@ -28,7 +28,7 @@
-

NoteRevision(noteRevisionId, noteId, type, mime, title, content, isProtected, dateModifiedFrom, dateModifiedTo, utcDateModifiedFrom, utcDateModifiedTo)

+

NoteRevision(noteRevisionId, noteId, type, mime, title, isProtected, dateLastEdited, dateCreated, utcDateLastEdited, utcDateCreated, utcDateModified)

NoteRevision represents snapshot of note's title and content at some point in the past. It's used for seamless note versioning.
@@ -45,7 +45,7 @@ -

new NoteRevision(noteRevisionId, noteId, type, mime, title, content, isProtected, dateModifiedFrom, dateModifiedTo, utcDateModifiedFrom, utcDateModifiedTo)

+

new NoteRevision(noteRevisionId, noteId, type, mime, title, isProtected, dateLastEdited, dateCreated, utcDateLastEdited, utcDateCreated, utcDateModified)

@@ -193,29 +193,6 @@ - - - - - - - - content - - - - - -string - - - - - - - - - @@ -246,7 +223,7 @@ - dateModifiedFrom + dateLastEdited @@ -269,7 +246,7 @@ - dateModifiedTo + dateCreated @@ -292,7 +269,7 @@ - utcDateModifiedFrom + utcDateLastEdited @@ -315,7 +292,30 @@ - utcDateModifiedTo + utcDateCreated + + + + + +string + + + + + + + + + + + + + + + + + utcDateModified @@ -372,7 +372,7 @@
Source:
@@ -431,6 +431,320 @@ +

Methods

+ + + + + + + +

(async) getContent() → {Promise.<*>}

+ + + + + + + + + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + +
Source:
+
+ + + + + + + +
+ + + + + + + + + + + + + + + +
Returns:
+ + + + +
+
+ Type +
+
+ +Promise.<*> + + +
+
+ + + + + + + + + + + + + +

isStringNote() → {boolean}

+ + + + + + + + + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + +
Source:
+
+ + + + + + + +
+ + + + + + + + + + + + + + + +
Returns:
+ + +
+ true if the note has string content (not binary) +
+ + + +
+
+ Type +
+
+ +boolean + + +
+
+ + + + + + + + + + + + + +

(async) setContent() → {Promise}

+ + + + + + + + + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + +
Source:
+
+ + + + + + + +
+ + + + + + + + + + + + + + + +
Returns:
+ + + + +
+
+ Type +
+
+ +Promise + + +
+
+ + + + + + + + + diff --git a/docs/backend_api/entities_note.js.html b/docs/backend_api/entities_note.js.html index b995a7531..06ba0c295 100644 --- a/docs/backend_api/entities_note.js.html +++ b/docs/backend_api/entities_note.js.html @@ -42,8 +42,6 @@ const LABEL_DEFINITION = 'label-definition'; const RELATION = 'relation'; const RELATION_DEFINITION = 'relation-definition'; -const STRING_MIME_TYPES = ["application/x-javascript"]; - /** * This represents a Note which is a central object in the Trilium Notes project. * @@ -53,6 +51,7 @@ const STRING_MIME_TYPES = ["application/x-javascript"]; * @property {string} title - note title * @property {boolean} isProtected - true if note is protected * @property {boolean} isDeleted - true if note is deleted + * @property {boolean} isErased - true if note's content is erased after it has been deleted * @property {string} dateCreated - local date time (with offset) * @property {string} dateModified - local date time (with offset) * @property {string} utcDateCreated @@ -72,7 +71,7 @@ class Note extends Entity { super(row); this.isProtected = !!this.isProtected; - /* true if content (meaning any kind of potentially encrypted content) is either not encrypted + /* true if content is either not encrypted * or encrypted, but with available protected session (so effectively decrypted) */ this.isContentAvailable = true; @@ -81,7 +80,7 @@ class Note extends Entity { this.isContentAvailable = protectedSessionService.isProtectedSessionAvailable(); if (this.isContentAvailable) { - protectedSessionService.decryptNote(this); + this.title = protectedSessionService.decryptString(this.title); } else { this.title = "[protected]"; @@ -116,7 +115,7 @@ class Note extends Entity { if (this.isProtected) { if (this.isContentAvailable) { - protectedSessionService.decryptNoteContent(this); + this.content = this.content === null ? null : protectedSessionService.decrypt(this.content); } else { this.content = ""; @@ -142,7 +141,7 @@ class Note extends Entity { /** @returns {Promise} */ async setContent(content) { - // force updating note itself so that dateChanged is represented correctly even for the content + // force updating note itself so that dateModified is represented correctly even for the content this.forcedChange = true; await this.save(); @@ -157,7 +156,7 @@ class Note extends Entity { if (this.isProtected) { if (this.isContentAvailable) { - protectedSessionService.encryptNoteContent(pojo); + pojo.content = protectedSessionService.encrypt(pojo.content); } else { throw new Error(`Cannot update content of noteId=${this.noteId} since we're out of protected session.`); @@ -199,9 +198,7 @@ class Note extends Entity { /** @returns {boolean} true if the note has string content (not binary) */ isStringNote() { - return ["text", "code", "relation-map", "search"].includes(this.type) - || this.mime.startsWith('text/') - || STRING_MIME_TYPES.includes(this.mime); + return utils.isStringNote(this.type, this.mime); } /** @returns {string} JS script environment - either "frontend" or "backend" */ @@ -447,7 +444,7 @@ class Note extends Entity { } /** - * Creates given attribute name-value pair if it doesn't exist. + * Update's given attribute's value or creates it if it doesn't exist * * @param {string} type - attribute type (label, relation, etc.) * @param {string} name - attribute name @@ -456,9 +453,17 @@ class Note extends Entity { */ async setAttribute(type, name, value) { const attributes = await this.getOwnedAttributes(); - let attr = attributes.find(attr => attr.type === type && (value === undefined || attr.value === value)); + let attr = attributes.find(attr => attr.type === type && attr.name === name); - if (!attr) { + if (attr) { + if (attr.value !== value) { + attr.value = value; + await attr.save(); + + this.invalidateAttributeCache(); + } + } + else { attr = new Attribute({ noteId: this.noteId, type: type, @@ -560,7 +565,7 @@ class Note extends Entity { async toggleRelation(enabled, name, value) { return await this.toggleAttribute(RELATION, enabled, name, value); } /** - * Create label name-value pair if it doesn't exist yet. + * Update's given label's value or creates it if it doesn't exist * * @param {string} name - label name * @param {string} [value] - label value @@ -569,7 +574,7 @@ class Note extends Entity { async setLabel(name, value) { return await this.setAttribute(LABEL, name, value); } /** - * Create relation name-value pair if it doesn't exist yet. + * Update's given relation's value or creates it if it doesn't exist * * @param {string} name - relation name * @param {string} [value] - relation value (noteId) @@ -774,7 +779,7 @@ class Note extends Entity { updatePojo(pojo) { if (pojo.isProtected) { if (this.isContentAvailable) { - protectedSessionService.encryptNote(pojo); + pojo.title = protectedSessionService.encrypt(pojo.title); } else { // updating protected note outside of protected session means we will keep original ciphertexts diff --git a/docs/backend_api/entities_note_revision.js.html b/docs/backend_api/entities_note_revision.js.html index 112e22a8d..50f8befae 100644 --- a/docs/backend_api/entities_note_revision.js.html +++ b/docs/backend_api/entities_note_revision.js.html @@ -31,6 +31,10 @@ const Entity = require('./entity'); const protectedSessionService = require('../services/protected_session'); const repository = require('../services/repository'); +const utils = require('../services/utils'); +const sql = require('../services/sql'); +const dateUtils = require('../services/date_utils'); +const syncTableService = require('../services/sync_table'); /** * NoteRevision represents snapshot of note's title and content at some point in the past. It's used for seamless note versioning. @@ -40,19 +44,19 @@ const repository = require('../services/repository'); * @param {string} type * @param {string} mime * @param {string} title - * @param {string} content * @param {string} isProtected - * @param {string} dateModifiedFrom - * @param {string} dateModifiedTo - * @param {string} utcDateModifiedFrom - * @param {string} utcDateModifiedTo + * @param {string} dateLastEdited + * @param {string} dateCreated + * @param {string} utcDateLastEdited + * @param {string} utcDateCreated + * @param {string} utcDateModified * * @extends Entity */ class NoteRevision extends Entity { static get entityName() { return "note_revisions"; } static get primaryKeyName() { return "noteRevisionId"; } - static get hashedProperties() { return ["noteRevisionId", "noteId", "title", "content", "isProtected", "dateModifiedFrom", "dateModifiedTo", "utcDateModifiedFrom", "utcDateModifiedTo"]; } + static get hashedProperties() { return ["noteRevisionId", "noteId", "title", "contentLength", "isProtected", "dateLastEdited", "dateCreated", "utcDateLastEdited", "utcDateCreated", "utcDateModified"]; } constructor(row) { super(row); @@ -60,7 +64,12 @@ class NoteRevision extends Entity { this.isProtected = !!this.isProtected; if (this.isProtected) { - protectedSessionService.decryptNoteRevision(this); + if (protectedSessionService.isProtectedSessionAvailable()) { + this.title = protectedSessionService.decryptString(this.title); + } + else { + this.title = "[Protected]"; + } } } @@ -68,12 +77,98 @@ class NoteRevision extends Entity { return await repository.getEntity("SELECT * FROM notes WHERE noteId = ?", [this.noteId]); } - beforeSaving() { - if (this.isProtected) { - protectedSessionService.encryptNoteRevision(this); + /** @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 it's 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 {Promise<*>} */ + async getContent(silentNotFoundError = false) { + if (this.content === undefined) { + const res = await sql.getRow(`SELECT content, hash 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); + } + } + + this.content = res.content; + + if (this.isProtected) { + if (protectedSessionService.isProtectedSessionAvailable()) { + this.content = protectedSessionService.decrypt(this.content); + } + else { + this.content = ""; + } + } + + if (this.isStringNote()) { + this.content = this.content === null + ? "" + : this.content.toString("UTF-8"); + } } - super.beforeSaving(); + return this.content; + } + + /** @returns {Promise} */ + async setContent(content) { + // force updating note itself so that utcDateModified is represented correctly even for the content + this.forcedChange = true; + this.contentLength = content.length; + await this.save(); + + this.content = content; + + const pojo = { + noteRevisionId: this.noteRevisionId, + content: content, + utcDateModified: dateUtils.utcNowDateTime(), + hash: utils.hash(this.noteRevisionId + "|" + content) + }; + + 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.`); + } + } + + await sql.upsert("note_revision_contents", "noteRevisionId", pojo); + + await syncTableService.addNoteRevisionContentSync(this.noteRevisionId); + } + + // cannot be static! + updatePojo(pojo) { + if (pojo.isProtected) { + if (protectedSessionService.isProtectedSessionAvailable()) { + pojo.title = protectedSessionService.encrypt(pojo.title); + } + else { + // updating protected note outside of protected session means we will keep original ciphertexts + delete pojo.title; + } + } + + delete pojo.content; } } diff --git a/docs/frontend_api/NoteShort.html b/docs/frontend_api/NoteShort.html index d120e54a5..1823f4bfc 100644 --- a/docs/frontend_api/NoteShort.html +++ b/docs/frontend_api/NoteShort.html @@ -30,10 +30,7 @@

NoteShort(treeCache, row, branches)

-
FIXME: rethink how attributes are cached in Note entities since they are long lived inside the cache. -Attribute cache should be limited to "transaction". - -This note's representation is used in note tree and is kept in TreeCache.
+
This note's representation is used in note tree and is kept in TreeCache.
@@ -191,7 +188,7 @@ This note's representation is used in note tree and is kept in TreeCache.
Source:
@@ -281,7 +278,7 @@ This note's representation is used in note tree and is kept in TreeCache.
Source:
@@ -349,7 +346,7 @@ This note's representation is used in note tree and is kept in TreeCache.
Source:
@@ -417,7 +414,7 @@ This note's representation is used in note tree and is kept in TreeCache.
Source:
@@ -475,7 +472,7 @@ This note's representation is used in note tree and is kept in TreeCache.
Source:
@@ -533,7 +530,7 @@ This note's representation is used in note tree and is kept in TreeCache.
Source:
@@ -591,7 +588,7 @@ This note's representation is used in note tree and is kept in TreeCache.
Source:
@@ -649,7 +646,7 @@ This note's representation is used in note tree and is kept in TreeCache.
Source:
@@ -707,7 +704,7 @@ This note's representation is used in note tree and is kept in TreeCache.
Source:
@@ -775,7 +772,7 @@ This note's representation is used in note tree and is kept in TreeCache.
Source:
@@ -843,7 +840,7 @@ This note's representation is used in note tree and is kept in TreeCache.
Source:
@@ -901,7 +898,7 @@ This note's representation is used in note tree and is kept in TreeCache.
Source:
@@ -959,7 +956,7 @@ This note's representation is used in note tree and is kept in TreeCache.
Source:
@@ -1107,7 +1104,7 @@ This note's representation is used in note tree and is kept in TreeCache.
Source:
@@ -1274,7 +1271,7 @@ This note's representation is used in note tree and is kept in TreeCache.
Source:
@@ -1448,7 +1445,7 @@ This note's representation is used in note tree and is kept in TreeCache.
Source:
@@ -1554,7 +1551,7 @@ This note's representation is used in note tree and is kept in TreeCache.
Source:
@@ -1656,7 +1653,7 @@ This note's representation is used in note tree and is kept in TreeCache.
Source:
@@ -1758,7 +1755,7 @@ This note's representation is used in note tree and is kept in TreeCache.
Source:
@@ -1860,7 +1857,7 @@ This note's representation is used in note tree and is kept in TreeCache.
Source:
@@ -2011,7 +2008,7 @@ This note's representation is used in note tree and is kept in TreeCache.
Source:
@@ -2178,7 +2175,7 @@ This note's representation is used in note tree and is kept in TreeCache.
Source:
@@ -2345,7 +2342,7 @@ This note's representation is used in note tree and is kept in TreeCache.
Source:
@@ -2500,7 +2497,7 @@ This note's representation is used in note tree and is kept in TreeCache.
Source:
@@ -2606,7 +2603,7 @@ This note's representation is used in note tree and is kept in TreeCache.
Source:
@@ -2708,7 +2705,7 @@ This note's representation is used in note tree and is kept in TreeCache.
Source:
@@ -2859,7 +2856,7 @@ This note's representation is used in note tree and is kept in TreeCache.
Source:
@@ -3026,7 +3023,7 @@ This note's representation is used in note tree and is kept in TreeCache.
Source:
@@ -3193,7 +3190,7 @@ This note's representation is used in note tree and is kept in TreeCache.
Source:
@@ -3348,7 +3345,7 @@ This note's representation is used in note tree and is kept in TreeCache.
Source:
@@ -3518,7 +3515,7 @@ This note's representation is used in note tree and is kept in TreeCache.
Source:
@@ -3669,7 +3666,7 @@ This note's representation is used in note tree and is kept in TreeCache.
Source:
@@ -3779,7 +3776,7 @@ This note's representation is used in note tree and is kept in TreeCache.
Source:
@@ -3953,7 +3950,7 @@ This note's representation is used in note tree and is kept in TreeCache.
Source:
@@ -4059,7 +4056,7 @@ This note's representation is used in note tree and is kept in TreeCache.
Source:
@@ -4210,7 +4207,7 @@ This note's representation is used in note tree and is kept in TreeCache.
Source:
@@ -4365,7 +4362,7 @@ This note's representation is used in note tree and is kept in TreeCache.
Source:
@@ -4476,7 +4473,7 @@ Cache is note instance scoped.
Source:
@@ -4560,7 +4557,7 @@ Cache is note instance scoped.
Source:
diff --git a/docs/frontend_api/entities_note_short.js.html b/docs/frontend_api/entities_note_short.js.html index feac110e3..dd056d723 100644 --- a/docs/frontend_api/entities_note_short.js.html +++ b/docs/frontend_api/entities_note_short.js.html @@ -28,7 +28,6 @@
import server from '../services/server.js';
 import Attribute from './attribute.js';
-import branches from "../services/branches.js";
 
 const LABEL = 'label';
 const LABEL_DEFINITION = 'label-definition';
@@ -36,9 +35,6 @@ const RELATION = 'relation';
 const RELATION_DEFINITION = 'relation-definition';
 
 /**
- * FIXME: rethink how attributes are cached in Note entities since they are long lived inside the cache.
- * Attribute cache should be limited to "transaction".
- *
  * This note's representation is used in note tree and is kept in TreeCache.
  */
 class NoteShort {
diff --git a/docs/frontend_api/global.html b/docs/frontend_api/global.html
index 22f49fed0..7f4006e31 100644
--- a/docs/frontend_api/global.html
+++ b/docs/frontend_api/global.html
@@ -204,7 +204,7 @@
 
             
 
-            name of the JAM icon to be used (e.g. "clock" for "jam-clock" icon)
+            name of the boxicon to be used (e.g. "time" for "bx-time" icon)
         
 
     
diff --git a/docs/frontend_api/services_frontend_script_api.js.html b/docs/frontend_api/services_frontend_script_api.js.html
index 60cc942ba..82a89d2ac 100644
--- a/docs/frontend_api/services_frontend_script_api.js.html
+++ b/docs/frontend_api/services_frontend_script_api.js.html
@@ -100,7 +100,7 @@ function FrontendScriptApi(startNote, currentNote, originEntity = null, tabConte
     /**
      * @typedef {Object} ToolbarButtonOptions
      * @property {string} title
-     * @property {string} [icon] - name of the JAM icon to be used (e.g. "clock" for "jam-clock" icon)
+     * @property {string} [icon] - name of the boxicon to be used (e.g. "time" for "bx-time" icon)
      * @property {function} action - callback handling the click on the button
      * @property {string} [shortcut] - keyboard shortcut for the button, e.g. "alt+t"
      */
@@ -118,7 +118,7 @@ function FrontendScriptApi(startNote, currentNote, originEntity = null, tabConte
             .click(opts.action);
 
         if (opts.icon) {
-            button.append($("<span>").addClass("jam jam-" + opts.icon))
+            button.append($("<span>").addClass("bx bx-" + opts.icon))
                   .append("&nbsp;");
         }
 
diff --git a/package-lock.json b/package-lock.json
index a02db234f..9c594c099 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -2411,9 +2411,9 @@
       "integrity": "sha1-QGXiATz5+5Ft39gu+1Bq1MZ2kGI="
     },
     "dayjs": {
-      "version": "1.8.16",
-      "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.8.16.tgz",
-      "integrity": "sha512-XPmqzWz/EJiaRHjBqSJ2s6hE/BUoCIHKgdS2QPtTQtKcS9E4/Qn0WomoH1lXanWCzri+g7zPcuNV4aTZ8PMORQ=="
+      "version": "1.8.17",
+      "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.8.17.tgz",
+      "integrity": "sha512-47VY/htqYqr9GHd7HW/h56PpQzRBSJcxIQFwqL3P20bMF/3az5c3PWdVY3LmPXFl6cQCYHL7c79b9ov+2bOBbw=="
     },
     "debug": {
       "version": "4.1.1",
diff --git a/package.json b/package.json
index 57caefc2f..970eb3899 100644
--- a/package.json
+++ b/package.json
@@ -27,7 +27,7 @@
     "commonmark": "0.29.0",
     "cookie-parser": "1.4.4",
     "csurf": "1.10.0",
-    "dayjs": "1.8.16",
+    "dayjs": "1.8.17",
     "debug": "4.1.1",
     "ejs": "2.7.1",
     "electron-debug": "3.0.1",
diff --git a/src/entities/note.js b/src/entities/note.js
index a07fa433d..641b764f1 100644
--- a/src/entities/note.js
+++ b/src/entities/note.js
@@ -416,7 +416,7 @@ class Note extends Entity {
     }
 
     /**
-     * Creates given attribute name-value pair if it doesn't exist.
+     * Update's given attribute's value or creates it if it doesn't exist
      *
      * @param {string} type - attribute type (label, relation, etc.)
      * @param {string} name - attribute name
@@ -425,9 +425,17 @@ class Note extends Entity {
      */
     async setAttribute(type, name, value) {
         const attributes = await this.getOwnedAttributes();
-        let attr = attributes.find(attr => attr.type === type && (value === undefined || attr.value === value));
+        let attr = attributes.find(attr => attr.type === type && attr.name === name);
 
-        if (!attr) {
+        if (attr) {
+            if (attr.value !== value) {
+                attr.value = value;
+                await attr.save();
+
+                this.invalidateAttributeCache();
+            }
+        }
+        else {
             attr = new Attribute({
                 noteId: this.noteId,
                 type: type,
@@ -529,7 +537,7 @@ class Note extends Entity {
     async toggleRelation(enabled, name, value) { return await this.toggleAttribute(RELATION, enabled, name, value); }
 
     /**
-     * Create label name-value pair if it doesn't exist yet.
+     * Update's given label's value or creates it if it doesn't exist
      *
      * @param {string} name - label name
      * @param {string} [value] - label value
@@ -538,7 +546,7 @@ class Note extends Entity {
     async setLabel(name, value) { return await this.setAttribute(LABEL, name, value); }
 
     /**
-     * Create relation name-value pair if it doesn't exist yet.
+     * Update's given relation's value or creates it if it doesn't exist
      *
      * @param {string} name - relation name
      * @param {string} [value] - relation value (noteId)
diff --git a/src/public/javascripts/entities/note_short.js b/src/public/javascripts/entities/note_short.js
index 9d93c2ff5..a29844803 100644
--- a/src/public/javascripts/entities/note_short.js
+++ b/src/public/javascripts/entities/note_short.js
@@ -1,6 +1,5 @@
 import server from '../services/server.js';
 import Attribute from './attribute.js';
-import branches from "../services/branches.js";
 
 const LABEL = 'label';
 const LABEL_DEFINITION = 'label-definition';
diff --git a/src/public/javascripts/services/note_detail_image.js b/src/public/javascripts/services/note_detail_image.js
index c6ac8417f..546e2d5a3 100644
--- a/src/public/javascripts/services/note_detail_image.js
+++ b/src/public/javascripts/services/note_detail_image.js
@@ -1,6 +1,7 @@
 import utils from "./utils.js";
 import toastService from "./toast.js";
 import server from "./server.js";
+import noteDetailService from "./note_detail.js";
 
 class NoteDetailImage {
     /**
@@ -12,14 +13,16 @@ class NoteDetailImage {
         this.$imageWrapper = ctx.$tabContent.find('.note-detail-image-wrapper');
         this.$imageView = ctx.$tabContent.find('.note-detail-image-view');
         this.$copyToClipboardButton = ctx.$tabContent.find(".image-copy-to-clipboard");
+        this.$uploadNewRevisionButton = ctx.$tabContent.find(".image-upload-new-revision");
+        this.$uploadNewRevisionInput = ctx.$tabContent.find(".image-upload-new-revision-input");
         this.$fileName = ctx.$tabContent.find(".image-filename");
         this.$fileType = ctx.$tabContent.find(".image-filetype");
         this.$fileSize = ctx.$tabContent.find(".image-filesize");
 
         this.$imageDownloadButton = ctx.$tabContent.find(".image-download");
-        this.$imageDownloadButton.click(() => utils.download(this.getFileUrl()));
+        this.$imageDownloadButton.on('click', () => utils.download(this.getFileUrl()));
 
-        this.$copyToClipboardButton.click(() => {
+        this.$copyToClipboardButton.on('click',() => {
             this.$imageWrapper.attr('contenteditable','true');
 
             try {
@@ -39,6 +42,34 @@ class NoteDetailImage {
                 this.$imageWrapper.removeAttr('contenteditable');
             }
         });
+
+        this.$uploadNewRevisionButton.on("click", () => {
+            this.$uploadNewRevisionInput.trigger("click");
+        });
+
+        this.$uploadNewRevisionInput.on('change', async () => {
+            const formData = new FormData();
+            formData.append('upload', this.$uploadNewRevisionInput[0].files[0]);
+
+            const result = await $.ajax({
+                url: baseApiUrl + 'images/' + this.ctx.note.noteId,
+                headers: server.getHeaders(),
+                data: formData,
+                type: 'PUT',
+                timeout: 60 * 60 * 1000,
+                contentType: false, // NEEDED, DON'T REMOVE THIS
+                processData: false, // NEEDED, DON'T REMOVE THIS
+            });
+
+            if (result.uploaded) {
+                toastService.showMessage("New revision of the image has been uploaded.")
+
+                await noteDetailService.reload();
+            }
+            else {
+                toastService.showError("Could not upload new revision of the image: " + result.message);
+            }
+        });
     }
 
     async render() {
@@ -51,7 +82,9 @@ class NoteDetailImage {
         this.$fileSize.text((attributeMap.fileSize || "?") + " bytes");
         this.$fileType.text(this.ctx.note.mime);
 
-        this.$imageView.prop("src", `api/images/${this.ctx.note.noteId}/${this.ctx.note.title}`);
+        const imageHash = this.ctx.note.utcDateModified.replace(" ", "_");
+
+        this.$imageView.prop("src", `api/images/${this.ctx.note.noteId}/${this.ctx.note.title}?${imageHash}`);
     }
 
     selectImage(element) {
diff --git a/src/routes/api/clipper.js b/src/routes/api/clipper.js
index 5be97ea45..9bf806950 100644
--- a/src/routes/api/clipper.js
+++ b/src/routes/api/clipper.js
@@ -82,7 +82,7 @@ async function addImagesToNote(images, note, content) {
 
             const buffer = Buffer.from(dataUrl.split(",")[1], 'base64');
 
-            const {note: imageNote, url} = await imageService.saveImage(buffer, filename, note.noteId, true);
+            const {note: imageNote, url} = await imageService.saveImage(note.noteId, buffer, filename, true);
 
             await new Attribute({
                 noteId: note.noteId,
diff --git a/src/routes/api/image.js b/src/routes/api/image.js
index a0f6ba4aa..ed2d0ea43 100644
--- a/src/routes/api/image.js
+++ b/src/routes/api/image.js
@@ -25,8 +25,8 @@ async function returnImage(req, res) {
 }
 
 async function uploadImage(req) {
-    const noteId = req.query.noteId;
-    const file = req.file;
+    const {noteId} = req.query;
+    const {file} = req;
 
     const note = await repository.getNote(noteId);
 
@@ -38,7 +38,7 @@ async function uploadImage(req) {
         return [400, "Unknown image type: " + file.mimetype];
     }
 
-    const {url} = await imageService.saveImage(file.buffer, file.originalname, noteId, true);
+    const {url} = await imageService.saveImage(noteId, file.buffer, file.originalname, true);
 
     return {
         uploaded: true,
@@ -46,7 +46,30 @@ async function uploadImage(req) {
     };
 }
 
+async function updateImage(req) {
+    const {noteId} = req.params;
+    const {file} = req;
+
+    const note = await repository.getNote(noteId);
+
+    if (!note) {
+        return [404, `Note ${noteId} doesn't exist.`];
+    }
+
+    if (!["image/png", "image/jpeg", "image/gif", "image/webp"].includes(file.mimetype)) {
+        return {
+            uploaded: false,
+            message: "Unknown image type: " + file.mimetype
+        };
+    }
+
+    await imageService.updateImage(noteId, file.buffer, file.originalname);
+
+    return { uploaded: true };
+}
+
 module.exports = {
     returnImage,
-    uploadImage
+    uploadImage,
+    updateImage
 };
\ No newline at end of file
diff --git a/src/routes/api/sender.js b/src/routes/api/sender.js
index 49a2a5462..06d87d7b4 100644
--- a/src/routes/api/sender.js
+++ b/src/routes/api/sender.js
@@ -16,7 +16,7 @@ async function uploadImage(req) {
 
     const parentNote = await dateNoteService.getDateNote(req.headers['x-local-date']);
 
-    const {noteId} = await imageService.saveImage(file.buffer, originalName, parentNote.noteId, true);
+    const {noteId} = await imageService.saveImage(parentNote.noteId, file.buffer, originalName, true);
 
     return {
         noteId: noteId
diff --git a/src/routes/routes.js b/src/routes/routes.js
index f28a3e697..4b4d4e43a 100644
--- a/src/routes/routes.js
+++ b/src/routes/routes.js
@@ -171,6 +171,7 @@ function register(app) {
 
     route(GET, '/api/images/:noteId/:filename', [auth.checkApiAuthOrElectron], imageRoute.returnImage);
     route(POST, '/api/images', [auth.checkApiAuthOrElectron, uploadMiddleware, csrfMiddleware], imageRoute.uploadImage, apiResultHandler);
+    route(PUT, '/api/images/:noteId', [auth.checkApiAuthOrElectron, uploadMiddleware, csrfMiddleware], imageRoute.updateImage, apiResultHandler);
 
     apiRoute(GET, '/api/recent-changes', recentChangesApiRoute.getRecentChanges);
 
diff --git a/src/services/image.js b/src/services/image.js
index 41fc126f0..9b364b227 100644
--- a/src/services/image.js
+++ b/src/services/image.js
@@ -13,38 +13,62 @@ const jimp = require('jimp');
 const imageType = require('image-type');
 const sanitizeFilename = require('sanitize-filename');
 
-async function saveImage(buffer, originalName, parentNoteId, shrinkImageSwitch) {
-    const origImageFormat = imageType(buffer);
+async function processImage(uploadBuffer, originalName, shrinkImageSwitch) {
+    const origImageFormat = imageType(uploadBuffer);
 
     if (origImageFormat.ext === "webp") {
         // JIMP does not support webp at the moment: https://github.com/oliver-moran/jimp/issues/144
         shrinkImageSwitch = false;
     }
 
-    const finalImageBuffer = shrinkImageSwitch ? await shrinkImage(buffer, originalName) : buffer;
+    const finalImageBuffer = shrinkImageSwitch ? await shrinkImage(uploadBuffer, originalName) : uploadBuffer;
 
     const imageFormat = imageType(finalImageBuffer);
 
-    const parentNote = await repository.getNote(parentNoteId);
+    return {
+        buffer: finalImageBuffer,
+        imageFormat
+    };
+}
+
+async function updateImage(noteId, uploadBuffer, originalName) {
+    const {buffer, imageFormat} = await processImage(uploadBuffer, originalName, true);
+
+    const note = await repository.getNote(noteId);
+
+    note.mime = 'image/' + imageFormat.ext.toLowerCase();
+
+    await note.setContent(buffer);
+
+    await note.setLabel('originalFileName', originalName);
+    await note.setLabel('fileSize', buffer.byteLength);
+}
+
+async function saveImage(parentNoteId, uploadBuffer, originalName, shrinkImageSwitch) {
+    const {buffer, imageFormat} = await processImage(uploadBuffer, originalName, shrinkImageSwitch);
 
     const fileName = sanitizeFilename(originalName);
 
-    const {note} = await noteService.createNote(parentNoteId, fileName, finalImageBuffer, {
+    const parentNote = await repository.getNote(parentNoteId);
+
+    const {note} = await noteService.createNote(parentNoteId, fileName, buffer, {
         target: 'into',
         type: 'image',
         isProtected: parentNote.isProtected && protectedSessionService.isProtectedSessionAvailable(),
         mime: 'image/' + imageFormat.ext.toLowerCase(),
         attributes: [
             { type: 'label', name: 'originalFileName', value: originalName },
-            { type: 'label', name: 'fileSize', value: finalImageBuffer.byteLength }
+            { type: 'label', name: 'fileSize', value: buffer.byteLength }
         ]
     });
 
+    const imageHash = note.utcDateModified.replace(" ", "_");
+
     return {
         fileName,
         note,
         noteId: note.noteId,
-        url: `api/images/${note.noteId}/${fileName}`
+        url: `api/images/${note.noteId}/${fileName}?${imageHash}`
     };
 }
 
@@ -107,5 +131,6 @@ async function optimize(buffer) {
 }
 
 module.exports = {
-    saveImage
+    saveImage,
+    updateImage
 };
\ No newline at end of file
diff --git a/src/services/import/enex.js b/src/services/import/enex.js
index 045187ead..2f4fa7614 100644
--- a/src/services/import/enex.js
+++ b/src/services/import/enex.js
@@ -255,7 +255,7 @@ async function importEnex(taskContext, file, parentNote) {
                 try {
                     const originalName = "image." + resource.mime.substr(6);
 
-                    const {url} = await imageService.saveImage(resource.content, originalName, noteEntity.noteId, taskContext.data.shrinkImages);
+                    const {url} = await imageService.saveImage(noteEntity.noteId, resource.content, originalName, taskContext.data.shrinkImages);
 
                     const imageLink = ``;
 
diff --git a/src/services/import/single.js b/src/services/import/single.js
index f5a9167ca..ed98f01ee 100644
--- a/src/services/import/single.js
+++ b/src/services/import/single.js
@@ -32,7 +32,7 @@ async function importSingleFile(taskContext, file, parentNote) {
 }
 
 async function importImage(file, parentNote, taskContext) {
-    const {note} = await imageService.saveImage(file.buffer, file.originalname, parentNote.noteId, taskContext.data.shrinkImages);
+    const {note} = await imageService.saveImage(parentNote.noteId, file.buffer, file.originalname, taskContext.data.shrinkImages);
 
     taskContext.increaseProgressCount();
 
diff --git a/src/services/notes.js b/src/services/notes.js
index f1ffa1f17..a376859e5 100644
--- a/src/services/notes.js
+++ b/src/services/notes.js
@@ -373,15 +373,11 @@ async function updateNote(noteId, noteUpdates) {
     note.isProtected = noteUpdates.isProtected;
     await note.save();
 
-    // this might be simplified to just !== undefined
-    if (!['file', 'image', 'render'].includes(note.type)) {
+    if (noteUpdates.content !== undefined && noteUpdates.content !== null) {
         noteUpdates.content = await saveLinks(note, noteUpdates.content);
 
         await note.setContent(noteUpdates.content);
     }
-    else if (noteUpdates.content) {
-        await note.setContent(noteUpdates.content);
-    }
 
     if (noteTitleChanged) {
         await triggerNoteTitleChanged(note);
diff --git a/src/views/details/image.ejs b/src/views/details/image.ejs
index 30a510cc4..e8c1aa05f 100644
--- a/src/views/details/image.ejs
+++ b/src/views/details/image.ejs
@@ -1,28 +1,32 @@
 
- Original file name: - +
+ -     + - File type: - - -     - - File size: - - -

- - - -     - - - -

+ +
-
\ No newline at end of file + +
+ + Original file name: + + + + + File type: + + + + + File size: + + +
+ + + \ No newline at end of file