M6: version history — note revisions + restore
CI & Build / Python lint (push) Successful in 2s
CI & Build / TypeScript typecheck (push) Successful in 5s
CI & Build / Python tests (push) Successful in 7s
CI & Build / Build & push image (push) Successful in 30s

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:
2026-07-22 16:08:30 -04:00
co-authored by Claude Opus 4.8
parent 47974f62ee
commit efbf981a2a
8 changed files with 252 additions and 2 deletions
+34
View File
@@ -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")
+1
View File
@@ -22,6 +22,7 @@ const paths: Record<string, string> = {
calendar: '<path d="M8 2v4"/><path d="M16 2v4"/><rect width="18" height="18" x="3" y="4" rx="2"/><path d="M3 10h18"/>',
close: '<path d="M18 6 6 18"/><path d="m6 6 12 12"/>',
merge: '<circle cx="18" cy="18" r="3"/><circle cx="6" cy="6" r="3"/><path d="M6 21V9a9 9 0 0 0 9 9"/>',
history: '<path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"/><path d="M3 3v5h5"/><path d="M12 7v5l4 2"/>',
};
</script>
+77 -1
View File
@@ -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"
+21
View File
@@ -29,6 +29,14 @@ export interface Attachment {
mime: string;
}
// A past version of a note's title+body (version history).
export interface NoteRevision {
id: string;
title: string | null;
body: string;
created_at: string | null;
}
export interface Note {
id: string;
title: string | null;
@@ -204,6 +212,17 @@ export const useNotesStore = defineStore("notes", () => {
if (idx >= 0) items.value.splice(idx, 1);
}
async function fetchRevisions(id: string): Promise<NoteRevision[]> {
const res = await api.get<{ revisions: NoteRevision[] }>(`/api/notes/${id}/revisions`);
return res.revisions;
}
async function restoreRevision(id: string, revId: string): Promise<Note> {
const note = await api.post<Note>(`/api/notes/${id}/revisions/${revId}/restore`);
reconcile(note);
return note;
}
return {
items,
loading,
@@ -229,5 +248,7 @@ export const useNotesStore = defineStore("notes", () => {
trash,
restore,
deleteForever,
fetchRevisions,
restoreRevision,
};
});
+12 -1
View File
@@ -3,4 +3,15 @@
Imported for side effects only (model registration on Base.metadata).
"""
from . import group, label, note, note_attachment, note_item, note_link, settings, share, user # noqa: F401
from . import ( # noqa: F401
group,
label,
note,
note_attachment,
note_item,
note_link,
note_revision,
settings,
share,
user,
)
+27
View File
@@ -0,0 +1,27 @@
from __future__ import annotations
import uuid
from datetime import datetime
from sqlalchemy import DateTime, ForeignKey, Index, Text, func
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column
from . import Base
class NoteRevision(Base):
"""A point-in-time snapshot of a note's title+body, written on each edit that
changes either — so an accidental overwrite can be viewed and restored. Only
title+body are versioned in v1 (not items/attachments/labels)."""
__tablename__ = "note_revisions"
__table_args__ = (Index("ix_note_revisions_note_created", "note_id", "created_at"),)
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
note_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("notes.id", ondelete="CASCADE"), nullable=False
)
title: Mapped[str | None] = mapped_column(Text(), nullable=True)
body: Mapped[str] = mapped_column(Text(), nullable=False, server_default="")
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now())
+66
View File
@@ -17,6 +17,7 @@ from .models.note import NOTE_COLORS, Note
from .models.note_attachment import NoteAttachment
from .models.note_item import NoteItem
from .models.note_link import NoteLink
from .models.note_revision import NoteRevision
ALLOWED_IMAGE_MIMES = {"image/png": ".png", "image/jpeg": ".jpg", "image/gif": ".gif", "image/webp": ".webp"}
@@ -543,6 +544,8 @@ async def update_note(note_id: str):
if note is None:
return jsonify({"error": "not found"}), 404
old_display = note.display_title
old_title = note.title
old_body = note.body
if "title" in data:
title = data["title"] if isinstance(data["title"], str) else ""
note.title = title.strip() or None
@@ -576,6 +579,69 @@ async def update_note(note_id: str):
# repoints inbound [[Old Name]] references so backlinks survive (skip pure
# case/whitespace changes, which still resolve).
new_display = note.display_title
if old_display and new_display and old_display.strip().lower() != new_display.strip().lower():
await _rename_inbound_links(db, note, old_display, new_display)
# Version history: snapshot the PRE-edit title+body whenever either changed.
if note.title != old_title or note.body != old_body:
db.add(NoteRevision(note_id=note.id, title=old_title, body=old_body))
await db.commit()
await db.refresh(note)
return jsonify(await _serialize_note(db, note))
def _serialize_revision(rev: NoteRevision) -> dict:
return {
"id": str(rev.id),
"title": rev.title,
"body": rev.body,
"created_at": rev.created_at.isoformat() if rev.created_at else None,
}
@bp.get("/<note_id>/revisions")
@login_required
async def list_revisions(note_id: str):
async with session_scope() as db:
note = await _get_owned(db, note_id)
if note is None:
return jsonify({"error": "not found"}), 404
rows = (
await db.scalars(
select(NoteRevision)
.where(NoteRevision.note_id == note.id)
.order_by(NoteRevision.created_at.desc())
.limit(50)
)
).all()
return jsonify({"revisions": [_serialize_revision(r) for r in rows]})
@bp.post("/<note_id>/revisions/<rev_id>/restore")
@login_required
async def restore_revision(note_id: str, rev_id: str):
try:
rid = uuid.UUID(rev_id)
except (ValueError, TypeError):
return jsonify({"error": "not found"}), 404
async with session_scope() as db:
note = await _get_owned(db, note_id)
if note is None:
return jsonify({"error": "not found"}), 404
rev = await db.scalar(select(NoteRevision).where(NoteRevision.id == rid, NoteRevision.note_id == note.id))
if rev is None:
return jsonify({"error": "not found"}), 404
if note.title == rev.title and note.body == rev.body:
return jsonify(await _serialize_note(db, note)) # already at this version — no-op
# Snapshot the CURRENT state first, so restoring is itself undoable, then apply
# the revision — with the same title/body ripple as a normal edit.
old_display = note.display_title
db.add(NoteRevision(note_id=note.id, title=note.title, body=note.body))
note.title = rev.title
note.body = rev.body
note.display_title = derive_display_title(note.title, note.body)
await _rewrite_links(db, note)
await _reconcile_tags(db, note)
new_display = note.display_title
if old_display and new_display and old_display.strip().lower() != new_display.strip().lower():
await _rename_inbound_links(db, note, old_display, new_display)
await db.commit()
+14
View File
@@ -194,3 +194,17 @@ async def test_reminders_requires_auth(app):
client = app.test_client()
resp = await client.get("/api/notes/reminders")
assert resp.status_code == 401
async def test_list_revisions_requires_auth(app):
client = app.test_client()
resp = await client.get("/api/notes/00000000-0000-0000-0000-000000000000/revisions")
assert resp.status_code == 401
async def test_restore_revision_requires_auth(app):
client = app.test_client()
resp = await client.post(
"/api/notes/00000000-0000-0000-0000-000000000000/revisions/00000000-0000-0000-0000-000000000001/restore"
)
assert resp.status_code == 401