238 lines
9.0 KiB
Python
238 lines
9.0 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": "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",
|
|
})
|