M6 1902a: richer facet query + saved-filters storage (backend)
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 5s
CI & Build / Python tests (push) Successful in 8s
CI & Build / Build & push image (push) Successful in 33s

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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FRgehjoz7Yv8LkUfADxACm
This commit is contained in:
2026-07-23 08:36:49 -04:00
co-authored by Claude Opus 4.8
parent 69bf04e948
commit 5fdc124c77
8 changed files with 272 additions and 4 deletions
+35
View File
@@ -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")
+2
View File
@@ -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:
+1
View File
@@ -13,6 +13,7 @@ from . import ( # noqa: F401
note_link,
note_link_preview,
note_revision,
saved_filter,
settings,
share,
user,
+27
View File
@@ -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())
+35 -4
View File
@@ -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())
+123
View File
@@ -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("/<filter_id>")
@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("/<filter_id>")
@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})
+8
View File
@@ -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)
+41
View File
@@ -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