M9 S1d: split the notes.py monolith into a cohesive package
The 1574-line notes.py becomes a `notes/` package. The heavy shared logic moves
into focused modules; the route handlers + blueprint registration stay together
in __init__ so registration is trivially correct (most routes have no CI
auth-test that would otherwise catch a route silently dropping out):
- notes/_bp.py — the Blueprint (isolated so route modules could import it
without a cycle; also the seam for a later route split).
- notes/serialize.py — note (+labels/items/attachments/previews) serialization.
- notes/links.py — [[wiki-link]] + #tag parsing and reconciliation.
- notes/recurrence.py — recurring-reminder next-occurrence math.
- notes/helpers.py — display-title/empty/filter/owner-fetch + filename/slug utils.
- notes/import_export.py — export markdown + Keep/native import specs + zip budget.
- notes/__init__.py — the `/api/notes` routes + re-exports the external surface
(app.py imports `bp`; sync.py + tests import helpers).
Pure reorganization — no behavior change (routes/helpers moved verbatim). Callers
(app.py, sync.py, test_notes.py) are unchanged: `from thoughtsync.notes import X`
resolves via the package __init__ (rule 22 — the package replaces the module).
No import cycle (nothing in the package's dep chain imports notes; only app.py +
sync.py consume it). New test_all_note_routes_registered asserts all 29 route
endpoints are attached, so CI catches any module that fails to register. Runtime
DB behavior operator-verified on deploy (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,134 @@
|
||||
"""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_item import NoteItem
|
||||
from ..models.note_link_preview import NoteLinkPreview
|
||||
|
||||
|
||||
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 _serialize_item(item: NoteItem) -> dict:
|
||||
return {"id": str(item.id), "text": item.text, "checked": item.checked, "position": item.position}
|
||||
|
||||
|
||||
async def _items_for_notes(db, note_ids: list) -> dict:
|
||||
"""Map note_id -> [checklist items] in one query, ordered by position."""
|
||||
result: dict = {}
|
||||
if not note_ids:
|
||||
return result
|
||||
items = (
|
||||
await db.scalars(
|
||||
select(NoteItem).where(NoteItem.note_id.in_(note_ids)).order_by(NoteItem.position, NoteItem.created_at)
|
||||
)
|
||||
).all()
|
||||
for item in items:
|
||||
result.setdefault(item.note_id, []).append(_serialize_item(item))
|
||||
return result
|
||||
|
||||
|
||||
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, [])
|
||||
items = await _items_for_notes(db, [note.id])
|
||||
data["items"] = items.get(note.id, [])
|
||||
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)
|
||||
items_map = await _items_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_map.get(n.id, [])
|
||||
data["attachments"] = attach_map.get(n.id, [])
|
||||
data["previews"] = preview_map.get(n.id, [])
|
||||
out.append(data)
|
||||
return out
|
||||
Reference in New Issue
Block a user