"""Global search: Cmd/K modal, tsvector full-text search across all entities.""" import re from fastapi import APIRouter, Request, Depends, Query from fastapi.templating import Jinja2Templates from fastapi.responses import JSONResponse from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import text from typing import Optional from core.database import get_db from core.sidebar import get_sidebar_data router = APIRouter(prefix="/search", tags=["search"]) templates = Jinja2Templates(directory="templates") def build_prefix_tsquery(query: str) -> Optional[str]: """Build a prefix-matching tsquery string from user input. 'Sys Admin' -> 'Sys:* & Admin:*' Returns None if no valid terms. """ terms = query.strip().split() # Keep only alphanumeric terms >= 2 chars clean = [re.sub(r'[^\w]', '', t) for t in terms] clean = [t for t in clean if len(t) >= 2] if not clean: return None return " & ".join(f"{t}:*" for t in clean) # Entity search configs # Each has: type, label, table, name_col, joins, extra_cols, url, icon SEARCH_ENTITIES = [ { "type": "tasks", "label": "Tasks", "table": "tasks", "alias": "t", "name_col": "t.title", "status_col": "t.status", "joins": "LEFT JOIN domains d ON t.domain_id = d.id LEFT JOIN projects p ON t.project_id = p.id", "domain_col": "d.name", "project_col": "p.name", "url": "/tasks/{id}", "icon": "task", }, { "type": "projects", "label": "Projects", "table": "projects", "alias": "p", "name_col": "p.name", "status_col": "p.status", "joins": "LEFT JOIN domains d ON p.domain_id = d.id", "domain_col": "d.name", "project_col": "NULL", "url": "/projects/{id}", "icon": "project", }, { "type": "notes", "label": "Notes", "table": "notes", "alias": "n", "name_col": "n.title", "status_col": "NULL", "joins": "LEFT JOIN domains d ON n.domain_id = d.id LEFT JOIN projects p ON n.project_id = p.id", "domain_col": "d.name", "project_col": "p.name", "url": "/notes/{id}", "icon": "note", }, { "type": "contacts", "label": "Contacts", "table": "contacts", "alias": "c", "name_col": "(c.first_name || ' ' || coalesce(c.last_name, ''))", "status_col": "NULL", "joins": "", "domain_col": "c.company", "project_col": "NULL", "url": "/contacts/{id}", "icon": "contact", }, { "type": "links", "label": "Links", "table": "links", "alias": "l", "name_col": "l.label", "status_col": "NULL", "joins": "LEFT JOIN domains d ON l.domain_id = d.id LEFT JOIN projects p ON l.project_id = p.id", "domain_col": "d.name", "project_col": "p.name", "url": "/links", "icon": "link", }, { "type": "lists", "label": "Lists", "table": "lists", "alias": "l", "name_col": "l.name", "status_col": "NULL", "joins": "LEFT JOIN domains d ON l.domain_id = d.id LEFT JOIN projects p ON l.project_id = p.id", "domain_col": "d.name", "project_col": "p.name", "url": "/lists/{id}", "icon": "list", }, { "type": "meetings", "label": "Meetings", "table": "meetings", "alias": "m", "name_col": "m.title", "status_col": "m.status", "joins": "", "domain_col": "NULL", "project_col": "NULL", "url": "/meetings/{id}", "icon": "meeting", }, { "type": "decisions", "label": "Decisions", "table": "decisions", "alias": "d", "name_col": "d.title", "status_col": "d.status", "joins": "", "domain_col": "NULL", "project_col": "NULL", "url": "/decisions/{id}", "icon": "decision", }, { "type": "weblinks", "label": "Weblinks", "table": "weblinks", "alias": "w", "name_col": "w.label", "status_col": "NULL", "joins": "", "domain_col": "NULL", "project_col": "NULL", "url": "/weblinks", "icon": "weblink", }, { "type": "processes", "label": "Processes", "table": "processes", "alias": "p", "name_col": "p.name", "status_col": "p.status", "joins": "", "domain_col": "p.category", "project_col": "NULL", "url": "/processes/{id}", "icon": "process", }, { "type": "appointments", "label": "Appointments", "table": "appointments", "alias": "a", "name_col": "a.title", "status_col": "NULL", "joins": "", "domain_col": "a.location", "project_col": "NULL", "url": "/appointments/{id}", "icon": "appointment", }, ] async def _search_entity(entity: dict, q: str, tsquery_str: str, limit: int, db: AsyncSession) -> list[dict]: """Search a single entity using prefix tsquery, with ILIKE fallback.""" a = entity["alias"] results = [] seen_ids = set() # 1. tsvector prefix search if tsquery_str: sql = f""" SELECT {a}.id, {entity['name_col']} as name, {entity['status_col']} as status, {entity['domain_col']} as domain_name, {entity['project_col']} as project_name, ts_rank({a}.search_vector, to_tsquery('english', :tsq)) as rank FROM {entity['table']} {a} {entity['joins']} WHERE {a}.is_deleted = false AND {a}.search_vector @@ to_tsquery('english', :tsq) ORDER BY rank DESC LIMIT :lim """ try: result = await db.execute(text(sql), {"tsq": tsquery_str, "lim": limit}) for r in result: row = dict(r._mapping) results.append(row) seen_ids.add(str(row["id"])) except Exception: pass # 2. ILIKE fallback if < 3 tsvector results if len(results) < 3: ilike_param = f"%{q.strip()}%" remaining = limit - len(results) if remaining > 0: # Build exclusion for already-found IDs exclude_sql = "" params = {"ilike_q": ilike_param, "lim2": remaining} if seen_ids: id_placeholders = ", ".join(f":ex_{i}" for i in range(len(seen_ids))) exclude_sql = f"AND {a}.id NOT IN ({id_placeholders})" for i, sid in enumerate(seen_ids): params[f"ex_{i}"] = sid sql2 = f""" SELECT {a}.id, {entity['name_col']} as name, {entity['status_col']} as status, {entity['domain_col']} as domain_name, {entity['project_col']} as project_name, 0.0 as rank FROM {entity['table']} {a} {entity['joins']} WHERE {a}.is_deleted = false AND {entity['name_col']} ILIKE :ilike_q {exclude_sql} ORDER BY {entity['name_col']} LIMIT :lim2 """ try: result = await db.execute(text(sql2), params) for r in result: results.append(dict(r._mapping)) except Exception: pass return results @router.get("/api") async def search_api( q: str = Query("", min_length=1), entity_type: Optional[str] = None, limit: int = Query(5, ge=1, le=20), db: AsyncSession = Depends(get_db), ): """JSON search endpoint for the Cmd/K modal.""" if not q or not q.strip() or len(q.strip()) < 2: return JSONResponse({"results": [], "query": q}) tsquery_str = build_prefix_tsquery(q) results = [] entities = SEARCH_ENTITIES if entity_type: entities = [e for e in entities if e["type"] == entity_type] for entity in entities: rows = await _search_entity(entity, q, tsquery_str, limit, db) for row in rows: results.append({ "type": entity["type"], "type_label": entity["label"], "id": str(row["id"]), "name": row["name"], "status": row.get("status"), "context": " / ".join(filter(None, [row.get("domain_name"), row.get("project_name")])), "url": entity["url"].format(id=row["id"]), "rank": float(row.get("rank", 0)), "icon": entity["icon"], }) # Sort all results by rank descending results.sort(key=lambda r: r["rank"], reverse=True) return JSONResponse({"results": results[:20], "query": q}) @router.get("/") async def search_page( request: Request, q: str = "", db: AsyncSession = Depends(get_db), ): """Full search page (fallback for non-JS).""" sidebar = await get_sidebar_data(db) results = [] if q and q.strip() and len(q.strip()) >= 2: tsquery_str = build_prefix_tsquery(q) for entity in SEARCH_ENTITIES: rows = await _search_entity(entity, q, tsquery_str, 10, db) for row in rows: results.append({ "type": entity["type"], "type_label": entity["label"], "id": str(row["id"]), "name": row["name"], "status": row.get("status"), "context": " / ".join(filter(None, [row.get("domain_name"), row.get("project_name")])), "url": entity["url"].format(id=row["id"]), "icon": entity["icon"], }) return templates.TemplateResponse("search.html", { "request": request, "sidebar": sidebar, "results": results, "query": q, "page_title": "Search", "active_nav": "search", })