From be96bad80babfccea3793c51a9fa778f65a6d77e Mon Sep 17 00:00:00 2001 From: chen Date: Tue, 7 Jul 2026 06:09:02 +0800 Subject: [PATCH 1/4] fix: distinct /api/search error codes and search UX polish --- api/search.py | 100 +++++++++++++++++++++++++------- docs/api-reference.md | 20 ++++++- models/error_codes.py | 5 ++ static/js/search.js | 65 ++++++++++++++++++--- static/js/search.test.js | 71 +++++++++++++++++++---- tests/test_api_integration.py | 5 +- tests/test_error_codes.py | 14 +++++ tests/test_error_propagation.py | 19 ++++++ tests/test_search.py | 48 ++++++++++++++- utils/search_index.py | 36 +++++++++++- 10 files changed, 335 insertions(+), 48 deletions(-) diff --git a/api/search.py b/api/search.py index e16427f..808d1be 100644 --- a/api/search.py +++ b/api/search.py @@ -3,6 +3,7 @@ from __future__ import annotations import logging +import os from datetime import datetime, timezone from typing import Any @@ -32,9 +33,14 @@ _DEFAULT_LIMIT = 50 _MAX_LIMIT = 500 +_MAX_QUERY_LEN = 500 _MAX_SEARCH_SINCE_DAYS = 36_500 +class _SearchIndexUnavailable(Exception): + """Raised when the FTS index exists but is locked during rebuild.""" + + def _parse_limit(raw: str | None, default: int = _DEFAULT_LIMIT) -> int: """Parse a positive integer limit from a query string value.""" if raw is None or raw.strip() == "": @@ -53,10 +59,10 @@ def _parse_since_days(raw: str | None) -> int | None: return None try: days = int(str(raw).strip()) - except ValueError: - return None + except ValueError as exc: + raise ValueError("Invalid since_days: must be a positive integer") from exc if days <= 0: - return None + raise ValueError("Invalid since_days: must be a positive integer") return min(days, _MAX_SEARCH_SINCE_DAYS) @@ -71,6 +77,16 @@ def _rank_search_hits(results: list[SearchHitDict]) -> list[SearchHitDict]: ) +def _projects_dir_available(projects_dir: str) -> bool: + try: + if not os.path.isdir(projects_dir): + return False + os.listdir(projects_dir) + return True + except OSError: + return False + + def _message_searchable_text(msg: MessageDict) -> str: text = msg.get("text", "") or msg.get("content", "") if not isinstance(text, str): @@ -131,6 +147,8 @@ def _search_via_index( max_results=need, sql_offset=sql_offset, ) + if indexed["index_locked"]: + raise _SearchIndexUnavailable() if not indexed["query_ok"]: return None if indexed["sql_rows_fetched"] == 0: @@ -215,9 +233,20 @@ def _search_live_scan( @search_bp.route("/api/search") def search() -> FlaskReturn: - query = request.args.get("q", "").strip() + raw_query = request.args.get("q", "") + query = raw_query.strip() if not query: - return json_response([]) + return error_response( + ErrorCode.SEARCH_EMPTY_QUERY, + "Search query is required", + 400, + ) + if len(query) > _MAX_QUERY_LEN: + return error_response( + ErrorCode.SEARCH_QUERY_TOO_LONG, + f"Search query must be at most {_MAX_QUERY_LEN} characters", + 400, + ) try: max_results = _parse_limit(request.args.get("limit")) @@ -228,30 +257,36 @@ def search() -> FlaskReturn: 400, ) + since_days_raw = request.args.get("since_days") + try: + since_days = _parse_since_days(since_days_raw) + except ValueError: + return error_response( + ErrorCode.SEARCH_INVALID_SINCE_DAYS, + "Invalid since_days: must be a positive integer", + 400, + ) + query_lower = query.lower() 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(request.args.get("since_days")), + since_days=since_days, now=datetime.now(timezone.utc), ) base = current_app.config.get("CLAUDE_PROJECTS_DIR") or get_claude_projects_dir() - rules = current_app.config.get("EXCLUSION_RULES") or [] + if not _projects_dir_available(base): + return error_response( + ErrorCode.SEARCH_PROJECTS_UNAVAILABLE, + "Claude projects directory is not accessible", + 503, + ) - indexed = _search_via_index( - base, - rules, - query, - query_lower, - since_ms=since_ms, - max_results=max_results, - ) - if indexed is not None: - return json_response(indexed) + rules = current_app.config.get("EXCLUSION_RULES") or [] - return json_response( - _search_live_scan( + try: + indexed = _search_via_index( base, rules, query, @@ -259,4 +294,29 @@ def search() -> FlaskReturn: since_ms=since_ms, max_results=max_results, ) - ) + if indexed is not None: + return json_response(indexed) + + return json_response( + _search_live_scan( + base, + rules, + query, + query_lower, + since_ms=since_ms, + max_results=max_results, + ) + ) + except _SearchIndexUnavailable: + return error_response( + ErrorCode.SEARCH_INDEX_UNAVAILABLE, + "Search index is temporarily unavailable", + 503, + ) + except Exception: + _logger.exception("Unexpected error during search") + return error_response( + ErrorCode.INTERNAL_ERROR, + "Search failed", + 500, + ) diff --git a/docs/api-reference.md b/docs/api-reference.md index d979cf3..08449b1 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -56,6 +56,11 @@ Extra fields may appear for specific codes (for example `since` on invalid bulk- | `code` | HTTP | Routes | Meaning | |--------|------|--------|---------| | `SEARCH_INVALID_LIMIT` | 400 | `GET /api/search` | Query param `limit` is not a positive integer | +| `SEARCH_EMPTY_QUERY` | 400 | `GET /api/search` | Query param `q` is empty or whitespace | +| `SEARCH_QUERY_TOO_LONG` | 400 | `GET /api/search` | Query param `q` exceeds 500 characters | +| `SEARCH_INVALID_SINCE_DAYS` | 400 | `GET /api/search` | Query param `since_days` is not a positive integer | +| `SEARCH_PROJECTS_UNAVAILABLE` | 503 | `GET /api/search` | Claude projects directory is missing or not readable | +| `SEARCH_INDEX_UNAVAILABLE` | 503 | `GET /api/search` | FTS index is locked during rebuild | | `INVALID_PATH` | 400 | Session, stats, export session | Path traversal or rejected URL segment | | `SESSION_NOT_FOUND` | 404 | Session, stats, export session | File missing on disk or session excluded by rules | | `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" **Source:** [`api/search.py`](../api/search.py) -Case-insensitive substring search across all non-excluded messages in all projects. Linear scan — suitable for local history size, not indexed search. +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. #### Query parameters | Name | Type | Default | Description | |------|------|---------|-------------| -| `q` | string | `""` | Search string; whitespace stripped; empty → `[]` | +| `q` | string | — | Search string (required); whitespace stripped; max 500 characters | | `limit` | integer | `50` | Max results; must be ≥ 1; **capped at 500** | +| `all_history` | flag | off | When `1` or `true`, search all history (no time window) | +| `since_days` | integer | `30` | When `all_history` is off, include messages from the last *N* days (positive integer; capped at 36 500) | + +By default, messages older than 30 days are excluded. Sessions without a parseable timestamp may still match when windowed. #### Response — `200 OK` @@ -306,10 +315,17 @@ Case-insensitive substring search across all non-excluded messages in all projec | Status | `code` | When | |--------|--------|------| +| 400 | `SEARCH_EMPTY_QUERY` | `q` is empty or whitespace | +| 400 | `SEARCH_QUERY_TOO_LONG` | `q` exceeds 500 characters | | 400 | `SEARCH_INVALID_LIMIT` | `limit` not a positive integer (e.g. `abc`, `0`, `1.5`) | +| 400 | `SEARCH_INVALID_SINCE_DAYS` | `since_days` not a positive integer | +| 503 | `SEARCH_PROJECTS_UNAVAILABLE` | Projects directory missing or not readable | +| 503 | `SEARCH_INDEX_UNAVAILABLE` | FTS index locked during rebuild | +| 500 | `INTERNAL_ERROR` | Unexpected server failure | ```bash curl -s "http://127.0.0.1:5000/api/search?q=parser&limit=10" | jq '.[0]' +curl -s "http://127.0.0.1:5000/api/search?q=" # → 400 SEARCH_EMPTY_QUERY curl -s "http://127.0.0.1:5000/api/search?q=test&limit=abc" # → 400 ``` diff --git a/models/error_codes.py b/models/error_codes.py index 1d81781..2a55bb2 100644 --- a/models/error_codes.py +++ b/models/error_codes.py @@ -7,6 +7,11 @@ class ErrorCode(StrEnum): SEARCH_INVALID_LIMIT = "SEARCH_INVALID_LIMIT" + SEARCH_EMPTY_QUERY = "SEARCH_EMPTY_QUERY" + SEARCH_QUERY_TOO_LONG = "SEARCH_QUERY_TOO_LONG" + SEARCH_INVALID_SINCE_DAYS = "SEARCH_INVALID_SINCE_DAYS" + SEARCH_PROJECTS_UNAVAILABLE = "SEARCH_PROJECTS_UNAVAILABLE" + SEARCH_INDEX_UNAVAILABLE = "SEARCH_INDEX_UNAVAILABLE" INVALID_PATH = "INVALID_PATH" SESSION_NOT_FOUND = "SESSION_NOT_FOUND" INVALID_REQUEST_BODY = "INVALID_REQUEST_BODY" diff --git a/static/js/search.js b/static/js/search.js index 8192c15..1c4583f 100644 --- a/static/js/search.js +++ b/static/js/search.js @@ -6,8 +6,26 @@ import { showProjects } from './projects.js'; // ==================== Search ==================== +const SEARCH_LIMIT = 50; + let lastSearchRequestId = 0; +export function highlightSnippet(snippet, query) { + if (!snippet) return ''; + if (!query) return esc(snippet); + const hay = snippet.toLowerCase(); + const needle = query.toLowerCase(); + const idx = hay.indexOf(needle); + if (idx < 0) return esc(snippet); + return ( + esc(snippet.slice(0, idx)) + + '' + + esc(snippet.slice(idx, idx + query.length)) + + '' + + esc(snippet.slice(idx + query.length)) + ); +} + export function showSearchPage() { setHamburgerVisible(false); setWorkspaceMode(false); @@ -21,6 +39,14 @@ export function showSearchPage() {

Search

+

+ By default, search covers the last 30 days. Chats without a parseable date may still appear. + Results are capped at ${SEARCH_LIMIT} (up to 500 via the API). Check + + to include older messages. +