Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
301 changes: 183 additions & 118 deletions api/search.py
Original file line number Diff line number Diff line change
@@ -1,118 +1,183 @@
"""
API route for search — mirrors src/app/api/search/route.ts
GET /api/search?q=...&type=all|chat|composer&all_history=1
"""

import logging
from typing import Any

from flask import Blueprint, Response, current_app, request

from api.flask_config import json_response

from models import ParseWarningCollector, SearchResult
from services.search import (
DEFAULT_SEARCH_WINDOW_DAYS,
rank_results,
resolve_search_since_ms,
search_cli_sessions,
search_global_storage,
search_legacy_workspaces,
)
from utils.workspace_path import get_cli_chats_path, resolve_workspace_path

bp = Blueprint("search", __name__)
_logger = logging.getLogger(__name__)

_MAX_SEARCH_SINCE_DAYS = 36_500 # ~100 years; avoids timedelta overflow on bad input


def _parse_since_days_param(raw: str | None) -> int | None:
if raw is None or not str(raw).strip():
return None
try:
days = int(raw)
except ValueError:
return None
if days <= 0 or days > _MAX_SEARCH_SINCE_DAYS:
return None
return days


@bp.route("/api/search")
def search() -> tuple[Response, int] | Response:
"""Search chats, composers, and CLI sessions across Cursor storage.

Args:
q: Search query string (required; 400 when empty).
type: Filter scope — ``all`` (default), ``chat``, or ``composer``.

Returns:
JSON ``{"results": [...]}`` with optional ``warnings``. 400 when ``q`` is
empty; 500 with ``{"error": ..., "results": []}`` on unexpected failure.
"""
try:
query = request.args.get("q", "").strip()
search_type = request.args.get("type", "all")
rules = current_app.config.get("EXCLUSION_RULES") or []
all_history = request.args.get("all_history") in ("1", "true")
since_ms = resolve_search_since_ms(
all_history=all_history,
since_days=_parse_since_days_param(request.args.get("since_days")),
)

if not query:
return json_response({"error": "No search query provided"}, 400)
workspace_path = resolve_workspace_path()
parse_warnings = ParseWarningCollector()
query_lower = query.lower()

results: list[SearchResult] = []
if search_type != "chat":
results.extend(
search_global_storage(
workspace_path,
query,
query_lower,
rules,
parse_warnings,
since_ms=since_ms,
)
)
results.extend(
search_legacy_workspaces(
workspace_path,
query,
query_lower,
search_type,
rules,
since_ms=since_ms,
)
)
if search_type == "all":
results.extend(
search_cli_sessions(
get_cli_chats_path(),
query,
query_lower,
rules,
parse_warnings,
since_ms=since_ms,
)
)

payload: dict[str, Any] = {
"results": rank_results(results),
"allHistory": since_ms is None,
"searchWindowDays": (
None if since_ms is None else (
_parse_since_days_param(request.args.get("since_days"))
or DEFAULT_SEARCH_WINDOW_DAYS
)
),
}
return json_response(parse_warnings.attach_to(payload))

except Exception:
_logger.exception("Search failed")
return json_response({"error": "Search failed", "results": []}, 500)
"""
API route for search — mirrors src/app/api/search/route.ts
GET /api/search?q=...&type=all|chat|composer&all_history=1&workspace=<id>
"""

from __future__ import annotations

import logging
import os
import sqlite3
from typing import Any

from flask import Blueprint, Response, current_app, request

from api.flask_config import json_response

from models import ParseWarningCollector, SearchResult
from services.search import (
DEFAULT_SEARCH_WINDOW_DAYS,
rank_results,
resolve_search_since_ms,
search_cli_sessions,
search_global_storage,
search_legacy_workspaces,
)
from utils.cli_chat_reader import list_cli_projects
from utils.workspace_path import get_cli_chats_path, resolve_workspace_path

bp = Blueprint("search", __name__)
_logger = logging.getLogger(__name__)

_MAX_SEARCH_SINCE_DAYS = 36_500 # ~100 years; avoids timedelta overflow on bad input
_MAX_SEARCH_QUERY_LEN = 500
_VALID_SEARCH_TYPES = frozenset({"all", "chat", "composer"})


def _parse_since_days_param(raw: str | None) -> int | None:
if raw is None or not str(raw).strip():
return None
try:
days = int(raw)
except ValueError:
return None
if days <= 0 or days > _MAX_SEARCH_SINCE_DAYS:
return None
return days


def _search_error(
message: str,
code: str,
status: int,
) -> tuple[Response, int]:
return json_response({"error": message, "code": code}, status)


def _workspace_exists(workspace_id: str, workspace_path: str) -> bool:
if workspace_id == "global":
return True
if workspace_id.startswith("cli:"):
project_id = workspace_id[4:]
return any(
cp.get("project_id") == project_id
for cp in list_cli_projects(get_cli_chats_path())
)
return os.path.isdir(os.path.join(workspace_path, workspace_id))
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated


def _filter_results_by_workspace(
results: list[SearchResult],
workspace_id: str,
) -> list[SearchResult]:
return [r for r in results if r.get("workspaceId") == workspace_id]


@bp.route("/api/search")
def search() -> tuple[Response, int] | Response:
"""Search chats, composers, and CLI sessions across Cursor storage.

Args:
q: Search query string (required; 400 when empty).
type: Filter scope — ``all`` (default), ``chat``, or ``composer``.
workspace: Optional workspace folder hash; 404 when unknown.

Returns:
JSON ``{"results": [...]}`` with optional ``warnings``. Structured
``{"error", "code"}`` bodies for 400/404/503/500 failures.
"""
query = request.args.get("q", "").strip()
if not query:
return _search_error("No search query provided", "empty_query", 400)
if len(query) > _MAX_SEARCH_QUERY_LEN:
return _search_error("Search query is too long", "query_too_long", 400)

