From 5fdc124c772c12bf2ea2b179e8c69c4ed3ab3c96 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Thu, 23 Jul 2026 08:36:49 -0400 Subject: [PATCH] M6 1902a: richer facet query + saved-filters storage (backend) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GET /api/notes gains combinable, AND-ed facets alongside the existing filter/date/sort: multiple ?label= (notes with ALL), ?color, ?kind, ?has_reminder, ?has_attachment, and ?q (full-text over title+body, ranked) — so the facet bar's text box searches, not just filters. All optional; invalid color/kind → 400. saved_filters table (migration 0021) + /api/saved-filters CRUD (list / create / rename+repoint / delete, owner-scoped). `params` is a JSON facet dict mirroring the query surface; clean_params() whitelists facet keys so a saved view can't accumulate junk. Tests (DB-free): _truthy, clean_params key-whitelisting, saved-filters auth-guards. UI (facet bar + saved-views sidebar) lands next. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01FRgehjoz7Yv8LkUfADxACm --- alembic/versions/0021_saved_filters.py | 35 +++++++ src/thoughtsync/app.py | 2 + src/thoughtsync/models/all.py | 1 + src/thoughtsync/models/saved_filter.py | 27 ++++++ src/thoughtsync/notes.py | 39 +++++++- src/thoughtsync/saved_filters.py | 123 +++++++++++++++++++++++++ tests/test_notes.py | 8 ++ tests/test_saved_filters.py | 41 +++++++++ 8 files changed, 272 insertions(+), 4 deletions(-) create mode 100644 alembic/versions/0021_saved_filters.py create mode 100644 src/thoughtsync/models/saved_filter.py create mode 100644 src/thoughtsync/saved_filters.py create mode 100644 tests/test_saved_filters.py diff --git a/alembic/versions/0021_saved_filters.py b/alembic/versions/0021_saved_filters.py new file mode 100644 index 0000000..2a30606 --- /dev/null +++ b/alembic/versions/0021_saved_filters.py @@ -0,0 +1,35 @@ +"""saved_filters (richer search + saved views — M6 1902) + +Revision ID: 0021 +Revises: 0020 +Create Date: 2026-07-23 + +A named facet combination the user can re-apply in one click. `params` is a +JSON-encoded facet dict matching the GET /api/notes query. +""" +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects.postgresql import UUID + +revision = "0021" +down_revision = "0020" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.create_table( + "saved_filters", + sa.Column("id", UUID(as_uuid=True), primary_key=True), + sa.Column("owner_id", UUID(as_uuid=True), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False), + sa.Column("name", sa.Text(), nullable=False), + sa.Column("params", sa.Text(), nullable=False, server_default="{}"), + sa.Column("position", sa.Integer(), nullable=False, server_default="0"), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()), + ) + op.create_index("ix_saved_filters_owner", "saved_filters", ["owner_id", "position"]) + + +def downgrade() -> None: + op.drop_index("ix_saved_filters_owner", table_name="saved_filters") + op.drop_table("saved_filters") diff --git a/src/thoughtsync/app.py b/src/thoughtsync/app.py index 9f12073..508e4d6 100644 --- a/src/thoughtsync/app.py +++ b/src/thoughtsync/app.py @@ -14,6 +14,7 @@ from .db import session_scope from .graph import bp as graph_bp from .labels import bp as labels_bp from .notes import bp as notes_bp +from .saved_filters import bp as saved_filters_bp from .settings import get_public_config, get_setting, load_or_create_secret_key from .settings_api import bp as settings_bp from .sync import bp as sync_bp @@ -46,6 +47,7 @@ def create_app() -> Quart: app.register_blueprint(graph_bp) app.register_blueprint(settings_bp) app.register_blueprint(sync_bp) + app.register_blueprint(saved_filters_bp) @app.before_serving async def _bootstrap() -> None: diff --git a/src/thoughtsync/models/all.py b/src/thoughtsync/models/all.py index 9c2f03a..3d2fc47 100644 --- a/src/thoughtsync/models/all.py +++ b/src/thoughtsync/models/all.py @@ -13,6 +13,7 @@ from . import ( # noqa: F401 note_link, note_link_preview, note_revision, + saved_filter, settings, share, user, diff --git a/src/thoughtsync/models/saved_filter.py b/src/thoughtsync/models/saved_filter.py new file mode 100644 index 0000000..bdcc34b --- /dev/null +++ b/src/thoughtsync/models/saved_filter.py @@ -0,0 +1,27 @@ +from __future__ import annotations + +import uuid +from datetime import datetime + +from sqlalchemy import DateTime, ForeignKey, Integer, Text, func +from sqlalchemy.dialects.postgresql import UUID +from sqlalchemy.orm import Mapped, mapped_column + +from . import Base + + +class SavedFilter(Base): + """A named, saved facet combination (a 'view'/lens) the user can re-apply in one + click — e.g. "Yellow + #ideas". `params` is a JSON-encoded facet dict matching the + GET /api/notes query (q/color/kind/labels/has_reminder/has_attachment/date range).""" + + __tablename__ = "saved_filters" + + id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + owner_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False + ) + name: Mapped[str] = mapped_column(Text(), nullable=False) + params: Mapped[str] = mapped_column(Text(), nullable=False, server_default="{}") + position: Mapped[int] = mapped_column(Integer(), nullable=False, server_default="0") + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now()) diff --git a/src/thoughtsync/notes.py b/src/thoughtsync/notes.py index 31f4a9a..d6cc301 100644 --- a/src/thoughtsync/notes.py +++ b/src/thoughtsync/notes.py @@ -325,13 +325,24 @@ def _parse_iso_dt(raw: str) -> datetime: return datetime.fromisoformat(raw.replace("Z", "+00:00")) +def _truthy(raw: str | None) -> bool: + return raw in ("true", "1", "yes", "on") + + @bp.get("") @login_required async def list_notes(): filter_name = request.args.get("filter", "active") if filter_name not in VALID_FILTERS: return jsonify({"error": "invalid filter"}), 400 - label_param = request.args.get("label") + # Combinable facet filters (all optional, AND-ed together) — the rich-search / + # saved-filter lens. Multiple ?label= narrow to notes carrying ALL of them. + label_params = request.args.getlist("label") + color = request.args.get("color") + kind = request.args.get("kind") + has_reminder = _truthy(request.args.get("has_reminder")) + has_attachment = _truthy(request.args.get("has_attachment")) + query_text = (request.args.get("q") or "").strip() # Optional creation-date range — the "browse by when" / Timeline lens. Both bounds # are ISO-8601 instants forming a HALF-OPEN interval [created_after, created_before), # so a client can pass local day-boundaries (start-of-day .. start-of-next-day) @@ -343,12 +354,24 @@ async def list_notes(): async with session_scope() as db: stmt = select(Note).where(visible_to_user("note", Note.owner_id, Note.id, g.user_id)) stmt = apply_filter(stmt, filter_name) - if label_param: + for raw_label in label_params: try: - lid = uuid.UUID(label_param) + lid = uuid.UUID(raw_label) except (ValueError, TypeError): return jsonify({"error": "invalid label"}), 400 stmt = stmt.where(Note.id.in_(select(NoteLabel.note_id).where(NoteLabel.label_id == lid))) + if color is not None: + if color not in NOTE_COLORS: + return jsonify({"error": "invalid color"}), 400 + stmt = stmt.where(Note.color == color) + if kind is not None: + if kind not in ("text", "list"): + return jsonify({"error": "invalid kind"}), 400 + stmt = stmt.where(Note.kind == kind) + if has_reminder: + stmt = stmt.where(Note.remind_at.is_not(None)) + if has_attachment: + stmt = stmt.where(Note.id.in_(select(NoteAttachment.note_id))) if after_param: try: stmt = stmt.where(Note.created_at >= _parse_iso_dt(after_param)) @@ -359,7 +382,15 @@ async def list_notes(): stmt = stmt.where(Note.created_at < _parse_iso_dt(before_param)) except ValueError: return jsonify({"error": "invalid created_before"}), 400 - if sort == "created": + if query_text: + # Full-text match over title+body (generated tsvector, migration 0005), + # ranked — so the facet bar's text box searches, not just filters. + tsquery = func.websearch_to_tsquery("english", query_text) + search_col = literal_column("notes.search_vector") + stmt = stmt.where(search_col.op("@@")(tsquery)).order_by( + func.ts_rank(search_col, tsquery).desc(), Note.updated_at.desc() + ) + elif sort == "created": stmt = stmt.order_by(Note.created_at.desc()) else: stmt = stmt.order_by(Note.pinned.desc(), Note.position.desc(), Note.updated_at.desc()) diff --git a/src/thoughtsync/saved_filters.py b/src/thoughtsync/saved_filters.py new file mode 100644 index 0000000..0df3a3a --- /dev/null +++ b/src/thoughtsync/saved_filters.py @@ -0,0 +1,123 @@ +"""Saved filters ('views'/lenses) — a named facet combination the user can re-apply +in one click. Owner-scoped CRUD; `params` mirrors the GET /api/notes facet query.""" +from __future__ import annotations + +import json +import uuid + +from quart import Blueprint, g, jsonify, request +from sqlalchemy import func, select + +from .auth import login_required +from .db import session_scope +from .models.saved_filter import SavedFilter + +bp = Blueprint("saved_filters", __name__, url_prefix="/api/saved-filters") + +NAME_CAP = 100 +# Facet keys allowed in a saved view (must match the GET /api/notes query surface). +_ALLOWED_PARAM_KEYS = { + "q", + "color", + "kind", + "labels", + "has_reminder", + "has_attachment", + "created_after", + "created_before", + "filter", +} + + +def clean_params(raw: object) -> dict: + """Keep only recognized facet keys, so a saved view can't accumulate junk.""" + if not isinstance(raw, dict): + return {} + return {k: v for k, v in raw.items() if k in _ALLOWED_PARAM_KEYS} + + +def _serialize(sf: SavedFilter) -> dict: + try: + params = json.loads(sf.params) + except (ValueError, TypeError): + params = {} + return {"id": str(sf.id), "name": sf.name, "params": params, "position": sf.position} + + +@bp.get("") +@login_required +async def list_saved(): + async with session_scope() as db: + rows = ( + await db.scalars( + select(SavedFilter) + .where(SavedFilter.owner_id == g.user_id) + .order_by(SavedFilter.position, SavedFilter.created_at) + ) + ).all() + return jsonify({"filters": [_serialize(r) for r in rows]}) + + +@bp.post("") +@login_required +async def create_saved(): + data = await request.get_json(silent=True) or {} + name = (data.get("name") or "").strip() + if not name: + return jsonify({"error": "name is required"}), 400 + params = clean_params(data.get("params")) + async with session_scope() as db: + max_pos = await db.scalar( + select(func.coalesce(func.max(SavedFilter.position), -1)).where(SavedFilter.owner_id == g.user_id) + ) + sf = SavedFilter( + owner_id=g.user_id, name=name[:NAME_CAP], params=json.dumps(params), position=int(max_pos) + 1 + ) + db.add(sf) + await db.commit() + await db.refresh(sf) + return jsonify(_serialize(sf)), 201 + + +@bp.patch("/") +@login_required +async def rename_saved(filter_id: str): + try: + fid = uuid.UUID(filter_id) + except (ValueError, TypeError): + return jsonify({"error": "not found"}), 404 + data = await request.get_json(silent=True) or {} + async with session_scope() as db: + sf = await db.scalar( + select(SavedFilter).where(SavedFilter.id == fid, SavedFilter.owner_id == g.user_id) + ) + if sf is None: + return jsonify({"error": "not found"}), 404 + if "name" in data: + name = (data.get("name") or "").strip() + if not name: + return jsonify({"error": "name is required"}), 400 + sf.name = name[:NAME_CAP] + if "params" in data: + sf.params = json.dumps(clean_params(data.get("params"))) + await db.commit() + await db.refresh(sf) + return jsonify(_serialize(sf)) + + +@bp.delete("/") +@login_required +async def delete_saved(filter_id: str): + try: + fid = uuid.UUID(filter_id) + except (ValueError, TypeError): + return jsonify({"error": "not found"}), 404 + async with session_scope() as db: + sf = await db.scalar( + select(SavedFilter).where(SavedFilter.id == fid, SavedFilter.owner_id == g.user_id) + ) + if sf is None: + return jsonify({"error": "not found"}), 404 + await db.delete(sf) + await db.commit() + return jsonify({"ok": True}) diff --git a/tests/test_notes.py b/tests/test_notes.py index 76c32a1..fe934cf 100644 --- a/tests/test_notes.py +++ b/tests/test_notes.py @@ -6,6 +6,7 @@ from thoughtsync.notes import ( _attachment_ext, _escape_like, _header_filename, + _truthy, _keep_spec, _native_spec, _parse_iso_dt, @@ -259,6 +260,13 @@ def test_header_filename(): assert _header_filename("") == "file" +def test_truthy(): + assert _truthy("true") and _truthy("1") and _truthy("yes") and _truthy("on") + assert not _truthy("false") + assert not _truthy(None) + assert not _truthy("") + + def test_usec_to_dt(): # Google Keep timestamps are microseconds since the epoch (UTC). d = _usec_to_dt(1600000000000000) diff --git a/tests/test_saved_filters.py b/tests/test_saved_filters.py new file mode 100644 index 0000000..e95dbb5 --- /dev/null +++ b/tests/test_saved_filters.py @@ -0,0 +1,41 @@ +import pytest + +from thoughtsync.app import create_app +from thoughtsync.saved_filters import clean_params + + +@pytest.fixture +def app(): + return create_app() + + +def test_clean_params_whitelists_facet_keys(): + raw = { + "q": "hi", + "color": "yellow", + "labels": ["a"], + "has_reminder": True, + "junk": 1, + "__proto__": 2, + } + assert clean_params(raw) == {"q": "hi", "color": "yellow", "labels": ["a"], "has_reminder": True} + assert clean_params("nope") == {} + assert clean_params(None) == {} + + +async def test_list_saved_requires_auth(app): + client = app.test_client() + resp = await client.get("/api/saved-filters") + assert resp.status_code == 401 + + +async def test_create_saved_requires_auth(app): + client = app.test_client() + resp = await client.post("/api/saved-filters", json={"name": "x"}) + assert resp.status_code == 401 + + +async def test_delete_saved_requires_auth(app): + client = app.test_client() + resp = await client.delete("/api/saved-filters/00000000-0000-0000-0000-000000000000") + assert resp.status_code == 401