Skip to content

Commit 45c36ac

Browse files
Address export consolidation review findings
1 parent d75620d commit 45c36ac

7 files changed

Lines changed: 170 additions & 157 deletions

File tree

api/export_api.py

Lines changed: 40 additions & 128 deletions
Original file line numberDiff line numberDiff line change
@@ -4,32 +4,24 @@
44
GET /api/export/state — returns last export time
55
"""
66

7+
from __future__ import annotations
8+
79
import io
810
import json
911
import logging
1012
import os
11-
import sqlite3
1213
import zipfile
1314
from datetime import datetime
1415
from pathlib import Path
15-
from typing import Any
16+
from typing import Any, Literal
1617

1718
from flask import Blueprint, Response, request
1819

1920
from api.flask_config import exclusion_rules, json_response
20-
21-
from utils.workspace_path import resolve_workspace_path
21+
from services.export_engine import collect_export_entries
22+
from services.workspace_db import global_storage_db_path
2223
from utils.path_helpers import to_epoch_ms
23-
from utils.text_extract import extract_text_from_bubble, slug
24-
from utils.exclusion_rules import build_searchable_text, is_excluded_by_rules
25-
from utils.cursor_md_exporter import cursor_ide_chat_to_markdown
26-
from services.workspace_context import resolve_workspace_context_minimal
27-
from services.workspace_db import (
28-
load_bubble_map,
29-
load_code_block_diff_map,
30-
open_global_db,
31-
)
32-
from services.workspace_resolver import lookup_workspace_display_name
24+
from utils.workspace_path import resolve_workspace_path
3325

3426
bp = Blueprint("export_api", __name__)
3527
_logger = logging.getLogger(__name__)
@@ -75,6 +67,15 @@ def _save_export_state(count: int) -> None:
7567
json.dump(state, f, indent=2)
7668

7769

70+
def _read_last_export_ms(since: Literal["all", "last"]) -> int:
71+
if since != "last":
72+
return 0
73+
ts = _get_export_state().get("lastExportTime")
74+
if ts:
75+
return to_epoch_ms(ts)
76+
return 0
77+
78+
7879
@bp.route("/api/export/state")
7980
def get_export_state() -> Response:
8081
"""Return the last export timestamp."""
@@ -93,126 +94,37 @@ def export_chats() -> tuple[Response, int] | Response:
9394
"""
9495
try:
9596
body = request.get_json(silent=True) or {}
96-
since = "last" if body.get("since") == "last" else "all"
97+
since: Literal["all", "last"] = (
98+
"last" if body.get("since") == "last" else "all"
99+
)
97100

