Files
inkwell/src/thoughtsync/notes/serialize.py
T
bvandeusen 761c3b5e82
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
server: the body is the checklist here too, and note_items is dropped
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.
2026-08-24 08:03:37 -04:00

134 lines
4.6 KiB
Python

"""Note serialization — turn a Note (+ its labels/items/attachments/previews) into
the JSON dict the API returns. The bulk loaders (`*_for_notes`) fetch each child
collection for a batch of notes in one query, so list endpoints avoid N+1s."""
from __future__ import annotations
from sqlalchemy import select
from ..models.label import Label, NoteLabel
from ..models.note import Note
from ..models.note_attachment import NoteAttachment
from ..models.note_link_preview import NoteLinkPreview
from .checklist import parse_items
async def _labels_for_notes(db, note_ids: list) -> dict:
"""Map note_id -> [{id, name}] in one query (no lazy relationship loading)."""
result: dict = {}
if not note_ids:
return result
rows = await db.execute(
select(NoteLabel.note_id, Label.id, Label.name, Label.color, NoteLabel.via_tag)
.join(Label, Label.id == NoteLabel.label_id)
.where(NoteLabel.note_id.in_(note_ids))
.order_by(Label.name)
)
for note_id, label_id, name, color, via_tag in rows.all():
result.setdefault(note_id, []).append(
{"id": str(label_id), "name": name, "color": color, "via_tag": via_tag}
)
return result
def items_of(body: str | None) -> list[dict]:
"""The note's checklist, read out of its body. No query, because there is no table.
Still emitted in the payload after M304, and that is not a second source of truth:
it is DERIVED on the way out, so it cannot disagree with the body it came from. It
saves every consumer that only wants to draw checkboxes from carrying a parser, and
the ones that do carry one (the native clients, the browser) are free to ignore it
and read the body.
The id is the item's ORDINAL, which is what the rewriters in `checklist.py` take,
so a client holding one can act on it directly. It also shifts when an item is
removed — every mutation returns the reloaded note for exactly that reason.
"""
return [
{"id": str(i), "text": item.text, "checked": item.checked, "position": i}
for i, item in enumerate(parse_items(body))
]
def _attachment_url(note_id, att_id) -> str:
return f"/api/notes/{note_id}/attachments/{att_id}"
async def _attachments_for_notes(db, note_ids: list) -> dict:
result: dict = {}
if not note_ids:
return result
rows = (
await db.scalars(
select(NoteAttachment).where(NoteAttachment.note_id.in_(note_ids)).order_by(NoteAttachment.created_at)
)
).all()
for att in rows:
result.setdefault(att.note_id, []).append(
{
"id": str(att.id),
"url": _attachment_url(att.note_id, att.id),
"filename": att.filename,
"mime": att.mime,
"size": att.size,
"sha256": att.sha256,
}
)
return result
def _serialize_preview(p: NoteLinkPreview) -> dict:
return {
"id": str(p.id),
"url": p.url,
"title": p.title,
"description": p.description,
"image_url": p.image_url,
"site_name": p.site_name,
}
async def _previews_for_notes(db, note_ids: list) -> dict:
"""Map note_id -> [link previews] in one query."""
result: dict = {}
if not note_ids:
return result
rows = (
await db.scalars(
select(NoteLinkPreview)
.where(NoteLinkPreview.note_id.in_(note_ids))
.order_by(NoteLinkPreview.created_at)
)
).all()
for p in rows:
result.setdefault(p.note_id, []).append(_serialize_preview(p))
return result
async def _serialize_note(db, note: Note) -> dict:
data = note.serialize()
labels = await _labels_for_notes(db, [note.id])
data["labels"] = labels.get(note.id, [])
data["items"] = items_of(note.body)
attachments = await _attachments_for_notes(db, [note.id])
data["attachments"] = attachments.get(note.id, [])
previews = await _previews_for_notes(db, [note.id])
data["previews"] = previews.get(note.id, [])
return data
async def _serialize_notes(db, notes: list) -> list:
ids = [n.id for n in notes]
labels_map = await _labels_for_notes(db, ids)
attach_map = await _attachments_for_notes(db, ids)
preview_map = await _previews_for_notes(db, ids)
out = []
for n in notes:
data = n.serialize()
data["labels"] = labels_map.get(n.id, [])
data["items"] = items_of(n.body)
data["attachments"] = attach_map.get(n.id, [])
data["previews"] = preview_map.get(n.id, [])
out.append(data)
return out