Named revisions (#9490)

This commit is contained in:
Elian Doran
2026-04-18 20:04:54 +03:00
committed by GitHub
41 changed files with 1170 additions and 321 deletions

View File

@@ -52,6 +52,7 @@
"dompurify": "3.4.0",
"draggabilly": "3.0.0",
"force-graph": "1.51.2",
"htmldiff-js": "1.0.5",
"i18next": "26.0.4",
"i18next-http-backend": "3.0.4",
"jquery": "4.0.0",

View File

@@ -281,6 +281,7 @@ export type CommandMappings = {
backInNoteHistory: CommandData;
forwardInNoteHistory: CommandData;
forceSaveRevision: CommandData;
saveNamedRevision: CommandData;
scrollToActiveNote: CommandData;
quickSearch: CommandData;
collapseTree: CommandData;

View File

@@ -1,6 +1,7 @@
import { CreateChildrenResponse, SqlExecuteResponse } from "@triliumnext/commons";
import bundleService from "../services/bundle.js";
import dialog from "../services/dialog.js";
import dateNoteService from "../services/date_notes.js";
import froca from "../services/froca.js";
import { t } from "../services/i18n.js";
@@ -216,4 +217,21 @@ export default class Entrypoints extends Component {
toastService.showMessage(t("entrypoints.note-revision-created"));
}
async saveNamedRevisionCommand() {
const noteId = appContext.tabManager.getActiveContextNoteId();
if (!noteId) return;
const name = await dialog.prompt({
title: t("entrypoints.save-named-revision-title"),
message: t("entrypoints.save-named-revision-message"),
defaultValue: ""
});
// null means the user cancelled
if (name === null) return;
await server.post(`notes/${noteId}/revision`, { description: name || undefined });
toastService.showMessage(t("entrypoints.note-revision-created"));
}
}

View File

@@ -835,6 +835,7 @@ table.promoted-attributes-in-tooltip th {
text-align: start;
color: var(--main-text-color) !important;
max-width: 500px;
white-space: pre-line;
box-shadow: 10px 10px 93px -25px #aaaaaa;
}
@@ -960,15 +961,19 @@ table.promoted-attributes-in-tooltip th {
background-color: var(--active-item-background-color);
}
.help-button {
.help-button,
.custom-title-bar-button {
float: inline-end;
background: none;
font-weight: 900;
color: orange;
border: 0;
cursor: pointer;
}
.help-button {
color: orange;
}
.multiplicity {
font-size: 1.3em;
}
@@ -1147,11 +1152,90 @@ a.external:not(.no-arrow):after, a[href^="http://"]:not(.no-arrow):after, a[href
padding: 0.5rem 1rem 0.5rem 1rem !important; /* make modal header padding slightly smaller */
}
.modal-header .help-button {
.modal-header .help-button,
.modal-header .custom-title-bar-button {
padding: 6px;
margin: 0 12px;
}
.modal-content-with-sidebar {
flex-direction: row !important;
}
.modal-content-with-sidebar > .modal-sidebar {
display: flex;
flex-direction: column;
border-right: 1px solid var(--main-border-color);
flex-shrink: 0;
min-height: 0;
}
.modal-content-with-sidebar .modal-sidebar-header {
padding: 0.75rem 1rem;
flex-shrink: 0;
text-align: center;
border-bottom: 1px solid var(--main-border-color);
}
.modal-content-with-sidebar .modal-sidebar-header h5 {
margin: 0;
font-size: 1em;
}
.modal-content-with-sidebar > .modal-main > .modal-header > .modal-title {
visibility: hidden;
flex-grow: 1;
width: 0;
padding: 0;
margin: 0;
overflow: hidden;
}
.modal-content-with-sidebar > .modal-main > .modal-header {
flex-wrap: nowrap;
}
.modal-content-with-sidebar > .modal-main {
display: flex;
flex-direction: column;
flex-grow: 1;
min-width: 0;
min-height: 0;
overflow: hidden;
}
.modal-content-with-sidebar > .modal-main > .modal-body {
overflow: auto;
flex-grow: 1;
min-height: 0;
}
body.mobile .modal-content-with-sidebar {
flex-direction: column !important;
}
body.mobile .modal-content-with-sidebar > .modal-sidebar {
border-right: none;
border-bottom: 1px solid var(--main-border-color);
height: 30vh;
flex-shrink: 0;
overflow: hidden;
order: 1;
}
body.mobile .modal-content-with-sidebar > .modal-main {
order: 0;
}
body.mobile .modal-content-with-sidebar .modal-sidebar-header {
display: none;
}
body.mobile .modal-content-with-sidebar > .modal-main > .modal-header > .modal-title {
visibility: visible;
width: auto;
}
.ck-mentions .ck-button {
font-size: var(--detail-font-size) !important;
padding: 5px;

View File

@@ -287,6 +287,7 @@
"confirm_delete_all": "Do you want to delete all revisions of this note?",
"no_revisions": "No revisions for this note yet...",
"restore_button": "Restore",
"highlight_changes": "Highlight changes",
"diff_on": "Show diff",
"diff_off": "Show content",
"diff_on_hint": "Click to show note source diff",
@@ -300,11 +301,39 @@
"revision_deleted": "Note revision has been deleted.",
"snapshot_interval": "Note Revision Snapshot Interval: {{seconds}}s.",
"maximum_revisions": "Note Revision Snapshot Limit: {{number}}.",
"save_revision_now": "Save a revision now",
"save_named_revision": "Save named revision...",
"snapshot_header": "Note revision snapshot",
"snapshot_interval_value": "Interval: {{seconds}}s",
"snapshot_limit_value": "Limit: {{number}}",
"settings": "Note Revision Settings",
"menu_tooltip": "Revision options",
"download_button": "Download",
"mime": "MIME: ",
"file_size": "File size:",
"preview_not_available": "Preview isn't available for this note type."
"preview_not_available": "Preview isn't available for this note type.",
"save_revision": "Save revision",
"save_revision_tooltip": "Manually save a snapshot of the current note",
"description_placeholder": "Name this revision",
"revision_saved": "Note revision has been saved.",
"edit_description": "Edit name",
"description_updated": "Revision name has been updated.",
"source_auto": "Auto-save",
"source_manual": "Manual save",
"source_etapi": "ETAPI",
"source_llm": "LLM",
"source_restore": "Restore",
"source_unknown": "Snapshot",
"date_today": "Today",
"date_yesterday": "Yesterday",
"date_this_week": "This week",
"date_this_month": "This month",
"source_description_auto": "Automatically saved by the system at regular intervals",
"source_description_manual": "Manually saved by the user",
"source_description_etapi": "Created via the External Trilium API",
"source_description_llm": "Created by the AI assistant",
"source_description_restore": "Saved before restoring a previous revision",
"source_description_unknown": "Source not available"
},
"sort_child_notes": {
"sort_children_by": "Sort children by...",
@@ -718,6 +747,7 @@
"print_note": "Print note",
"view_revisions": "Note revisions...",
"save_revision": "Save revision",
"save_named_revision": "Save named revision...",
"advanced": "Advanced",
"convert_into_attachment_failed": "Converting note '{{title}}' failed.",
"convert_into_attachment_successful": "Note '{{title}}' has been converted to attachment.",
@@ -1897,6 +1927,8 @@
},
"entrypoints": {
"note-revision-created": "Note revision has been created.",
"save-named-revision-title": "Save named revision",
"save-named-revision-message": "Enter a name for this revision (leave empty for no name):",
"note-executed": "Note executed.",
"sql-error": "Error occurred while executing SQL query: {{message}}"
},
@@ -2507,5 +2539,9 @@
"move_note": "Move note",
"clone_note": "Clone note"
}
},
"common": {
"save": "Save",
"cancel": "Cancel"
}
}

View File

