"""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