M6 1902a: richer facet query + saved-filters storage (backend)
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:
@@ -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")
|
||||
@@ -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:
|
||||
|
||||
@@ -13,6 +13,7 @@ from . import ( # noqa: F401
|
||||
note_link,
|
||||
note_link_preview,
|
||||
note_revision,
|
||||
saved_filter,
|
||||
settings,
|
||||
share,
|
||||
user,
|
||||
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
|
||||
@@ -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})
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user