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,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
|
||||
Reference in New Issue
Block a user