Skip to content

Commit 5e4df43

Browse files
feat(export): consolidate HTTP and CLI bulk export behind shared engine (#54)
* feat(export): consolidate HTTP and CLI bulk export behind shared engine * fix(export): address PR review — parity asserts, stderr warnings, JSONL newlinefix(export): address PR review — parity asserts, stderr warnings, JSONL newline * fix(export): address CodeRabbit review (engine hardening + manifest parity) - Resolve first_timestamp from mtime without mutating SessionDict metadata - Apply mtime fallback for API and CLI export paths - Guard invalid since in run_bulk_export; use Literal types for since/fmt - Export MANIFEST_SHARED_KEYS; unify manifest JSONL via serialize_manifest_jsonl - CLI zip uses ZipSink.finalize; --no-zip uses same serializer - Public format_duration; ListSink no longer duplicates BulkExportResult.exports * fix(export): restore incremental mtime diagnostic and batch parse resilience * fix(export): ZipSink deferred writes, mode guards, UTC/POSIX paths
1 parent 99c30c1 commit 5e4df43

5 files changed

Lines changed: 542 additions & 280 deletions

File tree

api/export_api.py

Lines changed: 23 additions & 135 deletions
Original file line numberDiff line numberDiff line change
@@ -18,32 +18,20 @@
1818
export_state_lock,
1919
load_export_state_from_disk,
2020
)
21-
from utils.session_path import (
22-
get_claude_projects_dir,
23-
list_projects,
24-
list_sessions,
25-
)
21+
from utils.session_path import get_claude_projects_dir, list_projects
2622
from utils.jsonl_parser import parse_session
23+
from utils.exclusion_rules import is_session_excluded
2724
from utils.session_stats import compute_stats
2825
from utils.md_exporter import session_to_markdown
2926
from utils.json_exporter import session_to_json
30-
from utils.exclusion_rules import is_session_excluded
3127
from utils.slugify import slugify
32-
from utils.export_day_filter import collect_sessions_for_latest_activity_day
28+
from utils.export_engine import EXPORT_ERRORS as _EXPORT_ERRORS, ZipSink, run_bulk_export
3329

3430
export_bp = Blueprint("export", __name__)
3531

3632
# Tests monkeypatch this path; keep in sync with utils.export_state_store.
3733
_STATE_FILE = EXPORT_STATE_FILE
3834

39-
_EXPORT_ERRORS = (
40-
json.JSONDecodeError,
41-
KeyError,
42-
ValueError,
43-
OSError,
44-
FileNotFoundError,
45-
)
46-
4735

4836
def _state_lock() -> Any:
4937
return export_state_lock(_STATE_FILE)
@@ -120,128 +108,28 @@ def bulk_export() -> FlaskReturn:
120108
)
121109

122110
buf = io.BytesIO()
123-
count = 0
124-
manifest: list[dict[str, Any]] = []
125-
new_sessions_map: dict[str, float] = {}
126-
latest_day = None
111+
112+
def _on_export_error(sid: str, exc: Exception) -> None:
113+
current_app.logger.warning(
114+
"Failed to export %s: %s", sid[:10], exc
115+
)
127116

