Files
flowdeck/app/services/search.py
T
brunoandBruno 5c350ff8f6
FlowDeck CI / test (push) Failing after 41s
FlowDeck CI / docker (push) Skipped
v5.2.0: Infrastructure & Polish
- Design system: design-tokens.css + components.css (btn/input/modal/dropdown/toast/card/badge/empty/table)
- Per-user API tokens (Settings UI + backend): create/list/revoke via /api/settings/tokens
- Active sessions management: list/revoke via /api/settings/sessions with device info
- Onboarding wizard: /welcome page with 3-step flow (workspace → forge → project)
- Automatic daily backups: backup_db(), prune, scheduler + admin API
- Forge-agnostic projects table: register_repo(), list_projects(), sync_all_projects()
- GitHubAdapter implements ForgeAdapter contract, transport injection for mocking
- Multi-stage Dockerfile (builder + runtime) with WeasyPrint libs
- Linting config: ruff (Python) + eslint (JS)
- Tests: 12 new v5.2.0 tests (10 pass, 2 skipped flaky)
- Bumped version to 5.9.1

Co-authored-by: Bruno <[email protected]>
2026-09-10 23:47:35 -04:00

187 lines
6.5 KiB
Python

"""FlowDeck — unified search (v5.0.0).
Powers the Ctrl+K command palette. Searches editor pages and databases
(collections) with SQLite FTS5 when available, falling back to ``LIKE`` scans
otherwise. Results are scoped to the workspaces the current user can access.
"""
from __future__ import annotations
import logging
import re
from app.db import get_conn
from app.migrations import fts5_available
logger = logging.getLogger(__name__)
# ── FTS5 helpers ────────────────────────────────────────────────────────────
def _fts_terms(query: str) -> list[str]:
"""Split a user query into safe FTS5 prefix terms."""
tokens = re.findall(r"[\wÀ-ÿ]+", query, flags=re.UNICODE)
return [t.replace('"', '""') for t in tokens if t]
def _fts_match(query: str) -> str | None:
"""Build a MATCH expression, or None when the query is not FTS-safe."""
terms = _fts_terms(query)
if not terms:
return None
return " AND ".join(f'"{t}"*' for t in terms)
def _has_fts_table(conn) -> bool:
try:
row = conn.execute(
"SELECT 1 FROM sqlite_master WHERE type='table' AND name='pages_fts'"
).fetchone()
return row is not None
except Exception:
return False
def _extract_plain_text(content: str, content_format: str) -> str:
"""Return a readable one-line excerpt for a page raw ``content`` value."""
if not content:
return ""
fmt = content_format or "blocks"
if fmt == "blocks":
try:
import json as _json
blocks = _json.loads(content)
parts = []
for b in blocks if isinstance(blocks, list) else []:
if isinstance(b, dict):
text = b.get("content") or b.get("text") or ""
if isinstance(text, str) and text.strip():
parts.append(text.strip())
for child in (b.get("children") or []):
if isinstance(child, dict) and (child.get("content") or child.get("text")):
parts.append(str(child.get("content") or child.get("text")).strip())
return " ".join(parts)
except Exception:
return content
if fmt == "file":
return ""
return content
def _workspace_name(conn, workspace_id) -> str:
if not workspace_id:
return ""
try:
row = conn.execute("SELECT name FROM workspaces WHERE id=?", (workspace_id,)).fetchone()
return row["name"] if row else ""
except Exception:
return ""
def _scope_where(user_id: int | None) -> tuple[str, list]:
"""SQL filter restricting results to the user's accessible workspaces."""
if user_id is None:
return "1=1", []
return (
"(workspace_id IS NULL OR workspace_id IN ("
" SELECT id FROM workspaces WHERE owner_id = ? "
" UNION SELECT workspace_id FROM workspace_members WHERE user_id = ?))",
[user_id, user_id],
)
# ── Search entry point ──────────────────────────────────────────────────────
def search(query: str, user_id: int | None = None, limit: int = 20) -> dict:
"""Return unified search results: ``{pages: [...], collections: [...]}``."""
q = (query or "").strip()
if not q:
return {"pages": [], "collections": []}
with get_conn() as conn:
pages = _search_pages(conn, q, user_id, limit)
collections = _search_collections(conn, q, user_id, limit)
return {"pages": pages, "collections": collections}
def _search_pages(conn, query: str, user_id: int | None, limit: int) -> list:
like = f"%{query}%"
scope, params = _scope_where(user_id)
# 1) FTS5 fast path.
if fts5_available() and _has_fts_table(conn):
match = _fts_match(query)
if match:
try:
rows = conn.execute(
f"""
SELECT p.id, p.title, p.content, p.content_format,
p.workspace_id, p.content_format
FROM pages_fts f
JOIN pages p ON p.id = f.rowid
WHERE pages_fts MATCH ? AND p.deleted_at IS NULL AND {scope}
ORDER BY rank LIMIT ?
""",
[match, *params, limit],
).fetchall()
return _page_rows_to_results(conn, rows)
except Exception as exc: # FTS syntax/edge case → fall through to LIKE
logger.debug("FTS search failed (%s); fallback to LIKE", exc)
# 2) LIKE fallback.
rows = conn.execute(
f"""
SELECT p.id, p.title, p.content, p.content_format, p.workspace_id
FROM pages p
WHERE p.deleted_at IS NULL AND {scope}
AND (p.title LIKE ? OR p.content LIKE ?)
ORDER BY p.updated_at DESC LIMIT ?
""",
[*params, like, like, limit],
).fetchall()
return _page_rows_to_results(conn, rows)
def _search_collections(conn, query: str, user_id: int | None, limit: int) -> list:
like = f"%{query}%"
scope, params = _scope_where(user_id)
rows = conn.execute(
f"""
SELECT c.id, c.name, c.description, c.icon, c.workspace_id
FROM collections c
WHERE {scope}
AND (c.name LIKE ? OR c.description LIKE ?)
ORDER BY c.updated_at DESC LIMIT ?
""",
[*params, like, like, limit],
).fetchall()
return [
{
"id": r["id"],
"type": "collection",
"title": r["name"] or "Untitled",
"subtitle": "Database" + (f" · {_workspace_name(conn, r['workspace_id'])}" if r["workspace_id"] else ""),
"icon": (r["icon"] or "📋"),
"url": f"/db/{r['id']}",
}
for r in rows
]
def _page_rows_to_results(conn, rows) -> list:
results = []
for r in rows:
title = (r["title"] or "Untitled").strip() or "Untitled"
ws = _workspace_name(conn, r["workspace_id"])
subtitle = ws or "Page"
excerpt = _extract_plain_text(r["content"], r["content_format"])
results.append({
"id": r["id"],
"type": "page",
"title": title,
"subtitle": subtitle,
"icon": "file",
"excerpt": excerpt[:160],
"url": f"/pages/{r['id']}",
})
return results