191 lines
7.0 KiB
Python
191 lines
7.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",
|
|
},
|
|
]
|
|
|
|
|
|
@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",
|
|
})
|