server: the body is the checklist here too, and note_items is dropped
CI & Build / Build now, or wait for Android? (push) Successful in 2s
CI & Build / Python lint (push) Successful in 2s
CI & Build / TypeScript typecheck (push) Successful in 6s
CI & Build / Python tests (push) Failing after 8s
CI & Build / integration (push) Successful in 20s
CI & Build / Build & push image (push) Skipped

M304 steps 3 and the server half of 4. The client half landed in 668f7fa; these
belong in one deploy, and the protocol floor below is what enforces that.

notes/checklist.py is the Python half of a grammar that now exists three times —
here, core/src/local/derive.rs, and (next) frontend/src/notes/markdown.ts. That
triplication is the deliberate cost: the alternative is a round trip to the server
before a phone can draw a checkbox. Each copy names the other two, and each is
tested against the same table of cases, including the near-misses that must stay
prose: `-[ ] x`, `- []`, `- [ ]x`, a `[ ]` mid-sentence.

Routes: add/update/delete items stop touching rows and rewrite note.body, all
through one _rewrite_body that runs the same sequence the PATCH route runs for a
body change — because it IS a body change. Revisions, #tag reconciliation, the
name, and link unfurls therefore happen in one place rather than three routes each
remembering to.

The reorder route is gone (rule 22). Reordering a checklist is moving a line, and
no client ever called it — the only reference in the tree was a test asserting the
route existed.

The API still returns `items`, DERIVED from the body on the way out. That is not a
second source of truth and it cannot disagree with the body it came from; it keeps
the web client working across the rest of this milestone and saves any consumer
that only wants to draw checkboxes from carrying a parser.

Export drops its separate items block, in both formats. The body already ends with
those exact lines, so writing them again would double every checklist in an export
and then double it again on re-import. Import still ACCEPTS items, because a Keep
takeout has a list and not a blob; it folds them in before the Note is built, so
display_title and _reconcile_tags both see the finished text.

Protocol 3 on both sides now. A v2 client is refused rather than half-served —
which matters more than I first said: _apply_note_items returned early on an absent
`items` key, so an un-bumped v3 client against a v2 server would not have LOST the
rows, it would have kept them and then had the migration fold them a second time.
Duplicated lists rather than missing ones. The floor prevents both.

Migration 0027 folds every existing row into its note's body and drops the table.
It inlines its own copy of the fold on purpose — a migration has to keep producing
what it produced the day it ran — and a test pins that copy against the app's until
they are allowed to diverge. updated_at is deliberately untouched: a client holding
an unpushed edit keeps the newer timestamp, so last-write-wins keeps its work
instead of the migration silently winning.