128117
with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf:
129-
if since == "last":
130-
d, rows, _n = collect_sessions_for_latest_activity_day(
131-
projects,
132-
list_sessions=list_sessions,
133-
parse_session=parse_session,
134-
is_session_excluded=is_session_excluded,
135-
rules=rules,
136-
)
137-
latest_day = d
138-
for project, sess_info, session, _st, _en in rows:
139-
sid = sess_info["id"]
140-
try:
141-
stats = compute_stats(session)
142-
md = session_to_markdown(session, stats)
143-
title_slug = slugify(session["title"], default="session")
144-
short_id = sid[:8]
145-
proj_slug = slugify(project["name"], default="project")
146-
ts = session["metadata"].get("first_timestamp", "")
147-
ts_file = (
148-
ts[:19].replace(":", "-")
149-
if ts
150-
else "0000-00-00T00-00-00"
151-
)
152-
rel_path = (
153-
f"{proj_slug}/{ts_file}__{title_slug}__{short_id}.md"
154-
)
155-
zf.writestr(rel_path, md)
156-
manifest.append(
157-
{
158-
"session_id": sid,
159-
"title": session["title"],
160-
"project": project["name"],
161-
"tokens": session["metadata"]["total_input_tokens"]
162-
+ session["metadata"]["total_output_tokens"],
163-
"tool_calls": session["metadata"][
164-
"total_tool_calls"
165-
],
166-
"cost_estimate_usd": stats.get(
167-
"cost_estimate_usd"
168-
),
169-
}
170-
)
171-
new_sessions_map[sid] = sess_info.get("modified", 0)
172-
count += 1
173-
except _EXPORT_ERRORS as e:
174-
current_app.logger.warning(
175-
"Failed to export %s: %s", sid[:10], e
176-
)
177-
continue
178-
else:
179-
for project in projects:
180-
sessions = list_sessions(project["path"])
181-
for sess_info in sessions:
182-
sid = sess_info["id"]
183-
try:
184-
if since == "incremental":
185-
prev_mtime = last_export_sessions.get(sid, 0)
186-
curr_mtime = sess_info.get("modified", 0)
187-
if curr_mtime and curr_mtime <= prev_mtime:
188-
continue
189-
190-
session = parse_session(sess_info["path"])
191-
if session["title"] == "Untitled Session":
192-
continue
193-
194-
if is_session_excluded(
195-
rules,
196-
session,
197-
project.get("display_name") or project["name"],
198-
):
199-
continue
200-
201-
stats = compute_stats(session)
202-
md = session_to_markdown(session, stats)
203-
title_slug = slugify(
204-
session["title"], default="session"
205-
)
206-
short_id = sid[:8]
207-
proj_slug = slugify(project["name"], default="project")
208-
ts = session["metadata"].get("first_timestamp", "")
209-
ts_file = (
210-
ts[:19].replace(":", "-")
211-
if ts
212-
else "0000-00-00T00-00-00"
213-
)
214-
rel_path = f"{proj_slug}/{ts_file}__{title_slug}__{short_id}.md"
215-
zf.writestr(rel_path, md)
216-
manifest.append(
217-
{
218-
"session_id": sid,
219-
"title": session["title"],
220-
"project": project["name"],
221-
"tokens": session["metadata"][
222-
"total_input_tokens"
223-
]
224-
+ session["metadata"]["total_output_tokens"],
225-
"tool_calls": session["metadata"][
226-
"total_tool_calls"
227-
],
228-
"cost_estimate_usd": stats.get(
229-
"cost_estimate_usd"
230-
),
231-
}
232-
)
233-
new_sessions_map[sid] = sess_info.get("modified", 0)
234-
count += 1
235-
except _EXPORT_ERRORS as e:
236-
current_app.logger.warning(
237-
"Failed to export %s: %s", sid[:10], e
238-
)
239-
continue
240-
if manifest:
241-
manifest_str = "\n".join(
242-
json.dumps(e, default=str) for e in manifest
243-
)
244-
zf.writestr("manifest.jsonl", manifest_str)
118+
result = run_bulk_export(
119+
projects=projects,
120+
since=since,
121+
rules=rules,
122+
last_export_sessions=last_export_sessions,
123+
sink=ZipSink(zf),
124+
fmt="md",
125+
path_layout="api",
126+
manifest_style="api",
127+
on_export_error=_on_export_error,
128+
)
129+
130+
count = result.exported_session_count
131+
new_sessions_map = result.new_sessions_map
132+
latest_day = result.latest_day
245133

246134
if count == 0:
247135
return error_response(

0 commit comments

Comments
 (0)