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.
134 lines
4.6 KiB
Python
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
|