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
+92
View File
@@ -0,0 +1,92 @@
"""Small shared helpers + constants for the notes package: display-name derivation,
board-filter narrowing, the owner-scoped fetch, and filename/slug sanitizers used by
both the attachment routes and the importer."""
from __future__ import annotations
import os
import re
from quart import g
from sqlalchemy import select
from ..models.note import Note
from ..responses import parse_uuid
ALLOWED_IMAGE_MIMES = {"image/png": ".png", "image/jpeg": ".jpg", "image/gif": ".gif", "image/webp": ".webp"}
VALID_FILTERS = {"active", "archived", "trash"}
DISPLAY_TITLE_CAP = 200
def derive_display_title(title: str | None, body: str | None) -> str:
"""The note's display NAME: the explicit title if set, else the first non-empty
line of the body (trimmed, length-capped). Persisted as notes.display_title so a
body-only note is still nameable/searchable/linkable — the user never has to type
a title. Deterministic (literal first line, no AI)."""
if title and title.strip():
return title.strip()[:DISPLAY_TITLE_CAP]
for line in (body or "").splitlines():
stripped = line.strip()
if stripped:
return stripped[:DISPLAY_TITLE_CAP]
return ""
def is_empty_note(title: str | None, body: str | None) -> bool:
return not (title or "").strip() and not (body or "").strip()
def parse_list_items(raw: object) -> list[str]:
"""Trimmed, non-empty checklist item texts from a create payload's `items`."""
if not isinstance(raw, list):
return []
return [s.strip() for s in raw if isinstance(s, str) and s.strip()]
def apply_filter(stmt, filter_name: str):
"""Narrow a notes query to one board view."""
if filter_name == "archived":
return stmt.where(Note.deleted_at.is_(None), Note.archived.is_(True))
if filter_name == "trash":
return stmt.where(Note.deleted_at.is_not(None))
return stmt.where(Note.deleted_at.is_(None), Note.archived.is_(False))
async def _get_owned(db, note_id: str) -> Note | None:
"""Fetch a note the current user OWNS (mutations are owner-only in M1/M2)."""
nid = parse_uuid(note_id)
if nid is None:
return None
return await db.scalar(select(Note).where(Note.id == nid, Note.owner_id == g.user_id))
def _escape_like(s: str) -> str:
"""Escape LIKE wildcards so user input matches literally (escape char = \\)."""
return s.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
def _slugify(text: str) -> str:
"""A filesystem-safe slug from a note's display name (for the .md filename)."""
s = re.sub(r"[^\w\s-]", "", (text or "").strip().lower())
s = re.sub(r"[\s_-]+", "-", s).strip("-")
return s[:60] or "note"
def _safe_filename(name: str | None) -> str:
"""The upload's original name reduced to a safe basename (display + download)."""
base = os.path.basename((name or "").strip().replace("\\", "/"))
return base[:255] or "file"
def _attachment_ext(filename: str, mime: str) -> str:
"""Storage extension: the original file's extension, else a known image ext."""
ext = os.path.splitext(filename)[1].lower()
if ext and len(ext) <= 12:
return ext
return ALLOWED_IMAGE_MIMES.get(mime, "")
def _header_filename(name: str) -> str:
"""Sanitize a filename for a Content-Disposition header (drop quotes/newlines)."""
return re.sub(r'[\r\n"]', "", name or "")[:255] or "file"