Skip to content

Commit a4171f4

Browse files
committed
refactor(export): centralize export state management and enhance error handling
- Introduced a new utility module for managing export state with atomic I/O and locking mechanisms. - Updated the export API to validate request bodies and handle invalid 'since' parameters, returning appropriate error responses. - Enhanced the README to clarify bulk export options and CLI export flag behaviors. - Added unit tests for new error handling scenarios in the export API and improved logging for session parsing failures. - Refactored existing code to utilize the new export state management utilities, ensuring consistency across API and CLI.
1 parent de2990c commit a4171f4

7 files changed

Lines changed: 246 additions & 79 deletions

File tree

README.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,12 +19,12 @@ Browse and export Claude Code chat history — Web GUI and CLI.
1919
- **Smooth transitions** — staggered card/message animations, crossfade content swaps
2020
- **Scroll-to-top button** in bottom-right corner
2121
- **Per-model badges** in session header
22-
- **Bulk export** — download all sessions, incremental updates, or latest-day slice as a zip; if there is nothing to export, the API returns **422** JSON (`Nothing to export`) instead of an empty zip
22+
- **Bulk export** — download all sessions, incremental updates, or latest-day slice as a zip; if there is nothing to export, the API returns **422** with JSON body `{"error": "Nothing to export", "since": "<mode>"}` (the `since` field echoes your request: `"all"`, `"last"`, or `"incremental"`) instead of an empty zip
2323

2424
### CLI Export
2525
- Standalone script to export all sessions to Markdown with YAML frontmatter
2626
- Rich Markdown: token usage, tool calls, thinking blocks, model info, timestamps
27-
- `--since last` — export every session that overlaps the **latest UTC calendar day** present in your history (zip name includes `last-MM-DD`)
27+
- `--since last` — export every session that overlaps the **latest UTC calendar day** present in your history (default zip name: `claude-code-export-last-MM-DD-YYYY-MM-DD.zip` — the first `MM-DD` is that latest UTC day, and `YYYY-MM-DD` is the export date)
2828
- `--since incremental` — export only sessions **new or changed since the last export** (file mtime + saved state)
2929
- `--project` flag to export a subset of projects
3030

@@ -70,7 +70,7 @@ python scripts/export.py
7070
# Export to specific directory, no zip
7171
python scripts/export.py --out ./exports --no-zip
7272

73-
# Latest calendar day (UTC): all sessions active on that day; zip like claude-code-export-last-04-06-2026-05-08.zip
73+
# Latest calendar day (UTC): all sessions active on that day; zip pattern claude-code-export-last-MM-DD-YYYY-MM-DD.zip (e.g. claude-code-export-last-04-06-2026-05-08.zip — 04-06 = latest UTC day, 2026-05-08 = export date)
7474
python scripts/export.py --since last
7575

7676
# Incremental (only new/updated sessions since last run, using export state)

api/export_api.py

Lines changed: 18 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -3,14 +3,17 @@
33
import io
44
import json
55
import os
6-
import tempfile
7-
import threading
86
import zipfile
9-
from contextlib import contextmanager
107
from datetime import datetime
118

129
from flask import Blueprint, current_app, jsonify, request, send_file
1310

