diff --git a/src/becca/entities/bnote.ts b/src/becca/entities/bnote.ts index 3ae4d60b5..e2d0299dc 100644 --- a/src/becca/entities/bnote.ts +++ b/src/becca/entities/bnote.ts @@ -3,6 +3,8 @@ import protectedSessionService from "../../services/protected_session.js"; import log from "../../services/log.js"; import sql from "../../services/sql.js"; +import optionService from "../../services/options.js"; +import eraseService from "../../services/erase.js"; import utils from "../../services/utils.js"; import dateUtils from "../../services/date_utils.js"; import AbstractBeccaEntity from "./abstract_becca_entity.js"; @@ -68,7 +70,7 @@ class BNote extends AbstractBeccaEntity { /** set during the deletion operation, before it is completed (removed from becca completely). */ isBeingDeleted!: boolean; isDecrypted!: boolean; - + ownedAttributes!: BAttribute[]; parentBranches!: BBranch[]; parents!: BNote[]; @@ -455,8 +457,8 @@ class BNote extends AbstractBeccaEntity { return this.getAttributes().find( attr => attr.name.toLowerCase() === name - && (!value || attr.value.toLowerCase() === value) - && attr.type === type); + && (!value || attr.value.toLowerCase() === value) + && attr.type === type); } getRelationTarget(name: string) { @@ -1107,7 +1109,7 @@ class BNote extends AbstractBeccaEntity { } getRevisions(): BRevision[] { - return sql.getRows("SELECT * FROM revisions WHERE noteId = ?", [this.noteId]) + return sql.getRows("SELECT * FROM revisions WHERE noteId = ? ORDER BY revisions.utcDateCreated ASC", [this.noteId]) .map(row => new BRevision(row)); } @@ -1612,10 +1614,31 @@ class BNote extends AbstractBeccaEntity { revision.setContent(noteContent); + this.eraseExcessRevisionSnapshots() return revision; }); } + // Limit the number of Snapshots to revisionSnapshotNumberLimit + // Delete older Snapshots that exceed the limit + eraseExcessRevisionSnapshots() { + // lable has a higher priority + let revisionSnapshotNumberLimit = parseInt(this.getLabelValue("versioningLimit") ?? ""); + if (!Number.isInteger(revisionSnapshotNumberLimit)) { + revisionSnapshotNumberLimit = parseInt(optionService.getOption('revisionSnapshotNumberLimit')); + } + if (revisionSnapshotNumberLimit >= 0) { + const revisions = this.getRevisions(); + if (revisions.length - revisionSnapshotNumberLimit > 0) { + const revisionIds = revisions + .slice(0, revisions.length - revisionSnapshotNumberLimit) + .map(revision => revision.revisionId) + .filter((id): id is string => id !== undefined); + eraseService.eraseRevisions(revisionIds); + } + } + } + /** * @param matchBy - choose by which property we detect if to update an existing attachment. * Supported values are either 'attachmentId' (default) or 'title' diff --git a/src/public/app/widgets/dialogs/revisions.js b/src/public/app/widgets/dialogs/revisions.js index 4e074cfcd..818b4c781 100644 --- a/src/public/app/widgets/dialogs/revisions.js +++ b/src/public/app/widgets/dialogs/revisions.js @@ -8,6 +8,7 @@ import openService from "../../services/open.js"; import protectedSessionHolder from "../../services/protected_session_holder.js"; import BasicWidget from "../basic_widget.js"; import dialogService from "../../services/dialog.js"; +import options from "../../services/options.js"; const TPL = ` + `; @@ -85,20 +91,29 @@ export default class RevisionsDialog extends BasicWidget { this.$list = this.$widget.find(".revision-list"); this.$listDropdown = this.$widget.find(".revision-list-dropdown"); + this.listDropdown = bootstrap.Dropdown.getOrCreateInstance(this.$listDropdown); this.$content = this.$widget.find(".revision-content"); this.$title = this.$widget.find(".revision-title"); this.$titleButtons = this.$widget.find(".revision-title-buttons"); this.$eraseAllRevisionsButton = this.$widget.find(".revisions-erase-all-revisions-button"); - - this.$listDropdown.dropdown(); + this.$snapshotInterval = this.$widget.find(".revisions-snapshot-interval"); + this.$maximumRevisions = this.$widget.find(".maximum-revisions-for-current-note"); + this.$revisionSettingsButton = this.$widget.find(".revision-settings-button") + this.listDropdown.show(); this.$listDropdown.parent().on('hide.bs.dropdown', e => { - // prevent closing dropdown by clicking outside - if (e.clickEvent) { - e.preventDefault(); - } + // Prevent closing dropdown by pressing ESC and clicking outside + e.preventDefault(); }); + document.addEventListener('keydown', e => { + // Close the revision dialog when revision element is focused and ESC is pressed + if (e.key === 'Escape' || + e.target.classList.contains(['dropdown-item', 'active'])) { + this.modal.hide(); + } + }, true) + this.$widget.on('shown.bs.modal', () => { this.$list.find(`[data-revision-id="${this.revisionId}"]`) .trigger('focus'); @@ -116,11 +131,6 @@ export default class RevisionsDialog extends BasicWidget { } }); - this.$list.on('click', '.dropdown-item', e => { - e.preventDefault(); - return false; - }); - this.$list.on('focus', '.dropdown-item', e => { this.$list.find('.dropdown-item').each((i, el) => { $(el).toggleClass('active', el === e.target); @@ -128,6 +138,10 @@ export default class RevisionsDialog extends BasicWidget { this.setContentPane(); }); + + this.$revisionSettingsButton.on('click', async () => { + appContext.tabManager.openContextWithNote('_optionsOther', { activate: true }); + }); } async showRevisionsEvent({ noteId = appContext.tabManager.getActiveContextNoteId() }) { @@ -153,7 +167,7 @@ export default class RevisionsDialog extends BasicWidget { ); } - this.$listDropdown.dropdown('show'); + this.listDropdown.show(); if (this.revisionItems.length > 0) { if (!this.revisionId) { @@ -165,6 +179,17 @@ export default class RevisionsDialog extends BasicWidget { } this.$eraseAllRevisionsButton.toggle(this.revisionItems.length > 0); + + // Show the footer of the revisions dialog + this.$snapshotInterval.text(t("revisions.snapshot_interval", { seconds: options.getInt('revisionSnapshotTimeInterval') })) + let revisionsNumberLimit = parseInt(this.note.getLabelValue("versioningLimit") ?? ""); + if (!Number.isInteger(revisionsNumberLimit)) { + revisionsNumberLimit = parseInt(options.getInt('revisionSnapshotNumberLimit')); + } + if (revisionsNumberLimit === -1) { + revisionsNumberLimit = "∞" + } + this.$maximumRevisions.text(t("revisions.maximum_revisions", { number: revisionsNumberLimit })) } async setContentPane() { @@ -245,12 +270,20 @@ export default class RevisionsDialog extends BasicWidget { } else if (revisionItem.type === 'code') { this.$content.html($("
").text(fullRevision.content));
         } else if (revisionItem.type === 'image') {
-            this.$content.html($("")
-                // the reason why we put this inline as base64 is that we do not want to let user copy this
-                // as a URL to be used in a note. Instead, if they copy and paste it into a note, it will be uploaded as a new note
-                .attr("src", `data:${fullRevision.mime};base64,${fullRevision.content}`)
-                .css("max-width", "100%")
-                .css("max-height", "100%"));
+            if (fullRevision.mime === "image/svg+xml") {
+                let encodedSVG = encodeURIComponent(fullRevision.content); //Base64 of other format images may be embedded in svg
+                this.$content.html($("")
+                    .attr("src", `data:${fullRevision.mime};utf8,${encodedSVG}`)
+                    .css("max-width", "100%")
+                    .css("max-height", "100%"));
+            } else {
+                this.$content.html($("")
+                    // the reason why we put this inline as base64 is that we do not want to let user copy this
+                    // as a URL to be used in a note. Instead, if they copy and paste it into a note, it will be uploaded as a new note
+                    .attr("src", `data:${fullRevision.mime};base64,${fullRevision.content}`)
+                    .css("max-width", "100%")
+                    .css("max-height", "100%"));
+            }
         } else if (revisionItem.type === 'file') {
             const $table = $("")
                 .append($("").append(
diff --git a/src/public/app/widgets/type_widgets/content_widget.js b/src/public/app/widgets/type_widgets/content_widget.js
index 6c2ddc0cb..d50252289 100644
--- a/src/public/app/widgets/type_widgets/content_widget.js
+++ b/src/public/app/widgets/type_widgets/content_widget.js
@@ -23,6 +23,7 @@ import SearchEngineOptions from "./options/other/search_engine.js";
 import TrayOptions from "./options/other/tray.js";
 import NoteErasureTimeoutOptions from "./options/other/note_erasure_timeout.js";
 import RevisionsSnapshotIntervalOptions from "./options/other/revisions_snapshot_interval.js";
+import RevisionSnapshotsLimitOptions from "./options/other/revision_snapshots_limit.js";
 import NetworkConnectionsOptions from "./options/other/network_connections.js";
 import AdvancedSyncOptions from "./options/advanced/sync.js";
 import DatabaseIntegrityCheckOptions from "./options/advanced/database_integrity_check.js";
@@ -88,6 +89,7 @@ const CONTENT_WIDGETS = {
         NoteErasureTimeoutOptions,
         AttachmentErasureTimeoutOptions,
         RevisionsSnapshotIntervalOptions,
+        RevisionSnapshotsLimitOptions,
         NetworkConnectionsOptions
     ],
     _optionsAdvanced: [
diff --git a/src/public/app/widgets/type_widgets/options/other/revision_snapshots_limit.js b/src/public/app/widgets/type_widgets/options/other/revision_snapshots_limit.js
new file mode 100644
index 000000000..58cb409ac
--- /dev/null
+++ b/src/public/app/widgets/type_widgets/options/other/revision_snapshots_limit.js
@@ -0,0 +1,42 @@
+import OptionsWidget from "../options_widget.js";
+import { t } from "../../../../services/i18n.js";
+import server from "../../../../services/server.js";
+import toastService from "../../../../services/toast.js";
+
+const TPL = `
+
+

${t("revisions_snapshot_limit.note_revisions_snapshot_limit_title")}

+ +

${t("revisions_snapshot_limit.note_revisions_snapshot_limit_description")}

+ +
+ + +
+ + +
`; + +export default class RevisionSnapshotsLimitOptions extends OptionsWidget { + doRender() { + this.$widget = $(TPL); + this.$revisionSnapshotsNumberLimit = this.$widget.find(".revision-snapshot-number-limit"); + this.$revisionSnapshotsNumberLimit.on('change', () => { + let revisionSnapshotNumberLimit = this.$revisionSnapshotsNumberLimit.val(); + if (!isNaN(revisionSnapshotNumberLimit) && revisionSnapshotNumberLimit >= -1) { + this.updateOption('revisionSnapshotNumberLimit', revisionSnapshotNumberLimit) + } + }); + this.$eraseExcessRevisionSnapshotsButton = this.$widget.find(".erase-excess-revision-snapshots-now-button"); + this.$eraseExcessRevisionSnapshotsButton.on('click', () => { + server.post('revisions/erase-all-excess-revisions').then(() => { + toastService.showMessage(t("revisions_snapshot_limit.erase_excess_revision_snapshots_prompt")); + }); + }); + } + + async optionsLoaded(options) { + this.$revisionSnapshotsNumberLimit.val(options.revisionSnapshotNumberLimit); + } +} diff --git a/src/public/translations/en/translation.json b/src/public/translations/en/translation.json index faf0a4fe9..fc2307c6d 100644 --- a/src/public/translations/en/translation.json +++ b/src/public/translations/en/translation.json @@ -247,6 +247,9 @@ "revisions_deleted": "Note revisions has been deleted.", "revision_restored": "Note revision has been restored.", "revision_deleted": "Note revision has been deleted.", + "snapshot_interval":"Note Revisions Snapshot Interval: {{seconds}}s.", + "maximum_revisions":"Maximum revisions for current note: {{number}}.", + "settings":"Settings for Note revisions.", "download_button": "Download", "mime": "MIME: ", "file_size": "File size:", @@ -1088,7 +1091,14 @@ "revisions_snapshot_interval": { "note_revisions_snapshot_interval_title": "Note Revisions Snapshot Interval", "note_revisions_snapshot_description": "Note revision snapshot time interval is time in seconds after which a new note revision will be created for the note. See wiki for more info.", - "snapshot_time_interval_label": "Note revision snapshot time interval (in seconds)" + "snapshot_time_interval_label": "Note revision snapshot time interval (in seconds):" + }, + "revisions_snapshot_limit": { + "note_revisions_snapshot_limit_title": "Note Revision Snapshots Limit", + "note_revisions_snapshot_limit_description": "The note revision snapshot number limit refers to the maximum number of revisions that can be saved for each note. Where -1 means no limit, 0 means delete all revisions. You can set the maximum revisions for a single note through the #versioningLimit label.", + "snapshot_number_limit_label": "Note revision snapshot number limit:", + "erase_excess_revision_snapshots": "Erase excess revision snapshots now", + "erase_excess_revision_snapshots_prompt": "Excess revision snapshots have been erased." }, "search_engine": { "title": "Search Engine", diff --git a/src/routes/api/options.ts b/src/routes/api/options.ts index 4c7b06d2a..ddba39d4a 100644 --- a/src/routes/api/options.ts +++ b/src/routes/api/options.ts @@ -11,6 +11,7 @@ const ALLOWED_OPTIONS = new Set([ 'eraseEntitiesAfterTimeInSeconds', 'protectedSessionTimeout', 'revisionSnapshotTimeInterval', + 'revisionSnapshotNumberLimit', 'zoomFactor', 'theme', 'syncServerHost', diff --git a/src/routes/api/revisions.ts b/src/routes/api/revisions.ts index 7e98318d5..0d55faee3 100644 --- a/src/routes/api/revisions.ts +++ b/src/routes/api/revisions.ts @@ -112,6 +112,13 @@ function eraseRevision(req: Request) { eraseService.eraseRevisions([req.params.revisionId]); } +function eraseAllExcessRevisions() { + let allNoteIds = sql.getRows("SELECT noteId FROM notes WHERE SUBSTRING(noteId, 1, 1) != '_'") as { noteId: string }[]; + allNoteIds.forEach(row => { + becca.getNote(row.noteId)?.eraseExcessRevisionSnapshots() + }); +} + function restoreRevision(req: Request) { const revision = becca.getRevision(req.params.revisionId); @@ -139,6 +146,8 @@ function restoreRevision(req: Request) { } note.title = revision.title; + note.mime = revision.mime; + note.type = revision.type as any; note.setContent(revisionContent, { forceSave: true }); }); } @@ -211,6 +220,7 @@ export default { downloadRevision, getEditedNotesOnDate, eraseAllRevisions, + eraseAllExcessRevisions, eraseRevision, restoreRevision }; diff --git a/src/routes/routes.ts b/src/routes/routes.ts index fd440804d..f1845eb1f 100644 --- a/src/routes/routes.ts +++ b/src/routes/routes.ts @@ -184,6 +184,7 @@ function register(app: express.Application) { apiRoute(GET, '/api/notes/:noteId/revisions', revisionsApiRoute.getRevisions); apiRoute(DEL, '/api/notes/:noteId/revisions', revisionsApiRoute.eraseAllRevisions); + apiRoute(PST, '/api/revisions/erase-all-excess-revisions', revisionsApiRoute.eraseAllExcessRevisions); apiRoute(GET, '/api/revisions/:revisionId', revisionsApiRoute.getRevision); apiRoute(GET, '/api/revisions/:revisionId/blob', revisionsApiRoute.getRevisionBlob); apiRoute(DEL, '/api/revisions/:revisionId', revisionsApiRoute.eraseRevision); diff --git a/src/services/options_init.ts b/src/services/options_init.ts index 2adbadd2b..e32787dc9 100644 --- a/src/services/options_init.ts +++ b/src/services/options_init.ts @@ -49,6 +49,7 @@ async function initNotSyncedOptions(initialized: boolean, theme: string, opts: N const defaultOptions: DefaultOption[] = [ { name: 'revisionSnapshotTimeInterval', value: '600', isSynced: true }, + { name: 'revisionSnapshotNumberLimit', value: '-1', isSynced: true }, { name: 'protectedSessionTimeout', value: '600', isSynced: true }, { name: 'zoomFactor', value: process.platform === "win32" ? '0.9' : '1.0', isSynced: false }, { name: 'overrideThemeFonts', value: 'false', isSynced: false },