The downgrade is honest rather than faithful. It recreates an empty note_items and
leaves the bodies alone, because once items are lines nothing distinguishes one this
migration wrote from one somebody typed, and a downgrade that guessed would eat
hand-written lists. Recreating the table is still necessary: 0015's downgrade drops
a trigger ON note_items, and IF EXISTS covers the trigger, not the table.
This commit is contained in:
2026-08-24 08:03:37 -04:00
parent 32dafca148
commit 761c3b5e82
13 changed files with 566 additions and 254 deletions
+64 -73
View File
@@ -31,10 +31,16 @@ from ..labeling import reconcile_manual_labels, resolve_owned_label_ids
from ..models.label import Label, NoteLabel
from ..models.note import Note
from ..models.note_attachment import NoteAttachment
from ..models.note_item import NoteItem
from ..models.note_link_preview import NoteLinkPreview
from ..models.note_revision import NoteRevision
from ..revisions import should_snapshot
from .checklist import (
append_item,
parse_items,
remove_item,
set_item_checked,
set_item_text,
)
from ..responses import json_error, not_found, parse_uuid
from ..retention import purge_note
from ..settings import get_setting
@@ -70,7 +76,7 @@ from .tags import (
parse_tags,
)
from .recurrence import REMINDER_RECURRENCES, next_occurrence, normalize_recurrence
from .serialize import _items_for_notes, _labels_for_notes, _serialize_note, _serialize_notes
from .serialize import _labels_for_notes, _serialize_note, _serialize_notes
__all__ = [
"bp",
@@ -225,7 +231,6 @@ async def export_notes():
).all()
ids = [n.id for n in notes_list]
labels_map = await _labels_for_notes(db, ids)
items_map = await _items_for_notes(db, ids)
att_rows = (
(await db.scalars(select(NoteAttachment).where(NoteAttachment.note_id.in_(ids)))).all() if ids else []
)
@@ -247,7 +252,6 @@ async def export_notes():
with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf:
for n in notes_list:
labels = labels_map.get(n.id, [])
items = items_map.get(n.id, [])
atts = att_by_note.get(n.id, [])
short = str(n.id)[:8]
payload["notes"].append(
@@ -263,13 +267,12 @@ async def export_notes():
"created_at": n.created_at.isoformat() if n.created_at else None,
"updated_at": n.updated_at.isoformat() if n.updated_at else None,
"labels": [lb["name"] for lb in labels],
"items": [{"text": it["text"], "checked": it["checked"]} for it in items],
"attachments": [
{"file": f"attachments/{short}/{os.path.basename(a.path)}", "mime": a.mime} for a in atts
],
}
)
zf.writestr(f"notes/{_slugify(n.display_title)}-{short}.md", _note_markdown(n, labels, items))
zf.writestr(f"notes/{_slugify(n.display_title)}-{short}.md", _note_markdown(n, labels))
for a in atts:
src = Config.media_root() / a.path
if src.is_file():
@@ -385,24 +388,6 @@ async def reorder_notes():
return jsonify({"ok": True})
async def _name_for(db, note: Note, item_texts: list[str] | None = None) -> str:
"""The note's display name, consulting its checklist only when the body is silent.
`item_texts` short-circuits the query for callers that already hold the items
(create, import). Everyone else pays one narrow SELECT, and only when the body
produced nothing — which is the uncommon case.
"""
name = derive_display_title(note.body)
if name:
return name
if item_texts is not None:
return derive_display_title("", item_texts[0] if item_texts else None)
first = await db.scalar(
select(NoteItem.text).where(NoteItem.note_id == note.id).order_by(NoteItem.position).limit(1)
)
return derive_display_title("", first)
@bp.post("")
@login_required
async def create_note():
@@ -419,17 +404,20 @@ async def create_note():
Note.owner_id == g.user_id, Note.deleted_at.is_(None)
)
)
# Items still arrive separately — a client holds a list, not a blob — but they
# are folded into the body, which is where a checklist lives now (M304).
for text in item_texts:
body = append_item(body, text)
note = Note(
owner_id=g.user_id,
display_title=derive_display_title(body, item_texts[0] if item_texts else None),
display_title=derive_display_title(body),
body=body,
color=normalize_color(data.get("color")),
position=int(max_pos) + 1,
)
db.add(note)
await db.flush() # assign note.id before writing items/links
for pos, text in enumerate(item_texts):
db.add(NoteItem(note_id=note.id, text=text, position=pos))
await db.flush() # assign note.id before writing links
# The FOLDED body: an item can carry a #tag too.
await _reconcile_tags(db, note)
await db.commit()
await db.refresh(note)
@@ -484,7 +472,7 @@ async def update_note(note_id: str):
if "recurrence" in data:
note.recurrence = normalize_recurrence(data["recurrence"])
if "body" in data:
note.display_title = await _name_for(db, note)
note.display_title = derive_display_title(note.body)
await _reconcile_tags(db, note)
# Version history: snapshot the PRE-edit body, once per editing session
# rather than once per write — see revisions.should_snapshot. Writing often
@@ -543,7 +531,7 @@ async def restore_revision(note_id: str, rev_id: str):
# the revision — with the same body ripple as a normal edit.
db.add(NoteRevision(note_id=note.id, body=note.body))
note.body = rev.body
note.display_title = await _name_for(db, note)
note.display_title = derive_display_title(note.body)
await _reconcile_tags(db, note)
await db.commit()
await db.refresh(note)
@@ -575,11 +563,35 @@ async def set_note_labels(note_id: str):
return jsonify(await _serialize_note(db, note))
async def _get_item(db, note: Note, item_id: str) -> NoteItem | None:
iid = parse_uuid(item_id)
if iid is None:
def _item_index(item_id: str) -> int | None:
"""An item's id is its ordinal (see serialize.items_of). Anything else is a stale
id from a client that has not reloaded, and the answer to those is 404."""
try:
index = int(item_id)
except (TypeError, ValueError):
return None
return await db.scalar(select(NoteItem).where(NoteItem.id == iid, NoteItem.note_id == note.id))
return index if index >= 0 else None
async def _rewrite_body(db, note: Note, body: str):
"""Every item mutation is a body edit, so all of them land here.
One place means one place that snapshots a revision, re-derives `#tags`, recomputes
the name and queues link unfurls — rather than three routes each remembering to.
Deliberately the same sequence the PATCH route runs for a body change, because it
IS a body change.
"""
old_body = note.body
if await should_snapshot(db, note.id, old_body, body):
db.add(NoteRevision(note_id=note.id, body=old_body))
note.body = body
note.display_title = derive_display_title(body)
await _reconcile_tags(db, note)
await db.commit()
await db.refresh(note)
if note.body != old_body:
schedule_unfurls(note.id, note.body)
return jsonify(await _serialize_note(db, note))
@bp.post("/<note_id>/items")
@@ -591,70 +603,49 @@ async def add_item(note_id: str):
note = await _get_owned(db, note_id)
if note is None:
return not_found()
max_pos = await db.scalar(
select(func.coalesce(func.max(NoteItem.position), -1)).where(NoteItem.note_id == note.id)
)
db.add(NoteItem(note_id=note.id, text=text, position=int(max_pos) + 1))
await db.commit()
return jsonify(await _serialize_note(db, note)), 201
response = await _rewrite_body(db, note, append_item(note.body, text))
return response, 201
@bp.patch("/<note_id>/items/<item_id>")
@login_required
async def update_item(note_id: str, item_id: str):
data = await request.get_json(silent=True) or {}
index = _item_index(item_id)
if index is None:
return not_found()
async with session_scope() as db:
note = await _get_owned(db, note_id)
if note is None:
return not_found()
item = await _get_item(db, note, item_id)
if item is None:
if index >= len(parse_items(note.body)):
return not_found()
body = note.body
if "text" in data and isinstance(data["text"], str):
item.text = data["text"]
body = set_item_text(body, index, data["text"])
if "checked" in data:
item.checked = bool(data["checked"])
await db.commit()
return jsonify(await _serialize_note(db, note))
body = set_item_checked(body, index, bool(data["checked"]))
return await _rewrite_body(db, note, body)
@bp.delete("/<note_id>/items/<item_id>")
@login_required
async def delete_item(note_id: str, item_id: str):
index = _item_index(item_id)
if index is None:
return not_found()
async with session_scope() as db:
note = await _get_owned(db, note_id)
if note is None:
return not_found()
item = await _get_item(db, note, item_id)
if item is None:
if index >= len(parse_items(note.body)):
return not_found()
await db.delete(item)
await db.commit()
return jsonify(await _serialize_note(db, note))
return await _rewrite_body(db, note, remove_item(note.body, index))
@bp.post("/<note_id>/items/reorder")
@login_required
async def reorder_items(note_id: str):
data = await request.get_json(silent=True) or {}
order = data.get("item_ids")
if not isinstance(order, list):
return json_error("item_ids must be a list", 400)
async with session_scope() as db:
note = await _get_owned(db, note_id)
if note is None:
return not_found()
existing = {
str(i.id): i for i in (await db.scalars(select(NoteItem).where(NoteItem.note_id == note.id))).all()
}
pos = 0
for iid in order:
item = existing.get(str(iid))
if item is not None:
item.position = pos
pos += 1
await db.commit()
return jsonify(await _serialize_note(db, note))
# The reorder route is gone with M304. Reordering a checklist is moving a line, which
# is something a text editor already does and no client ever called this for — the
# only reference to it in the tree was a test asserting the route existed.
@bp.post("/<note_id>/attachments")