11+
from utils.export_state_store import (
12+
EXPORT_STATE_FILE,
13+
atomic_write_export_state,
14+
export_state_lock,
15+
load_export_state_from_disk,
16+
)
1417
from utils.session_path import (
1518
get_claude_projects_dir,
1619
list_projects,
@@ -24,64 +27,22 @@
2427
from utils.slugify import slugify
2528
from utils.export_day_filter import collect_sessions_for_latest_activity_day
2629

27-
try:
28-
import fcntl
29-
except ImportError:
30-
fcntl = None # Windows: fall back to threading lock (same process only)
31-
3230
export_bp = Blueprint("export", __name__)
3331

34-
_STATE_FILE = os.path.join(
35-
os.path.expanduser("~"), ".claude-code-chat-browser", "export_state.json"
36-
)
37-
38-
_fallback_lock = threading.Lock()
32+
# Tests monkeypatch this path; keep in sync with utils.export_state_store.
33+
_STATE_FILE = EXPORT_STATE_FILE
3934

4035

41-
@contextmanager
4236
def _state_lock():
43-
"""Serialize export_state.json reads/writes (POSIX: flock; else threading)."""
44-
if fcntl is not None:
45-
lock_path = _STATE_FILE + ".lock"
46-
os.makedirs(os.path.dirname(lock_path), exist_ok=True)
47-
lock_fp = open(lock_path, "a+")
48-
try:
49-
fcntl.flock(lock_fp.fileno(), fcntl.LOCK_EX)
50-
yield
51-
finally:
52-
fcntl.flock(lock_fp.fileno(), fcntl.LOCK_UN)
53-
lock_fp.close()
54-
else:
55-
with _fallback_lock:
56-
yield
37+
return export_state_lock(_STATE_FILE)
5738

5839

5940
def _load_state_from_disk() -> dict:
60-
if os.path.exists(_STATE_FILE):
61-
try:
62-
with open(_STATE_FILE, encoding="utf-8") as f:
63-
return json.load(f)
64-
except Exception:
65-
pass
66-
return {}
41+
return load_export_state_from_disk(_STATE_FILE)
6742

6843

6944
def _atomic_write_state(state: dict) -> None:
70-
"""Write state atomically (temp file + replace) under _state_lock."""
71-
dir_name = os.path.dirname(_STATE_FILE) or "."
72-
os.makedirs(dir_name, exist_ok=True)
73-
fd, tmp_path = tempfile.mkstemp(dir=dir_name, suffix=".tmp")
74-
try:
75-
with os.fdopen(fd, "w", encoding="utf-8") as f:
76-
json.dump(state, f, indent=2)
77-
os.replace(tmp_path, _STATE_FILE)
78-
except BaseException:
79-
try:
80-
if os.path.exists(tmp_path):
81-
os.unlink(tmp_path)
82-
except OSError:
83-
pass
84-
raise
45+
atomic_write_export_state(state, _STATE_FILE)
8546

8647

8748
def _read_state() -> dict:
@@ -115,10 +76,15 @@ def get_export_state():
11576

11677
@export_bp.route("/api/export", methods=["POST"])
11778
def bulk_export():
118-
body = request.get_json(silent=True) or {}
79+
body = request.get_json(silent=True)
80+
if body is None:
81+
body = {}
82+
if not isinstance(body, dict):
83+
return jsonify({"error": "Invalid request body"}), 400
84+
11985
since = body.get("since", "all")
12086
if since not in ("all", "last", "incremental"):
121-
since = "all"
87+
return jsonify({"error": "Invalid since mode", "since": since}), 400
12288

12389
base = (
12490
current_app.config.get("CLAUDE_PROJECTS_DIR")

scripts/export.py

Lines changed: 24 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,11 @@
3636
)
3737
from utils.slugify import slugify
3838
from utils.export_day_filter import collect_sessions_for_latest_activity_day
39-
39+
from utils.export_state_store import (
40+
atomic_write_export_state,
41+
export_state_lock,
42+
load_export_state_from_disk,
43+
)
4044

4145
STATE_DIR = os.path.join(os.path.expanduser("~"), ".claude-code-chat-browser")
4246
STATE_FILE = os.path.join(STATE_DIR, "export_state.json")
@@ -766,28 +770,28 @@ def _load_state() -> dict:
766770
767771
{"<session-uuid>": <mtime-float>, ...}
768772
"""
769-
if not os.path.isfile(STATE_FILE):
770-
return {}
771-
with open(STATE_FILE, "r") as f:
772-
data = json.load(f)
773-
# Migrate: if the file has neither "sessions" nor "lastExportTime" it is
774-
# the old flat dict of session_id → mtime.
775-
if "sessions" not in data and "lastExportTime" not in data:
776-
return {"sessions": data}
777-
return data
773+
with export_state_lock(STATE_FILE):
774+
return load_export_state_from_disk(STATE_FILE)
778775

779776

780777
def _save_state(sessions: dict, count: int, out_dir: str):
781-
"""Persist export state with standardised fields matching cursor-chat-browser."""
782-
os.makedirs(STATE_DIR, exist_ok=True)
783-
state = {
784-
"lastExportTime": datetime.now().isoformat(),
785-
"exportedCount": count,
786-
"exportDir": out_dir,
787-
"sessions": sessions,
788-
}
789-
with open(STATE_FILE, "w") as f:
790-
json.dump(state, f, indent=2)
778+
"""Persist export state with standardised fields matching cursor-chat-browser.
779+
780+
Merges ``sessions`` into any concurrent updates on disk (same lock/atomic
781+
path as the web API).
782+
"""
783+
with export_state_lock(STATE_FILE):
784+
disk = load_export_state_from_disk(STATE_FILE)
785+
disk["lastExportTime"] = datetime.now().isoformat()
786+
disk["exportedCount"] = count
787+
disk["exportDir"] = out_dir
788+
base = disk.get("sessions")
789+
if not isinstance(base, dict):
790+
base = {}
791+
merged = dict(base)
792+
merged.update(sessions)
793+
disk["sessions"] = merged
794+
atomic_write_export_state(disk, STATE_FILE)
791795

792796

793797
def _die(msg: str):

tests/test_export_api_bulk.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,34 @@ def isolated_state(tmp_path, monkeypatch):
2323
return path
2424

2525

26+
def test_bulk_export_invalid_since_returns_400(isolated_state, tmp_path):
27+
app = Flask(__name__)
28+
app.config["TESTING"] = True
29+
app.config["CLAUDE_PROJECTS_DIR"] = str(tmp_path)
30+
app.register_blueprint(export_bp)
31+
client = app.test_client()
32+
resp = client.post("/api/export", json={"since": "lst"})
33+
assert resp.status_code == 400
34+
body = resp.get_json()
35+
assert body["error"] == "Invalid since mode"
36+
assert body["since"] == "lst"
37+
38+
39+
def test_bulk_export_non_object_json_returns_400(isolated_state, tmp_path):
40+
app = Flask(__name__)
41+
app.config["TESTING"] = True
42+
app.config["CLAUDE_PROJECTS_DIR"] = str(tmp_path)
43+
app.register_blueprint(export_bp)
44+
client = app.test_client()
45+
resp = client.post(
46+
"/api/export",
47+
data=json.dumps(["all"]),
48+
content_type="application/json",
49+
)
50+
assert resp.status_code == 400
51+
assert resp.get_json()["error"] == "Invalid request body"
52+
53+
2654
def test_bulk_export_empty_returns_422_json(isolated_state, tmp_path):
2755
app = Flask(__name__)
2856
app.config["TESTING"] = True

tests/test_export_day_filter.py

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
11
"""Unit tests for utils/export_day_filter.py."""
22

3+
import logging
34
from datetime import date
45

6+
import pytest
7+
58
from utils.export_day_filter import (
69
collect_sessions_for_latest_activity_day,
710
day_overlaps_session,
@@ -61,3 +64,58 @@ def parse_session(path):
6164
assert n == 2
6265
assert len(rows) == 1
6366
assert rows[0][2]["title"] == "One"
67+
68+
69+
def test_collect_latest_day_logs_parse_failure(caplog):
70+
"""Parse errors must be visible: they can change which day wins ``d = max(...)``."""
71+
72+
def list_sessions(path):
73+
return [
74+
{"id": "a", "path": "broken.jsonl", "modified": 0.0},
75+
{"id": "b", "path": "good.jsonl", "modified": 0.0},
76+
]
77+
78+
def parse_session(path):
79+
if path == "broken.jsonl":
80+
raise ValueError("simulated corrupt jsonl")
81+
return {
82+
"title": "OK",
83+
"metadata": {
84+
"first_timestamp": "2026-04-05T10:00:00Z",
85+
"last_timestamp": "2026-04-05T12:00:00Z",
86+
},
87+
}
88+
89+
projects = [{"name": "proj", "path": "/x", "display_name": "P"}]
90+
with caplog.at_level(logging.ERROR, logger="utils.export_day_filter"):
91+
d, rows, n = collect_sessions_for_latest_activity_day(
92+
projects,
93+
list_sessions=list_sessions,
94+
parse_session=parse_session,
95+
is_session_excluded=lambda *a, **k: False,
96+
rules=[],
97+
)
98+
assert "broken.jsonl" in caplog.text
99+
assert "simulated corrupt jsonl" in caplog.text
100+
assert d == date(2026, 4, 5)
101+
assert n == 2
102+
assert len(rows) == 1
103+
104+
105+
def test_collect_latest_day_abort_on_parse_error():
106+
def list_sessions(path):
107+
return [{"id": "a", "path": "bad.jsonl", "modified": 0.0}]
108+
109+
def parse_session(path):
110+
raise RuntimeError("fail fast")
111+
112+
projects = [{"name": "proj", "path": "/x", "display_name": "P"}]
113+
with pytest.raises(RuntimeError, match="fail fast"):
114+
collect_sessions_for_latest_activity_day(
115+
projects,
116+
list_sessions=list_sessions,
117+
parse_session=parse_session,
118+
is_session_excluded=lambda *a, **k: False,
119+
rules=[],
120+
abort_on_parse_error=True,
121+
)

utils/export_day_filter.py

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,11 @@
22

33
from __future__ import annotations
44

5+
import logging
56
from datetime import date, datetime, timezone
67

8+
logger = logging.getLogger(__name__)
9+
710

811
def iso_timestamp_to_date(ts: str | None) -> date | None:
912
"""First 10 chars of an ISO timestamp as a UTC calendar date."""
@@ -46,12 +49,17 @@ def collect_sessions_for_latest_activity_day(
4649
parse_session,
4750
is_session_excluded,
4851
rules,
52+
abort_on_parse_error: bool = False,
4953
) -> tuple[date | None, list[tuple[dict, dict, dict, date, date]], int]:
5054
"""Parse sessions in *projects*, skip untitled/excluded, return (D, rows, n_scanned).
5155
52-
*D* is the latest session **end** calendar date (UTC). Each row is
53-
``(project, sess_info, session, start_date, end_date)`` for sessions that
54-
overlap *D*. *n_scanned* counts every ``.jsonl`` file visited.
56+
*D* is the latest session **end** calendar date (UTC) from successfully
57+
parsed sessions only (``d = max(...)`` over parsed rows). Parse failures are
58+
logged and skipped unless *abort_on_parse_error* is true, in which case the
59+
first failure is re-raised.
60+
61+
Each row is ``(project, sess_info, session, start_date, end_date)`` for
62+
sessions that overlap *D*. *n_scanned* counts every ``.jsonl`` file visited.
5563
"""
5664
parsed: list[tuple[dict, dict, dict, date, date]] = []
5765
total_scan = 0
@@ -60,7 +68,15 @@ def collect_sessions_for_latest_activity_day(
6068
total_scan += 1
6169
try:
6270
session = parse_session(sess_info["path"])
63-
except Exception:
71+
except Exception as e:
72+
logger.error(
73+
"Failed to parse session for latest-day selection %s: %s: %s",
74+
sess_info["path"],
75+
type(e).__name__,
76+
e,
77+
)
78+
if abort_on_parse_error:
79+
raise
6480
continue
6581
if session["title"] == "Untitled Session":
6682
continue

0 commit comments

Comments
 (0)