Files
lifeos-dev/routers/search.py

219 lines
8.0 KiB
Python

"""Global search: Cmd/K modal, tsvector full-text search across all entities."""
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")
# Entity search configs: (table, title_col, subtitle_query, url_pattern, icon)
SEARCH_ENTITIES = [
{
"type": "tasks",
"label": "Tasks",
"query": """
SELECT t.id, t.title as name, t.status,
d.name as domain_name, p.name as project_name,
ts_rank(t.search_vector, websearch_to_tsquery('english', :q)) as rank
FROM tasks t
LEFT JOIN domains d ON t.domain_id = d.id
LEFT JOIN projects p ON t.project_id = p.id
WHERE t.is_deleted = false AND t.search_vector @@ websearch_to_tsquery('english', :q)
ORDER BY rank DESC LIMIT :lim
""",
"url": "/tasks/{id}",
"icon": "task",
},
{
"type": "projects",
"label": "Projects",
"query": """
SELECT p.id, p.name, p.status,
d.name as domain_name, NULL as project_name,
ts_rank(p.search_vector, websearch_to_tsquery('english', :q)) as rank
FROM projects p
LEFT JOIN domains d ON p.domain_id = d.id
WHERE p.is_deleted = false AND p.search_vector @@ websearch_to_tsquery('english', :q)
ORDER BY rank DESC LIMIT :lim
""",
"url": "/projects/{id}",
"icon": "project",
},
{
"type": "notes",
"label": "Notes",
"query": """
SELECT n.id, n.title as name, NULL as status,
d.name as domain_name, p.name as project_name,
ts_rank(n.search_vector, websearch_to_tsquery('english', :q)) as rank
FROM notes n
LEFT JOIN domains d ON n.domain_id = d.id
LEFT JOIN projects p ON n.project_id = p.id
WHERE n.is_deleted = false AND n.search_vector @@ websearch_to_tsquery('english', :q)
ORDER BY rank DESC LIMIT :lim
""",
"url": "/notes/{id}",
"icon": "note",
},
{
"type": "contacts",
"label": "Contacts",
"query": """
SELECT c.id, (c.first_name || ' ' || coalesce(c.last_name, '')) as name,
NULL as status, c.company as domain_name, NULL as project_name,
ts_rank(c.search_vector, websearch_to_tsquery('english', :q)) as rank
FROM contacts c
WHERE c.is_deleted = false AND c.search_vector @@ websearch_to_tsquery('english', :q)
ORDER BY rank DESC LIMIT :lim
""",
"url": "/contacts/{id}",
"icon": "contact",
},
{
"type": "links",
"label": "Links",
"query": """
SELECT l.id, l.label as name, NULL as status,
d.name as domain_name, p.name as project_name,
ts_rank(l.search_vector, websearch_to_tsquery('english', :q)) as rank
FROM links l
LEFT JOIN domains d ON l.domain_id = d.id
LEFT JOIN projects p ON l.project_id = p.id
WHERE l.is_deleted = false AND l.search_vector @@ websearch_to_tsquery('english', :q)
ORDER BY rank DESC LIMIT :lim
""",
"url": "/links",
"icon": "link",
},
{
"type": "lists",
"label": "Lists",
"query": """
SELECT l.id, l.name, NULL as status,
d.name as domain_name, p.name as project_name,
ts_rank(l.search_vector, websearch_to_tsquery('english', :q)) as rank
FROM lists l
LEFT JOIN domains d ON l.domain_id = d.id
LEFT JOIN projects p ON l.project_id = p.id
WHERE l.is_deleted = false AND l.search_vector @@ websearch_to_tsquery('english', :q)
ORDER BY rank DESC LIMIT :lim
""",
"url": "/lists/{id}",
"icon": "list",
},
{
"type": "meetings",
"label": "Meetings",
"query": """
SELECT m.id, m.title as name, m.status,
NULL as domain_name, NULL as project_name,
ts_rank(m.search_vector, websearch_to_tsquery('english', :q)) as rank
FROM meetings m
WHERE m.is_deleted = false AND m.search_vector @@ websearch_to_tsquery('english', :q)
ORDER BY rank DESC LIMIT :lim
""",
"url": "/meetings/{id}",
"icon": "meeting",
},
{
"type": "decisions",
"label": "Decisions",
"query": """
SELECT d.id, d.title as name, d.status,
NULL as domain_name, NULL as project_name,
ts_rank(d.search_vector, websearch_to_tsquery('english', :q)) as rank
FROM decisions d
WHERE d.is_deleted = false AND d.search_vector @@ websearch_to_tsquery('english', :q)
ORDER BY rank DESC LIMIT :lim
""",
"url": "/decisions/{id}",
"icon": "decision",
},
]
@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():
return JSONResponse({"results": [], "query": q})
results = []
entities = SEARCH_ENTITIES
if entity_type:
entities = [e for e in entities if e["type"] == entity_type]
for entity in entities:
try:
result = await db.execute(text(entity["query"]), {"q": q.strip(), "lim": limit})
rows = [dict(r._mapping) for r in result]
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"],
})
except Exception:
# Table might not have search_vector yet, skip silently
continue
# 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():
for entity in SEARCH_ENTITIES:
try:
result = await db.execute(text(entity["query"]), {"q": q.strip(), "lim": 10})
rows = [dict(r._mapping) for r in result]
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"],
})
except Exception:
continue
return templates.TemplateResponse("search.html", {
"request": request, "sidebar": sidebar,
"results": results, "query": q,
"page_title": "Search", "active_nav": "search",
})