1- """
2- API route for export — produces per-chat Markdown in a zip download.
3- POST /api/export { since: "all"|"last", zip: true }
4- GET /api/export/state — returns last export time
5- """
6-
7- from __future__ import annotations
8-
9- import io
10- import json
11- import logging
12- import os
13- import zipfile
14- from datetime import datetime
15- from pathlib import Path
16- from typing import Any , Literal
17-
18- from flask import Blueprint , Response , request
19-
20- from api .flask_config import exclusion_rules , json_response
21- from services .export_engine import collect_export_entries , read_last_export_ms
22- from services .workspace_db import global_storage_db_path
23- from utils .workspace_path import resolve_workspace_path
24-
25- bp = Blueprint ("export_api" , __name__ )
26- _logger = logging .getLogger (__name__ )
27-
28-
29- def _get_state_dir () -> str :
30- return os .path .join (str (Path .home ()), ".cursor-chat-browser" )
31-
32-
33- def _get_export_state () -> dict [str , Any ]:
34- """Read the export state file."""
35- state_path = os .path .join (_get_state_dir (), "export_state.json" )
36- if os .path .isfile (state_path ):
37- try :
38- with open (state_path , "r" , encoding = "utf-8" ) as f :
39- parsed = json .load (f )
40- if isinstance (parsed , dict ):
41- return parsed
42- _logger .warning (
43- "Export state in %s is not a JSON object (got %s); ignoring" ,
44- state_path ,
45- type (parsed ).__name__ ,
46- )
47- except (json .JSONDecodeError , ValueError , OSError ) as e :
48- _logger .warning (
49- "Could not read export state from %s: %s" ,
50- state_path ,
51- e ,
52- )
53- return {}
54-
55-
56- def _save_export_state (count : int ) -> None :
57- """Save export state after an export."""
58- state_dir = _get_state_dir ()
59- os .makedirs (state_dir , exist_ok = True )
60- state = {
61- "lastExportTime" : datetime .now ().isoformat (),
62- "exportedCount" : count ,
63- }
64- state_path = os .path .join (state_dir , "export_state.json" )
65- with open (state_path , "w" , encoding = "utf-8" ) as f :
66- json .dump (state , f , indent = 2 )
67-
68-
69- @bp .route ("/api/export/state" )
70- def get_export_state () -> Response :
71- """Return the last export timestamp."""
72- state = _get_export_state ()
73- return json_response (state )
74-
75-
76- @bp .route ("/api/export" , methods = ["POST" ])
77- def export_chats () -> tuple [Response , int ] | Response :
78- """Export chats as a zip archive.
79-
80- Exclusion rules (``EXCLUSION_RULES`` app config key) are evaluated against
81- each chat's project name, title, and model. Rules are loaded once at
82- application startup; an app restart is required to pick up changes to the
83- exclusion rules file.
84- """
85- try :
86- body = request .get_json (silent = True )
87- if not isinstance (body , dict ):
88- return json_response ({"error" : "request body must be a JSON object" }, 400 )
89- since : Literal ["all" , "last" ] = (
90- "last" if body .get ("since" ) == "last" else "all"
91- )
92-
93- workspace_path = resolve_workspace_path ()
94- gdb = global_storage_db_path (workspace_path )
95- if not os .path .isfile (gdb ):
96- return json_response ({"error" : "Cursor global storage not found" }, 404 )
97-
98- exported = collect_export_entries (
99- workspace_path = workspace_path ,
100- exclusion_rules = exclusion_rules (),
101- since = since ,
102- last_export_ms = read_last_export_ms (since , state = _get_export_state ()),
103- out_dir = "" ,
104- include_composer = True ,
105- include_cli = False ,
106- )
107- count = len (exported )
108- if count == 0 :
109- return json_response (
110- {"error" : "No conversations to export" + (
111- " since last export" if since == "last" else ""
112- )},
113- 404 ,
114- )
115-
116- buf = io .BytesIO ()
117- with zipfile .ZipFile (buf , "w" , zipfile .ZIP_DEFLATED ) as zf :
118- for entry in exported :
119- zf .writestr (entry ["rel_path" ], entry ["content" ])
120-
121- buf .seek (0 )
122- _save_export_state (count )
123-
124- filename = "cursor-export.zip"
125- return Response (
126- buf .getvalue (),
127- mimetype = "application/zip" ,
128- headers = {
129- "Content-Disposition" : f'attachment; filename="{ filename } "' ,
130- "X-Export-Count" : str (count ),
131- },
132- )
133-
134- except Exception as e :
135- _logger .error (
136- "Export failed: %s (%s)" ,
137- e ,
138- type (e ).__name__ ,
139- exc_info = True ,
140- )
141- return json_response ({"error" : "Export failed" }, 500 )
1+ """
2+ API route for export — produces per-chat Markdown in a zip download.
3+ POST /api/export { since: "all"|"last", zip: true }
4+ GET /api/export/state — returns last export time
5+ """
6+
7+ from __future__ import annotations
8+
9+ import io
10+ import json
11+ import logging
12+ import os
13+ import zipfile
14+ from datetime import datetime
15+ from pathlib import Path
16+ from typing import Any , Literal
17+
18+ from flask import Blueprint , Response , request
19+
20+ from api .flask_config import exclusion_rules , json_response
21+ from services .export_engine import collect_export_entries , read_last_export_ms
22+ from services .workspace_db import global_storage_db_path
23+ from utils .workspace_path import resolve_workspace_path
24+
25+ bp = Blueprint ("export_api" , __name__ )
26+ _logger = logging .getLogger (__name__ )
27+
28+
29+ def _get_state_dir () -> str :
30+ return os .path .join (str (Path .home ()), ".cursor-chat-browser" )
31+
32+
33+ def _get_export_state () -> dict [str , Any ]:
34+ """Read the export state file."""
35+ state_path = os .path .join (_get_state_dir (), "export_state.json" )
36+ if os .path .isfile (state_path ):
37+ try :
38+ with open (state_path , "r" , encoding = "utf-8" ) as f :
39+ parsed = json .load (f )
40+ if isinstance (parsed , dict ):
41+ return parsed
42+ _logger .warning (
43+ "Export state in %s is not a JSON object (got %s); ignoring" ,
44+ state_path ,
45+ type (parsed ).__name__ ,
46+ )
47+ except (json .JSONDecodeError , ValueError , OSError ) as e :
48+ _logger .warning (
49+ "Could not read export state from %s: %s" ,
50+ state_path ,
51+ e ,
52+ )
53+ return {}
54+
55+
56+ def _save_export_state (count : int ) -> None :
57+ """Save export state after an export."""
58+ state_dir = _get_state_dir ()
59+ os .makedirs (state_dir , exist_ok = True )
60+ state = {
61+ "lastExportTime" : datetime .now ().isoformat (),
62+ "exportedCount" : count ,
63+ }
64+ state_path = os .path .join (state_dir , "export_state.json" )
65+ with open (state_path , "w" , encoding = "utf-8" ) as f :
66+ json .dump (state , f , indent = 2 )
67+
68+
69+ @bp .route ("/api/export/state" )
70+ def get_export_state () -> Response :
71+ """Return the last export timestamp."""
72+ state = _get_export_state ()
73+ return json_response (state )
74+
75+
76+ @bp .route ("/api/export" , methods = ["POST" ])
77+ def export_chats () -> tuple [Response , int ] | Response :
78+ """Export chats as a zip archive.
79+
80+ Exclusion rules (``EXCLUSION_RULES`` app config key) are evaluated against
81+ each chat's project name, title, and model. Rules are loaded once at
82+ application startup; an app restart is required to pick up changes to the
83+ exclusion rules file.
84+ """
85+ try :
86+ body = request .get_json (silent = True )
87+ if not isinstance (body , dict ):
88+ return json_response ({"error" : "request body must be a JSON object" }, 400 )
89+ since : Literal ["all" , "last" ] = (
90+ "last" if body .get ("since" ) == "last" else "all"
91+ )
92+
93+ workspace_path = resolve_workspace_path ()
94+ gdb = global_storage_db_path (workspace_path )
95+ if not os .path .isfile (gdb ):
96+ return json_response ({"error" : "Cursor global storage not found" }, 404 )
97+
98+ exported = collect_export_entries (
99+ workspace_path = workspace_path ,
100+ exclusion_rules = exclusion_rules (),
101+ since = since ,
102+ last_export_ms = read_last_export_ms (since , state = _get_export_state ()),
103+ out_dir = "" ,
104+ include_composer = True ,
105+ include_cli = False ,
106+ )
107+ count = len (exported )
108+ if count == 0 :
109+ return json_response (
110+ {"error" : "No conversations to export" + (
111+ " since last export" if since == "last" else ""
112+ )},
113+ 404 ,
114+ )
115+
116+ buf = io .BytesIO ()
117+ with zipfile .ZipFile (buf , "w" , zipfile .ZIP_DEFLATED ) as zf :
118+ for entry in exported :
119+ zf .writestr (entry ["rel_path" ], entry ["content" ])
120+
121+ buf .seek (0 )
122+ _save_export_state (count )
123+
124+ filename = "cursor-export.zip"
125+ return Response (
126+ buf .getvalue (),
127+ mimetype = "application/zip" ,
128+ headers = {
129+ "Content-Disposition" : f'attachment; filename="{ filename } "' ,
130+ "X-Export-Count" : str (count ),
131+ },
132+ )
133+
134+ except Exception as e :
135+ _logger .error (
136+ "Export failed: %s (%s)" ,
137+ e ,
138+ type (e ).__name__ ,
139+ exc_info = True ,
140+ )
141+ return json_response ({"error" : "Export failed" }, 500 )
142+
0 commit comments