Skip to content

Commit ae468e9

Browse files
Fix/export workspace path override (#114)
* Address export consolidation review findings * Add regression tests for --base-dir workspace path override * Harden export state parsing and listing cache fast path * Flatten nested mocks in base_dir override tests * Harden export_engine: skip malformed composer keys, guard CLI session meta
1 parent e2660d4 commit ae468e9

5 files changed

Lines changed: 262 additions & 150 deletions

File tree

api/export_api.py

Lines changed: 142 additions & 141 deletions
Original file line numberDiff line numberDiff line change
@@ -1,141 +1,142 @@
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+

services/export_engine.py

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -230,10 +230,17 @@ def _collect_ide_export_entries(
230230
ctx = orch.ctx
231231
exported: list[CollectedExportEntry] = []
232232
for row in db_data.ide_composer_rows:
233-
composer_id = row["key"].split(":")[1]
233+
row_key = row["key"]
234+
if ":" not in row_key:
235+
_logger.debug(
236+
"Skipping composer row with malformed key %r",
237+
row_key,
238+
)
239+
continue
240+
composer_id = row_key.split(":", 1)[1]
234241
try:
235242
cd = json.loads(row["value"])
236-
except (json.JSONDecodeError, ValueError) as parse_err:
243+
except (json.JSONDecodeError, TypeError, ValueError) as parse_err:
237244
_logger.debug(
238245
"Skipping corrupt composerData row %s: %s",
239246
composer_id,
@@ -383,9 +390,11 @@ def _collect_cli_export_entries(
383390
continue
384391

385392
for session in cp["sessions"]:
386-
meta = session.get("meta", {})
393+
raw_meta = session.get("meta")
394+
meta = raw_meta if isinstance(raw_meta, dict) else {}
387395
session_id = session["session_id"]
388-
created_ms: int = meta.get("createdAt") or int(
396+
created_raw = meta.get("createdAt")
397+
created_ms = to_epoch_ms(created_raw) if created_raw else int(
389398
datetime.now().timestamp() * 1000,
390399
)
391400
session_name = meta.get("name") or f"Session {session_id[:8]}"

services/workspace_listing.py

Lines changed: 22 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -21,12 +21,15 @@
2121
from models import Bubble, ParseWarningCollector
2222
from services.export_engine import WorkspaceOrchestration, prepare_workspace_orchestration
2323
from services.summary_cache import (
24+
fingerprint_workspace_storage,
2425
get_cached_projects,
2526
nocache_enabled,
2627
set_cached_projects,
2728
)
2829
from services.workspace_db import (
2930
COMPOSER_ROWS_WITH_HEADERS_SQL,
31+
collect_workspace_entries,
32+
global_storage_db_path,
3033
load_project_layouts_for_composer,
3134
load_project_layouts_map,
3235
open_global_db,
@@ -91,14 +94,29 @@ def list_workspace_projects(
9194
:meth:`models.ParseWarningCollector.to_api_list`; empty when no skips.
9295
"""
9396
effective_nocache = nocache_enabled(request_nocache=nocache)
94-
orch = prepare_workspace_orchestration(
95-
workspace_path, rules, nocache=effective_nocache,
96-
)
97+
workspace_entries: list[dict[str, Any]] | None = None
9798
if not effective_nocache:
98-
cached = get_cached_projects(orch.fingerprint)
99+
workspace_entries = collect_workspace_entries(workspace_path)
100+
gdb = global_storage_db_path(workspace_path)
101+
cli_path = get_cli_chats_path()
102+
fingerprint = fingerprint_workspace_storage(
103+
workspace_path,
104+
workspace_entries,
105+
global_db_path=gdb if os.path.isfile(gdb) else None,
106+
rules=rules,
107+
cli_chats_path=cli_path if os.path.isdir(cli_path) else None,
108+
)
109+
cached = get_cached_projects(fingerprint)
99110
if cached is not None:
100111
return cached
101112

113+
orch = prepare_workspace_orchestration(
114+
workspace_path,
115+
rules,
116+
nocache=effective_nocache,
117+
workspace_entries=workspace_entries,
118+
)
119+
102120
projects, warnings = _build_workspace_projects_uncached(
103121
workspace_path, rules, orch,
104122
)

0 commit comments

Comments
 (0)