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
+48
View File
@@ -0,0 +1,48 @@
"""Recurring-reminder math — validate a recurrence interval and roll a reminder
forward to its next occurrence (used when a recurring reminder is completed)."""
from __future__ import annotations
import calendar
from datetime import datetime, timedelta
REMINDER_RECURRENCES = {"daily", "weekly", "monthly", "yearly"}
def normalize_recurrence(value: object) -> str | None:
return value if value in REMINDER_RECURRENCES else None
def _add_months(dt: datetime, months: int) -> datetime:
"""Shift a datetime by whole months, clamping the day to the target month's length
(so Jan 31 + 1 month → Feb 28/29). Keeps the time-of-day."""
m = dt.month - 1 + months
year = dt.year + m // 12
month = m % 12 + 1
day = min(dt.day, calendar.monthrange(year, month)[1])
return dt.replace(year=year, month=month, day=day)
def _advance_once(dt: datetime, recurrence: str) -> datetime | None:
if recurrence == "daily":
return dt + timedelta(days=1)
if recurrence == "weekly":
return dt + timedelta(weeks=1)
if recurrence == "monthly":
return _add_months(dt, 1)
if recurrence == "yearly":
return _add_months(dt, 12)
return None
def next_occurrence(remind_at: datetime, recurrence: str, after: datetime) -> datetime | None:
"""The next reminder fire time strictly after `after`, rolling a recurring reminder
forward past any missed occurrences. None if `recurrence` isn't a known interval."""
nxt = _advance_once(remind_at, recurrence)
if nxt is None:
return None
while nxt <= after:
step = _advance_once(nxt, recurrence)
if step is None or step == nxt:
break
nxt = step
return nxt