M2 search: Postgres FTS backend + top search bar + results
CI & Build / Python lint (push) Successful in 2s
CI & Build / TypeScript typecheck (push) Successful in 5s
CI & Build / Python tests (push) Successful in 8s
CI & Build / Build & push image (push) Successful in 31s

- Migration 0005: generated tsvector column (title A + body B) + GIN index on
  notes; GET /api/notes/search?q= (websearch_to_tsquery, ts_rank, ACL-scoped,
  excludes trash), labels merged into results.
- Persistent AppShell layout (parent route + <RouterView> children) so the new
  top search box keeps focus across board/search/label navigation.
- SearchView (debounced live search from the shell → /search?q=, results masonry,
  no-match empty state); BoardView/SearchView render inside the shared shell.

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-19 22:03:19 -04:00
co-authored by Claude Opus 4.8
parent 2046600a95
commit ffc008bf4d
7 changed files with 216 additions and 67 deletions
+31 -1
View File
@@ -4,7 +4,7 @@ import uuid
from datetime import datetime, timezone
from quart import Blueprint, g, jsonify, request
from sqlalchemy import delete, select
from sqlalchemy import delete, func, literal_column, select
from .acl import visible_to_user
from .auth import login_required
@@ -93,6 +93,36 @@ async def list_notes():
return jsonify({"notes": out})
@bp.get("/search")
@login_required
async def search_notes():
q = (request.args.get("q") or "").strip()
if not q:
return jsonify({"notes": []})
async with session_scope() as db:
tsquery = func.websearch_to_tsquery("english", q)
# search_vector is a generated column (migration 0005), not mapped on the ORM.
search_col = literal_column("notes.search_vector")
stmt = (
select(Note)
.where(
visible_to_user("note", Note.owner_id, Note.id, g.user_id),
Note.deleted_at.is_(None),
search_col.op("@@")(tsquery),
)
.order_by(func.ts_rank(search_col, tsquery).desc(), Note.updated_at.desc())
.limit(100)
)
notes = (await db.scalars(stmt)).all()
labels_map = await _labels_for_notes(db, [n.id for n in notes])
out = []
for n in notes:
data = n.serialize()
data["labels"] = labels_map.get(n.id, [])
out.append(data)
return jsonify({"notes": out})
@bp.post("")
@login_required
async def create_note():