various enhancements for new tabs and bug fixes
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
"""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
|
||||
@@ -14,174 +15,162 @@ router = APIRouter(prefix="/search", tags=["search"])
|
||||
templates = Jinja2Templates(directory="templates")
|
||||
|
||||
|
||||
# Entity search configs: (table, title_col, subtitle_query, url_pattern, icon)
|
||||
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",
|
||||
"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": "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",
|
||||
"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": "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",
|
||||
"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": "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",
|
||||
"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": "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",
|
||||
"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": "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",
|
||||
"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": "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",
|
||||
"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": "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",
|
||||
"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",
|
||||
"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",
|
||||
"query": """
|
||||
SELECT w.id, w.label as name, NULL as status,
|
||||
NULL as domain_name, NULL as project_name,
|
||||
ts_rank(w.search_vector, websearch_to_tsquery('english', :q)) as rank
|
||||
FROM weblinks w
|
||||
WHERE w.is_deleted = false AND w.search_vector @@ websearch_to_tsquery('english', :q)
|
||||
ORDER BY rank DESC LIMIT :lim
|
||||
""",
|
||||
"url": "/weblinks",
|
||||
"icon": "weblink",
|
||||
"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",
|
||||
"query": """
|
||||
SELECT p.id, p.name, p.status,
|
||||
p.category as domain_name, NULL as project_name,
|
||||
ts_rank(p.search_vector, websearch_to_tsquery('english', :q)) as rank
|
||||
FROM processes p
|
||||
WHERE p.is_deleted = false AND p.search_vector @@ websearch_to_tsquery('english', :q)
|
||||
ORDER BY rank DESC LIMIT :lim
|
||||
""",
|
||||
"url": "/processes/{id}",
|
||||
"icon": "process",
|
||||
"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",
|
||||
"query": """
|
||||
SELECT a.id, a.title as name, NULL as status,
|
||||
a.location as domain_name, NULL as project_name,
|
||||
ts_rank(a.search_vector, websearch_to_tsquery('english', :q)) as rank
|
||||
FROM appointments a
|
||||
WHERE a.is_deleted = false AND a.search_vector @@ websearch_to_tsquery('english', :q)
|
||||
ORDER BY rank DESC LIMIT :lim
|
||||
""",
|
||||
"url": "/appointments/{id}",
|
||||
"icon": "appointment",
|
||||
"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),
|
||||
@@ -190,33 +179,30 @@ async def search_api(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""JSON search endpoint for the Cmd/K modal."""
|
||||
if not q or not q.strip():
|
||||
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:
|
||||
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
|
||||
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)
|
||||
@@ -234,24 +220,22 @@ async def search_page(
|
||||
sidebar = await get_sidebar_data(db)
|
||||
results = []
|
||||
|
||||
if q and q.strip():
|
||||
if q and q.strip() and len(q.strip()) >= 2:
|
||||
tsquery_str = build_prefix_tsquery(q)
|
||||
|
||||
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
|
||||
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,
|
||||
|
||||
Reference in New Issue
Block a user