@@ -1,3 +1,10 @@
declare module "htmldiff-js" {
const HtmlDiff: {
execute(oldHtml: string, newHtml: string): string;
};
export default HtmlDiff;
}
// TODO: Use real @types/ but that one generates a lot of errors.
declare module "draggabilly" {
type DraggabillyEventData = {};

View File

@@ -3,74 +3,209 @@ body.mobile .revisions-dialog {
height: 95vh;
}
.modal-header {
display: flex;
flex-wrap: wrap;
gap: 0.25em;
font-size: 0.9em;
}
.modal-title {
flex-grow: 1;
width: 100%;
}
.modal-body {
height: fit-content !important;
flex-direction: column;
padding: 0;
}
.modal-footer {
font-size: 0.9em;
}
.revision-list {
height: fit-content !important;
max-height: 20vh;
border-bottom: 1px solid var(--main-border-color) !important;
padding: 0 1em;
flex-shrink: 0;
}
.modal-body > .revision-content-wrapper {
flex-grow: 1;
max-width: unset !important;
height: 100%;
margin: 0;
display: block !important;
}
.modal-body > .revision-content-wrapper > div:first-of-type {
flex-direction: column;
}
.revision-title {
font-size: 1rem;
}
.revision-title-buttons {
text-align: center;
display: flex;
gap: 0.25em;
.revision-toolbar-actions {
flex-wrap: wrap;
}
.revision-content {
padding: 0.5em;
height: fit-content;
}
}
body.desktop .revisions-dialog {
.revision-list {
width: 300px;
}
.modal-content-with-sidebar {
height: 80vh;
}
}
.revisions-dialog {
.revision-title-buttons {
.modal-body {
padding: 0;
display: flex;
flex-direction: column;
}
.modal-sidebar {
background-color: var(--card-background-color);
}
.modal-sidebar .dropdown-menu.static {
background-color: transparent !important;
border-radius: 0 !important;
}
.revision-toolbar {
flex-shrink: 0;
border-bottom: 1px solid var(--main-border-color);
padding: 8px 20px;
}
.revision-title {
font-size: 1.2em;
margin: 8px 0;
}
.revision-toolbar-actions {
display: flex;
align-items: center;
gap: 4px;
}
.revision-menu-header {
font-weight: bold;
font-size: 0.85em;
text-transform: uppercase;
opacity: 0.6;
}
.revision-content-wrapper {
flex-grow: 1;
display: flex;
flex-direction: column;
min-width: 0;
min-height: 0;
}
.revision-content {
flex-grow: 1;
min-height: 0;
overflow: auto;
padding: 20px;
}
.no-items {
padding-block: 3em;
}
.revision-list {
flex: 1 1 0;
min-height: 0;
overflow: auto;
.dropdown-item {
min-height: 2.5em;
>div {
padding-left: 0.25em;
min-width: 0;
}
}
}
.revision-item-description {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
font-size: 0.85em;
opacity: 0.7;
}
.revision-group-header {
font-size: 0.75em;
font-weight: bold;
text-transform: uppercase;
opacity: 0.5;
padding: 6px 12px 2px;
}
.revision-item-meta {
font-size: 0.85em;
opacity: 0.7;
}
.revision-description-icon {
opacity: 0.5;
flex-shrink: 0;
}
.revision-description-editor {
display: flex;
gap: 5px;
align-items: center;
margin: 3px 0;
input {
flex-grow: 1;
}
}
.revision-description-display {
display: flex;
align-items: center;
margin: 3px 0;
gap: 5px;
min-height: 24px;
}
.revision-description-text {
font-size: 0.9em;
&.empty {
opacity: 0.5;
font-style: italic;
}
}
.revision-diff-code {
font-family: var(--font-family-monospace, monospace);
font-size: 0.9rem;
white-space: pre-wrap;
word-break: break-all;
max-width: 100%;
padding: 0;
}
/* HTML diff styles (htmldiff-js) */
.revision-diff-content {
ins {
text-decoration: none;
&.diffins,
&.diffmod {
background-color: color-mix(in srgb, var(--bs-success) 25%, transparent);
}
}
del {
text-decoration: line-through;
&.diffdel,
&.diffmod {
background-color: color-mix(in srgb, var(--bs-danger) 25%, transparent);
}
}
/* Image diff styles */
ins img,
del img {
border: 3px solid;
border-radius: 4px;
position: relative;
}
del img {
border-color: var(--bs-danger);
opacity: 0.6;
}
ins img {
border-color: var(--bs-success);
}
}
.revision-content.type-file {
display: flex;
min-width: 0;

View File

@@ -1,8 +1,10 @@
import "./revisions.css";
import type { RevisionItem, RevisionPojo } from "@triliumnext/commons";
import { dayjs, type RevisionItem, type RevisionPojo } from "@triliumnext/commons";
import clsx from "clsx";
import { diffWords } from "diff";
import HtmlDiff from "htmldiff-js";
import { Fragment } from "preact";
import type { CSSProperties } from "preact/compat";
import { Dispatch, StateUpdater, useEffect, useRef, useState } from "preact/hooks";
@@ -20,11 +22,13 @@ import toast from "../../services/toast";
import utils from "../../services/utils";
import ActionButton from "../react/ActionButton";
import Button from "../react/Button";
import FormList, { FormListItem } from "../react/FormList";
import Dropdown from "../react/Dropdown";
import FormList, { FormDropdownDivider, FormListItem } from "../react/FormList";
import FormToggle from "../react/FormToggle";
import { useTriliumEvent } from "../react/hooks";
import Modal from "../react/Modal";
import { RawHtmlBlock } from "../react/RawHtml";
import NoItems from "../react/NoItems";
import { RawHtmlBlock, SanitizedHtml } from "../react/RawHtml";
import PdfViewer from "../type_widgets/file/PdfViewer";
export default function RevisionsDialog() {
@@ -33,7 +37,7 @@ export default function RevisionsDialog() {
const [ revisions, setRevisions ] = useState<RevisionItem[]>();
const [ currentRevision, setCurrentRevision ] = useState<RevisionItem>();
const [ shown, setShown ] = useState(false);
const [ showDiff, setShowDiff ] = useState(false);
const [ showDiff, setShowDiff ] = useState(true);
const [ refreshCounter, setRefreshCounter ] = useState(0);
useTriliumEvent("showRevisions", async ({ noteId }) => {
@@ -54,114 +58,390 @@ export default function RevisionsDialog() {
}
}, [ note, refreshCounter ]);
const revisionsLoaded = revisions !== undefined;
const hasRevisions = !!revisions?.length;
if (revisions?.length && !currentRevision) {
setCurrentRevision(revisions[0]);
}
const onHidden = () => {
setShown(false);
setShowDiff(true);
setNote(undefined);
setCurrentRevision(undefined);
setRevisions(undefined);
};
if (revisionsLoaded && !hasRevisions) {
return (
<Modal
className="revisions-dialog"
size="md"
title={t("revisions.note_revisions")}
helpPageId="vZWERwf8U3nx"
header={note && (
<RevisionsMenu
note={note}
onRevisionSaved={() => {
setRefreshCounter(c => c + 1);
setCurrentRevision(undefined);
}}
onAllDeleted={() => {
setRevisions([]);
setCurrentRevision(undefined);
}}
hasRevisions={false}
/>
)}
onHidden={onHidden}
show={shown}
>
<NoItems icon="bx bx-history" text={t("revisions.no_revisions")} />
</Modal>
);
}
return (
<Modal
className="revisions-dialog"
size="xl"
title={t("revisions.note_revisions")}
helpPageId="vZWERwf8U3nx"
bodyStyle={{ display: "flex", height: "80vh" }}
header={
!!revisions?.length && (
<>
{["text", "code", "mermaid"].includes(currentRevision?.type ?? "") && (
<FormToggle
currentValue={showDiff}
onChange={(newValue) => setShowDiff(newValue)}
switchOnName={t("revisions.diff_on")}
switchOffName={t("revisions.diff_off")}
switchOnTooltip={t("revisions.diff_on_hint")}
switchOffTooltip={t("revisions.diff_off_hint")}
/>
)}
&nbsp;
<Button
text={t("revisions.delete_all_revisions")}
size="small"
style={{ padding: "0 10px" }}
onClick={async () => {
const text = t("revisions.confirm_delete_all");
if (note && await dialog.confirm(text)) {
await server.remove(`notes/${note.noteId}/revisions`);
setRevisions([]);
setCurrentRevision(undefined);
toast.showMessage(t("revisions.revisions_deleted"));
}
}}
/>
</>
)
header={note && (
<RevisionsMenu
note={note}
onRevisionSaved={() => {
setRefreshCounter(c => c + 1);
setCurrentRevision(undefined);
}}
onAllDeleted={() => {
setRevisions([]);
setCurrentRevision(undefined);
}}
hasRevisions={true}
/>
)}
sidebar={
<RevisionsList
revisions={revisions ?? []}
onSelect={(revisionId) => {
const correspondingRevision = (revisions ?? []).find((r) => r.revisionId === revisionId);
if (correspondingRevision) {
setCurrentRevision(correspondingRevision);
}
}}
currentRevision={currentRevision}
/>
}
footer={<RevisionFooter note={note} />}
footerStyle={{ paddingTop: 0, paddingBottom: 0 }}
onHidden={() => {
setShown(false);
setShowDiff(false);
setNote(undefined);
setCurrentRevision(undefined);
setRevisions(undefined);
}}
onHidden={onHidden}
show={shown}
>
<RevisionsList
revisions={revisions ?? []}
onSelect={(revisionId) => {
const correspondingRevision = (revisions ?? []).find((r) => r.revisionId === revisionId);
if (correspondingRevision) {
setCurrentRevision(correspondingRevision);
<RevisionToolbar
revisionItem={currentRevision}
showDiff={showDiff}
setShowDiff={setShowDiff}
setShown={setShown}
onRevisionDeleted={() => {
setRefreshCounter(c => c + 1);
setCurrentRevision(undefined);
}}
onDescriptionUpdated={(revisionId, description) => {
setRevisions(prev => prev?.map(r =>
r.revisionId === revisionId ? { ...r, description } : r
));
if (currentRevision?.revisionId === revisionId) {
setCurrentRevision({ ...currentRevision, description });
}
}}
currentRevision={currentRevision}
/>
<div className="revision-content-wrapper" style={{
flexGrow: "1",
marginInlineStart: "20px",
display: "flex",
flexDirection: "column",
maxWidth: "calc(100% - 150px)",
minWidth: 0
}}>
<div className="revision-content-wrapper">
<RevisionPreview
noteContent={noteContent}
revisionItem={currentRevision}
showDiff={showDiff}
setShown={setShown}
onRevisionDeleted={() => {
setRefreshCounter(c => c + 1);
setCurrentRevision(undefined);
}} />
/>
</div>
</Modal>
);
}
function RevisionsMenu({ note, onRevisionSaved, onAllDeleted, hasRevisions }: {
note: FNote,
onRevisionSaved: () => void,
onAllDeleted: () => void,
hasRevisions: boolean
}) {
let revisionsNumberLimit: number | string = parseInt(note.getLabelValue("versioningLimit") ?? "", 10);
if (!Number.isInteger(revisionsNumberLimit)) {
revisionsNumberLimit = options.getInt("revisionSnapshotNumberLimit") ?? 0;
}
if (revisionsNumberLimit === -1) {
revisionsNumberLimit = "∞";
}
return (
<Dropdown
text={<span className="bx bx-dots-horizontal-rounded" />}
hideToggleArrow
buttonClassName="custom-title-bar-button"
noSelectButtonStyle
buttonProps={{ title: t("revisions.menu_tooltip") }}
dropdownContainerClassName="mobile-bottom-menu"
dropdownOptions={{ popperConfig: { strategy: "fixed" } }}
>
<FormListItem
icon="bx bx-save"
onClick={async () => {
await server.post(`notes/${note.noteId}/revision`);
toast.showMessage(t("revisions.revision_saved"));
onRevisionSaved();
}}
>
{t("revisions.save_revision_now")}
</FormListItem>
<FormListItem
icon="bx bx-purchase-tag"
onClick={async () => {
const name = await dialog.prompt({
title: t("entrypoints.save-named-revision-title"),
message: t("entrypoints.save-named-revision-message"),
defaultValue: ""
});
if (name === null) return;
await server.post(`notes/${note.noteId}/revision`, { description: name || undefined });
toast.showMessage(t("revisions.revision_saved"));
onRevisionSaved();
}}
>
{t("revisions.save_named_revision")}
</FormListItem>
<FormDropdownDivider />
<FormListItem disabled className="revision-menu-header">
{t("revisions.snapshot_header")}
</FormListItem>
<FormListItem disabled>
{t("revisions.snapshot_interval_value", { seconds: options.getInt("revisionSnapshotTimeInterval") })}
</FormListItem>
<FormListItem disabled>
{t("revisions.snapshot_limit_value", { number: revisionsNumberLimit })}
</FormListItem>
<FormListItem
icon="bx bx-cog"
onClick={() => appContext.tabManager.openContextWithNote("_optionsOther", { activate: true })}
>
{t("revisions.settings")}
</FormListItem>
{hasRevisions && (
<>
<FormDropdownDivider />
<FormListItem
icon="bx bx-trash"
onClick={async () => {
if (await dialog.confirm(t("revisions.confirm_delete_all"))) {
await server.remove(`notes/${note.noteId}/revisions`);
onAllDeleted();
toast.showMessage(t("revisions.revisions_deleted"));
}
}}
>
{t("revisions.delete_all_revisions")}
</FormListItem>
</>
)}
</Dropdown>
);
}
const REVISION_SOURCE_ICONS: Record<string, string> = {
auto: "bx bx-time-five",
manual: "bx bx-save",
etapi: "bx bx-code-alt",
llm: "bx bx-bot",
restore: "bx bx-history"
};
const DEFAULT_REVISION_ICON = "bx bx-file";
function getRevisionSourceTitle(source?: string): string {
return t(`revisions.source_description_${source ?? "unknown"}`);
}
type DateGroup = "today" | "yesterday" | "this_week" | "this_month" | "older";
function getDateGroup(dateStr: string): DateGroup {
const date = dayjs(dateStr);
const now = dayjs();
if (date.isSame(now, "day")) return "today";
if (date.isSame(now.subtract(1, "day"), "day")) return "yesterday";
if (date.isSame(now, "week")) return "this_week";
if (date.isSame(now, "month")) return "this_month";
return "older";
}
function getDateGroupLabel(group: DateGroup, dateStr: string): string {
if (group === "older") return dayjs(dateStr).format("MMMM YYYY");
return t(`revisions.date_${group}`);
}
function formatRevisionDate(dateStr: string, group: DateGroup): string {
const date = dayjs(dateStr);
switch (group) {
case "today":
case "yesterday":
return date.format("HH:mm");
case "this_week":
return date.format("dddd · HH:mm");
default:
return date.isSame(dayjs(), "year")
? date.format("MMM D · HH:mm")
: date.format("MMM D, YYYY · HH:mm");
}
}
function buildRevisionTooltip(item: RevisionItem): string {
const dateLine = item.dateCreated
? `${dayjs(item.dateCreated).format("YYYY-MM-DD HH:mm")} (${dayjs(item.dateCreated).fromNow()})`
: "";
return [
item.description,
getRevisionSourceTitle(item.source),
dateLine,
item.contentLength && utils.formatSize(item.contentLength)
].filter(Boolean).join("\n");
}
function RevisionsList({ revisions, onSelect, currentRevision }: { revisions: RevisionItem[], onSelect: (val: string) => void, currentRevision?: RevisionItem }) {
let lastGroup: DateGroup | "" = "";
return (
<FormList onSelect={onSelect} fullHeight wrapperClassName="revision-list">
{revisions.map((item) =>
<FormListItem
key={item.revisionId}
value={item.revisionId}
active={currentRevision && item.revisionId === currentRevision.revisionId}
>
{item.dateCreated && item.dateCreated.substr(0, 16)} ({item.contentLength && utils.formatSize(item.contentLength)})
</FormListItem>
)}
{revisions.map((item) => {
const group = item.dateCreated ? getDateGroup(item.dateCreated) : "" as DateGroup;
const showHeader = group !== lastGroup;
lastGroup = group;
return (
<Fragment key={item.revisionId}>
{showHeader && (
<div className="revision-group-header">{item.dateCreated ? getDateGroupLabel(group, item.dateCreated) : ""}</div>
)}
<FormListItem
key={item.revisionId}
value={item.revisionId}
icon={REVISION_SOURCE_ICONS[item.source ?? ""] ?? DEFAULT_REVISION_ICON}
title={buildRevisionTooltip(item)}
active={currentRevision && item.revisionId === currentRevision.revisionId}
>
<div>
<div className="revision-item-date">
{item.dateCreated && formatRevisionDate(item.dateCreated, group)}
</div>
{item.description && (
<div className="revision-item-description">
{item.description}
</div>
)}
</div>
</FormListItem>
</Fragment>
);
})}
</FormList>);
}
function RevisionPreview({noteContent, revisionItem, showDiff, setShown, onRevisionDeleted }: {
function RevisionToolbar({ revisionItem, showDiff, setShowDiff, setShown, onRevisionDeleted, onDescriptionUpdated }: {
revisionItem?: RevisionItem,
showDiff: boolean,
setShowDiff: Dispatch<StateUpdater<boolean>>,
setShown: Dispatch<StateUpdater<boolean>>,
onRevisionDeleted?: () => void,
onDescriptionUpdated?: (revisionId: string, description: string) => void,
}) {
const canShowDiff = ["text", "code", "mermaid"].includes(revisionItem?.type ?? "");
const canInteract = revisionItem && (!revisionItem.isProtected || protected_session_holder.isProtectedSessionAvailable());
const [ editingDescription, setEditingDescription ] = useState(false);
const [ descriptionDraft, setDescriptionDraft ] = useState("");
useEffect(() => {
setEditingDescription(false);
}, [revisionItem]);
return (
<div className="revision-toolbar">
{revisionItem && (
<div className="revision-toolbar-actions">
{canShowDiff && (
<FormToggle
currentValue={showDiff}
onChange={(newValue) => setShowDiff(newValue)}
switchOnName={t("revisions.highlight_changes")}
switchOffName={t("revisions.highlight_changes")}
/>
)}
<div style="flex-grow: 1" />
{canInteract && (
<>
<ActionButton
icon="bx bx-trash"
text={t("revisions.delete_button")}
onClick={async () => {
if (await dialog.confirm(t("revisions.confirm_delete"))) {
await server.remove(`revisions/${revisionItem.revisionId}`);
toast.showMessage(t("revisions.revision_deleted"));
onRevisionDeleted?.();
}
}} frame />
<ActionButton
icon="bx bx-download"
text={t("revisions.download_button")}
onClick={() => {
if (revisionItem.revisionId) {
open.downloadRevision(revisionItem.noteId, revisionItem.revisionId);
}
}}
frame />
<Button
icon="bx bx-history"
text={t("revisions.restore_button")}
onClick={async () => {
if (await dialog.confirm(t("revisions.confirm_restore"))) {
await server.post(`revisions/${revisionItem.revisionId}/restore`);
setShown(false);
toast.showMessage(t("revisions.revision_restored"));
}
}}/>
</>
)}
</div>
)}
{revisionItem && (
<RevisionDescription
revisionItem={revisionItem}
editing={editingDescription}
draft={descriptionDraft}
onEdit={() => {
setDescriptionDraft(revisionItem.description || "");
setEditingDescription(true);
}}
onDraftChange={setDescriptionDraft}
onSave={async () => {
await server.patch(`revisions/${revisionItem.revisionId}`, { description: descriptionDraft });
setEditingDescription(false);
toast.showMessage(t("revisions.description_updated"));
onDescriptionUpdated?.(revisionItem.revisionId!, descriptionDraft);
}}
onCancel={() => setEditingDescription(false)}
/>
)}
</div>
);
}
function RevisionPreview({noteContent, revisionItem, showDiff }: {
noteContent?: string,
revisionItem?: RevisionItem,
showDiff: boolean,
setShown: Dispatch<StateUpdater<boolean>>,
onRevisionDeleted?: () => void
}) {
const [ fullRevision, setFullRevision ] = useState<RevisionPojo>();
@@ -174,54 +454,60 @@ function RevisionPreview({noteContent, revisionItem, showDiff, setShown, onRevis
}, [revisionItem]);
return (
<>
<div style="flex-grow: 0; display: flex; justify-content: space-between;">
<h3 className="revision-title" style="margin: 3px; flex-grow: 100;">{revisionItem?.title ?? t("revisions.no_revisions")}</h3>
{(revisionItem && <div className="revision-title-buttons">
{(!revisionItem.isProtected || protected_session_holder.isProtectedSessionAvailable()) &&
<>
<Button
icon="bx bx-history"
text={t("revisions.restore_button")}
onClick={async () => {
if (await dialog.confirm(t("revisions.confirm_restore"))) {
await server.post(`revisions/${revisionItem.revisionId}/restore`);
setShown(false);
toast.showMessage(t("revisions.revision_restored"));
}
}}/>
&nbsp;
<Button
icon="bx bx-trash"
text={t("revisions.delete_button")}
onClick={async () => {
if (await dialog.confirm(t("revisions.confirm_delete"))) {
await server.remove(`revisions/${revisionItem.revisionId}`);
toast.showMessage(t("revisions.revision_deleted"));
onRevisionDeleted?.();
}
}} />
&nbsp;
<Button
kind="primary"
icon="bx bx-download"
text={t("revisions.download_button")}
onClick={() => {
if (revisionItem.revisionId) {
open.downloadRevision(revisionItem.noteId, revisionItem.revisionId);}
}
}/>
</>
}
</div>)}
<div
className={clsx("revision-content use-tn-links selectable-text", `type-${revisionItem?.type}`)}
style={{ wordBreak: "break-word" }}
>
<h3 className="revision-title">{revisionItem?.title}</h3>
<RevisionContent noteContent={noteContent} revisionItem={revisionItem} fullRevision={fullRevision} showDiff={showDiff}/>
</div>
);
}
function RevisionDescription({ revisionItem, editing, draft, onEdit, onDraftChange, onSave, onCancel }: {
revisionItem: RevisionItem,
editing: boolean,
draft: string,
onEdit: () => void,
onDraftChange: (val: string) => void,
onSave: () => void,
onCancel: () => void
}) {
if (editing) {
return (
<div className="revision-description-editor">
<span className="bx bx-purchase-tag revision-description-icon" />
<input
type="text"
className="form-control form-control-sm"
placeholder={t("revisions.description_placeholder")}
value={draft}
onInput={(e) => onDraftChange((e.target as HTMLInputElement).value)}
onKeyDown={(e) => {
if (e.key === "Enter") onSave();
if (e.key === "Escape") onCancel();
}}
// eslint-disable-next-line jsx-a11y/no-autofocus
autoFocus
/>
<ActionButton icon="bx bx-check" text={t("common.save")} onClick={onSave} />
<ActionButton icon="bx bx-x" text={t("common.cancel")} onClick={onCancel} />
</div>
<div
className={clsx("revision-content use-tn-links selectable-text", `type-${revisionItem?.type}`)}
style={{ overflow: "auto", wordBreak: "break-word" }}
>
<RevisionContent noteContent={noteContent} revisionItem={revisionItem} fullRevision={fullRevision} showDiff={showDiff}/>
</div>
</>
);
}
return (
<div className="revision-description-display">
<span className="bx bx-purchase-tag revision-description-icon" />
<span className={clsx("revision-description-text", { empty: !revisionItem.description })}>
{revisionItem.description || t("revisions.description_placeholder")}
</span>
<ActionButton
icon="bx bx-edit-alt"
text={t("revisions.edit_description")}
onClick={onEdit}
/>
</div>
);
}
@@ -250,7 +536,7 @@ function RevisionContent({ noteContent, revisionItem, fullRevision, showDiff }:
case "text":
return <RevisionContentText content={content} />;
case "code":
return <pre style={CODE_STYLE}>{content}</pre>;
return <div className="revision-diff-code">{content}</div>;
case "image":
switch (revisionItem.mime) {
case "image/svg+xml": {
@@ -299,69 +585,33 @@ function RevisionContentDiff({ noteContent, itemContent, itemType }: {
itemContent: string | Buffer<ArrayBufferLike> | undefined,
itemType: string
}) {
const contentRef = useRef<HTMLDivElement>(null);
if (!noteContent || typeof itemContent !== "string") {
return <div className="revision-diff-content">{t("revisions.diff_not_available")}</div>;
}
useEffect(() => {
if (!noteContent || typeof itemContent !== "string") {
if (contentRef.current) {
contentRef.current.textContent = t("revisions.diff_not_available");
}
return;
}
let processedNoteContent = noteContent;
let processedItemContent = itemContent;
if (itemType === "text") {
processedNoteContent = utils.formatHtml(noteContent);
processedItemContent = utils.formatHtml(itemContent);
}
const diff = diffWords(processedNoteContent, processedItemContent);
const diffHtml = diff.map(part => {
let diffHtml: string;
if (itemType === "text") {
// Use proper HTML-aware diff for rich text content
diffHtml = HtmlDiff.execute(noteContent, itemContent);
} else {
// Use word diff for code/mermaid (plain text)
const diff = diffWords(noteContent, itemContent);
diffHtml = diff.map(part => {
if (part.added) {
return `<span class="revision-diff-added">${utils.escapeHtml(part.value)}</span>`;
} else if (part.removed) {
return `<span class="revision-diff-removed">${utils.escapeHtml(part.value)}</span>`;
}
return utils.escapeHtml(part.value);
}).join("");
}
if (contentRef.current) {
contentRef.current.innerHTML = diffHtml;
}
}, [noteContent, itemContent, itemType]);
return <div ref={contentRef} className="ck-content" style={{ whiteSpace: "pre-wrap" }} />;
return <SanitizedHtml
className={clsx("revision-diff-content", itemType === "text" ? "ck-content" : "revision-diff-code")}
html={diffHtml}
/>;
}
function RevisionFooter({ note }: { note?: FNote }) {
if (!note) {
return <></>;
}
let revisionsNumberLimit: number | string = parseInt(note?.getLabelValue("versioningLimit") ?? "", 10);
if (!Number.isInteger(revisionsNumberLimit)) {
revisionsNumberLimit = options.getInt("revisionSnapshotNumberLimit") ?? 0;
}
if (revisionsNumberLimit === -1) {
revisionsNumberLimit = "∞";
}
return <>
<span class="revisions-snapshot-interval flex-grow-1 my-0 py-0">
{t("revisions.snapshot_interval", { seconds: options.getInt("revisionSnapshotTimeInterval") })}
</span>
<span class="maximum-revisions-for-current-note flex-grow-1 my-0 py-0">
{t("revisions.maximum_revisions", { number: revisionsNumberLimit })}
</span>
<ActionButton
icon="bx bx-cog" text={t("revisions.settings")}
onClick={() => appContext.tabManager.openContextWithNote("_optionsOther", { activate: true })}
/>
</>;
}
function FilePreview({ revisionItem, fullRevision }: { revisionItem: RevisionItem, fullRevision: RevisionPojo }) {
return (

View File

@@ -77,9 +77,15 @@ export interface ModalProps {
* If true, the modal will not focus itself after becoming visible.
*/
noFocus?: boolean;
/**
* Content to display as a full-height sidebar on the left side of the modal.
* When set, the modal layout switches to a horizontal split with the sidebar
* spanning the entire height alongside the header, body and footer.
*/
sidebar?: ComponentChildren;
}
export default function Modal({ children, className, size, title, customTitleBarButtons: titleBarButtons, header, footer, footerStyle, footerAlignment, onShown, onSubmit, helpPageId, minWidth, maxWidth, zIndex, scrollable, onHidden, modalRef: externalModalRef, formRef, bodyStyle, show, stackable, keepInDom, noFocus }: ModalProps) {
export default function Modal({ children, className, size, title, customTitleBarButtons: titleBarButtons, header, footer, footerStyle, footerAlignment, onShown, onSubmit, helpPageId, minWidth, maxWidth, zIndex, scrollable, onHidden, modalRef: externalModalRef, formRef, bodyStyle, show, stackable, keepInDom, noFocus, sidebar }: ModalProps) {
const modalRef = useSyncedRef<HTMLDivElement>(externalModalRef);
const modalInstanceRef = useRef<BootstrapModal>();
const elementToFocus = useRef<Element | null>();
@@ -144,47 +150,62 @@ export default function Modal({ children, className, size, title, customTitleBar
return (
<div className={`modal fade mx-auto ${className}`} tabIndex={-1} style={dialogStyle} role="dialog" ref={modalRef}>
{(show || keepInDom) && <div className={`modal-dialog modal-${size} ${scrollable ? "modal-dialog-scrollable" : ""}`} style={documentStyle} role="document">
<div className="modal-content">
<div className="modal-header">
{!title || typeof title === "string" ? (
<h5 className="modal-title">{title ?? <>&nbsp;</>}</h5>
<div className={clsx("modal-content", sidebar && "modal-content-with-sidebar")}>
{sidebar && <div className="modal-sidebar">
{title && <div className="modal-sidebar-header">
<h5>{title}</h5>
</div>}
{sidebar}
</div>}
<ModalMain sidebar={!!sidebar}>
<div className="modal-header">
{!title || typeof title === "string" ? (
<h5 className="modal-title">{title ?? <>&nbsp;</>}</h5>
) : (
title
)}
{header}
{helpPageId && (
<button className="help-button" type="button" data-in-app-help={helpPageId} title={t("modal.help_title")}>?</button>
)}
{titleBarButtons?.filter((b) => b !== null).map((titleBarButton) => (
<button type="button"
className={clsx("custom-title-bar-button bx", titleBarButton.iconClassName)}
title={titleBarButton.title}
onClick={titleBarButton.onClick} />
))}
<button type="button" className="btn-close" data-bs-dismiss="modal" aria-label={t("modal.close")} />
</div>
{onSubmit ? (
<form ref={formRef} onSubmit={(e) => {
e.preventDefault();
onSubmit();
}}>
<ModalInner footer={footer} bodyStyle={bodyStyle} footerStyle={footerStyle} footerAlignment={footerAlignment}>{children}</ModalInner>
</form>
) : (
title
<ModalInner footer={footer} bodyStyle={bodyStyle} footerStyle={footerStyle} footerAlignment={footerAlignment}>
{children}
</ModalInner>
)}
{header}
{helpPageId && (
<button className="help-button" type="button" data-in-app-help={helpPageId} title={t("modal.help_title")}>?</button>
)}
{titleBarButtons?.filter((b) => b !== null).map((titleBarButton) => (
<button type="button"
className={clsx("custom-title-bar-button bx", titleBarButton.iconClassName)}
title={titleBarButton.title}
onClick={titleBarButton.onClick} />
))}
<button type="button" className="btn-close" data-bs-dismiss="modal" aria-label={t("modal.close")} />
</div>
{onSubmit ? (
<form ref={formRef} onSubmit={(e) => {
e.preventDefault();
onSubmit();
}}>
<ModalInner footer={footer} bodyStyle={bodyStyle} footerStyle={footerStyle} footerAlignment={footerAlignment}>{children}</ModalInner>
</form>
) : (
<ModalInner footer={footer} bodyStyle={bodyStyle} footerStyle={footerStyle} footerAlignment={footerAlignment}>
{children}
</ModalInner>
)}
</ModalMain>
</div>
</div>}
</div>
);
}
function ModalMain({ sidebar, children }: { sidebar: boolean; children: ComponentChildren }) {
if (sidebar) {
return <div className="modal-main">{children}</div>;
}
return <>{children}</>;
}
function ModalInner({ children, footer, footerAlignment, bodyStyle, footerStyle: _footerStyle }: Pick<ModalProps, "children" | "footer" | "footerAlignment" | "bodyStyle" | "footerStyle">) {
// Memoize footer style
const footerStyle = useMemo<CSSProperties>(() => {

View File

@@ -153,6 +153,7 @@ export function NoteContextMenu({ note, noteContext, itemsAtStart, itemsNearNote
<CommandItem command="showRevisions" icon="bx bx-history" text={t("note_actions.view_revisions")} />
<CommandItem command="forceSaveRevision" icon="bx bx-save" disabled={isInOptionsOrHelp} text={t("note_actions.save_revision")} />
<CommandItem command="saveNamedRevision" icon="bx bx-purchase-tag" disabled={isInOptionsOrHelp} text={t("note_actions.save_named_revision")} />
<FormDropdownDivider />

View File

@@ -1,5 +1,5 @@
import { Application } from "express";
import { beforeAll, describe, expect, it } from "vitest";
import { beforeAll, describe, it } from "vitest";
import supertest from "supertest";
import { createNote, login } from "./utils.js";
import config from "../../src/services/config.js";

View File

@@ -48,6 +48,8 @@ CREATE TABLE IF NOT EXISTS "revisions" (`revisionId` TEXT NOT NULL PRIMARY KEY,
type TEXT DEFAULT '' NOT NULL,
mime TEXT DEFAULT '' NOT NULL,
`title` TEXT NOT NULL,
`description` TEXT DEFAULT '' NOT NULL,
`source` TEXT DEFAULT 'auto' NOT NULL,
`isProtected` INT NOT NULL DEFAULT 0,
blobId TEXT DEFAULT NULL,
`utcDateLastEdited` TEXT NOT NULL,

Binary file not shown.

After

Width:  |  Height:  |  Size: 927 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 356 KiB

View File

@@ -1,13 +1,121 @@
<figure class="image">
<img style="aspect-ratio:2089/1515;" src="2_Note Revisions_image.png"
width="2089" height="1515">
</figure>
<p>Trilium supports seamless versioning of notes by storing snapshots ("revisions")
of notes at regular intervals.</p>
<h2>Note Revisions Snapshot Interval</h2>
<h2>Displaying the revisions</h2>
<ul>
<li>On the&nbsp;<a class="reference-link" href="#root/pOsGYCXsbNQG/gh7bpGYxajRS/Vc8PjrjAGuOp/_help_IjZS7iK5EXtb">New Layout</a>,
press the <a href="#root/pOsGYCXsbNQG/gh7bpGYxajRS/Vc8PjrjAGuOp/_help_8YBEPzcpUgxw">note context menu</a> and
select <em>Note revisions…</em>
</li>
<li>On the old layout, press directly the
<img class="image_resized" style="aspect-ratio:27/25;width:2.32%;"
src="1_Note Revisions_image.png" width="27" height="25">button in the&nbsp;<a class="reference-link" href="#root/pOsGYCXsbNQG/gh7bpGYxajRS/Vc8PjrjAGuOp/_help_8YBEPzcpUgxw">Note buttons</a>&nbsp;area.</li>
</ul>
<h2>Interaction</h2>
<aside class="admonition note">
<p>This documentation matches the redesign of the note revisions dialog on
v0.103.0, older versions have a similar dialog but with some differences.</p>
</aside>
<ul>
<li>The full list of revisions are displayed on the left in reverse chronological
order.
<ul>
<li>The revisions are grouped by the date the revision was taken.</li>
<li
>This list does not contain the <em>current state</em> of the note, so it
is possible to have notes with no revisions/snapshots saved.</li>
</ul>
</li>
<li>The icon of a revision indicates the <em>source</em> of that revision (e.g.
a
<img class="image_resized" style="aspect-ratio:20/21;width:2.49%;" src="Note Revisions_image.png"
width="20" height="21">icon for a manually saved revision).</li>
<li>Pressing the […] on the top-right of the dialog displays multiple options,
including:
<ul>
<li>Saving a new revision now.</li>
<li>Checking the interval and limit for this note (see below).</li>
<li>Deleting all the revisions of this note.</li>
</ul>
</li>
<li>For supported notes (text, code), changes are highlighted. This behavior
can be toggled via the <em>Highlight changes</em> at the top of the dialog.
<ul>
<li>The highlighted changes are relative to the <strong>current state of the note</strong>,
not to the revision prior to this one.</li>
</ul>
</li>
<li>For any given revision, the buttons on the top-right allow operating on
it:
<ul>
<li>Deleting the revision.</li>
<li>Downloading the revision locally.</li>
<li>Restoring the revision, which replaces the current content of the note
with the one from the revision. Another revision is saved containing the
current content of the note.</li>
</ul>
</li>
</ul>
<h2>Named revisions</h2>
<p>Named revisions are a new feature of Trilium v0.103.0 which allows adding
a short description of what the changes in the snapshot contain.</p>
<p>In the list of note revisions:</p>
<ul>
<li>The name of the revision is displayed underneath the time of the revision
in the sidebar, as well as at the top of the dialog where it is displayed
in full.</li>
<li>Clicking on the edit button near the name of the revision allows it to
be changed.</li>
</ul>
<p>To create a named revision, either:</p>
<ul>
<li>Go to the&nbsp;<a class="reference-link" href="#root/pOsGYCXsbNQG/gh7bpGYxajRS/Vc8PjrjAGuOp/_help_8YBEPzcpUgxw">Note buttons</a>,
select <em>Save named revision…</em>, enter the name of revision and confirm.</li>
<li
>Use the corresponding <a href="#root/pOsGYCXsbNQG/gh7bpGYxajRS/_help_A9Oc6YKKc65v">keyboard shortcut</a> or
the&nbsp;<a class="reference-link" href="#root/pOsGYCXsbNQG/gh7bpGYxajRS/wArbEsdSae6g/_help_F1r9QtzQLZqm">Jump to...</a>&nbsp;command
with the same name.</li>
<li>Save a revision normally, and adjust the name afterwards from the note
revision list.</li>
</ul>
<h2>When revisions are saved</h2>
<p>Revisions are saved:</p>
<ul>
<li>Automatically at a fixed interval. This behavior can be configured (see
below).</li>
<li>Manually, by:
<ul>
<li>Going to the press the <a href="#root/pOsGYCXsbNQG/gh7bpGYxajRS/Vc8PjrjAGuOp/_help_8YBEPzcpUgxw">note context menu</a> and
select <em>Save revision.</em>
</li>
<li>Using the <em>Force Save Revision</em> <a href="#root/pOsGYCXsbNQG/gh7bpGYxajRS/_help_A9Oc6YKKc65v">keyboard shortcut</a>.</li>
<li
>In the <em>Revisions</em> dialog, pressing the […] button in the top-right
and selecting <em>Save a revision now</em>.</li>
</ul>
</li>
</ul>
<p>Additionally, revisions can also come from somewhere else, and this is
indicated via the icon of the revision:</p>
<ul>
<li>Generated externally, by&nbsp;<a class="reference-link" href="#root/pOsGYCXsbNQG/tC7s2alapj8V/_help_pgxEVkzLl1OP">ETAPI (REST API)</a>.</li>
<li
>A modification created by&nbsp;<a class="reference-link" href="#root/pOsGYCXsbNQG/_help_GBBMSlVSOIGP">AI</a>.</li>
<li
>A revision is restored, causing the existing note content to be saved
as a revision to prevent potential data loss.</li>
</ul>
<h4>Snapshot interval</h4>
<p>Time interval of taking note snapshot is configurable in the Options -&gt;
Other dialog. This provides a tradeoff between more revisions and more
Other dialog. This provides a trade-off between more revisions and more
data to store.</p>
<p>To turn off note versioning for a particular note (or subtree), add
<p>To turn off note versioning for a particular note (or sub-tree), add
<code
spellcheck="false">disableVersioning</code> <a href="#root/_help_zEY4DaJG4YT5">label</a>to the note.</p>
<h2>Note Revision Snapshots Limit</h2>
spellcheck="false">disableVersioning</code> <a href="#root/_help_zEY4DaJG4YT5">label</a> to the note.</p>
<h4>Maximum revisions</h4>
<p>The limit on the number of note snapshots can be configured in the Options
-&gt; Other dialog. The note revision snapshot number limit refers to the
maximum number of revisions that can be saved for each note. Where -1 means
@@ -15,10 +123,5 @@
for a single note through the <code spellcheck="false">versioningLimit=X</code> label.</p>
<p>The note limit will not take effect immediately; it will only apply when
the note is modified.</p>
<p>You can click the <strong>Erase excess revision snapshots now</strong> button
to apply the changes immediately.</p>
<p>Note revisions can be accessed through the button on the right of ribbon
toolbar.</p>
<p>
<img src="Note Revisions_note-revisi.png">
</p>
<p>You can click the <em>Erase excess revision snapshots now</em> button to
apply the changes immediately.</p>

Binary file not shown.

After

Width:  |  Height:  |  Size: 548 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 140 KiB

View File

@@ -32,38 +32,46 @@
<p>The following features are supported by Trilium's Markdown format and
will show up in the preview pane:</p>
<ul>
<li>All standard and GitHub-flavored syntax (basic formatting, tables, blockquotes)</li>
<li
>Code blocks with syntax highlight (e.g. <code spellcheck="false">```js</code>)
and automatic syntax highlight</li>
<li><a class="reference-link" href="#root/_help_NwBbFdNZ9h7O">Block quotes &amp; admonitions</a>
</li>
<li><a class="reference-link" href="#root/_help_YfYAtQBcfo5V">Math Equations</a>
</li>
<li><a class="reference-link" href="#root/_help_s1aBHPd79XYj">Mermaid Diagrams</a>&nbsp;using
<li>
<p>All standard and GitHub-flavored syntax (basic formatting, tables, blockquotes)</p>
</li>
<li>
<p>Code blocks with syntax highlight (e.g. <code spellcheck="false">```js</code>)
and automatic syntax highlight</p>
</li>
<li>
<p><a class="reference-link" href="#root/_help_NwBbFdNZ9h7O">Block quotes &amp; admonitions</a>
</p>
</li>
<li>
<p><a class="reference-link" href="#root/_help_YfYAtQBcfo5V">Math Equations</a>
</p>
</li>
<li>
<p><a class="reference-link" href="#root/_help_s1aBHPd79XYj">Mermaid Diagrams</a>&nbsp;using
<code
spellcheck="false">```mermaid</code>
</li>
<li>
<p><a class="reference-link" href="#root/_help_nBAXQFj20hS1">Include Note</a>&nbsp;(no
builtin Markdown syntax, but HTML syntax works just fine):</p><pre><code class="language-text-x-trilium-auto">&lt;section class="include-note" data-note-id="vJDjQm0VK8Na" data-box-size="expandable"&gt;
</p>
</li>
<li>
<p><a class="reference-link" href="#root/_help_nBAXQFj20hS1">Include Note</a>&nbsp;(no
builtin Markdown syntax, but HTML syntax works just fine):</p><pre><code class="language-text-x-trilium-auto">&lt;section class="include-note" data-note-id="vJDjQm0VK8Na" data-box-size="expandable"&gt;
&amp;nbsp;
&lt;/section&gt;n</code></pre>
</li>
<li>
<p><a class="reference-link" href="#root/_help_hrZ1D00cLbal">Internal (reference) links</a>&nbsp;via
its HTML syntax, or through a <em>Wikilinks</em>-like format (only&nbsp;
<a
class="reference-link" href="#root/_help_m1lbrzyKDaRB">Note ID</a>):</p><pre><code class="language-text-x-trilium-auto">[[Hg8TS5ZOxti6]]</code></pre>
</li>
</li>
<li>
<p><a class="reference-link" href="#root/_help_hrZ1D00cLbal">Internal (reference) links</a>&nbsp;via
its HTML syntax, or through a <em>Wikilinks</em>-like format (only&nbsp;
<a
class="reference-link" href="#root/_help_m1lbrzyKDaRB">Note ID</a>):</p><pre><code class="language-text-x-trilium-auto">[[Hg8TS5ZOxti6]]</code></pre>
</li>
</ul>
<h2>Creating Markdown notes</h2>
<p>There are two ways to create a Markdown note:</p>
<ol>
<li>Create a new note (e.g. in the&nbsp;<a class="reference-link" href="#root/_help_oPVyFC7WL2Lp">Note Tree</a>)
and select the type <em>Markdown</em>, just like all the other note types.</li>
<li
>Create a note of type&nbsp;<a class="reference-link" href="#root/_help_6f9hih2hXXZk">Code</a>&nbsp;and
<li>Create a note of type&nbsp;<a class="reference-link" href="#root/_help_6f9hih2hXXZk">Code</a>&nbsp;and
select as the language either <em>Markdown</em> or <em>GitHub-Flavored Markdown</em>.
This maintains compatibility with your existing notes prior to the introduction
of this feature.</li>

View File

@@ -100,6 +100,7 @@
"reset-zoom-level": "Reset zoom level",
"copy-without-formatting": "Copy selected text without formatting",
"force-save-revision": "Force creating / saving new note revision of the active note",
"save-named-revision": "Save a named revision of the active note",
"toggle-book-properties": "Toggle Collection Properties",
"toggle-classic-editor-toolbar": "Toggle the Formatting tab for the editor with fixed toolbar",
"export-as-pdf": "Export the current note as a PDF",
@@ -200,7 +201,8 @@
"zoom-in": "Zoom In",
"reset-zoom-level": "Reset Zoom Level",
"copy-without-formatting": "Copy Without Formatting",
"force-save-revision": "Force Save Revision"
"force-save-revision": "Force Save Revision",
"save-named-revision": "Save Named Revision"
},
"login": {
"title": "Login",

View File

@@ -1,4 +1,4 @@
import type { AttachmentRow, AttributeType, CloneResponse, NoteRow, NoteType, RevisionRow } from "@triliumnext/commons";
import type { AttachmentRow, AttributeType, CloneResponse, NoteRow, NoteType, RevisionRow, RevisionSource } from "@triliumnext/commons";
import { dayjs, getNoteIcon } from "@triliumnext/commons";
import cloningService from "../../services/cloning.js";
@@ -1543,7 +1543,7 @@ class BNote extends AbstractBeccaEntity<BNote> {
return !(this.noteId in this.becca.notes) || this.isBeingDeleted;
}
saveRevision(): BRevision {
saveRevision(opts: { description?: string; source?: RevisionSource } = {}): BRevision {
return sql.transactional(() => {
let noteContent = this.getContent();
@@ -1552,6 +1552,8 @@ class BNote extends AbstractBeccaEntity<BNote> {
noteId: this.noteId,
// title and text should be decrypted now
title: this.title,
description: opts.description || "",
source: opts.source || "auto",
type: this.type,
mime: this.mime,
isProtected: this.isProtected,

View File

@@ -7,7 +7,7 @@ import becca from "../becca.js";
import AbstractBeccaEntity from "./abstract_becca_entity.js";
import sql from "../../services/sql.js";
import BAttachment from "./battachment.js";
import type { AttachmentRow, NoteType, RevisionPojo, RevisionRow } from "@triliumnext/commons";
import type { AttachmentRow, NoteType, RevisionPojo, RevisionRow, RevisionSource } from "@triliumnext/commons";
import eraseService from "../../services/erase.js";
interface ContentOpts {
@@ -31,7 +31,7 @@ class BRevision extends AbstractBeccaEntity<BRevision> {
return "revisionId";
}
static get hashedProperties() {
return ["revisionId", "noteId", "title", "isProtected", "dateLastEdited", "dateCreated", "utcDateLastEdited", "utcDateCreated", "utcDateModified", "blobId"];
return ["revisionId", "noteId", "title", "description", "source", "isProtected", "dateLastEdited", "dateCreated", "utcDateLastEdited", "utcDateCreated", "utcDateModified", "blobId"];
}
revisionId?: string;
@@ -39,6 +39,8 @@ class BRevision extends AbstractBeccaEntity<BRevision> {
type!: NoteType;
mime!: string;
title!: string;
description!: string;
source!: RevisionSource;
dateLastEdited?: string;
utcDateLastEdited?: string;
contentLength?: number;
@@ -61,6 +63,8 @@ class BRevision extends AbstractBeccaEntity<BRevision> {
this.mime = row.mime;
this.isProtected = !!row.isProtected;
this.title = row.title;
this.description = row.description || "";
this.source = row.source || "auto";
this.blobId = row.blobId;
this.dateLastEdited = row.dateLastEdited;
this.dateCreated = row.dateCreated;
@@ -193,6 +197,8 @@ class BRevision extends AbstractBeccaEntity<BRevision> {
mime: this.mime,
isProtected: this.isProtected,
title: this.title,
description: this.description,
source: this.source,
blobId: this.blobId,
dateLastEdited: this.dateLastEdited,
dateCreated: this.dateCreated,

View File

@@ -73,6 +73,8 @@ function mapRevisionToPojo(revision: BRevision) {
mime: revision.mime,
isProtected: revision.isProtected,
title: revision.title,
description: revision.description,
source: revision.source,
blobId: revision.blobId,
dateLastEdited: revision.dateLastEdited,
dateCreated: revision.dateCreated,

View File

@@ -192,7 +192,8 @@ function register(router: Router) {
eu.route<{ noteId: string }>(router, "post", "/etapi/notes/:noteId/revision", (req, res, next) => {
const note = eu.getAndCheckNote(req.params.noteId);
note.saveRevision();
const description = typeof req.body?.description === "string" ? req.body.description : "";
note.saveRevision({ description, source: "etapi" });
return res.sendStatus(204);
});

View File

@@ -6,6 +6,15 @@
// Migrations should be kept in descending order, so the latest migration is first.
const MIGRATIONS: (SqlMigration | JsMigration)[] = [
// Add description column to revisions table for manual revision comments
{
version: 238,
sql: /*sql*/`
ALTER TABLE revisions ADD COLUMN description TEXT DEFAULT '' NOT NULL;
ALTER TABLE revisions ADD COLUMN source TEXT DEFAULT 'auto' NOT NULL;
`,
ignoreErrors: true
},
// Clean up obsolete keyboard shortcut options from renamed actions
{
version: 237,

View File

@@ -351,7 +351,12 @@ function forceSaveRevision(req: Request<{ noteId: string }>) {
throw new ValidationError(`Note revision of a protected note cannot be created outside of a protected session.`);
}
note.saveRevision();
const description = typeof req.body?.description === "string" ? req.body.description : "";
const revision = note.saveRevision({ description, source: "manual" });
return {
revisionId: revision.revisionId
};
}
function convertNoteToAttachment(req: Request<{ noteId: string }>) {

View File

@@ -111,6 +111,18 @@ function eraseRevision(req: Request<{ revisionId: string }>) {
eraseService.eraseRevisions([req.params.revisionId]);
}
function updateRevisionDescription(req: Request<{ revisionId: string }>) {
const revision = becca.getRevisionOrThrow(req.params.revisionId);
const { description } = req.body;
if (typeof description !== "string") {
return [400, "Description must be a string."];
}
revision.description = description;
revision.save();
}
function eraseAllExcessRevisions() {
const allNoteIds = sql.getRows("SELECT noteId FROM notes WHERE SUBSTRING(noteId, 1, 1) != '_'") as { noteId: string }[];
allNoteIds.forEach((row) => {
@@ -125,7 +137,7 @@ function restoreRevision(req: Request<{ revisionId: string }>) {
const note = revision.getNote();
sql.transactional(() => {
note.saveRevision();
note.saveRevision({ source: "restore" });
for (const oldNoteAttachment of note.getAttachments()) {
oldNoteAttachment.markAsDeleted();
@@ -222,5 +234,6 @@ export default {
eraseAllRevisions,
eraseAllExcessRevisions,
eraseRevision,
restoreRevision
restoreRevision,
updateRevisionDescription
};

View File

@@ -186,6 +186,7 @@ function register(app: express.Application) {
apiRoute(GET, "/api/revisions/:revisionId", revisionsApiRoute.getRevision);
apiRoute(GET, "/api/revisions/:revisionId/blob", revisionsApiRoute.getRevisionBlob);
apiRoute(DEL, "/api/revisions/:revisionId", revisionsApiRoute.eraseRevision);
apiRoute(PATCH, "/api/revisions/:revisionId", revisionsApiRoute.updateRevisionDescription);
apiRoute(PST, "/api/revisions/:revisionId/restore", revisionsApiRoute.restoreRevision);
route(GET, "/api/revisions/:revisionId/image/:filename", [auth.checkApiAuthOrElectron], imageRoute.returnImageFromRevision);

View File

@@ -828,6 +828,14 @@ function getDefaultKeyboardActions() {
defaultShortcuts: [],
description: t("keyboard_actions.force-save-revision"),
scope: "window"
},
{
actionName: "saveNamedRevision",
friendlyName: t("keyboard_action_names.save-named-revision"),
iconClass: "bx bx-purchase-tag",
defaultShortcuts: [],
description: t("keyboard_actions.save-named-revision"),
scope: "window"
}
];

View File

@@ -116,7 +116,7 @@ export const noteTools = defineTools({
return { error: `Cannot update content for note type: ${note.type}` };
}
note.saveRevision();
note.saveRevision({ source: "llm" });
setNoteContentFromLlm(note, content);
return {
success: true,
@@ -158,7 +158,7 @@ export const noteTools = defineTools({
newContent = existingContent + (existingContent.endsWith("\n") ? "" : "\n") + content;
}
note.saveRevision();
note.saveRevision({ source: "llm" });
note.setContent(newContent);
return {
success: true,

View File

@@ -1,5 +1,5 @@
# Documentation
There are multiple types of documentation for Trilium:<img class="image-style-align-right" src="api/images/BZVD2exxpGnn/Documentation_image.png" width="205" height="162">
There are multiple types of documentation for Trilium:<img class="image-style-align-right" src="api/images/88cjtiwxfR49/Documentation_image.png" width="205" height="162">
* The _User Guide_ represents the user-facing documentation. This documentation can be browsed by users directly from within Trilium, by pressing <kbd>F1</kbd>.
* The _Developer's Guide_ represents a set of Markdown documents that present the internals of Trilium, for developers.

View File

@@ -4539,18 +4539,76 @@
"value": "bx bx-history",
"isInheritable": false,
"position": 30
},
{
"type": "relation",
"name": "internalLink",
"value": "IjZS7iK5EXtb",
"isInheritable": false,
"position": 40
},
{
"type": "relation",
"name": "internalLink",
"value": "8YBEPzcpUgxw",
"isInheritable": false,
"position": 50
},
{
"type": "relation",
"name": "internalLink",
"value": "pgxEVkzLl1OP",
"isInheritable": false,
"position": 70
},
{
"type": "relation",
"name": "internalLink",
"value": "GBBMSlVSOIGP",
"isInheritable": false,
"position": 80
},
{
"type": "relation",
"name": "internalLink",
"value": "A9Oc6YKKc65v",
"isInheritable": false,
"position": 90
},
{
"type": "relation",
"name": "internalLink",
"value": "F1r9QtzQLZqm",
"isInheritable": false,
"position": 100
}
],
"format": "markdown",
"dataFileName": "Note Revisions.md",
"attachments": [
{
"attachmentId": "1TA1nUFZzprY",
"title": "note-revisions.png",
"attachmentId": "BHquVQR30ess",
"title": "image.png",
"role": "image",
"mime": "image/png",
"position": 10,
"dataFileName": "Note Revisions_note-revisi.png"
"dataFileName": "Note Revisions_image.png"
},
{
"attachmentId": "eoYsKZfMMvlg",
"title": "image.png",
"role": "image",
"mime": "image/png",
"position": 10,
"dataFileName": "1_Note Revisions_image.png"
},
{
"attachmentId": "w1kmtyCISdjQ",
"title": "image.png",
"role": "image",
"mime": "image/png",
"position": 10,
"dataFileName": "2_Note Revisions_image.png"
}
]
},

Binary file not shown.

After

Width:  |  Height:  |  Size: 927 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 356 KiB

View File

@@ -1,20 +1,74 @@
# Note Revisions
<figure class="image"><img style="aspect-ratio:2089/1515;" src="2_Note Revisions_image.png" width="2089" height="1515"></figure>
Trilium supports seamless versioning of notes by storing snapshots ("revisions") of notes at regular intervals.
## Note Revisions Snapshot Interval
## Displaying the revisions
Time interval of taking note snapshot is configurable in the Options -> Other dialog. This provides a tradeoff between more revisions and more data to store.
* On the <a class="reference-link" href="../UI%20Elements/New%20Layout.md">New Layout</a>, press the [note context menu](../UI%20Elements/Note%20buttons.md) and select _Note revisions…_
* On the old layout, press directly the <img class="image_resized" style="aspect-ratio:27/25;width:2.32%;" src="1_Note Revisions_image.png" width="27" height="25"> button in the <a class="reference-link" href="../UI%20Elements/Note%20buttons.md">Note buttons</a> area.
To turn off note versioning for a particular note (or subtree), add `disableVersioning` [label](../../Advanced%20Usage/Attributes.md)to the note.
## Interaction
## Note Revision Snapshots Limit
> [!NOTE]
> This documentation matches the redesign of the note revisions dialog on v0.103.0, older versions have a similar dialog but with some differences.
* The full list of revisions are displayed on the left in reverse chronological order.
* The revisions are grouped by the date the revision was taken.
* This list does not contain the _current state_ of the note, so it is possible to have notes with no revisions/snapshots saved.
* The icon of a revision indicates the _source_ of that revision (e.g. a <img class="image_resized" style="aspect-ratio:20/21;width:2.49%;" src="Note Revisions_image.png" width="20" height="21"> icon for a manually saved revision).
* Pressing the \[…\] on the top-right of the dialog displays multiple options, including:
* Saving a new revision now.
* Checking the interval and limit for this note (see below).
* Deleting all the revisions of this note.
* For supported notes (text, code), changes are highlighted. This behavior can be toggled via the _Highlight changes_ at the top of the dialog.
* The highlighted changes are relative to the **current state of the note**, not to the revision prior to this one.
* For any given revision, the buttons on the top-right allow operating on it:
* Deleting the revision.
* Downloading the revision locally.
* Restoring the revision, which replaces the current content of the note with the one from the revision. Another revision is saved containing the current content of the note.
## Named revisions
Named revisions are a new feature of Trilium v0.103.0 which allows adding a short description of what the changes in the snapshot contain.
In the list of note revisions:
* The name of the revision is displayed underneath the time of the revision in the sidebar, as well as at the top of the dialog where it is displayed in full.
* Clicking on the edit button near the name of the revision allows it to be changed.
To create a named revision, either:
* Go to the <a class="reference-link" href="../UI%20Elements/Note%20buttons.md">Note buttons</a>, select _Save named revision…_, enter the name of revision and confirm.
* Use the corresponding [keyboard shortcut](../Keyboard%20Shortcuts.md) or the <a class="reference-link" href="../Navigation/Jump%20to.md">Jump to...</a> command with the same name.
* Save a revision normally, and adjust the name afterwards from the note revision list.
## When revisions are saved
Revisions are saved:
* Automatically at a fixed interval. This behavior can be configured (see below).
* Manually, by:
* Going to the press the [note context menu](../UI%20Elements/Note%20buttons.md) and select _Save revision._
* Using the _Force Save Revision_ [keyboard shortcut](../Keyboard%20Shortcuts.md).
* In the _Revisions_ dialog, pressing the \[…\] button in the top-right and selecting _Save a revision now_.
Additionally, revisions can also come from somewhere else, and this is indicated via the icon of the revision:
* Generated externally, by <a class="reference-link" href="../../Advanced%20Usage/ETAPI%20(REST%20API).md">ETAPI (REST API)</a>.
* A modification created by <a class="reference-link" href="../../AI.md">AI</a>.
* A revision is restored, causing the existing note content to be saved as a revision to prevent potential data loss.
#### Snapshot interval
Time interval of taking note snapshot is configurable in the Options -> Other dialog. This provides a trade-off between more revisions and more data to store.
To turn off note versioning for a particular note (or sub-tree), add `disableVersioning` [label](../../Advanced%20Usage/Attributes.md) to the note.
#### Maximum revisions
The limit on the number of note snapshots can be configured in the Options -> Other dialog. 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=X` label.
The note limit will not take effect immediately; it will only apply when the note is modified.
You can click the **Erase excess revision snapshots now** button to apply the changes immediately.
Note revisions can be accessed through the button on the right of ribbon toolbar.
![](Note%20Revisions_note-revisi.png)
You can click the _Erase excess revision snapshots now_ button to apply the changes immediately.

Binary file not shown.

After

Width:  |  Height:  |  Size: 548 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 140 KiB

View File

@@ -7,6 +7,7 @@ import "dayjs/plugin/isoWeek";
import "dayjs/plugin/isSameOrAfter";
import "dayjs/plugin/isSameOrBefore";
import "dayjs/plugin/quarterOfYear";
import "dayjs/plugin/relativeTime";
import "dayjs/plugin/utc";
//#region Plugins
@@ -17,6 +18,7 @@ import isoWeek from "dayjs/plugin/isoWeek.js";
import isSameOrAfter from "dayjs/plugin/isSameOrAfter.js";
import isSameOrBefore from "dayjs/plugin/isSameOrBefore.js";
import quarterOfYear from "dayjs/plugin/quarterOfYear.js";
import relativeTime from "dayjs/plugin/relativeTime.js";
import utc from "dayjs/plugin/utc.js";
import { DISPLAYABLE_LOCALE_IDS, LOCALE_IDS } from "./i18n.js";
@@ -27,6 +29,7 @@ dayjs.extend(isoWeek);
dayjs.extend(isSameOrAfter);
dayjs.extend(isSameOrBefore);
dayjs.extend(quarterOfYear);
dayjs.extend(relativeTime);
dayjs.extend(utc);
//#endregion

View File

@@ -94,7 +94,8 @@ const enum KeyboardActionNamesEnum {
zoomIn,
zoomReset,
copyWithoutFormatting,
forceSaveRevision
forceSaveRevision,
saveNamedRevision
}
export type KeyboardActionNames = keyof typeof KeyboardActionNamesEnum;

View File

@@ -21,6 +21,9 @@ export interface AttachmentRow {
encoding?: "base64";
}
export const REVISION_SOURCES = ["auto", "manual", "etapi", "llm", "restore"] as const;
export type RevisionSource = (typeof REVISION_SOURCES)[number];
export interface RevisionRow {
revisionId?: string;
noteId: string;
@@ -28,6 +31,8 @@ export interface RevisionRow {
mime: string;
isProtected?: boolean;
title: string;
description?: string;
source?: RevisionSource;
blobId?: string;
dateLastEdited?: string;
dateCreated?: string;

View File

@@ -1,4 +1,4 @@
import { AttachmentRow, AttributeRow, BranchRow, NoteRow, NoteType } from "./rows.js";
import { AttachmentRow, AttributeRow, BranchRow, NoteRow, NoteType, RevisionSource } from "./rows.js";
type Response = {
success: true,
@@ -33,6 +33,8 @@ export interface RevisionItem {
contentLength?: number;
type: NoteType;
title: string;
description?: string;
source?: RevisionSource;
isProtected?: boolean;
mime: string;
}
@@ -44,6 +46,8 @@ export interface RevisionPojo {
mime: string;
isProtected?: boolean;
title: string;
description?: string;
source?: RevisionSource;
blobId?: string;
dateLastEdited?: string;
dateCreated?: string;

8
pnpm-lock.yaml generated
View File

@@ -315,6 +315,9 @@ importers:
force-graph:
specifier: 1.51.2
version: 1.51.2
htmldiff-js:
specifier: 1.0.5
version: 1.0.5
i18next:
specifier: 26.0.4
version: 26.0.4(typescript@6.0.2)
@@ -9406,6 +9409,9 @@ packages:
resolution: {integrity: sha512-lw/7YsdKiP3kk5PnR1INY17iJuzdAtJewxr14ozKJWbbR97znovZ0mh+WEMZ8rjc3lgTK+ID/htTjuyGKB52Kw==}
hasBin: true
htmldiff-js@1.0.5:
resolution: {integrity: sha512-rmow9353OK0elkub15Sbze8Nj7BYfduqoJJw4yEvHHjOcHeCazNPk0PoUbjE8SvxKgjymeRIFU/OnS8jtitRtA==}
htmlfy@0.8.1:
resolution: {integrity: sha512-xWROBw9+MEGwxpotll0h672KCaLrKKiCYzsyN8ZgL9cQbVumFnyvsk2JqiB9ELAV1GLj1GG/jxZUjV9OZZi/yQ==}
@@ -24993,6 +24999,8 @@ snapshots:
dependencies:
concat-stream: 1.6.2
htmldiff-js@1.0.5: {}
htmlfy@0.8.1: {}
htmlparser2@10.0.0: