Skip to content

Commit 3b22f3f

Browse files
fix: distinct /api/search error codes and search UX polish
1 parent 6339016 commit 3b22f3f

10 files changed

Lines changed: 341 additions & 49 deletions

File tree

api/search.py

Lines changed: 80 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
from __future__ import annotations
44

55
import logging
6+
import os
67
from datetime import datetime, timezone
78
from typing import Any
89

@@ -31,9 +32,14 @@
3132

3233
_DEFAULT_LIMIT = 50
3334
_MAX_LIMIT = 500
35+
_MAX_QUERY_LEN = 500
3436
_MAX_SEARCH_SINCE_DAYS = 36_500
3537

3638

39+
class _SearchIndexUnavailable(Exception):
40+
"""Raised when the FTS index exists but is locked during rebuild."""
41+
42+
3743
def _parse_limit(raw: str | None, default: int = _DEFAULT_LIMIT) -> int:
3844
"""Parse a positive integer limit from a query string value."""
3945
if raw is None or raw.strip() == "":
@@ -52,13 +58,23 @@ def _parse_since_days(raw: str | None) -> int | None:
5258
return None
5359
try:
5460
days = int(str(raw).strip())
55-
except ValueError:
56-
return None
61+
except ValueError as exc:
62+
raise ValueError("Invalid since_days: must be a positive integer") from exc
5763
if days <= 0:
58-
return None
64+
raise ValueError("Invalid since_days: must be a positive integer")
5965
return min(days, _MAX_SEARCH_SINCE_DAYS)
6066

6167

68+
def _projects_dir_available(projects_dir: str) -> bool:
69+
try:
70+
if not os.path.isdir(projects_dir):
71+
return False
72+
os.listdir(projects_dir)
73+
return True
74+
except OSError:
75+
return False
76+
77+
6278
def _message_searchable_text(msg: MessageDict) -> str:
6379
text = msg.get("text", "") or msg.get("content", "")
6480
if not isinstance(text, str):
@@ -119,6 +135,8 @@ def _search_via_index(
119135
max_results=need,
120136
sql_offset=sql_offset,
121137
)
138+
if indexed["index_locked"]:
139+
raise _SearchIndexUnavailable()
122140
if not indexed["query_ok"]:
123141
return None
124142
if indexed["sql_rows_fetched"] == 0:
@@ -209,9 +227,20 @@ def _search_live_scan(
209227

210228
@search_bp.route("/api/search")
211229
def search() -> FlaskReturn:
212-
query = request.args.get("q", "").strip()
230+
raw_query = request.args.get("q", "")
231+
query = raw_query.strip()
213232
if not query:
214-
return json_response([])
233+
return error_response(
234+
ErrorCode.SEARCH_EMPTY_QUERY,
235+
"Search query is required",
236+
400,
237+
)
238+
if len(query) > _MAX_QUERY_LEN:
239+
return error_response(
240+
ErrorCode.SEARCH_QUERY_TOO_LONG,
241+
f"Search query must be at most {_MAX_QUERY_LEN} characters",
242+
400,
243+
)
215244

216245
try:
217246
max_results = _parse_limit(request.args.get("limit"))
@@ -222,35 +251,66 @@ def search() -> FlaskReturn:
222251
400,
223252
)
224253

254+
since_days_raw = request.args.get("since_days")
255+
try:
256+
since_days = _parse_since_days(since_days_raw)
257+
except ValueError:
258+
return error_response(
259+
ErrorCode.SEARCH_INVALID_SINCE_DAYS,
260+
"Invalid since_days: must be a positive integer",
261+
400,
262+
)
263+
225264
query_lower = query.lower()
226265
all_history = request.args.get("all_history") in ("1", "true")
227266
since_ms = resolve_search_since_ms(
228267
all_history=all_history,
229-
since_days=_parse_since_days(request.args.get("since_days")),
268+
since_days=since_days,
230269
now=datetime.now(timezone.utc),
231270
)
232271

