Files
lifeos-dev/routers/search.py

245 lines
9.3 KiB
Python

"""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",
})