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