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
49 lines
1.7 KiB
Python
49 lines
1.7 KiB
Python
"""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
|