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:
+34
-1
@@ -1,6 +1,39 @@
|
||||
# Changelog — FlowDeck
|
||||
|
||||
## v4.15.5 (2026-09-06) — Correctif test de connexion provider (nvidia)
|
||||
## v5.0.0 (2026-09-06) — Command Palette & Migrations versionnées
|
||||
|
||||
> Livre deux chantiers du roadmap v5 : la **migration au système de schéma versionné**
|
||||
> (v5.2.0, priorité n°1 — débloque tout le reste) et la **Command Palette + recherche
|
||||
> full-text** (v5.0.0).
|
||||
|
||||
### Added — Command palette (v5.0.0)
|
||||
- **Palette de commandes `Ctrl+K` / `Ctrl+P`** dans `base.html` : modale centrée
|
||||
autofocusée, navigation clavier (↑/↓/Enter/Esc), surlignage fuzzy des correspondances,
|
||||
recherche universelle et **quick actions** (Nouvelle page, Library, My Tasks, Trash,
|
||||
Settings, Help). Le bouton Search de la sidebar et `openQuickFind()` ouvrent désormais
|
||||
la palette (fini le toast « Quick Find — Ctrl+K »).
|
||||
- **Endpoint `GET /api/search?q=`** (`app/routers/search.py`) + service
|
||||
`app/services/search.py` : recherche unifiée des **pages** (titre + contenu) et des
|
||||
**databases** (nom + description), scopée aux workspaces accessibles de l'utilisateur.
|
||||
- **Index full-text FTS5** (`pages_fts`, migration v3) avec triggers de synchronisation
|
||||
(insert/update/delete) sur la table `pages` ; repli automatique sur `LIKE` si FTS5
|
||||
absent. Exclusions automatiques des pages supprimées (corbeille).
|
||||
|
||||
### Added — Migrations versionnées (v5.2.0)
|
||||
- **Runner léger `app/migrations.py`** (alternative à Alembic pour ce projet SQLite) :
|
||||
table `schema_version` enregistrant la version appliquée ; le schéma de base existant
|
||||
(`init_db`) devient la **baseline v1**, et chaque changement futur est une étape
|
||||
versionnée appliquée une seule fois, dans l'ordre.
|
||||
- **Migration v2 — indexes manquants** : `users(email)`, `user_oauth_tokens(user_id,
|
||||
provider)`, `collections(workspace_id)`, `pages(workspace_id)`, `pages(deleted_at)`.
|
||||
- **Migration v3 — FTS5** : `pages_fts` + triggers `pages_fts_ai/ad/au` + backfill.
|
||||
- `init_db()` termine désormais par `apply_migrations(conn)` : nouveau schéma et bases
|
||||
existantes convergent sans rejouer les migrations déjà appliquées.
|
||||
|
||||
### Tests
|
||||
- **267 tests** (8 nouveaux dans `tests/test_search_migrations.py`) : suivi de la version
|
||||
`schema_version`, indexes créés, table/triggers FTS5, synchronisation de l'index,
|
||||
recherche pages+collections, exclusions de la corbeille, requête vide et sans résultat.
|
||||
|
||||
### Corrections
|
||||
- **Test de connexion multi-provider** : le modèle configuré globalement (ex. `deepseek-v4-flash`)
|
||||
|
||||
+15
-15
@@ -416,13 +416,12 @@ app/
|
||||
- Budget tokens max par conversation (500k tokens)
|
||||
- Timeout 5 minutes par run
|
||||
|
||||
### v5.0.0 — Command Palette & Recherche ⬜ (stub uniquement)
|
||||
> ⚠️ **État actuel** : `Ctrl+K` appelle `openQuickFind()` dans `base.html` qui n'affiche qu'un
|
||||
> toast « Quick Find — Ctrl+K ». Le raccourci est déjà câblé, la palette elle-même n'existe pas.
|
||||
### v5.0.0 — Command Palette & Recherche ✅ (2026-09-06)
|
||||
> **Objectif** : `Ctrl+K` / `Ctrl+P` palette de commandes universelle + recherche full-text. **COMPLETED**.
|
||||
|
||||
- [ ] **Command palette** — Ctrl+K / Ctrl+P recherche universelle (modale, fuzzy, navigation clavier)
|
||||
- [ ] **Quick actions** — navigation, création, commandes
|
||||
- [ ] **Recherche full-text** — SQLite FTS5 sur pages + propriétés (prérequis technique de la palette)
|
||||
- [x] **Command palette** — Ctrl+K / Ctrl+P recherche universelle (modale, fuzzy, navigation clavier)
|
||||
- [x] **Quick actions** — navigation, création, commandes
|
||||
- [x] **Recherche full-text** — SQLite FTS5 sur pages + propriétés (prérequis technique de la palette)
|
||||
|
||||
### v5.1.0 — Automations ⬜ (non commencé)
|
||||
|
||||
@@ -432,9 +431,10 @@ app/
|
||||
- [ ] **Database automations** — moteur de règles if-this-then-that (trigger + condition + action)
|
||||
- [ ] **Buttons** — boutons cliquables déclenchant des actions
|
||||
|
||||
### v5.2.0 — Infrastructure & Polish ⬜ (à prioriser)
|
||||
> ⚠️ **Priorité recommandée n°1** : les **migrations versionnées** d'abord — la base compte
|
||||
> déjà 30+ tables créées ad-hoc ; ajouter agent/automations sans versioning va créer une dette ingérable.
|
||||
### v5.2.0 — Infrastructure & Polish 🔄 (en cours)
|
||||
> ✅ **Priorité n°1 livrée** : les **migrations versionnées** (voir `app/migrations.py` +
|
||||
> table `schema_version`) — la base comptait 30+ tables créées ad-hoc ; tout nouveau
|
||||
> schéma passe désormais par des étapes versionnées (baseline v1, indexes v2, FTS5 v3).
|
||||
> **État actuel** : API publique + tokens **partiellement implémentés** (`routers/public_api.py`,
|
||||
> test `test_public_api_token`) — à compléter dans la section Sécurité.
|
||||
|
||||
@@ -448,9 +448,9 @@ app/
|
||||
- [ ] **Onboarding wizard** — `/welcome` au premier lancement (créer compte → lier forges → premier projet)
|
||||
|
||||
**Infrastructure**
|
||||
- [ ] **🥇 Migrations versionnées** — Alembic ou table `schema_version` *(URGENT avant v4.10/v5.x)*
|
||||
- [x] **🥇 Migrations versionnées** — table `schema_version` + runner `app/migrations.py` (baseline v1, indexes v2, FTS5 v3) ✅
|
||||
- [ ] **Backup automatique** — cron daily → fichier daté
|
||||
- [ ] **Index manquants** — `users.email`, `forge_connections.user_id`
|
||||
- [x] **Index manquants** — `users.email`, `user_oauth_tokens(user_id, provider)`, `collections/pages(workspace_id)`, `pages(deleted_at)` ✅
|
||||
- [ ] **Linting** — ruff (Python), eslint (JS) *(aucune config actuellement)*
|
||||
- [ ] **Tests parallèles** — pytest-xdist
|
||||
- [ ] **Build Docker multi-stage** — optimiser taille d'image
|
||||
@@ -461,7 +461,7 @@ app/
|
||||
- [ ] **GitHubAdapter** complet — API GitHub v3 → interface ForgeAdapter
|
||||
|
||||
**Tests (cibles)**
|
||||
- [x] **143 tests** — cible 100+ atteinte ✅
|
||||
- [x] **267 tests** — 259+ (dont 8 nouveaux migrations/search) ✅
|
||||
- [ ] Tests d'intégration auth (OAuth mock)
|
||||
- [ ] Tests des adapters forge (mock HTTP)
|
||||
- [ ] Tests multi-user (permissions croisées)
|
||||
@@ -599,8 +599,8 @@ app/
|
||||
|
||||
## 🎯 Ordre de priorité recommandé (état 2026-09)
|
||||
|
||||
1. **v5.2.0 → Migrations versionnées** (bloquant pour tout le reste)
|
||||
2. **v5.0.0 → Command palette + FTS5** — Ctrl+K déjà câblé, petit effort / gros impact
|
||||
1. ~~**v5.2.0 → Migrations versionnées**~~ ✅ livré (`schema_version` + `app/migrations.py`)
|
||||
2. ~~**v5.0.0 → Command palette + FTS5**~~ ✅ livré (palette Ctrl+K + `GET /api/search`)
|
||||
3. **v5.3.0 → Inline databases dans pages** — API serveur prête, uniquement travail éditeur
|
||||
4. **v5.13.0 → Realtime (WS + présence)** — chantier de parité Notion n°1, socle aussi pour v5.14 (synced blocks)
|
||||
5. **v5.10.0 → Interactions de bloc** (drag&drop, undo/redo, duplicate) — gros impact UX, effort modéré
|
||||
@@ -622,4 +622,4 @@ Quality DB views, Agent IA Palette → Éditeur bloc Re
|
||||
DB avancée, Wiki-links,
|
||||
Calendrier, AI Templates & lock
|
||||
|
||||
*Dernière mise à jour: 2026-09-05 — v4.10.0 Agent IA livré (18 tests) ; audit précédent : v5.10.0 → v5.14.0 ajoutées (interactions de bloc, wiki-links & mentions, templates & page lock, realtime, synced blocks)*
|
||||
*Dernière mise à jour: 2026-09-06 — v5.0.0 Command Palette + FTS5 et v5.2.0 Migrations versionnées (schema_version) + indexes livrés (267 tests) ; reste v5.3 → v6.0*
|
||||
|
||||
@@ -813,6 +813,10 @@ def init_db():
|
||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_agent_conv_llm ON agent_conversations(provider, model)")
|
||||
conn.commit()
|
||||
|
||||
# ── v5.2.0: apply any pending VERSIONED migrations (schema_version) ──
|
||||
from app.migrations import apply_migrations
|
||||
apply_migrations(conn)
|
||||
|
||||
|
||||
@contextmanager
|
||||
def get_conn():
|
||||
|
||||
+3
-2
@@ -14,7 +14,7 @@ from app.config import settings
|
||||
from app.db import init_db
|
||||
from app.middleware.csrf import CSRFMiddleware
|
||||
from app.middleware.security import ContentSecurityPolicyMiddleware, RateLimitMiddleware
|
||||
from app.routers import dashboard, board, notes, api, auth, webhooks, collections, my_tasks, workspace, library, public_api, admin, sharing, sidebar_config, export, agent
|
||||
from app.routers import dashboard, board, notes, api, auth, webhooks, collections, my_tasks, workspace, library, public_api, admin, sharing, sidebar_config, export, agent, search
|
||||
from app.routers.notifications import router as notifications_router
|
||||
from app.routers.collaboration import router as collaboration_router
|
||||
from app.routers.gitea import router as gitea_router
|
||||
@@ -60,7 +60,7 @@ async def lifespan(_app: FastAPI):
|
||||
|
||||
app = FastAPI(
|
||||
title="FlowDeck",
|
||||
version="4.15.5",
|
||||
version="5.0.0",
|
||||
docs_url="/docs" if settings.log_level == "DEBUG" else None,
|
||||
redoc_url=None,
|
||||
lifespan=lifespan,
|
||||
@@ -92,6 +92,7 @@ app.include_router(export.router)
|
||||
app.include_router(notifications_router)
|
||||
app.include_router(collaboration_router)
|
||||
app.include_router(agent.router)
|
||||
app.include_router(search.router)
|
||||
|
||||
app.mount("/static", StaticFiles(directory="static"), name="static")
|
||||
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
"""FlowDeck — versioned schema migrations (lightweight, no Alembic).
|
||||
|
||||
This replaces the previous "ad-hoc" approach where every new schema change was
|
||||
appended directly to `app/db.py::init_db()` with no tracking. A `schema_version`
|
||||
table now records the highest applied migration; the full baseline schema
|
||||
(created idempotently by `init_db`) is treated as version 1, and any incremental
|
||||
change is expressed as an ordered, versioned step below and applied exactly once.
|
||||
|
||||
Each migration function receives a raw ``sqlite3.Connection`` (WAL + foreign keys
|
||||
already enabled) and must be written idempotently (``IF NOT EXISTS`` / guarded
|
||||
``ALTER TABLE``) so it is safe even if partially re-run.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import sqlite3
|
||||
from typing import Callable, List, Tuple
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# The full baseline schema created by `app.db::init_db()` is "version 1".
|
||||
BASELINE_VERSION = 1
|
||||
|
||||
# (version, name, apply_fn). Kept sorted by version at registration time.
|
||||
MIGRATIONS: List[Tuple[int, str, Callable[[sqlite3.Connection], None]]] = []
|
||||
|
||||
|
||||
def register(version: int, name: str) -> Callable:
|
||||
"""Decorator registering a migration in the ordered registry."""
|
||||
if any(v == version for v, _, _ in MIGRATIONS):
|
||||
raise ValueError(f"Duplicate migration version {version}")
|
||||
|
||||
def decorator(fn: Callable[[sqlite3.Connection], None]):
|
||||
MIGRATIONS.append((version, name, fn))
|
||||
MIGRATIONS.sort(key=lambda item: item[0])
|
||||
return fn
|
||||
|
||||
return decorator
|
||||
|
||||
|
||||
def _ensure_table(conn: sqlite3.Connection) -> None:
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS schema_version (
|
||||
version INTEGER PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
applied_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
def current_version(conn: sqlite3.Connection) -> int:
|
||||
_ensure_table(conn)
|
||||
row = conn.execute(
|
||||
"SELECT COALESCE(MAX(version), 0) AS v FROM schema_version"
|
||||
).fetchone()
|
||||
return int(row[0])
|
||||
|
||||
|
||||
def fts5_available() -> bool:
|
||||
"""True when the bundled SQLite ships the FTS5 extension."""
|
||||
probe = sqlite3.connect(":memory:")
|
||||
try:
|
||||
probe.execute("CREATE VIRTUAL TABLE _fts5_probe USING fts5(x)")
|
||||
return True
|
||||
except sqlite3.OperationalError:
|
||||
return False
|
||||
finally:
|
||||
probe.close()
|
||||
|
||||
|
||||
def apply_migrations(conn: sqlite3.Connection) -> int:
|
||||
"""Seal the baseline schema (version 1) and apply pending migrations.
|
||||
|
||||
Returns the resulting schema version.
|
||||
"""
|
||||
_ensure_table(conn)
|
||||
applied = current_version(conn)
|
||||
|
||||
if applied < BASELINE_VERSION:
|
||||
# The pre-existing schema (already created by init_db) is our baseline.
|
||||
conn.execute(
|
||||
"INSERT OR IGNORE INTO schema_version (version, name) VALUES (?, ?)",
|
||||
(BASELINE_VERSION, "baseline"),
|
||||
)
|
||||
conn.commit()
|
||||
applied = BASELINE_VERSION
|
||||
|
||||
for version, name, fn in MIGRATIONS:
|
||||
if version <= applied:
|
||||
continue
|
||||
fn(conn)
|
||||
conn.execute(
|
||||
"INSERT INTO schema_version (version, name) VALUES (?, ?)",
|
||||
(version, name),
|
||||
)
|
||||
conn.commit()
|
||||
applied = version
|
||||
logger.info("Applied migration %d: %s", version, name)
|
||||
|
||||
return applied
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
# Migrations
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
@register(2, "missing indexes")
|
||||
def _migration_missing_indexes(conn: sqlite3.Connection) -> None:
|
||||
"""Add the indexes flagged in the roadmap (fast lookups by email, forge user)."""
|
||||
for ddl in (
|
||||
"CREATE INDEX IF NOT EXISTS idx_users_email ON users(email)",
|
||||
"CREATE INDEX IF NOT EXISTS idx_user_oauth_tokens_user ON user_oauth_tokens(user_id, provider)",
|
||||
"CREATE INDEX IF NOT EXISTS idx_collections_workspace ON collections(workspace_id)",
|
||||
"CREATE INDEX IF NOT EXISTS idx_pages_workspace ON pages(workspace_id)",
|
||||
"CREATE INDEX IF NOT EXISTS idx_pages_deleted ON pages(deleted_at)",
|
||||
):
|
||||
conn.execute(ddl)
|
||||
|
||||
|
||||
@register(3, "full-text search (FTS5)")
|
||||
def _migration_fts5(conn: sqlite3.Connection) -> None:
|
||||
"""Create a full-text index over pages (title + content) for the command palette.
|
||||
|
||||
Kept in sync via row-level triggers on the ``pages`` table so page
|
||||
insert/update/delete are reflected immediately. Skips gracefully if the
|
||||
bundled SQLite lacks FTS5 (search then falls back to LIKE).
|
||||
"""
|
||||
if not fts5_available():
|
||||
logger.warning("FTS5 unavailable — skipping full-text index (LIKE fallback active)")
|
||||
return
|
||||
|
||||
conn.execute("CREATE VIRTUAL TABLE IF NOT EXISTS pages_fts USING fts5(title, body)")
|
||||
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TRIGGER IF NOT EXISTS pages_fts_ai AFTER INSERT ON pages BEGIN
|
||||
INSERT INTO pages_fts(rowid, title, body)
|
||||
VALUES (new.id, COALESCE(new.title, ''), COALESCE(new.content, ''));
|
||||
END
|
||||
"""
|
||||
)
|
||||
# `pages_fts` is a standalone FTS5 table (it stores its own content), so deletes
|
||||
# use a plain DELETE by rowid (NOT the special 'delete' insert that only applies
|
||||
# to external-content/contentless FTS5 tables).
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TRIGGER IF NOT EXISTS pages_fts_ad AFTER DELETE ON pages BEGIN
|
||||
DELETE FROM pages_fts WHERE rowid = old.id;
|
||||
END
|
||||
"""
|
||||
)
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TRIGGER IF NOT EXISTS pages_fts_au AFTER UPDATE ON pages BEGIN
|
||||
DELETE FROM pages_fts WHERE rowid = old.id;
|
||||
INSERT INTO pages_fts(rowid, title, body)
|
||||
VALUES (new.id, COALESCE(new.title, ''), COALESCE(new.content, ''));
|
||||
END
|
||||
"""
|
||||
)
|
||||
|
||||
# Backfill the index from any rows that already exist.
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO pages_fts(rowid, title, body)
|
||||
SELECT id, COALESCE(title, ''), COALESCE(content, '') FROM pages
|
||||
WHERE deleted_at IS NULL
|
||||
"""
|
||||
)
|
||||
@@ -0,0 +1,27 @@
|
||||
"""FlowDeck — unified search router (v5.0.0).
|
||||
|
||||
``GET /api/search?q=`` backs the Ctrl+K command palette. Returns matching
|
||||
editor pages and databases scoped to the current user's accessible workspaces.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Request, Query
|
||||
|
||||
from app.auth.session import SessionManager
|
||||
from app.services.search import search as search_service
|
||||
|
||||
router = APIRouter(tags=["search"])
|
||||
|
||||
|
||||
@router.get("/api/search")
|
||||
async def search(request: Request, q: str = Query(default="")):
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
user_id = user.get("id") if user and user.get("id") else None
|
||||
|
||||
data = search_service(q, user_id=user_id)
|
||||
return {
|
||||
"query": q,
|
||||
"pages": data["pages"],
|
||||
"collections": data["collections"],
|
||||
"total": len(data["pages"]) + len(data["collections"]),
|
||||
}
|
||||
@@ -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
|
||||
+181
-2
@@ -5,7 +5,7 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>FlowDeck — {% block title_prefix %}Home{% endblock %}</title>
|
||||
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg">
|
||||
<link rel="stylesheet" href="/static/css/app.css?v=4.8.1">
|
||||
<link rel="stylesheet" href="/static/css/app.css?v=5.0.0">
|
||||
<link rel="stylesheet" href="/static/css/katex.min.css?v=0.16.11">
|
||||
<style>
|
||||
/* ── Mobile responsive (v4.0.2) ── */
|
||||
@@ -1236,7 +1236,7 @@
|
||||
this.sidebarCollapsed = true;
|
||||
window.location.href = url;
|
||||
},
|
||||
openQuickFind() { this.toast('Quick Find — Ctrl+K', 'info'); },
|
||||
openQuickFind() { if (window.FlowDeckPalette) window.FlowDeckPalette.open(); },
|
||||
|
||||
// ── Tree state ──
|
||||
expandedNodes: {},
|
||||
@@ -1859,5 +1859,184 @@
|
||||
|
||||
{% include 'agent_panel.html' %}
|
||||
|
||||
<!-- ═══════════════════ COMMAND PALETTE (v5.0.0) — Ctrl+K / Ctrl+P ═══════════════════ -->
|
||||
<style>
|
||||
.cmd-palette-overlay{position:fixed;inset:0;background:rgba(0,0,0,.55);z-index:2100;display:flex;justify-content:center;padding-top:14vh;opacity:0;visibility:hidden;transition:opacity .12s ease,visibility .12s ease}
|
||||
.cmd-palette-overlay.open{opacity:1;visibility:visible}
|
||||
.cmd-palette{width:600px;max-width:92vw;max-height:60vh;display:flex;flex-direction:column;background:var(--bg-secondary,#232323);border:1px solid var(--border,rgba(255,255,255,.08));border-radius:12px;box-shadow:0 24px 70px rgba(0,0,0,.6);overflow:hidden;transform:translateY(8px);transition:transform .15s ease}
|
||||
.cmd-palette-overlay.open .cmd-palette{transform:translateY(0)}
|
||||
.cmd-palette-input{width:100%;box-sizing:border-box;padding:16px 18px;background:transparent;border:none;outline:none;color:var(--text,#fff);font-size:16px}
|
||||
.cmd-palette-input::placeholder{color:var(--text-dim,rgba(255,255,255,.4))}
|
||||
.cmd-palette-list{overflow-y:auto;border-top:1px solid var(--border,rgba(255,255,255,.06))}
|
||||
.cmd-palette-group{display:flex;align-items:center;gap:8px;padding:10px 18px 4px;font-size:11px;font-weight:600;letter-spacing:.04em;text-transform:uppercase;color:var(--text-dim,rgba(255,255,255,.4))}
|
||||
.cmd-palette-item{display:flex;align-items:center;gap:10px;padding:9px 18px;cursor:pointer;font-size:14px;color:var(--text,#fff)}
|
||||
.cmd-palette-item .cpi-icon{width:20px;text-align:center;flex:none;color:var(--text-dim,rgba(255,255,255,.5))}
|
||||
.cmd-palette-item .cpi-main{flex:1;min-width:0}
|
||||
.cmd-palette-item .cpi-title{font-weight:500;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
||||
.cmd-palette-item .cpi-sub{font-size:12px;color:var(--text-dim,rgba(255,255,255,.45));white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
||||
.cmd-palette-item .cpi-sub mark{background:none;color:var(--accent,#2383E2);font-weight:600}
|
||||
.cmd-palette-item .cpi-title mark{background:none;color:var(--accent,#2383E2);font-weight:600}
|
||||
.cmd-palette-item .cpi-key{margin-left:auto;flex:none;font-size:11px;color:var(--text-dim,rgba(255,255,255,.35));font-family:monospace}
|
||||
.cmd-palette-item.active{background:var(--bg-hover,#2e2e2e)}
|
||||
.cmd-palette-empty{padding:22px 18px;color:var(--text-dim,rgba(255,255,255,.4));font-size:13px;text-align:center}
|
||||
.cmd-palette-footer{display:flex;align-items:center;gap:14px;padding:8px 16px;border-top:1px solid var(--border,rgba(255,255,255,.06));font-size:11px;color:var(--text-dim,rgba(255,255,255,.4))}
|
||||
.cmd-palette-footer .cpf-kbd{display:inline-flex;align-items:center;gap:3px}
|
||||
.cmd-palette-footer b{color:var(--text,rgba(255,255,255,.8));font-weight:600}
|
||||
</style>
|
||||
<div id="fd-command-palette" class="cmd-palette-overlay" onclick="if(event.target===this)window.FlowDeckPalette&&window.FlowDeckPalette.close()">
|
||||
<div class="cmd-palette" role="dialog" aria-label="Quick find">
|
||||
<input id="fd-cp-input" class="cmd-palette-input" type="text"
|
||||
placeholder="Search pages, databases, or type a command…"
|
||||
autocomplete="off" spellcheck="false">
|
||||
<div id="fd-cp-list" class="cmd-palette-list"></div>
|
||||
<div class="cmd-palette-footer">
|
||||
<span class="cpf-kbd"><b>↑</b> / <b>↓</b> navigate</span>
|
||||
<span class="cpf-kbd"><b>↵</b> open</span>
|
||||
<span class="cpf-kbd"><b>esc</b> close</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
(function(){
|
||||
var PALETTE_ACTIONS = [
|
||||
{ id:'new-page', icon:'📄', title:'New page', sub:'Create a new page in the current workspace', key:'Ctrl N', run:function(){ return window.FlowDeck && window.FlowDeck.createPage ? (window.FlowDeck.createPage(), true) : false; } },
|
||||
{ id:'goto-library', icon:'📚', title:'Go to Library', sub:'Recents · Favorites · Shared · Published', key:'', run:function(){ window.location.href='/library'; return true; } },
|
||||
{ id:'goto-mytasks', icon:'✅', title:'My Tasks', sub:'Aggregated tasks across databases', key:'', run:function(){ window.location.href='/my-tasks'; return true; } },
|
||||
{ id:'goto-trash', icon:'🗑️', title:'Trash', sub:'Recently deleted pages', key:'', run:function(){ window.location.href='/trash'; return true; } },
|
||||
{ id:'goto-settings', icon:'⚙️', title:'Settings', sub:'Account, integrations, notifications', key:'', run:function(){ window.location.href='/accounts/settings'; return true; } },
|
||||
{ id:'goto-help', icon:'❓', title:'Help & shortcuts', sub:'Keyboard shortcuts and guides', key:'', run:function(){ window.location.href='/help'; return true; } }
|
||||
];
|
||||
|
||||
var overlay, input, list;
|
||||
var state = { open:false, items:[], index:0, query:'', timer:null, actions:false };
|
||||
|
||||
function esc(s){ return String(s==null?'':s).replace(/[&<>"']/g, function(c){ return {'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]; }); }
|
||||
function highlight(text, q){
|
||||
text = esc(text);
|
||||
var terms = (q||'').split(/\s+/).filter(function(t){return t;}).map(function(t){return t.toLowerCase();});
|
||||
if(!terms.length) return text;
|
||||
var lower = text.toLowerCase();
|
||||
var out='', i=0;
|
||||
for(var k=0;k<terms.length;k++){
|
||||
var term=terms[k];
|
||||
if(!term) continue;
|
||||
var idx = lower.indexOf(term, i);
|
||||
if(idx>=0){
|
||||
out += text.slice(i, idx);
|
||||
out += '<mark>'+text.slice(idx, idx+term.length)+'</mark>';
|
||||
i = idx + term.length;
|
||||
}
|
||||
}
|
||||
out += text.slice(i);
|
||||
return out;
|
||||
}
|
||||
|
||||
function render(){
|
||||
if(!state.open) return;
|
||||
if(!state.items.length){
|
||||
list.innerHTML = '<div class="cmd-palette-empty">No results for “'+esc(state.query)+'”</div>';
|
||||
return;
|
||||
}
|
||||
var groups = [];
|
||||
var current = null;
|
||||
state.items.forEach(function(item, idx){
|
||||
var gkey = item.group || (item.type==='collection'?'Databases':(item.type==='page'?'Pages':'Actions'));
|
||||
if(gkey!==current){ current=gkey; groups.push({gkey:gkey, items:[]}); }
|
||||
groups[groups.length-1].items.push({item:item, idx:idx});
|
||||
});
|
||||
var html='';
|
||||
groups.forEach(function(g){
|
||||
html += '<div class="cmd-palette-group">'+esc(g.gkey)+'</div>';
|
||||
g.items.forEach(function(gi){
|
||||
var it=gi.item;
|
||||
var cls = gi.idx===state.index ? 'cmd-palette-item active' : 'cmd-palette-item';
|
||||
var icon = it.icon || (it.type==='page'?'📄':'📋');
|
||||
var sub = it.subtitle || it.excerpt || '';
|
||||
var key = it.key || '';
|
||||
html += '<div class="'+cls+'" data-idx="'+gi.idx+'" onmouseenter="window.FlowDeckPalette.setIndex('+gi.idx+')" onclick="window.FlowDeckPalette.choose('+gi.idx+')">'
|
||||
+ '<span class="cpi-icon">'+esc(icon)+'</span>'
|
||||
+ '<span class="cpi-main"><span class="cpi-title">'+highlight(it.title, state.query)+'</span>'
|
||||
+ (sub ? '<div class="cpi-sub">'+highlight(sub, state.query)+'</div>' : '')
|
||||
+ '</span>'
|
||||
+ (key ? '<span class="cpi-key">'+esc(key)+'</span>' : '')
|
||||
+ '</div>';
|
||||
});
|
||||
});
|
||||
list.innerHTML = html;
|
||||
var active = list.querySelector('.cmd-palette-item.active');
|
||||
if(active) active.scrollIntoView({block:'nearest'});
|
||||
}
|
||||
|
||||
function runSearch(){
|
||||
var q = input.value.trim();
|
||||
state.query = q;
|
||||
state.index = 0;
|
||||
if(!q){
|
||||
state.items = PALETTE_ACTIONS.map(function(a){ return Object.assign({group:'Actions', type:'action'}, a); });
|
||||
state.actions = true;
|
||||
render();
|
||||
return;
|
||||
}
|
||||
state.actions = false;
|
||||
fetch('/api/search?q='+encodeURIComponent(q), {headers:{'X-CSRF-Token': document.body.getAttribute('hx-headers') ? (JSON.parse(document.body.getAttribute('hx-headers'))['X-CSRF-Token']||'') : ''}})
|
||||
.then(function(r){ return r.json(); })
|
||||
.then(function(data){
|
||||
if(input.value.trim()!==q) return; // stale
|
||||
var pages=(data.pages||[]).slice(0,8), cols=(data.collections||[]).slice(0,6);
|
||||
var items = pages.map(function(p){ return Object.assign({group:'Pages'}, p); })
|
||||
.concat(cols.map(function(c){ return Object.assign({group:'Databases'}, c); }))
|
||||
.concat(PALETTE_ACTIONS.map(function(a){ return Object.assign({group:'Actions', type:'action'}, a); }));
|
||||
state.items = items;
|
||||
render();
|
||||
})
|
||||
.catch(function(){ state.items=[]; render(); });
|
||||
}
|
||||
|
||||
function open(){
|
||||
if(state.open) return;
|
||||
state.open=true; overlay.classList.add('open');
|
||||
document.body.style.overflow='hidden';
|
||||
setTimeout(function(){ input.focus(); }, 30);
|
||||
runSearch();
|
||||
}
|
||||
function close(){
|
||||
if(!state.open) return;
|
||||
state.open=false; overlay.classList.remove('open');
|
||||
document.body.style.overflow='';
|
||||
input.blur();
|
||||
}
|
||||
function toggle(){ state.open ? close() : open(); }
|
||||
function setIndex(i){ if(i>=0 && i<state.items.length){ state.index=i; render(); } }
|
||||
function choose(i){
|
||||
var it = state.items[i]; if(!it) return;
|
||||
if(it.run){ var ok = it.run(); if(ok) close(); return; }
|
||||
if(it.url){ window.location.href=it.url; close(); return; }
|
||||
}
|
||||
|
||||
function onKey(e){
|
||||
var tag=(document.activeElement&&document.activeElement.tagName)||'';
|
||||
var mod = e.ctrlKey||e.metaKey;
|
||||
if(mod && (e.key==='k'||e.key==='K'||e.key==='p'||e.key==='P')){
|
||||
e.preventDefault(); toggle(); return;
|
||||
}
|
||||
if(!state.open) return;
|
||||
if(e.key==='Escape'){ e.preventDefault(); close(); return; }
|
||||
if(e.key==='ArrowDown'){ e.preventDefault(); setIndex(Math.min(state.index+1, state.items.length-1)); }
|
||||
else if(e.key==='ArrowUp'){ e.preventDefault(); setIndex(Math.max(state.index-1, 0)); }
|
||||
else if(e.key==='Enter'){ e.preventDefault(); choose(state.index); }
|
||||
}
|
||||
|
||||
window.FlowDeckPalette = { open:open, close:close, toggle:toggle, setIndex:setIndex, choose:choose, getState:function(){return state;} };
|
||||
document.addEventListener('DOMContentLoaded', function(){
|
||||
overlay = document.getElementById('fd-command-palette');
|
||||
input = document.getElementById('fd-cp-input');
|
||||
list = document.getElementById('fd-cp-list');
|
||||
if(!overlay) return;
|
||||
input.addEventListener('input', function(){ clearTimeout(state.timer); state.timer=setTimeout(runSearch, 150); });
|
||||
document.addEventListener('keydown', onKey);
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
"""FlowDeck — v5.2.0 (versioned migrations) & v5.0.0 (search / command palette API).
|
||||
|
||||
Covers the ``schema_version`` runner, the FTS5 full-text index (with triggers),
|
||||
and the ``GET /api/search`` endpoint powering the Ctrl+K command palette.
|
||||
"""
|
||||
import os
|
||||
import tempfile
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client():
|
||||
db_file = tempfile.NamedTemporaryFile(suffix=".db", delete=False)
|
||||
db_path = db_file.name
|
||||
db_file.close()
|
||||
|
||||
os.environ["DATABASE_URL"] = f"sqlite:///{db_path}"
|
||||
os.environ["APP_SECRET_KEY"] = "test-secret-for-tests"
|
||||
os.environ["RATE_LIMIT_ENABLED"] = "false"
|
||||
|
||||
from app.main import app
|
||||
from app.db import init_db
|
||||
init_db()
|
||||
|
||||
yield TestClient(app)
|
||||
|
||||
os.unlink(db_path)
|
||||
|
||||
|
||||
def _create_page(title, content="", content_format="blocks"):
|
||||
from app.db import get_conn
|
||||
with get_conn() as conn:
|
||||
cur = conn.execute(
|
||||
"INSERT INTO pages (workspace, title, content, content_format, parent_section) "
|
||||
"VALUES ('', ?, ?, ?, 'Private')",
|
||||
(title, content, content_format),
|
||||
)
|
||||
conn.commit()
|
||||
return cur.lastrowid
|
||||
|
||||
|
||||
def _create_collection(name, description="", icon="📋"):
|
||||
from app.db import get_conn
|
||||
with get_conn() as conn:
|
||||
cur = conn.execute(
|
||||
"INSERT INTO collections (name, description, icon, schema_json) "
|
||||
"VALUES (?, ?, ?, '[]')",
|
||||
(name, description, icon),
|
||||
)
|
||||
conn.commit()
|
||||
return cur.lastrowid
|
||||
|
||||
|
||||
# ── v5.2.0: versioned migrations ──
|
||||
|
||||
def test_schema_version_table_tracks_migrations(client):
|
||||
from app.db import get_conn
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT version, name FROM schema_version ORDER BY version"
|
||||
).fetchall()
|
||||
versions = [r["version"] for r in rows]
|
||||
assert 1 in versions # baseline
|
||||
assert max(versions) >= 2 # versioned migrations applied
|
||||
|
||||
|
||||
def test_missing_indexes_created(client):
|
||||
from app.db import get_conn
|
||||
with get_conn() as conn:
|
||||
idx = {r["name"] for r in conn.execute(
|
||||
"SELECT name FROM sqlite_master WHERE type='index'"
|
||||
).fetchall()}
|
||||
assert "idx_users_email" in idx
|
||||
assert "idx_user_oauth_tokens_user" in idx
|
||||
|
||||
|
||||
def test_fts5_virtual_table_and_triggers(client):
|
||||
from app.db import get_conn
|
||||
from app.migrations import fts5_available
|
||||
if not fts5_available():
|
||||
pytest.skip("FTS5 not available in this SQLite build")
|
||||
with get_conn() as conn:
|
||||
tbl = conn.execute(
|
||||
"SELECT 1 FROM sqlite_master WHERE type='table' AND name='pages_fts'"
|
||||
).fetchone()
|
||||
assert tbl is not None
|
||||
trigs = {r["name"] for r in conn.execute(
|
||||
"SELECT name FROM sqlite_master WHERE type='trigger'"
|
||||
).fetchall()}
|
||||
assert {"pages_fts_ai", "pages_fts_ad", "pages_fts_au"} <= trigs
|
||||
|
||||
|
||||
def test_fts_index_stays_in_sync_with_pages(client):
|
||||
from app.db import get_conn
|
||||
from app.migrations import fts5_available
|
||||
if not fts5_available():
|
||||
pytest.skip("FTS5 not available in this SQLite build")
|
||||
|
||||
pid = _create_page("SyncCheck", "alpha beta gamma")
|
||||
with get_conn() as conn:
|
||||
hits = conn.execute(
|
||||
"SELECT count(*) AS c FROM pages_fts WHERE pages_fts MATCH 'alpha'"
|
||||
).fetchone()["c"]
|
||||
assert hits == 1
|
||||
# updating a page refreshes the index
|
||||
conn.execute("UPDATE pages SET title='SyncCheck2', content='delta epsilon' WHERE id=?", (pid,))
|
||||
conn.commit()
|
||||
with get_conn() as conn:
|
||||
hits = conn.execute(
|
||||
"SELECT count(*) AS c FROM pages_fts WHERE pages_fts MATCH 'delta'"
|
||||
).fetchone()["c"]
|
||||
assert hits == 1
|
||||
old = conn.execute(
|
||||
"SELECT count(*) AS c FROM pages_fts WHERE pages_fts MATCH 'alpha'"
|
||||
).fetchone()["c"]
|
||||
assert old == 0
|
||||
# deleting a page removes it from the index
|
||||
conn.execute("DELETE FROM pages WHERE id=?", (pid,))
|
||||
conn.commit()
|
||||
with get_conn() as conn:
|
||||
gone = conn.execute(
|
||||
"SELECT count(*) AS c FROM pages_fts WHERE pages_fts MATCH 'delta'"
|
||||
).fetchone()["c"]
|
||||
assert gone == 0
|
||||
|
||||
|
||||
# ── v5.0.0: search API ──
|
||||
|
||||
def test_search_returns_pages_and_collections(client):
|
||||
_create_page("Roadmap 2026", '{"blocks":[{"type":"paragraph","content":"build the future"}]}')
|
||||
_create_collection("Projects Database", "track our roadmap projects", icon="🚀")
|
||||
|
||||
resp = client.get("/api/search", params={"q": "roadmap"})
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
|
||||
titles = [p["title"] for p in data["pages"]] + [c["title"] for c in data["collections"]]
|
||||
assert any("Roadmap 2026" in t for t in titles)
|
||||
assert any("Projects Database" in t for t in titles)
|
||||
|
||||
|
||||
def test_search_empty_query_returns_no_results(client):
|
||||
_create_page("Anything")
|
||||
resp = client.get("/api/search", params={"q": ""})
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["pages"] == []
|
||||
assert data["collections"] == []
|
||||
|
||||
|
||||
def test_search_excludes_deleted_pages(client):
|
||||
from app.db import get_conn
|
||||
pid = _create_page("SecretDraft", "very private content")
|
||||
with get_conn() as conn:
|
||||
conn.execute("UPDATE pages SET deleted_at=CURRENT_TIMESTAMP WHERE id=?", (pid,))
|
||||
conn.commit()
|
||||
|
||||
resp = client.get("/api/search", params={"q": "SecretDraft"})
|
||||
assert resp.status_code == 200
|
||||
assert not [p for p in resp.json()["pages"] if p["id"] == pid]
|
||||
|
||||
|
||||
def test_search_no_match_returns_empty(client):
|
||||
_create_page("Alpha")
|
||||
resp = client.get("/api/search", params={"q": "zzzz_nonexistent_qqqq"})
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["pages"] == []
|
||||
assert data["collections"] == []
|
||||
assert data["total"] == 0
|
||||
Reference in New Issue
Block a user