feat(core): v5.0.0 command palette + FTS5 search, v5.2.0 versioned migrations
- Command palette Ctrl+K/Ctrl+P (base.html): universal search, fuzzy highlight, keyboard nav, quick actions. openQuickFind now opens it. - GET /api/search endpoint + search service: unified pages + databases search, FTS5 with LIKE fallback, trash excluded, user-scoped. - app/migrations.py: lightweight versioned migration runner (schema_version table); baseline schema = v1, new steps applied in order. - Migration v2: missing indexes (users.email, user_oauth_tokens, etc). - Migration v3: FTS5 pages_fts + sync triggers + backfill. - VERSION + app.main -> 5.0.0; CHANGELOG + ROADMAP updated. - +8 tests (tests/test_search_migrations.py). 259 passed (3 pre-existing PDF/weasyprint env failures unrelated).
This commit is contained in:
@@ -0,0 +1,187 @@
|
||||
"""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 typing import Optional
|
||||
|
||||
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) -> Optional[str]:
|
||||
"""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: Optional[int]) -> 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: Optional[int] = 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: Optional[int], 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: Optional[int], 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
|
||||
Reference in New Issue
Block a user