"""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