feat(revisions): add a description field

This commit is contained in:
Elian Doran
2026-04-18 12:40:33 +03:00
parent a439e7c29b
commit 8aaa4d7bde
13 changed files with 206 additions and 38 deletions

View File

@@ -304,7 +304,13 @@
"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": "Add a description (optional)",
"revision_saved": "Note revision has been saved.",
"edit_description": "Edit description",
"description_updated": "Revision description has been updated."
},
"sort_child_notes": {
"sort_children_by": "Sort children by...",

View File

@@ -66,36 +66,47 @@ export default function RevisionsDialog() {
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"));
}
<>
{note && (
<SaveRevisionButton
noteId={note.noteId}
onSaved={() => {
setRefreshCounter(c => c + 1);
setCurrentRevision(undefined);
}}
/>
</>
)
)}
{!!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"));
}
}}
/>
</>
)}
</>
}
footer={<RevisionFooter note={note} />}
footerStyle={{ paddingTop: 0, paddingBottom: 0 }}
@@ -135,12 +146,50 @@ export default function RevisionsDialog() {
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 });
}
}} />
</div>
</Modal>
);
}
function SaveRevisionButton({ noteId, onSaved }: { noteId: string, onSaved: () => void }) {
const [ description, setDescription ] = useState("");
return (
<div style={{ display: "flex", gap: "5px", alignItems: "center", marginInlineEnd: "10px" }}>
<input
type="text"
className="form-control form-control-sm"
placeholder={t("revisions.description_placeholder")}
value={description}
onInput={(e) => setDescription((e.target as HTMLInputElement).value)}
style={{ width: "200px" }}
/>
<Button
icon="bx bx-save"
text={t("revisions.save_revision")}
title={t("revisions.save_revision_tooltip")}
size="small"
style={{ padding: "0 10px" }}
onClick={async () => {
await server.post(`notes/${noteId}/revision`, { description });
setDescription("");
toast.showMessage(t("revisions.revision_saved"));
onSaved();
}}
/>
</div>
);
}
function RevisionsList({ revisions, onSelect, currentRevision }: { revisions: RevisionItem[], onSelect: (val: string) => void, currentRevision?: RevisionItem }) {
return (
<FormList onSelect={onSelect} fullHeight wrapperClassName="revision-list">
@@ -150,20 +199,30 @@ function RevisionsList({ revisions, onSelect, currentRevision }: { revisions: Re
value={item.revisionId}
active={currentRevision && item.revisionId === currentRevision.revisionId}
>
{item.dateCreated && item.dateCreated.substr(0, 16)} ({item.contentLength && utils.formatSize(item.contentLength)})
<div>
{item.dateCreated && item.dateCreated.substr(0, 16)} ({item.contentLength && utils.formatSize(item.contentLength)})
{item.description && (
<div className="revision-description" style={{ fontSize: "0.85em", opacity: 0.7, whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>
{item.description}
</div>
)}
</div>
</FormListItem>
)}
</FormList>);
}
function RevisionPreview({noteContent, revisionItem, showDiff, setShown, onRevisionDeleted }: {
function RevisionPreview({noteContent, revisionItem, showDiff, setShown, onRevisionDeleted, onDescriptionUpdated }: {
noteContent?: string,
revisionItem?: RevisionItem,
showDiff: boolean,
setShown: Dispatch<StateUpdater<boolean>>,
onRevisionDeleted?: () => void
onRevisionDeleted?: () => void,
onDescriptionUpdated?: (revisionId: string, description: string) => void
}) {
const [ fullRevision, setFullRevision ] = useState<RevisionPojo>();
const [ editingDescription, setEditingDescription ] = useState(false);
const [ descriptionDraft, setDescriptionDraft ] = useState("");
useEffect(() => {
if (revisionItem) {
@@ -171,6 +230,7 @@ function RevisionPreview({noteContent, revisionItem, showDiff, setShown, onRevis
} else {
setFullRevision(undefined);
}
setEditingDescription(false);
}, [revisionItem]);
return (
@@ -215,6 +275,25 @@ function RevisionPreview({noteContent, revisionItem, showDiff, setShown, onRevis
}
</div>)}
</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
className={clsx("revision-content use-tn-links selectable-text", `type-${revisionItem?.type}`)}
style={{ overflow: "auto", wordBreak: "break-word" }}
@@ -225,6 +304,52 @@ function RevisionPreview({noteContent, revisionItem, showDiff, setShown, onRevis
);
}
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 style={{ display: "flex", gap: "5px", alignItems: "center", margin: "3px 0" }}>
<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
style={{ flexGrow: 1 }}
/>
<ActionButton icon="bx bx-check" text={t("revisions.edit_description")} onClick={onSave} />
<ActionButton icon="bx bx-x" text={t("revisions.edit_description")} onClick={onCancel} />
</div>
);
}
return (
<div style={{ display: "flex", alignItems: "center", margin: "3px 0", gap: "5px", minHeight: "24px" }}>
<span style={{ opacity: revisionItem.description ? 1 : 0.5, fontStyle: revisionItem.description ? "normal" : "italic", fontSize: "0.9em" }}>
{revisionItem.description || t("revisions.description_placeholder")}
</span>
<ActionButton
icon="bx bx-edit-alt"
text={t("revisions.edit_description")}
onClick={onEdit}
/>
</div>
);
}
const IMAGE_STYLE: CSSProperties = {
maxWidth: "100%",
maxHeight: "90%",

View File

@@ -48,6 +48,7 @@ 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,
`isProtected` INT NOT NULL DEFAULT 0,
blobId TEXT DEFAULT NULL,
`utcDateLastEdited` TEXT NOT NULL,

View File

@@ -1543,7 +1543,7 @@ class BNote extends AbstractBeccaEntity<BNote> {
return !(this.noteId in this.becca.notes) || this.isBeingDeleted;
}
saveRevision(): BRevision {
saveRevision(description?: string): BRevision {
return sql.transactional(() => {
let noteContent = this.getContent();
@@ -1552,6 +1552,7 @@ class BNote extends AbstractBeccaEntity<BNote> {
noteId: this.noteId,
// title and text should be decrypted now
title: this.title,
description: description || "",
type: this.type,
mime: this.mime,
isProtected: this.isProtected,

View File

@@ -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", "isProtected", "dateLastEdited", "dateCreated", "utcDateLastEdited", "utcDateCreated", "utcDateModified", "blobId"];
}
revisionId?: string;
@@ -39,6 +39,7 @@ class BRevision extends AbstractBeccaEntity<BRevision> {
type!: NoteType;
mime!: string;
title!: string;
description!: string;
dateLastEdited?: string;
utcDateLastEdited?: string;
contentLength?: number;
@@ -61,6 +62,7 @@ class BRevision extends AbstractBeccaEntity<BRevision> {
this.mime = row.mime;
this.isProtected = !!row.isProtected;
this.title = row.title;
this.description = row.description || "";
this.blobId = row.blobId;
this.dateLastEdited = row.dateLastEdited;
this.dateCreated = row.dateCreated;
@@ -193,6 +195,7 @@ class BRevision extends AbstractBeccaEntity<BRevision> {
mime: this.mime,
isProtected: this.isProtected,
title: this.title,
description: this.description,
blobId: this.blobId,
dateLastEdited: this.dateLastEdited,
dateCreated: this.dateCreated,

View File

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

View File

@@ -192,9 +192,10 @@ 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 = req.body?.description || "";
const revision = note.saveRevision(description);
return res.sendStatus(204);
res.status(201).json(mappers.mapRevisionToPojo(revision));
});
eu.route<{ noteId: string }>(router, "get", "/etapi/notes/:noteId/attachments", (req, res, next) => {

View File

@@ -6,6 +6,14 @@
// 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;
`,
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 = req.body?.description || "";
const revision = note.saveRevision(description);
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) => {
@@ -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

@@ -28,6 +28,7 @@ export interface RevisionRow {
mime: string;
isProtected?: boolean;
title: string;
description?: string;
blobId?: string;
dateLastEdited?: string;
dateCreated?: string;

View File

@@ -33,6 +33,7 @@ export interface RevisionItem {
contentLength?: number;
type: NoteType;
title: string;
description?: string;
isProtected?: boolean;
mime: string;
}
@@ -44,6 +45,7 @@ export interface RevisionPojo {
mime: string;
isProtected?: boolean;
title: string;
description?: string;
blobId?: string;
dateLastEdited?: string;
dateCreated?: string;