Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 11 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,13 +19,14 @@ Browse and export Claude Code chat history — Web GUI and CLI.
- **Smooth transitions** — staggered card/message animations, crossfade content swaps
- **Scroll-to-top button** in bottom-right corner
- **Per-model badges** in session header
- **Bulk export** — download all sessions as a zip
- **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

### CLI Export
- Standalone script to export all sessions to Markdown with YAML frontmatter
- Rich Markdown: token usage, tool calls, thinking blocks, model info, timestamps
- `--since last` flag for incremental export (only new/updated sessions)
- `--project` flag to export a specific project
- `--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)
- `--since incremental` — export only sessions **new or changed since the last export** (file mtime + saved state)
- `--project` flag to export a subset of projects

## Quick Start

Expand Down Expand Up @@ -60,7 +61,7 @@ python app.py --base-dir /path/to/claude/projects
```bash
# Activate venv first (see above), then:

# List all projects (shows directory names you can use with --project)
# List all projects (first column is a friendly name; --project accepts that or the dir slug)
python scripts/export.py list

# Export all sessions as zip
Expand All @@ -69,14 +70,17 @@ python scripts/export.py
# Export to specific directory, no zip
python scripts/export.py --out ./exports --no-zip

# Incremental export (only new sessions since last run)
# 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)
python scripts/export.py --since last

# Export specific project only (substring match on directory name)
# Incremental (only new/updated sessions since last run, using export state)
python scripts/export.py --since incremental

