-
Notifications
You must be signed in to change notification settings - Fork 1
Expand file tree
/
Copy pathexport_api.py
More file actions
175 lines (146 loc) · 6.19 KB
/
Copy pathexport_api.py
File metadata and controls
175 lines (146 loc) · 6.19 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
"""Export endpoints -- bulk zip download and single-session md/json."""
import io
import json
import os
import zipfile
from datetime import datetime
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.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
export_bp = Blueprint("export", __name__)
_STATE_FILE = os.path.join(os.path.expanduser("~"), ".claude-code-chat-browser", "export_state.json")
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 {}
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)
@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),
})
@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"
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 {}
buf = io.BytesIO()
count = 0
manifest = []
new_sessions_map: dict = {}
with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf:
for project in projects:
sessions = list_sessions(project["path"])
for sess_info in sessions:
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"]) or "session"
short_id = sid[:8]
proj_slug = slugify(project["name"]) or "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)
zf.writestr("manifest.jsonl", manifest_str)
if count > 0:
_write_state(new_sessions_map, count)
buf.seek(0)
date_tag = datetime.now().strftime("%Y-%m-%d")
suffix = "-since-last" if since == "last" else ""
return send_file(
buf,
mimetype="application/zip",
as_attachment=True,
download_name=f"claude-code-export{suffix}-{date_tag}.zip",
)
@export_bp.route("/api/export/session/<path:project_name>/<session_id>")
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()
try:
filepath = safe_join(base, project_name, f"{session_id}.jsonl")
except ValueError:
return jsonify({"error": "Invalid path"}), 400
if not os.path.isfile(filepath):
return jsonify({"error": "Session not found"}), 404
fmt = request.args.get("format", "md")
session = parse_session(filepath)
rules = current_app.config.get("EXCLUSION_RULES") or []
if is_session_excluded(rules, session, project_name):
return jsonify({"error": "Session not found"}), 404
stats = compute_stats(session)
title_slug = slugify(session["title"]) or "session"
if fmt == "json":
content = session_to_json(session, stats)
buf = io.BytesIO(content.encode("utf-8"))
buf.seek(0)
return send_file(
buf,
mimetype="application/json",
as_attachment=True,
download_name=f"{title_slug}.json",
)
md = session_to_markdown(session, stats)
buf = io.BytesIO(md.encode("utf-8"))
buf.seek(0)
return send_file(
buf,
mimetype="text/markdown",
as_attachment=True,
download_name=f"{title_slug}.md",
)