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