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:
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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())
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user