233272
base = current_app.config.get("CLAUDE_PROJECTS_DIR") or get_claude_projects_dir()
234-
rules = current_app.config.get("EXCLUSION_RULES") or []
273+
if not _projects_dir_available(base):
274+
return error_response(
275+
ErrorCode.SEARCH_PROJECTS_UNAVAILABLE,
276+
"Claude projects directory is not accessible",
277+
503,
278+
)
235279

236-
indexed = _search_via_index(
237-
base,
238-
rules,
239-
query,
240-
query_lower,
241-
since_ms=since_ms,
242-
max_results=max_results,
243-
)
244-
if indexed is not None:
245-
return json_response(indexed)
280+
rules = current_app.config.get("EXCLUSION_RULES") or []
246281

247-
return json_response(
248-
_search_live_scan(
282+
try:
283+
indexed = _search_via_index(
249284
base,
250285
rules,
251286
query,
252287
query_lower,
253288
since_ms=since_ms,
254289
max_results=max_results,
255290
)
256-
)
291+
if indexed is not None:
292+
return json_response(indexed)
293+
294+
return json_response(
295+
_search_live_scan(
296+
base,
297+
rules,
298+
query,
299+
query_lower,
300+
since_ms=since_ms,
301+
max_results=max_results,
302+
)
303+
)
304+
except _SearchIndexUnavailable:
305+
return error_response(
306+
ErrorCode.SEARCH_INDEX_UNAVAILABLE,
307+
"Search index is temporarily unavailable",
308+
503,
309+
)
310+
except Exception:
311+
_logger.exception("Unexpected error during search")
312+
return error_response(
313+
ErrorCode.INTERNAL_ERROR,
314+
"Search failed",
315+
500,
316+
)

docs/api-reference.md

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,11 @@ Extra fields may appear for specific codes (for example `since` on invalid bulk-
5656
| `code` | HTTP | Routes | Meaning |
5757
|--------|------|--------|---------|
5858
| `SEARCH_INVALID_LIMIT` | 400 | `GET /api/search` | Query param `limit` is not a positive integer |
59+
| `SEARCH_EMPTY_QUERY` | 400 | `GET /api/search` | Query param `q` is empty or whitespace |
60+
| `SEARCH_QUERY_TOO_LONG` | 400 | `GET /api/search` | Query param `q` exceeds 500 characters |
61+
| `SEARCH_INVALID_SINCE_DAYS` | 400 | `GET /api/search` | Query param `since_days` is not a positive integer |
62+
| `SEARCH_PROJECTS_UNAVAILABLE` | 503 | `GET /api/search` | Claude projects directory is missing or not readable |
63+
| `SEARCH_INDEX_UNAVAILABLE` | 503 | `GET /api/search` | FTS index is locked during rebuild |
5964
| `INVALID_PATH` | 400 | Session, stats, export session | Path traversal or rejected URL segment |
6065
| `SESSION_NOT_FOUND` | 404 | Session, stats, export session | File missing on disk or session excluded by rules |
6166
| `INVALID_REQUEST_BODY` | 400 | `POST /api/export` | Body is not a JSON object |
@@ -280,14 +285,18 @@ curl -s "http://127.0.0.1:5000/api/sessions/F--boost-capy/session_abc123/stats"
280285

281286
**Source:** [`api/search.py`](../api/search.py)
282287

283-
Case-insensitive substring search across all non-excluded messages in all projects. Linear scan — suitable for local history size, not indexed search.
288+
Case-insensitive substring search across all non-excluded messages in all projects. Uses a local FTS5 index when available; falls back to a live JSONL scan when the index is missing, stale, or the query cannot be served from SQLite.
284289

285290
#### Query parameters
286291

287292
| Name | Type | Default | Description |
288293
|------|------|---------|-------------|
289-
| `q` | string | `""` | Search string; whitespace stripped; empty → `[]` |
294+
| `q` | string | | Search string (required); whitespace stripped; max 500 characters |
290295
| `limit` | integer | `50` | Max results; must be ≥ 1; **capped at 500** |
296+
| `all_history` | flag | off | When `1` or `true`, search all history (no time window) |
297+
| `since_days` | integer | `30` | When `all_history` is off, include messages from the last *N* days (positive integer; capped at 36 500) |
298+
299+
By default, messages older than 30 days are excluded. Sessions without a parseable timestamp may still match when windowed.
291300

292301
#### Response — `200 OK`
293302

@@ -306,10 +315,17 @@ Case-insensitive substring search across all non-excluded messages in all projec
306315

307316
| Status | `code` | When |
308317
|--------|--------|------|
318+
| 400 | `SEARCH_EMPTY_QUERY` | `q` is empty or whitespace |
319+
| 400 | `SEARCH_QUERY_TOO_LONG` | `q` exceeds 500 characters |
309320
| 400 | `SEARCH_INVALID_LIMIT` | `limit` not a positive integer (e.g. `abc`, `0`, `1.5`) |
321+
| 400 | `SEARCH_INVALID_SINCE_DAYS` | `since_days` not a positive integer |
322+
| 503 | `SEARCH_PROJECTS_UNAVAILABLE` | Projects directory missing or not readable |
323+
| 503 | `SEARCH_INDEX_UNAVAILABLE` | FTS index locked during rebuild |
324+
| 500 | `INTERNAL_ERROR` | Unexpected server failure |
310325

311326
```bash
312327
curl -s "http://127.0.0.1:5000/api/search?q=parser&limit=10" | jq '.[0]'
328+
curl -s "http://127.0.0.1:5000/api/search?q=" # → 400 SEARCH_EMPTY_QUERY
313329
curl -s "http://127.0.0.1:5000/api/search?q=test&limit=abc" # → 400
314330
```
315331

models/error_codes.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,11 @@
77

88
class ErrorCode(StrEnum):
99
SEARCH_INVALID_LIMIT = "SEARCH_INVALID_LIMIT"
10+
SEARCH_EMPTY_QUERY = "SEARCH_EMPTY_QUERY"
11+
SEARCH_QUERY_TOO_LONG = "SEARCH_QUERY_TOO_LONG"
12+
SEARCH_INVALID_SINCE_DAYS = "SEARCH_INVALID_SINCE_DAYS"
13+
SEARCH_PROJECTS_UNAVAILABLE = "SEARCH_PROJECTS_UNAVAILABLE"
14+
SEARCH_INDEX_UNAVAILABLE = "SEARCH_INDEX_UNAVAILABLE"
1015
INVALID_PATH = "INVALID_PATH"
1116
SESSION_NOT_FOUND = "SESSION_NOT_FOUND"
1217
INVALID_REQUEST_BODY = "INVALID_REQUEST_BODY"

static/js/search.js

Lines changed: 56 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,26 @@ import { showProjects } from './projects.js';
66

77
// ==================== Search ====================
88

9+
const SEARCH_LIMIT = 50;
10+
911
let lastSearchRequestId = 0;
1012

13+
export function highlightSnippet(snippet, query) {
14+
if (!snippet) return '';
15+
if (!query) return esc(snippet);
16+
const hay = snippet.toLowerCase();
17+
const needle = query.toLowerCase();
18+
const idx = hay.indexOf(needle);
19+
if (idx < 0) return esc(snippet);
20+
return (
21+
esc(snippet.slice(0, idx))
22+
+ '<mark>'
23+
+ esc(snippet.slice(idx, idx + query.length))
24+
+ '</mark>'
25+
+ esc(snippet.slice(idx + query.length))
26+
);
27+
}
28+
1129
export function showSearchPage() {
1230
setHamburgerVisible(false);
1331
setWorkspaceMode(false);
@@ -21,6 +39,14 @@ export function showSearchPage() {
2139
</a>
2240
<br><br>
2341
<h1>Search</h1>
42+
<p class="text-muted text-sm search-help">
43+
By default, search covers the last 30 days. Chats without a parseable date may still appear.
44+
Results are capped at ${SEARCH_LIMIT} (up to 500 via the API). Check
45+
<label class="search-all-history-label">
46+
<input type="checkbox" id="search-all-history"> Search all history
47+
</label>
48+
to include older messages.
49+
</p>
2450
<div class="search-bar">
2551
<input class="input" type="text" id="search-input" placeholder="Search conversations..." autofocus>
2652
<button type="button" class="btn btn-primary" id="search-submit-btn">Search</button>
@@ -54,36 +80,57 @@ export async function doSearch() {
5480
const query = input.value.trim();
5581
if (!query) return;
5682

83+
const allHistory = document.getElementById('search-all-history')?.checked;
5784
const container = document.getElementById('search-results');
58-
container.innerHTML = '<div class="loading">Searching...</div>';
85+
container.innerHTML = '<div class="search-loading">Searching...</div>';
86+
87+
const params = new URLSearchParams({
88+
q: query,
89+
limit: String(SEARCH_LIMIT),
90+
});
91+
if (allHistory) params.set('all_history', '1');
5992

6093
try {
61-
const res = await fetch(`/api/search?q=${encodeURIComponent(query)}&limit=50`);
94+
const res = await fetch(`/api/search?${params.toString()}`);
6295
if (localRequestId !== lastSearchRequestId) return;
6396
if (!res.ok) {
64-
let msg = `Search failed (${res.status})`;
65-
try { msg = await res.text() || msg; } catch { /* ignore */ }
97+
let message = `Search failed (${res.status})`;
98+
let code = '';
99+
try {
100+
const body = await res.json();
101+
if (body && typeof body.error === 'string') message = body.error;
102+
if (body && typeof body.code === 'string') code = body.code;
103+
} catch {
104+
try { message = await res.text() || message; } catch { /* ignore */ }
105+
}
66106
if (localRequestId !== lastSearchRequestId) return;
67-
throw new Error(msg);
107+
const codeAttr = code ? ` data-error-code="${esc(code)}"` : '';
108+
container.innerHTML = `<p class="search-error"${codeAttr}>${esc(message)}</p>`;
109+
return;
68110
}
69111
const results = await res.json();
70112
if (localRequestId !== lastSearchRequestId) return;
71113

72-
let html = `<p class="text-muted text-sm">${results.length} result${results.length !== 1 ? 's' : ''}</p><br>`;
114+
let html = `<p class="text-muted text-sm">${results.length} result${results.length !== 1 ? 's' : ''}</p>`;
115+
if (results.length >= SEARCH_LIMIT) {
116+
html += `<p class="text-muted text-sm search-truncation">Showing the first ${SEARCH_LIMIT} matches. Narrow your query or raise <code>limit</code> in the API for more.</p>`;
117+
}
73118
html += '<div class="search-results">';
74119

75120
for (const r of results) {
76121
html += `<div class="search-result" data-project="${esc(r.project)}" data-session-id="${esc(r.session_id)}">
77122
<div><strong>${esc(r.title)}</strong> <span class="text-muted text-sm">${esc(r.project)} &bull; ${esc(r.role)}</span></div>
78-
<div class="snippet">...${esc(r.snippet)}...</div>
123+
<div class="snippet">...${highlightSnippet(r.snippet, query)}...</div>
79124
</div>`;
80125
}
81126

82-
if (!results.length) html += '<div class="empty-state">No results found.</div>';
127+
if (!results.length) {
128+
html += '<div class="search-empty">No results found.</div>';
129+
}
83130
html += '</div>';
84131
smoothSet(container, html);
85132
} catch (e) {
86133
if (localRequestId !== lastSearchRequestId) return;
87-
container.innerHTML = `<div class="loading">Error: ${esc(e.message)}</div>`;
134+
container.innerHTML = `<p class="search-error">${esc(e.message)}</p>`;
88135
}
89136
}

0 commit comments

Comments
 (0)