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
+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()