M9 S1d: split the notes.py monolith into a cohesive package
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 6s
CI & Build / Python tests (push) Successful in 7s
CI & Build / Build & push image (push) Successful in 31s

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:
2026-07-23 23:50:48 -04:00
co-authored by Claude Opus 4.8
parent 44a5466793
commit 36b8f65dc6
8 changed files with 856 additions and 687 deletions
+134
View File
@@ -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