search_type = request.args.get("type", "all")
if search_type not in _VALID_SEARCH_TYPES:
return _search_error("Invalid search type", "invalid_type", 400)

since_days_raw = request.args.get("since_days")
if (
since_days_raw is not None
and str(since_days_raw).strip()
and _parse_since_days_param(since_days_raw) is None
):
return _search_error("Invalid since_days parameter", "invalid_since_days", 400)

workspace_filter = request.args.get("workspace", "").strip() or None
workspace_path = resolve_workspace_path()
if workspace_filter and not _workspace_exists(workspace_filter, workspace_path):
return _search_error("Workspace not found", "workspace_not_found", 404)

try:
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
rules = current_app.config.get("EXCLUSION_RULES") or []
all_history = request.args.get("all_history") in ("1", "true")
since_ms = resolve_search_since_ms(
all_history=all_history,
since_days=_parse_since_days_param(since_days_raw),
)

parse_warnings = ParseWarningCollector()
query_lower = query.lower()

results: list[SearchResult] = []
if search_type != "chat":
results.extend(
search_global_storage(
workspace_path,
query,
query_lower,
rules,
parse_warnings,
since_ms=since_ms,
)
)
results.extend(
search_legacy_workspaces(
workspace_path,
query,
query_lower,
search_type,
rules,
since_ms=since_ms,
)
)
if search_type == "all":
results.extend(
search_cli_sessions(
get_cli_chats_path(),
query,
query_lower,
rules,
parse_warnings,
since_ms=since_ms,
)
)

ranked = rank_results(results)
if workspace_filter:
ranked = _filter_results_by_workspace(ranked, workspace_filter)

payload: dict[str, Any] = {
"results": ranked,
"allHistory": since_ms is None,
"searchWindowDays": (
None if since_ms is None else (
_parse_since_days_param(since_days_raw)
or DEFAULT_SEARCH_WINDOW_DAYS
)
),
}
return json_response(parse_warnings.attach_to(payload))

except sqlite3.OperationalError:
_logger.exception("Search index unavailable")
return _search_error(
"Search index is temporarily unavailable",
"search_index_unavailable",
503,
)
except Exception:
_logger.exception("Search failed")
return _search_error("Search failed", "internal_error", 500)

Expand Down
46 changes: 45 additions & 1 deletion static/css/style.css
Original file line number Diff line number Diff line change
Expand Up @@ -245,9 +245,53 @@ h3 { font-size: 1.15rem; font-weight: 600; }
}
.input:focus { border-color: var(--blue); box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.2); }

.search-bar { display: flex; gap: 0.5rem; }
.search-bar { display: flex; gap: 0.5rem; align-items: center; }
.search-bar .input { flex: 1; }

.search-info-tooltip {
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.search-info-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 1.25rem;
height: 1.25rem;
border-radius: 50%;
border: 1px solid var(--border);
color: var(--text-muted);
font-size: 0.75rem;
font-weight: 700;
font-style: italic;
cursor: help;
user-select: none;
}
.search-info-tooltip-text {
display: none;
position: absolute;
top: calc(100% + 0.5rem);
right: 0;
z-index: 60;
width: min(20rem, 70vw);
padding: 0.75rem 0.875rem;
border: 1px solid var(--info-border);
border-radius: 0.5rem;
background: var(--info-bg);
color: var(--info-text);
font-size: 0.8125rem;
line-height: 1.45;
box-shadow: 0 4px 12px var(--shadow);
}
.search-info-tooltip:hover .search-info-tooltip-text,
.search-info-tooltip:focus .search-info-tooltip-text,
.search-info-tooltip:focus-within .search-info-tooltip-text {
display: block;
}

/* ---------- Alerts ---------- */
.alert {
padding: 0.75rem 1rem;
Expand Down
13 changes: 11 additions & 2 deletions templates/search.html
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,14 @@ <h1>Search</h1>
<div class="form-group" style="margin-bottom:1.5rem">
<div class="search-bar">
<input type="text" id="search-input" class="input" placeholder="Search across all logs..." autofocus>
<span class="search-info-tooltip" tabindex="0" aria-label="Search window help">
<span class="search-info-icon" aria-hidden="true">i</span>
<span class="search-info-tooltip-text">
By default, search covers the most recent 30-day indexed window.
Chats without a parseable date are always included.
Check &ldquo;Include chats older than 30 days&rdquo; to search full history.
</span>
</span>
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
<button class="btn btn-primary" onclick="doSearch()">Search</button>
</div>
<label class="text-sm" style="display:flex;align-items:center;gap:0.5rem;margin-top:0.75rem;cursor:pointer">
Expand Down Expand Up @@ -94,13 +102,14 @@ <h1>Search</h1>
const params = new URLSearchParams({ q: query, type: type });
if (allHistory) params.set('all_history', '1');
const res = await fetch(`/api/search?${params.toString()}`);
const data = await res.json().catch(() => ({}));
if (!res.ok) {
showIncompleteResultsBanner('parse-warnings-host', []);
loading.style.display = 'none';
container.innerHTML = '<p class="text-danger">Search failed.</p>';
const message = typeof data.error === 'string' ? data.error : 'Search failed.';
container.innerHTML = `<p class="text-danger">${escapeHtml(message)}</p>`;
return;
}
Comment thread
clean6378-max-it marked this conversation as resolved.
const data = await res.json();
const results = data.results || [];
showIncompleteResultsBanner('parse-warnings-host', data.warnings);

Expand Down
Loading
Loading