98101
workspace_path = resolve_workspace_path()
99-
100-
# Determine last export timestamp for filtering
101-
last_export_ms = 0
102-
if since == "last":
103-
state = _get_export_state()
104-
ts_str = state.get("lastExportTime")
105-
if ts_str:
106-
last_export_ms = to_epoch_ms(ts_str)
107-
108-
# ── Workspace scanning via service layer ──────────────────────────────
109-
ctx = resolve_workspace_context_minimal(workspace_path)
110-
workspace_entries = ctx.workspace_entries
111-
composer_id_to_ws = ctx.composer_id_to_workspace_id
112-
113-
# Build display-name and slug maps
114-
ws_id_to_slug: dict[str, str] = {}
115-
ws_id_to_display_name: dict[str, str] = {}
116-
for e in workspace_entries:
117-
display = lookup_workspace_display_name(workspace_path, e["name"])
118-
if display != e["name"]:
119-
ws_id_to_display_name[e["name"]] = display
120-
ws_id_to_slug[e["name"]] = slug(display)
121-
122-
today = datetime.now().strftime("%Y-%m-%d")
123-
exported: list[dict[str, Any]] = []
124-
rules = exclusion_rules()
125-
126-
# ── Database reading via service layer ────────────────────────────────
127-
with open_global_db(workspace_path) as (global_db, _):
128-
if global_db is None:
129-
return json_response({"error": "Cursor global storage not found"}, 404)
130-
bubble_map = load_bubble_map(global_db)
131-
code_block_diff_map = load_code_block_diff_map(global_db)
132-
133-
try:
134-
composer_rows = global_db.execute(
135-
"SELECT key, value FROM cursorDiskKV WHERE key LIKE 'composerData:%'"
136-
" AND value LIKE '%fullConversationHeadersOnly%'"
137-
" AND value NOT LIKE '%fullConversationHeadersOnly\":[]%'"
138-
).fetchall()
139-
except sqlite3.Error:
140-
composer_rows = []
141-
142-
for row in composer_rows:
143-
composer_id = row["key"].split(":")[1]
144-
try:
145-
cd = json.loads(row["value"])
146-
headers = cd.get("fullConversationHeadersOnly") or []
147-
if not headers:
148-
continue
149-
150-
updated_at_ms = to_epoch_ms(cd.get("lastUpdatedAt"))
151-
if updated_at_ms is None:
152-
updated_at_ms = to_epoch_ms(cd.get("createdAt"))
153-
if updated_at_ms is None:
154-
updated_at_ms = 0
155-
if since == "last" and updated_at_ms and updated_at_ms <= last_export_ms:
156-
continue
157-
158-
ws_id = composer_id_to_ws.get(composer_id, "global")
159-
ws_slug = "other-chats" if ws_id == "global" else (ws_id_to_slug.get(ws_id) or slug(ws_id[:12]))
160-
ws_display_name = "Other chats" if ws_id == "global" else (ws_id_to_display_name.get(ws_id) or ws_slug)
161-
title = cd.get("name") or f"Chat {composer_id[:8]}"
162-
model_config = cd.get("modelConfig") or {}
163-
model_name = model_config.get("modelName")
164-
model_names = [model_name] if model_name and model_name != "default" else None
165-
166-
bubble_texts = []
167-
for h in headers:
168-
b = bubble_map.get(h.get("bubbleId"))
169-
if b:
170-
bt = extract_text_from_bubble(b)
171-
if bt:
172-
bubble_texts.append(bt)
173-
174-
searchable = build_searchable_text(
175-
project_name=ws_display_name,
176-
chat_title=title,
177-
model_names=model_names,
178-
chat_content_snippet="\n\n".join(bubble_texts) if bubble_texts else None,
179-
)
180-
if is_excluded_by_rules(rules, searchable):
181-
continue
182-
183-
title_slug = slug(title)
184-
ts_ms = updated_at_ms or int(datetime.now().timestamp() * 1000)
185-
ts_str = datetime.fromtimestamp(ts_ms / 1000).strftime("%Y-%m-%dT%H-%M-%S")
186-
filename = f"{ts_str}__{title_slug}__{composer_id[:8]}.md"
187-
rel_path = os.path.join(today, ws_slug, "chat", filename)
188-
189-
md = cursor_ide_chat_to_markdown(
190-
composer_data=cd,
191-
composer_id=composer_id,
192-
bubble_map=bubble_map,
193-
code_block_diff_map=code_block_diff_map,
194-
workspace_info={"ws_slug": ws_slug, "ws_display_name": ws_display_name},
195-
)
196-
exported.append({"path": rel_path, "content": md, "updatedAt": updated_at_ms})
197-
198-
except Exception as e:
199-
_logger.error(
200-
"Error processing composer %s for export: %s (%s)",
201-
composer_id,
202-
e,
203-
type(e).__name__,
204-
exc_info=True,
205-
)
206-
102+
gdb = global_storage_db_path(workspace_path)
103+
if not os.path.isfile(gdb):
104+
return json_response({"error": "Cursor global storage not found"}, 404)
105+
106+
exported = collect_export_entries(
107+
workspace_path=workspace_path,
108+
exclusion_rules=exclusion_rules(),
109+
since=since,
110+
last_export_ms=_read_last_export_ms(since),
111+
out_dir="",
112+
include_composer=True,
113+
include_cli=False,
114+
)
207115
count = len(exported)
208116
if count == 0:
209-
return json_response({"error": "No conversations to export" + (
210-
" since last export" if since == "last" else ""
211-
)}, 404)
117+
return json_response(
118+
{"error": "No conversations to export" + (
119+
" since last export" if since == "last" else ""
120+
)},
121+
404,
122+
)
123+
212124
buf = io.BytesIO()
213125
with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf:
214126
for entry in exported:
215-
zf.writestr(entry["path"], entry["content"])
127+
zf.writestr(entry["rel_path"], entry["content"])
216128

