mirror of
				https://github.com/zadam/trilium.git
				synced 2025-10-31 10:26:08 +01:00 
			
		
		
		
	Merge pull request #391 from SiriusXT/revisions_number_limit
Revisions number limit
This commit is contained in:
		| @@ -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"; | ||||
| @@ -1107,7 +1109,7 @@ class BNote extends AbstractBeccaEntity<BNote> { | ||||
|     } | ||||
|  | ||||
|     getRevisions(): BRevision[] { | ||||
|         return sql.getRows<RevisionRow>("SELECT * FROM revisions WHERE noteId = ?", [this.noteId]) | ||||
|         return sql.getRows<RevisionRow>("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<BNote> { | ||||
|  | ||||
|             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' | ||||
|   | ||||
| @@ -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 = ` | ||||
| <div class="revisions-dialog modal fade mx-auto" tabindex="-1" role="dialog"> | ||||
| @@ -66,6 +67,11 @@ const TPL = ` | ||||
|                     <div class="revision-content"></div> | ||||
|                 </div> | ||||
|             </div> | ||||
|             <div class="modal-footer py-0"> | ||||
|                 <span class="revisions-snapshot-interval flex-grow-1 my-0 py-0"></span> | ||||
|                 <span class="maximum-revisions-for-current-note flex-grow-1 my-0 py-0"></span> | ||||
|                 <button class="revision-settings-button icon-action bx bx-cog my-0 py-0" title="${t("revisions.settings")}"></button> | ||||
|             </div> | ||||
|         </div> | ||||
|     </div> | ||||
| </div>`; | ||||
| @@ -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) { | ||||
|             // 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($("<pre>").text(fullRevision.content)); | ||||
|         } else if (revisionItem.type === 'image') { | ||||
|             if (fullRevision.mime === "image/svg+xml") { | ||||
|                 let encodedSVG = encodeURIComponent(fullRevision.content); //Base64 of other format images may be embedded in svg | ||||
|                 this.$content.html($("<img>") | ||||
|                     .attr("src", `data:${fullRevision.mime};utf8,${encodedSVG}`) | ||||
|                     .css("max-width", "100%") | ||||
|                     .css("max-height", "100%")); | ||||
|             } else { | ||||
|                 this.$content.html($("<img>") | ||||
|                     // 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 = $("<table cellpadding='10'>") | ||||
|                 .append($("<tr>").append( | ||||
|   | ||||
| @@ -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: [ | ||||
|   | ||||
| @@ -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 = ` | ||||
| <div class="options-section"> | ||||
|     <h4>${t("revisions_snapshot_limit.note_revisions_snapshot_limit_title")}</h4> | ||||
|  | ||||
|     <p>${t("revisions_snapshot_limit.note_revisions_snapshot_limit_description")}</p> | ||||
|  | ||||
|     <div class="form-group"> | ||||
|         <label>${t("revisions_snapshot_limit.snapshot_number_limit_label")}</label> | ||||
|         <input class="revision-snapshot-number-limit form-control options-number-input" type="number" min="-1"> | ||||
|     </div> | ||||
|  | ||||
|     <button class="erase-excess-revision-snapshots-now-button btn btn-sm" style="padding: 0 10px"> | ||||
|                     ${t('revisions_snapshot_limit.erase_excess_revision_snapshots')}</button> | ||||
| </div>`; | ||||
|  | ||||
| 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); | ||||
|     } | ||||
| } | ||||
| @@ -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 <a href=\"https://triliumnext.github.io/Docs/Wiki/note-revisions.html\" class=\"external\">wiki</a> 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", | ||||
|   | ||||
| @@ -11,6 +11,7 @@ const ALLOWED_OPTIONS = new Set([ | ||||
|     'eraseEntitiesAfterTimeInSeconds', | ||||
|     'protectedSessionTimeout', | ||||
|     'revisionSnapshotTimeInterval', | ||||
|     'revisionSnapshotNumberLimit', | ||||
|     'zoomFactor', | ||||
|     'theme', | ||||
|     'syncServerHost', | ||||
|   | ||||
| @@ -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 | ||||
| }; | ||||
|   | ||||
| @@ -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); | ||||
|   | ||||
| @@ -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 }, | ||||
|   | ||||
		Reference in New Issue
	
	Block a user