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
|
# 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
|
### Corrections
|
||||||
- **Test de connexion multi-provider** : le modèle configuré globalement (ex. `deepseek-v4-flash`)
|
- **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)
|
- Budget tokens max par conversation (500k tokens)
|
||||||
- Timeout 5 minutes par run
|
- Timeout 5 minutes par run
|
||||||
|
|
||||||
### v5.0.0 — Command Palette & Recherche ⬜ (stub uniquement)
|
### v5.0.0 — Command Palette & Recherche ✅ (2026-09-06)
|
||||||
> ⚠️ **État actuel** : `Ctrl+K` appelle `openQuickFind()` dans `base.html` qui n'affiche qu'un
|
> **Objectif** : `Ctrl+K` / `Ctrl+P` palette de commandes universelle + recherche full-text. **COMPLETED**.
|
||||||
> toast « Quick Find — Ctrl+K ». Le raccourci est déjà câblé, la palette elle-même n'existe pas.
|
|
||||||
|
|
||||||
- [ ] **Command palette** — Ctrl+K / Ctrl+P recherche universelle (modale, fuzzy, navigation clavier)
|
- [x] **Command palette** — Ctrl+K / Ctrl+P recherche universelle (modale, fuzzy, navigation clavier)
|
||||||
- [ ] **Quick actions** — navigation, création, commandes
|
- [x] **Quick actions** — navigation, création, commandes
|
||||||
- [ ] **Recherche full-text** — SQLite FTS5 sur pages + propriétés (prérequis technique de la palette)
|
- [x] **Recherche full-text** — SQLite FTS5 sur pages + propriétés (prérequis technique de la palette)
|
||||||
|
|
||||||
### v5.1.0 — Automations ⬜ (non commencé)
|
### v5.1.0 — Automations ⬜ (non commencé)
|
||||||
|
|
||||||
@@ -432,9 +431,10 @@ app/
|
|||||||
- [ ] **Database automations** — moteur de règles if-this-then-that (trigger + condition + action)
|
- [ ] **Database automations** — moteur de règles if-this-then-that (trigger + condition + action)
|
||||||
- [ ] **Buttons** — boutons cliquables déclenchant des actions
|
- [ ] **Buttons** — boutons cliquables déclenchant des actions
|
||||||
|
|
||||||
### v5.2.0 — Infrastructure & Polish ⬜ (à prioriser)
|
### v5.2.0 — Infrastructure & Polish 🔄 (en cours)
|
||||||
> ⚠️ **Priorité recommandée n°1** : les **migrations versionnées** d'abord — la base compte
|
> ✅ **Priorité n°1 livrée** : les **migrations versionnées** (voir `app/migrations.py` +
|
||||||
> déjà 30+ tables créées ad-hoc ; ajouter agent/automations sans versioning va créer une dette ingérable.
|
> 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`,
|
> **É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é.
|
> 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)
|
- [ ] **Onboarding wizard** — `/welcome` au premier lancement (créer compte → lier forges → premier projet)
|
||||||
|
|
||||||
**Infrastructure**
|
**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é
|
- [ ] **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)*
|
- [ ] **Linting** — ruff (Python), eslint (JS) *(aucune config actuellement)*
|
||||||
- [ ] **Tests parallèles** — pytest-xdist
|
- [ ] **Tests parallèles** — pytest-xdist
|
||||||
- [ ] **Build Docker multi-stage** — optimiser taille d'image
|
- [ ] **Build Docker multi-stage** — optimiser taille d'image
|
||||||
@@ -461,7 +461,7 @@ app/
|
|||||||
- [ ] **GitHubAdapter** complet — API GitHub v3 → interface ForgeAdapter
|
- [ ] **GitHubAdapter** complet — API GitHub v3 → interface ForgeAdapter
|
||||||
|
|
||||||
**Tests (cibles)**
|
**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 d'intégration auth (OAuth mock)
|
||||||
- [ ] Tests des adapters forge (mock HTTP)
|
- [ ] Tests des adapters forge (mock HTTP)
|
||||||
- [ ] Tests multi-user (permissions croisées)
|
- [ ] Tests multi-user (permissions croisées)
|
||||||
@@ -599,8 +599,8 @@ app/
|
|||||||
|
|
||||||
## 🎯 Ordre de priorité recommandé (état 2026-09)
|
## 🎯 Ordre de priorité recommandé (état 2026-09)
|
||||||
|
|
||||||
1. **v5.2.0 → Migrations versionnées** (bloquant pour tout le reste)
|
1. ~~**v5.2.0 → Migrations versionnées**~~ ✅ livré (`schema_version` + `app/migrations.py`)
|
||||||
2. **v5.0.0 → Command palette + FTS5** — Ctrl+K déjà câblé, petit effort / gros impact
|
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
|
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)
|
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é
|
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,
|
DB avancée, Wiki-links,
|
||||||
Calendrier, AI Templates & lock
|
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.execute("CREATE INDEX IF NOT EXISTS idx_agent_conv_llm ON agent_conversations(provider, model)")
|
||||||
conn.commit()
|
conn.commit()
|
||||||
|
|
||||||
|
# ── v5.2.0: apply any pending VERSIONED migrations (schema_version) ──
|
||||||
|
from app.migrations import apply_migrations
|
||||||
|
apply_migrations(conn)
|
||||||
|
|
||||||
|
|
||||||
@contextmanager
|
@contextmanager
|
||||||
def get_conn():
|
def get_conn():
|
||||||
|
|||||||
+3
-2
@@ -14,7 +14,7 @@ from app.config import settings
|
|||||||
from app.db import init_db
|
from app.db import init_db
|
||||||
from app.middleware.csrf import CSRFMiddleware
|
from app.middleware.csrf import CSRFMiddleware
|
||||||
from app.middleware.security import ContentSecurityPolicyMiddleware, RateLimitMiddleware
|
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.notifications import router as notifications_router
|
||||||
from app.routers.collaboration import router as collaboration_router
|
from app.routers.collaboration import router as collaboration_router
|
||||||
from app.routers.gitea import router as gitea_router
|
from app.routers.gitea import router as gitea_router
|
||||||
@@ -60,7 +60,7 @@ async def lifespan(_app: FastAPI):
|
|||||||
|
|
||||||
app = FastAPI(
|
app = FastAPI(
|
||||||
title="FlowDeck",
|
title="FlowDeck",
|
||||||
version="4.15.5",
|
version="5.0.0",
|
||||||
docs_url="/docs" if settings.log_level == "DEBUG" else None,
|
docs_url="/docs" if settings.log_level == "DEBUG" else None,
|
||||||
redoc_url=None,
|
redoc_url=None,
|
||||||
lifespan=lifespan,
|
lifespan=lifespan,
|
||||||
@@ -92,6 +92,7 @@ app.include_router(export.router)
|
|||||||
app.include_router(notifications_router)
|
app.include_router(notifications_router)
|
||||||
app.include_router(collaboration_router)
|
app.include_router(collaboration_router)
|
||||||
app.include_router(agent.router)
|
app.include_router(agent.router)
|
||||||
|
app.include_router(search.router)
|
||||||
|
|
||||||
app.mount("/static", StaticFiles(directory="static"), name="static")
|
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">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>FlowDeck — {% block title_prefix %}Home{% endblock %}</title>
|
<title>FlowDeck — {% block title_prefix %}Home{% endblock %}</title>
|
||||||
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg">
|
<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">
|
<link rel="stylesheet" href="/static/css/katex.min.css?v=0.16.11">
|
||||||
<style>
|
<style>
|
||||||
/* ── Mobile responsive (v4.0.2) ── */
|
/* ── Mobile responsive (v4.0.2) ── */
|
||||||
@@ -1236,7 +1236,7 @@
|
|||||||
this.sidebarCollapsed = true;
|
this.sidebarCollapsed = true;
|
||||||
window.location.href = url;
|
window.location.href = url;
|
||||||
},
|
},
|
||||||
openQuickFind() { this.toast('Quick Find — Ctrl+K', 'info'); },
|
openQuickFind() { if (window.FlowDeckPalette) window.FlowDeckPalette.open(); },
|
||||||
|
|
||||||
// ── Tree state ──
|
// ── Tree state ──
|
||||||
expandedNodes: {},
|
expandedNodes: {},
|
||||||
@@ -1859,5 +1859,184 @@
|
|||||||
|
|
||||||
{% include 'agent_panel.html' %}
|
{% 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>
|
</body>
|
||||||
</html>
|
</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