217129
buf.seek(0)
218130
_save_export_state(count)
@@ -234,4 +146,4 @@ def export_chats() -> tuple[Response, int] | Response:
234146
type(e).__name__,
235147
exc_info=True,
236148
)
237-
return json_response({"error": "Export failed"}, 500)
149+
return json_response({"error": "Export failed"}, 500)

scripts/export.py

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -194,10 +194,7 @@ def main() -> None:
194194
exclusion_rules = load_rules(
195195
resolve_exclusion_rules_path(opts.get("exclusion_rules_path")),
196196
)
197-
base_dir = opts.get("base_dir")
198-
if base_dir:
199-
os.environ["WORKSPACE_PATH"] = base_dir
200-
workspace_path = resolve_workspace_path()
197+
workspace_path = resolve_workspace_path(override=opts.get("base_dir"))
201198

202199
state_dir = get_global_state_dir()
203200
state_path = os.path.join(state_dir, "export_state.json")

services/export_engine.py

Lines changed: 19 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
from typing import Any, Literal, TypedDict
1212

1313
from models import Bubble
14-
from services.summary_cache import fingerprint_workspace_storage
14+
from services.summary_cache import fingerprint_workspace_storage, nocache_enabled
1515
from services.workspace_context import (
1616
WorkspaceContext,
1717
enrich_workspace_context_from_global_db,
@@ -236,11 +236,9 @@ def _collect_ide_export_entries(
236236
if not isinstance(headers, list) or not headers:
237237
continue
238238

239-
updated_at = to_epoch_ms(cd.get("lastUpdatedAt"))
240-
if updated_at is None:
241-
updated_at = to_epoch_ms(cd.get("createdAt"))
242-
if updated_at is None:
243-
updated_at = 0
239+
updated_at = to_epoch_ms(cd.get("lastUpdatedAt")) or to_epoch_ms(
240+
cd.get("createdAt"),
241+
)
244242
if since == "last" and updated_at <= last_export_ms:
245243
continue
246244

@@ -271,7 +269,8 @@ def _collect_ide_export_entries(
271269
else (orch.workspace_id_to_display_name.get(ws_id) or ws_slug)
272270
)
273271
title = cd.get("name") or f"Chat {composer_id[:8]}"
274-
model_config = cd.get("modelConfig") or {}
272+
raw_model_config = cd.get("modelConfig")
273+
model_config = raw_model_config if isinstance(raw_model_config, dict) else {}
275274
model_name = model_config.get("modelName")
276275
model_names = [model_name] if model_name and model_name != "default" else None
277276

@@ -468,11 +467,13 @@ def collect_export_entries(
468467
last_export_ms: int,
469468
out_dir: str,
470469
include_composer: bool = True,
470+
include_cli: bool = True,
471471
nocache: bool = False,
472472
) -> list[ExportEntry]:
473473
"""Collect exportable conversations (IDE + CLI) via shared orchestration."""
474+
effective_nocache = nocache_enabled(request_nocache=nocache)
474475
orch = prepare_workspace_orchestration(
475-
workspace_path, exclusion_rules, nocache=nocache,
476+
workspace_path, exclusion_rules, nocache=effective_nocache,
476477
)
477478
today = datetime.now().strftime("%Y-%m-%d")
478479
exported: list[ExportEntry] = []
@@ -492,13 +493,14 @@ def collect_export_entries(
492493
),
493494
)
494495

495-
exported.extend(
496-
_collect_cli_export_entries(
497-
exclusion_rules=exclusion_rules,
498-
since=since,
499-
last_export_ms=last_export_ms,
500-
today=today,
501-
out_dir=out_dir,
502-
),
503-
)
496+
if include_cli:
497+
exported.extend(
498+
_collect_cli_export_entries(
499+
exclusion_rules=exclusion_rules,
500+
since=since,
501+
last_export_ms=last_export_ms,
502+
today=today,
503+
out_dir=out_dir,
504+
),
505+
)
504506
return exported

tests/test_api_export.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -115,7 +115,7 @@ def test_no_conversations_returns_404(self, workspace_storage, export_state_dir)
115115

116116
def test_internal_failure_returns_500(self, client, export_state_dir):
117117
with patch(
118-
"api.export_api.resolve_workspace_context_minimal",
118+
"api.export_api.collect_export_entries",
119119
side_effect=RuntimeError("simulated export failure"),
120120
):
121121
response = _post_export(client)

0 commit comments

Comments
 (0)