# Export specific project only (substring on friendly name from list and/or dir name under ~/.claude/projects/)
python scripts/export.py --project boost-capy
```

The `--project` flag matches against the directory names under `~/.claude/projects/`. These are path-based names like `F--boost-capy` or `d--harbor-forge`. You can use any substring — for example `boost-capy` will match `F--boost-capy`. Run `python scripts/export.py list` to see all available project names.
The `--project` flag matches a **case-insensitive substring** of either the **Project** column from `list` (derived from the session working directory) or the internal directory name under `~/.claude/projects/` (for example `F--boost-capy` or `d--harbor-forge`). A substring like `boost-capy` matches `F--boost-capy`; you can also paste the friendly name shown in `list`.

## Data Source

Expand Down
251 changes: 189 additions & 62 deletions api/export_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,120 +8,245 @@

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

from utils.session_path import get_claude_projects_dir, list_projects, list_sessions
from utils.export_state_store import (
EXPORT_STATE_FILE,
atomic_write_export_state,
export_state_lock,
load_export_state_from_disk,
)
from utils.session_path import (
get_claude_projects_dir,
list_projects,
list_sessions,
)
from utils.jsonl_parser import parse_session
from utils.session_stats import compute_stats
from utils.md_exporter import session_to_markdown
from utils.json_exporter import session_to_json
from utils.exclusion_rules import is_session_excluded
from utils.slugify import slugify
from utils.export_day_filter import collect_sessions_for_latest_activity_day

export_bp = Blueprint("export", __name__)

_STATE_FILE = os.path.join(os.path.expanduser("~"), ".claude-code-chat-browser", "export_state.json")
# Tests monkeypatch this path; keep in sync with utils.export_state_store.
_STATE_FILE = EXPORT_STATE_FILE


def _state_lock():
return export_state_lock(_STATE_FILE)


def _load_state_from_disk() -> dict:
return load_export_state_from_disk(_STATE_FILE)


def _atomic_write_state(state: dict) -> None:
atomic_write_export_state(state, _STATE_FILE)


def _read_state() -> dict:
if os.path.exists(_STATE_FILE):
try:
with open(_STATE_FILE) as f:
return json.load(f)
except Exception:
pass
return {}
with _state_lock():
return _load_state_from_disk()


def _write_state(sessions_map: dict, count: int):
os.makedirs(os.path.dirname(_STATE_FILE), exist_ok=True)
state = _read_state()
state["lastExportTime"] = datetime.now().isoformat()
state["exportedCount"] = count
state.setdefault("sessions", {}).update(sessions_map)
with open(_STATE_FILE, "w") as f:
json.dump(state, f, indent=2)
def _write_state(sessions_map: dict, count: int) -> None:
"""Persist merge of *sessions_map* and update last-export metadata (*count* = this run only)."""
with _state_lock():
state = _load_state_from_disk()
state["lastExportTime"] = datetime.now().isoformat()
state["exportedCount"] = count
state.setdefault("sessions", {}).update(sessions_map)
_atomic_write_state(state)


@export_bp.route("/api/export/state")
def get_export_state():
state = _read_state()
return jsonify({
"last_export_time": state.get("lastExportTime"),
"export_count": state.get("exportedCount", 0),
})
n = state.get("exportedCount", 0)
return jsonify(
{
"last_export_time": state.get("lastExportTime"),
# Sessions exported in the last completed bulk export (not a lifetime total).
"last_export_session_count": n,
"export_count": n,
}
)


@export_bp.route("/api/export", methods=["POST"])
def bulk_export():
body = request.get_json(silent=True) or {}
since = "last" if body.get("since") == "last" else "all"
body = request.get_json(silent=True)
if body is None:
body = {}
if not isinstance(body, dict):
return jsonify({"error": "Invalid request body"}), 400

base = current_app.config.get("CLAUDE_PROJECTS_DIR") or get_claude_projects_dir()
since = body.get("since", "all")
if since not in ("all", "last", "incremental"):
return jsonify({"error": "Invalid since mode", "since": since}), 400

base = (
current_app.config.get("CLAUDE_PROJECTS_DIR")
or get_claude_projects_dir()
)
projects = list_projects(base)
rules = current_app.config.get("EXCLUSION_RULES") or []

state = _read_state()
last_export_sessions: dict = state.get("sessions", {}) if since == "last" else {}
last_export_sessions: dict = (
state.get("sessions", {}) if since == "incremental" else {}
)

buf = io.BytesIO()
count = 0
manifest = []
new_sessions_map: dict = {}
latest_day = None

with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf:
for project in projects:
sessions = list_sessions(project["path"])
for sess_info in sessions:
if since == "last":
d, rows, _n = collect_sessions_for_latest_activity_day(
projects,
list_sessions=list_sessions,
parse_session=parse_session,
is_session_excluded=is_session_excluded,
rules=rules,
)
latest_day = d
for project, sess_info, session, _st, _en in rows:
sid = sess_info["id"]
try:
if since == "last":
prev_mtime = last_export_sessions.get(sid, 0)
curr_mtime = sess_info.get("modified", 0)
if curr_mtime and curr_mtime <= prev_mtime:
continue

session = parse_session(sess_info["path"])
if session["title"] == "Untitled Session":
continue

if is_session_excluded(
rules,
session,
project.get("display_name") or project["name"],
):
continue

stats = compute_stats(session)
md = session_to_markdown(session, stats)
title_slug = slugify(session["title"], default="session")
short_id = sid[:8]
proj_slug = slugify(project["name"], default="project")
ts = session["metadata"].get("first_timestamp", "")
ts_file = ts[:19].replace(":", "-") if ts else "0000-00-00T00-00-00"
rel_path = f"{proj_slug}/{ts_file}__{title_slug}__{short_id}.md"
ts_file = (
ts[:19].replace(":", "-")
if ts
else "0000-00-00T00-00-00"
)
rel_path = (
f"{proj_slug}/{ts_file}__{title_slug}__{short_id}.md"
)
zf.writestr(rel_path, md)
manifest.append({
"session_id": sid,
"title": session["title"],
"project": project["name"],
"tokens": session["metadata"]["total_input_tokens"]
+ session["metadata"]["total_output_tokens"],
"tool_calls": session["metadata"]["total_tool_calls"],
"cost_estimate_usd": stats.get("cost_estimate_usd"),
})
manifest.append(
{
"session_id": sid,
"title": session["title"],
"project": project["name"],
"tokens": session["metadata"]["total_input_tokens"]
+ session["metadata"]["total_output_tokens"],
"tool_calls": session["metadata"][
"total_tool_calls"
],
"cost_estimate_usd": stats.get(
"cost_estimate_usd"
),
}
)
new_sessions_map[sid] = sess_info.get("modified", 0)
count += 1
except Exception as e:
current_app.logger.warning("Failed to export %s: %s", sid[:10], e)
current_app.logger.warning(
"Failed to export %s: %s", sid[:10], e
)
continue
else:
for project in projects:
sessions = list_sessions(project["path"])
for sess_info in sessions:
sid = sess_info["id"]
try:
if since == "incremental":
prev_mtime = last_export_sessions.get(sid, 0)
curr_mtime = sess_info.get("modified", 0)
if curr_mtime and curr_mtime <= prev_mtime:
continue

session = parse_session(sess_info["path"])
if session["title"] == "Untitled Session":
continue

if is_session_excluded(
rules,
session,
project.get("display_name") or project["name"],
):
continue

stats = compute_stats(session)
md = session_to_markdown(session, stats)
title_slug = slugify(
session["title"], default="session"
)
short_id = sid[:8]
proj_slug = slugify(project["name"], default="project")
ts = session["metadata"].get("first_timestamp", "")
ts_file = (
ts[:19].replace(":", "-")
if ts
else "0000-00-00T00-00-00"
)
rel_path = f"{proj_slug}/{ts_file}__{title_slug}__{short_id}.md"
zf.writestr(rel_path, md)
manifest.append(
{
"session_id": sid,
"title": session["title"],
"project": project["name"],
"tokens": session["metadata"][
"total_input_tokens"
]
+ session["metadata"]["total_output_tokens"],
"tool_calls": session["metadata"][
"total_tool_calls"
],
"cost_estimate_usd": stats.get(
"cost_estimate_usd"
),
}
)
new_sessions_map[sid] = sess_info.get("modified", 0)
count += 1
except Exception as e:
current_app.logger.warning(
"Failed to export %s: %s", sid[:10], e
)
continue
if manifest:
manifest_str = "\n".join(json.dumps(e, default=str) for e in manifest)
manifest_str = "\n".join(
json.dumps(e, default=str) for e in manifest
)
zf.writestr("manifest.jsonl", manifest_str)

if count > 0:
_write_state(new_sessions_map, count)

if count == 0:
return (
jsonify(
{
"error": "Nothing to export",
"since": since,
}
),
422,
)

buf.seek(0)
date_tag = datetime.now().strftime("%Y-%m-%d")
suffix = "-since-last" if since == "last" else ""
if since == "last":
if latest_day is not None:
suffix = f"-last-{latest_day.strftime('%m-%d')}"
else:
suffix = "-last"
elif since == "incremental":
suffix = "-incremental"
else:
suffix = ""
return send_file(
buf,
mimetype="application/zip",
Expand All @@ -134,7 +259,11 @@ def bulk_export():
def export_session(project_name, session_id):
import os
from utils.session_path import safe_join
base = current_app.config.get("CLAUDE_PROJECTS_DIR") or get_claude_projects_dir()

base = (
current_app.config.get("CLAUDE_PROJECTS_DIR")
or get_claude_projects_dir()
)
try:
filepath = safe_join(base, project_name, f"{session_id}.jsonl")
except ValueError:
Expand Down Expand Up @@ -171,5 +300,3 @@ def export_session(project_name, session_id):
as_attachment=True,
download_name=f"{title_slug}.md",
)


Loading
Loading