- 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]>
187 lines
6.5 KiB
Python
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
|