M6: version history — note revisions + restore
A note's title+body is snapshotted on each edit that changes either, so an accidental overwrite can be viewed and restored (task 1906). Underwrites 'dump freely, nothing is lost'. Backend: note_revisions table (migration 0014) + NoteRevision model; update_note records a revision of the PRE-edit state whenever title/body changes; GET /api/notes/<id>/revisions (newest 50) and POST /api/notes/<id>/revisions/<rev_id>/restore (snapshots the current state first so restore is itself undoable, then applies the revision with the usual title/body ripple — display name, links, #tags, backlinks). Title+body only in v1. Frontend: a History toggle in the modal editor opens a panel of past versions (timestamp + preview) with per-row Restore. Store gains fetchRevisions/restoreRevision. Migration 0014 runs on deploy; DB behavior operator-verified (no Postgres CI lane). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FRgehjoz7Yv8LkUfADxACm
This commit is contained in:
@@ -8,7 +8,7 @@ import Icon from "./Icon.vue";
|
||||
import LabelPicker from "./LabelPicker.vue";
|
||||
import NoteChecklist from "./NoteChecklist.vue";
|
||||
import { fromLocalInput, toLocalInput } from "../notes/datetime";
|
||||
import type { Note, NoteLabel } from "../stores/notes";
|
||||
import type { Note, NoteLabel, NoteRevision } from "../stores/notes";
|
||||
import { LABEL_CHIP_CLASSES, type NoteColor } from "../notes/colors";
|
||||
|
||||
// One editor for BOTH composing and editing. `inline` renders the board composer
|
||||
@@ -457,6 +457,47 @@ async function act(fn: () => Promise<void>) {
|
||||
emit("close");
|
||||
}
|
||||
|
||||
// ---- version history (modal edit only) ----
|
||||
const showHistory = ref(false);
|
||||
const revisions = ref<NoteRevision[]>([]);
|
||||
|
||||
async function loadRevisions() {
|
||||
if (!noteId.value) {
|
||||
revisions.value = [];
|
||||
return;
|
||||
}
|
||||
try {
|
||||
revisions.value = await notes.fetchRevisions(noteId.value);
|
||||
} catch {
|
||||
revisions.value = [];
|
||||
}
|
||||
}
|
||||
function toggleHistory() {
|
||||
showHistory.value = !showHistory.value;
|
||||
if (showHistory.value) void loadRevisions();
|
||||
}
|
||||
async function restoreRevisionAt(revId: string) {
|
||||
const id = noteId.value;
|
||||
if (!id) return;
|
||||
const updated = await notes.restoreRevision(id, revId);
|
||||
title.value = updated.title ?? "";
|
||||
body.value = updated.body;
|
||||
color.value = updated.color;
|
||||
baseline.value = { title: updated.title, body: updated.body, color: updated.color };
|
||||
void loadRevisions(); // the pre-restore state became a new revision
|
||||
}
|
||||
function revLabel(iso: string | null): string {
|
||||
if (!iso) return "";
|
||||
return new Date(iso).toLocaleString(undefined, { month: "short", day: "numeric", hour: "numeric", minute: "2-digit" });
|
||||
}
|
||||
function revPreview(rev: NoteRevision): string {
|
||||
const t = (rev.title ?? "").trim();
|
||||
const b = rev.body.trim().replace(/\s+/g, " ");
|
||||
const s = t && b ? `${t} — ${b}` : t || b;
|
||||
if (!s) return "(empty)";
|
||||
return s.length > 80 ? `${s.slice(0, 80)}…` : s;
|
||||
}
|
||||
|
||||
defineExpose({ open });
|
||||
</script>
|
||||
|
||||
@@ -637,6 +678,29 @@ defineExpose({ open });
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="!inline && !isCreate && showHistory"
|
||||
class="flex flex-col gap-1 border-t border-neutral-100 pt-2 dark:border-neutral-800"
|
||||
>
|
||||
<p class="text-xs font-semibold uppercase tracking-wide text-neutral-400">History</p>
|
||||
<p v-if="!revisions.length" class="text-xs text-neutral-400">
|
||||
No earlier versions yet — your edits will show up here.
|
||||
</p>
|
||||
<ul v-else class="flex max-h-40 flex-col gap-0.5 overflow-y-auto">
|
||||
<li v-for="rev in revisions" :key="rev.id" class="flex items-center gap-2 rounded-md px-1 py-1 text-xs">
|
||||
<span class="shrink-0 tabular-nums text-neutral-400">{{ revLabel(rev.created_at) }}</span>
|
||||
<span class="min-w-0 flex-1 truncate text-neutral-600 dark:text-neutral-300">{{ revPreview(rev) }}</span>
|
||||
<button
|
||||
type="button"
|
||||
class="shrink-0 rounded px-1.5 py-0.5 font-medium text-brand-700 hover:bg-brand/10 focus:outline-none focus-visible:ring-2 focus-visible:ring-brand dark:text-brand"
|
||||
@click="restoreRevisionAt(rev.id)"
|
||||
>
|
||||
Restore
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between gap-2 border-t border-neutral-100 px-3 py-2 dark:border-neutral-800">
|
||||
@@ -675,6 +739,18 @@ defineExpose({ open });
|
||||
:model-value="labelList"
|
||||
@update:model-value="onLabelsChange"
|
||||
/>
|
||||
<button
|
||||
v-if="!inline && !isCreate"
|
||||
type="button"
|
||||
class="icon-btn"
|
||||
:class="showHistory ? 'text-brand-700 dark:text-brand' : ''"
|
||||
title="Version history"
|
||||
aria-label="Version history"
|
||||
:aria-pressed="showHistory"
|
||||
@click="toggleHistory"
|
||||
>
|
||||
<Icon name="history" />
|
||||
</button>
|
||||
<template v-if="!inline && !isCreate && !liveNote.trashed">
|
||||
<button
|
||||
type="button"
|
||||
|
||||
Reference in New Issue
Block a user