1- """
2- API route for search — mirrors src/app/api/search/route.ts
3- GET /api/search?q=...&type=all|chat|composer&all_history=1
4- """
5-
6- import logging
7- from typing import Any
8-
9- from flask import Blueprint , Response , current_app , request
10-
11- from api .flask_config import json_response
12-
13- from models import ParseWarningCollector , SearchResult
14- from services .search import (
15- DEFAULT_SEARCH_WINDOW_DAYS ,
16- rank_results ,
17- resolve_search_since_ms ,
18- search_cli_sessions ,
19- search_global_storage ,
20- search_legacy_workspaces ,
21- )
22- from utils .workspace_path import get_cli_chats_path , resolve_workspace_path
23-
24- bp = Blueprint ("search" , __name__ )
25- _logger = logging .getLogger (__name__ )
26-
27- _MAX_SEARCH_SINCE_DAYS = 36_500 # ~100 years; avoids timedelta overflow on bad input
28-
29-
30- def _parse_since_days_param (raw : str | None ) -> int | None :
31- if raw is None or not str (raw ).strip ():
32- return None
33- try :
34- days = int (raw )
35- except ValueError :
36- return None
37- if days <= 0 or days > _MAX_SEARCH_SINCE_DAYS :
38- return None
39- return days
40-
41-
42- @bp .route ("/api/search" )
43- def search () -> tuple [Response , int ] | Response :
44- """Search chats, composers, and CLI sessions across Cursor storage.
45-
46- Args:
47- q: Search query string (required; 400 when empty).
48- type: Filter scope — ``all`` (default), ``chat``, or ``composer``.
49-
50- Returns:
51- JSON ``{"results": [...]}`` with optional ``warnings``. 400 when ``q`` is
52- empty; 500 with ``{"error": ..., "results": []}`` on unexpected failure.
53- """
54- try :
55- query = request .args .get ("q" , "" ).strip ()
56- search_type = request .args .get ("type" , "all" )
57- rules = current_app .config .get ("EXCLUSION_RULES" ) or []
58- all_history = request .args .get ("all_history" ) in ("1" , "true" )
59- since_ms = resolve_search_since_ms (
60- all_history = all_history ,
61- since_days = _parse_since_days_param (request .args .get ("since_days" )),
62- )
63-
64- if not query :
65- return json_response ({"error" : "No search query provided" }, 400 )
66- workspace_path = resolve_workspace_path ()
67- parse_warnings = ParseWarningCollector ()
68- query_lower = query .lower ()
69-
70- results : list [SearchResult ] = []
71- if search_type != "chat" :
72- results .extend (
73- search_global_storage (
74- workspace_path ,
75- query ,
76- query_lower ,
77- rules ,
78- parse_warnings ,
79- since_ms = since_ms ,
80- )
81- )
82- results .extend (
83- search_legacy_workspaces (
84- workspace_path ,
85- query ,
86- query_lower ,
87- search_type ,
88- rules ,
89- since_ms = since_ms ,
90- )
91- )
92- if search_type == "all" :
93- results .extend (
94- search_cli_sessions (
95- get_cli_chats_path (),
96- query ,
97- query_lower ,
98- rules ,
99- parse_warnings ,
100- since_ms = since_ms ,
101- )
102- )
103-
104- payload : dict [str , Any ] = {
105- "results" : rank_results (results ),
106- "allHistory" : since_ms is None ,
107- "searchWindowDays" : (
108- None if since_ms is None else (
109- _parse_since_days_param (request .args .get ("since_days" ))
110- or DEFAULT_SEARCH_WINDOW_DAYS
111- )
112- ),
113- }
114- return json_response (parse_warnings .attach_to (payload ))
115-
116- except Exception :
117- _logger .exception ("Search failed" )
118- return json_response ({"error" : "Search failed" , "results" : []}, 500 )
1+ """
2+ API route for search — mirrors src/app/api/search/route.ts
3+ GET /api/search?q=...&type=all|chat|composer&all_history=1&workspace=<id>
4+ """
5+
6+ from __future__ import annotations
7+
8+ import logging
9+ import os
10+ import sqlite3
11+ from typing import Any
12+
13+ from flask import Blueprint , Response , current_app , request
14+
15+ from api .flask_config import json_response
16+
17+ from models import ParseWarningCollector , SearchResult
18+ from services .search import (
19+ DEFAULT_SEARCH_WINDOW_DAYS ,
20+ rank_results ,
21+ resolve_search_since_ms ,
22+ search_cli_sessions ,
23+ search_global_storage ,
24+ search_legacy_workspaces ,
25+ )
26+ from utils .cli_chat_reader import list_cli_projects
27+ from utils .workspace_path import get_cli_chats_path , resolve_workspace_path
28+
29+ bp = Blueprint ("search" , __name__ )
30+ _logger = logging .getLogger (__name__ )
31+
32+ _MAX_SEARCH_SINCE_DAYS = 36_500 # ~100 years; avoids timedelta overflow on bad input
33+ _MAX_SEARCH_QUERY_LEN = 500
34+ _VALID_SEARCH_TYPES = frozenset ({"all" , "chat" , "composer" })
35+
36+
37+ def _parse_since_days_param (raw : str | None ) -> int | None :
38+ if raw is None or not str (raw ).strip ():
39+ return None
40+ try :
41+ days = int (raw )
42+ except ValueError :
43+ return None
44+ if days <= 0 or days > _MAX_SEARCH_SINCE_DAYS :
45+ return None
46+ return days
47+
48+
49+ def _search_error (
50+ message : str ,
51+ code : str ,
52+ status : int ,
53+ ) -> tuple [Response , int ]:
54+ return json_response ({"error" : message , "code" : code }, status )
55+
56+
57+ def _workspace_exists (workspace_id : str , workspace_path : str ) -> bool :
58+ if workspace_id == "global" :
59+ return True
60+ if workspace_id .startswith ("cli:" ):
61+ project_id = workspace_id [4 :]
62+ return any (
63+ cp .get ("project_id" ) == project_id
64+ for cp in list_cli_projects (get_cli_chats_path ())
65+ )
66+ return os .path .isdir (os .path .join (workspace_path , workspace_id ))
67+
68+
69+ def _filter_results_by_workspace (
70+ results : list [SearchResult ],
71+ workspace_id : str ,
72+ ) -> list [SearchResult ]:
73+ return [r for r in results if r .get ("workspaceId" ) == workspace_id ]
74+
75+
76+ @bp .route ("/api/search" )
77+ def search () -> tuple [Response , int ] | Response :
78+ """Search chats, composers, and CLI sessions across Cursor storage.
79+
80+ Args:
81+ q: Search query string (required; 400 when empty).
82+ type: Filter scope — ``all`` (default), ``chat``, or ``composer``.
83+ workspace: Optional workspace folder hash; 404 when unknown.
84+
85+ Returns:
86+ JSON ``{"results": [...]}`` with optional ``warnings``. Structured
87+ ``{"error", "code"}`` bodies for 400/404/503/500 failures.
88+ """
89+ query = request .args .get ("q" , "" ).strip ()
90+ if not query :
91+ return _search_error ("No search query provided" , "empty_query" , 400 )
92+ if len (query ) > _MAX_SEARCH_QUERY_LEN :
93+ return _search_error ("Search query is too long" , "query_too_long" , 400 )
94+
95+ search_type = request .args .get ("type" , "all" )
96+ if search_type not in _VALID_SEARCH_TYPES :
97+ return _search_error ("Invalid search type" , "invalid_type" , 400 )
98+
99+ since_days_raw = request .args .get ("since_days" )
100+ if (
101+ since_days_raw is not None
102+ and str (since_days_raw ).strip ()
103+ and _parse_since_days_param (since_days_raw ) is None
104+ ):
105+ return _search_error ("Invalid since_days parameter" , "invalid_since_days" , 400 )
106+
107+ workspace_filter = request .args .get ("workspace" , "" ).strip () or None
108+ workspace_path = resolve_workspace_path ()
109+ if workspace_filter and not _workspace_exists (workspace_filter , workspace_path ):
110+ return _search_error ("Workspace not found" , "workspace_not_found" , 404 )
111+
112+ try :
113+ rules = current_app .config .get ("EXCLUSION_RULES" ) or []
114+ all_history = request .args .get ("all_history" ) in ("1" , "true" )
115+ since_ms = resolve_search_since_ms (
116+ all_history = all_history ,
117+ since_days = _parse_since_days_param (since_days_raw ),
118+ )
119+
120+ parse_warnings = ParseWarningCollector ()
121+ query_lower = query .lower ()
122+
123+ results : list [SearchResult ] = []
124+ if search_type != "chat" :
125+ results .extend (
126+ search_global_storage (
127+ workspace_path ,
128+ query ,
129+ query_lower ,
130+ rules ,
131+ parse_warnings ,
132+ since_ms = since_ms ,
133+ )
134+ )
135+ results .extend (
136+ search_legacy_workspaces (
137+ workspace_path ,
138+ query ,
139+ query_lower ,
140+ search_type ,
141+ rules ,
142+ since_ms = since_ms ,
143+ )
144+ )
145+ if search_type == "all" :
146+ results .extend (
147+ search_cli_sessions (
148+ get_cli_chats_path (),
149+ query ,
150+ query_lower ,
151+ rules ,
152+ parse_warnings ,
153+ since_ms = since_ms ,
154+ )
155+ )
156+
157+ ranked = rank_results (results )
158+ if workspace_filter :
159+ ranked = _filter_results_by_workspace (ranked , workspace_filter )
160+
161+ payload : dict [str , Any ] = {
162+ "results" : ranked ,
163+ "allHistory" : since_ms is None ,
164+ "searchWindowDays" : (
165+ None if since_ms is None else (
166+ _parse_since_days_param (since_days_raw )
167+ or DEFAULT_SEARCH_WINDOW_DAYS
168+ )
169+ ),
170+ }
171+ return json_response (parse_warnings .attach_to (payload ))
172+
173+ except sqlite3 .OperationalError :
174+ _logger .exception ("Search index unavailable" )
175+ return _search_error (
176+ "Search index is temporarily unavailable" ,
177+ "search_index_unavailable" ,
178+ 503 ,
179+ )
180+ except Exception :
181+ _logger .exception ("Search failed" )
182+ return _search_error ("Search failed" , "internal_error" , 500 )
183+
0 commit comments