From efbf981a2a63c52106a16484f895d0e75713668f Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Wed, 22 Jul 2026 16:08:30 -0400 Subject: [PATCH] =?UTF-8?q?M6:=20version=20history=20=E2=80=94=20note=20re?= =?UTF-8?q?visions=20+=20restore?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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//revisions (newest 50) and POST /api/notes//revisions//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) Claude-Session: https://claude.ai/code/session_01FRgehjoz7Yv8LkUfADxACm --- alembic/versions/0014_note_revisions.py | 34 +++++++++++ frontend/src/components/Icon.vue | 1 + frontend/src/components/NoteEditor.vue | 78 ++++++++++++++++++++++++- frontend/src/stores/notes.ts | 21 +++++++ src/thoughtsync/models/all.py | 13 ++++- src/thoughtsync/models/note_revision.py | 27 +++++++++ src/thoughtsync/notes.py | 66 +++++++++++++++++++++ tests/test_notes.py | 14 +++++ 8 files changed, 252 insertions(+), 2 deletions(-) create mode 100644 alembic/versions/0014_note_revisions.py create mode 100644 src/thoughtsync/models/note_revision.py diff --git a/alembic/versions/0014_note_revisions.py b/alembic/versions/0014_note_revisions.py new file mode 100644 index 0000000..3fc7d4d --- /dev/null +++ b/alembic/versions/0014_note_revisions.py @@ -0,0 +1,34 @@ +"""note_revisions + +Revision ID: 0014 +Revises: 0013 +Create Date: 2026-07-22 + +Version history: a snapshot of a note's title+body written on each edit that +changes either, so an accidental overwrite can be restored. +""" +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects.postgresql import UUID + +revision = "0014" +down_revision = "0013" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.create_table( + "note_revisions", + sa.Column("id", UUID(as_uuid=True), primary_key=True), + sa.Column("note_id", UUID(as_uuid=True), sa.ForeignKey("notes.id", ondelete="CASCADE"), nullable=False), + sa.Column("title", sa.Text(), nullable=True), + sa.Column("body", sa.Text(), nullable=False, server_default=""), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()), + ) + op.create_index("ix_note_revisions_note_created", "note_revisions", ["note_id", "created_at"]) + + +def downgrade() -> None: + op.drop_index("ix_note_revisions_note_created", table_name="note_revisions") + op.drop_table("note_revisions") diff --git a/frontend/src/components/Icon.vue b/frontend/src/components/Icon.vue index a30f409..d155b3a 100644 --- a/frontend/src/components/Icon.vue +++ b/frontend/src/components/Icon.vue @@ -22,6 +22,7 @@ const paths: Record = { calendar: '', close: '', merge: '', + history: '', }; diff --git a/frontend/src/components/NoteEditor.vue b/frontend/src/components/NoteEditor.vue index 66a3e64..fb7351e 100644 --- a/frontend/src/components/NoteEditor.vue +++ b/frontend/src/components/NoteEditor.vue @@ -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) { emit("close"); } +// ---- version history (modal edit only) ---- +const showHistory = ref(false); +const revisions = ref([]); + +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 }); @@ -637,6 +678,29 @@ defineExpose({ open }); + +
+

History

+

+ No earlier versions yet — your edits will show up here. +

+
    +
  • + {{ revLabel(rev.created_at) }} + {{ revPreview(rev) }} + +
  • +
+
@@ -675,6 +739,18 @@ defineExpose({ open }); :model-value="labelList" @update:model-value="onLabelsChange" /> +