mirror of
https://github.com/zadam/trilium.git
synced 2026-05-06 10:46:57 +02:00
feat(revisions): add a description field
This commit is contained in:
@@ -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...",
|
||||
|
||||
@@ -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")}
|
||||
/>
|
||||
)}
|
||||
|
||||
<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")}
|
||||
/>
|
||||
)}
|
||||
|
||||
<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%",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 }>) {
|
||||
|
||||
@@ -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
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -28,6 +28,7 @@ export interface RevisionRow {
|
||||
mime: string;
|
||||
isProtected?: boolean;
|
||||
title: string;
|
||||
description?: string;
|
||||
blobId?: string;
|
||||
dateLastEdited?: string;
|
||||
dateCreated?: string;
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user