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"/>',
|
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"/>',
|
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"/>',
|
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>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import Icon from "./Icon.vue";
|
|||||||
import LabelPicker from "./LabelPicker.vue";
|
import LabelPicker from "./LabelPicker.vue";
|
||||||
import NoteChecklist from "./NoteChecklist.vue";
|
import NoteChecklist from "./NoteChecklist.vue";
|
||||||
import { fromLocalInput, toLocalInput } from "../notes/datetime";
|
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";
|
import { LABEL_CHIP_CLASSES, type NoteColor } from "../notes/colors";
|
||||||
|
|
||||||
// One editor for BOTH composing and editing. `inline` renders the board composer
|
// One editor for BOTH composing and editing. `inline` renders the board composer
|
||||||
@@ -457,6 +457,47 @@ async function act(fn: () => Promise<void>) {
|
|||||||
emit("close");
|
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 });
|
defineExpose({ open });
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -637,6 +678,29 @@ defineExpose({ open });
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</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>
|
||||||
|
|
||||||
<div class="flex items-center justify-between gap-2 border-t border-neutral-100 px-3 py-2 dark:border-neutral-800">
|
<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"
|
:model-value="labelList"
|
||||||
@update:model-value="onLabelsChange"
|
@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">
|
<template v-if="!inline && !isCreate && !liveNote.trashed">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -29,6 +29,14 @@ export interface Attachment {
|
|||||||
mime: string;
|
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 {
|
export interface Note {
|
||||||
id: string;
|
id: string;
|
||||||
title: string | null;
|
title: string | null;
|
||||||
@@ -204,6 +212,17 @@ export const useNotesStore = defineStore("notes", () => {
|
|||||||
if (idx >= 0) items.value.splice(idx, 1);
|
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 {
|
return {
|
||||||
items,
|
items,
|
||||||
loading,
|
loading,
|
||||||
@@ -229,5 +248,7 @@ export const useNotesStore = defineStore("notes", () => {
|
|||||||
trash,
|
trash,
|
||||||
restore,
|
restore,
|
||||||
deleteForever,
|
deleteForever,
|
||||||
|
fetchRevisions,
|
||||||
|
restoreRevision,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3,4 +3,15 @@
|
|||||||
Imported for side effects only (model registration on Base.metadata).
|
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_attachment import NoteAttachment
|
||||||
from .models.note_item import NoteItem
|
from .models.note_item import NoteItem
|
||||||
from .models.note_link import NoteLink
|
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"}
|
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:
|
if note is None:
|
||||||
return jsonify({"error": "not found"}), 404
|
return jsonify({"error": "not found"}), 404
|
||||||
old_display = note.display_title
|
old_display = note.display_title
|
||||||
|
old_title = note.title
|
||||||
|
old_body = note.body
|
||||||
if "title" in data:
|
if "title" in data:
|
||||||
title = data["title"] if isinstance(data["title"], str) else ""
|
title = data["title"] if isinstance(data["title"], str) else ""
|
||||||
note.title = title.strip() or None
|
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
|
# repoints inbound [[Old Name]] references so backlinks survive (skip pure
|
||||||
# case/whitespace changes, which still resolve).
|
# case/whitespace changes, which still resolve).
|
||||||
new_display = note.display_title
|
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():
|
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 _rename_inbound_links(db, note, old_display, new_display)
|
||||||
await db.commit()
|
await db.commit()
|
||||||
|
|||||||
@@ -194,3 +194,17 @@ async def test_reminders_requires_auth(app):
|
|||||||
client = app.test_client()
|
client = app.test_client()
|
||||||
resp = await client.get("/api/notes/reminders")
|
resp = await client.get("/api/notes/reminders")
|
||||||
assert resp.status_code == 401
|
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