|
#!/usr/bin/env python3 |
|
"""Split conversations.json into one readable Markdown file per chat. |
|
|
|
The export ships as a single JSON array on one line — a gigabyte with no |
|
newlines. Instead of a hand-rolled character scanner (quadratic and slow), this |
|
walks the array with the stdlib C decoder via JSONDecoder.raw_decode() at |
|
successive offsets: about two seconds for 1 GB. |
|
|
|
Layout is hybrid — a chat with no attachments is a flat file, a chat with |
|
attachments becomes a folder: |
|
|
|
chats/2026-02-24 Bike tire pressure.md — flat |
|
chats/2026-02-24 Greenhouse layout/ — folder |
|
chat.md |
|
attachments/soil-test.md |
|
chats/_index.md — table of contents |
|
|
|
Only the conversation itself is kept: human and assistant text. Tool calls |
|
collapse to a one-line marker, while thinking blocks and tool_result payloads |
|
are dropped — in a real export those account for ~92% of the bytes. |
|
|
|
Usage: |
|
python3 split_chats.py --dry-run # show the plan |
|
python3 split_chats.py # split |
|
python3 split_chats.py --force # DELETE and rebuild an existing chats/ |
|
""" |
|
|
|
import argparse |
|
import json |
|
import os |
|
import re |
|
import shutil |
|
import sys |
|
from pathlib import Path |
|
|
|
SRC = "conversations.json" |
|
OUT_DIR = "chats" |
|
CHAT_FILE = "chat.md" |
|
ATT_DIR = "attachments" |
|
|
|
ILLEGAL = re.compile(r'[/\\*?"<>|\x00-\x1f]') |
|
SPACES = re.compile(r"\s+") |
|
|
|
|
|
def safe_name(raw: str, fallback: str, limit: int = 100) -> str: |
|
"""A file/folder name that is both filesystem-safe and still readable.""" |
|
name = (raw or "").replace(":", " -") |
|
name = ILLEGAL.sub("-", name) |
|
name = SPACES.sub(" ", name).strip().strip(".") |
|
if len(name) > limit: |
|
name = name[:limit].rsplit(" ", 1)[0].rstrip(" -,") |
|
while len(name.encode("utf-8")) > 180: |
|
name = name[:-1] |
|
return name or fallback |
|
|
|
|
|
def md_link(text: str, target: str) -> str: |
|
return f"[{text}](<{target}>)" |
|
|
|
|
|
def human_size(n: int) -> str: |
|
return f"{n / 1024:.1f} KB" if n < 1024 * 1024 else f"{n / 1048576:.1f} MB" |
|
|
|
|
|
def short_ts(ts: str) -> str: |
|
"""2026-02-24T15:30:05.650472Z -> 2026-02-24 15:30""" |
|
if not ts or "T" not in ts: |
|
return ts or "" |
|
date, time = ts.split("T", 1) |
|
return f"{date} {time[:5]}" |
|
|
|
|
|
def tool_marker(block: dict) -> str: |
|
"""One-line stand-in for a tool call.""" |
|
name = block.get("name") or block.get("tool_identifier") or "tool" |
|
payload = block.get("input") |
|
hint = "" |
|
if isinstance(payload, dict): |
|
for key in ("query", "prompt", "url", "command", "path", "file_path"): |
|
if payload.get(key): |
|
hint = str(payload[key]) |
|
break |
|
else: |
|
if payload: |
|
hint = json.dumps(payload, ensure_ascii=False) |
|
elif payload: |
|
hint = str(payload) |
|
hint = SPACES.sub(" ", hint).strip() |
|
if len(hint) > 160: |
|
hint = hint[:160] + "…" |
|
return f"`🔧 {name}`" + (f" — “{hint}”" if hint else "") |
|
|
|
|
|
def message_text(msg: dict) -> str: |
|
"""All readable text of a message: text blocks, else the text field.""" |
|
parts = [(b.get("text") or "").strip() for b in msg.get("content") or [] |
|
if b.get("type") == "text"] |
|
joined = "\n\n".join(p for p in parts if p) |
|
return joined or (msg.get("text") or "").strip() |
|
|
|
|
|
def render_message(msg: dict, att_paths: dict) -> list: |
|
"""One message -> Markdown lines. An empty message yields an empty list.""" |
|
body = [] |
|
for block in msg.get("content") or []: |
|
btype = block.get("type") |
|
if btype == "text": |
|
text = (block.get("text") or "").strip() |
|
if text: |
|
body.append(text) |
|
elif btype == "tool_use": |
|
body.append(tool_marker(block)) |
|
# thinking / tool_result / token_budget / flag are dropped |
|
|
|
if not body: |
|
fallback = (msg.get("text") or "").strip() |
|
if fallback: |
|
body.append(fallback) |
|
|
|
attachments = msg.get("attachments") or [] |
|
files = msg.get("files") or [] |
|
if not body and not attachments and not files: |
|
return [] # genuinely empty in the export — emit nothing at all |
|
|
|
who = "👤 **You**" if msg.get("sender") == "human" else "🤖 **Claude**" |
|
lines = [f"### {who} · {short_ts(msg.get('created_at'))}", ""] |
|
if body: |
|
lines += ["\n\n".join(body), ""] |
|
|
|
for att in attachments: |
|
fname = att.get("file_name") or "unnamed" |
|
size = human_size(att.get("file_size") or len(att.get("extracted_content") or "")) |
|
target = att_paths.get(id(att)) |
|
link = md_link(fname, target) if target else f"**{fname}**" |
|
lines += [f"📎 Attachment: {link} ({size})", ""] |
|
|
|
# files[] carries a name but no content — the bytes are not in the export |
|
att_names = {a.get("file_name") for a in attachments} |
|
for fl in files: |
|
fname = fl.get("file_name") |
|
if fname and fname not in att_names: |
|
lines += [f"🖼 File `{fname}` — content not included in the export", ""] |
|
|
|
return lines |
|
|
|
|
|
def render_chat(conv: dict, att_paths: dict) -> str: |
|
uuid = conv["uuid"] |
|
name = (conv.get("name") or "").strip() or "Untitled" |
|
msgs = conv["chat_messages"] |
|
|
|
lines = [ |
|
f"# {name}", |
|
"", |
|
"| | |", |
|
"| --- | --- |", |
|
f"| Date | {short_ts(conv.get('created_at'))} |", |
|
f"| Updated | {short_ts(conv.get('updated_at'))} |", |
|
f"| Messages | {len(msgs)} |", |
|
f"| UUID | `{uuid}` |", |
|
f"| Chat in Claude | https://claude.ai/chat/{uuid} |", |
|
"", |
|
] |
|
|
|
summary = (conv.get("summary") or "").strip() |
|
if summary: |
|
# a blank line would end the callout — every line needs its own ">" |
|
lines.append("> [!NOTE] Summary") |
|
lines += ["> " + ln if ln.strip() else ">" for ln in summary.splitlines()] |
|
lines.append("") |
|
|
|
lines += ["---", ""] |
|
for msg in sorted(msgs, key=lambda m: m.get("created_at") or ""): |
|
lines += render_message(msg, att_paths) |
|
|
|
return "\n".join(lines).rstrip() + "\n" |
|
|
|
|
|
def iter_conversations(path): |
|
"""Yield conversations from the one-line JSON array. |
|
|
|
Reads the file into memory: a 1 GB export is a ~1 GB str (the export is |
|
pure ASCII thanks to the \\uXXXX escaping) plus one decoded object at a |
|
time. Budget a few GB of RAM for a gigabyte-sized export. |
|
""" |
|
text = Path(path).read_text(encoding="utf-8") |
|
dec = json.JSONDecoder() |
|
i = text.index("[") + 1 |
|
while True: |
|
while i < len(text) and text[i] in ", \t\r\n": |
|
i += 1 |
|
if i >= len(text) or text[i] == "]": |
|
return |
|
conv, i = dec.raw_decode(text, i) |
|
yield conv |
|
|
|
|
|
def chat_title(conv: dict) -> str: |
|
"""Chat name; for untitled chats, the start of the first human message.""" |
|
name = (conv.get("name") or "").strip() |
|
if name: |
|
return name |
|
for msg in sorted(conv["chat_messages"], key=lambda m: m.get("created_at") or ""): |
|
if msg.get("sender") != "human": |
|
continue |
|
text = SPACES.sub(" ", message_text(msg)) |
|
if text: |
|
return text[:60].rstrip() + ("…" if len(text) > 60 else "") |
|
return "" |
|
|
|
|
|
def skip_reason(conv: dict): |
|
"""Why this chat is not worth a file, or None if it is. |
|
|
|
A chat is kept when it has at least one line of readable text or one |
|
attachment with extracted content. Anything else would land on disk as a |
|
file made entirely of empty placeholders. |
|
""" |
|
msgs = conv["chat_messages"] |
|
if not msgs: |
|
return "no messages" |
|
|
|
has_text = any(message_text(m) for m in msgs) |
|
has_att = any((a.get("extracted_content") or "").strip() |
|
for m in msgs for a in m.get("attachments") or []) |
|
if has_text or has_att: |
|
return None |
|
|
|
if any(m.get("files") for m in msgs): |
|
return "file names only, content not included in the export" |
|
return "empty messages with no text and no attachments" |
|
|
|
|
|
def main() -> int: |
|
parser = argparse.ArgumentParser( |
|
description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) |
|
parser.add_argument("--dry-run", action="store_true", help="write nothing") |
|
parser.add_argument("--force", action="store_true", |
|
help="delete an existing output directory and rebuild it") |
|
parser.add_argument("--keep-empty", action="store_true", |
|
help="also emit files for chats with no readable text") |
|
parser.add_argument("--src", default=SRC, help=f"source file (default: {SRC})") |
|
parser.add_argument("--out", default=OUT_DIR, help=f"output directory (default: {OUT_DIR})") |
|
args = parser.parse_args() |
|
|
|
src = Path(args.src).resolve() |
|
out = Path(args.out).resolve() |
|
if not src.is_file(): |
|
print(f"No such file: {src}", file=sys.stderr) |
|
return 1 |
|
if out.exists() and any(out.iterdir()) and not args.force and not args.dry_run: |
|
print(f"{out} already exists and is not empty. Use --force to rebuild.", file=sys.stderr) |
|
return 1 |
|
if out.exists() and args.force and not args.dry_run: |
|
shutil.rmtree(out) |
|
if not args.dry_run: |
|
out.mkdir(parents=True, exist_ok=True) |
|
|
|
print(f"Reading {src.name} ({src.stat().st_size / 1e9:.2f} GB)...") |
|
|
|
used_names = {} |
|
index_rows = [] |
|
empty_chats = [] |
|
stats = {"folders": 0, "flat": 0, "attachments": 0, "messages": 0, "bytes": 0} |
|
|
|
for conv in iter_conversations(src): |
|
msgs = conv["chat_messages"] |
|
title = chat_title(conv) |
|
|
|
reason = None if args.keep_empty else skip_reason(conv) |
|
if reason: |
|
empty_chats.append((conv["uuid"], title, (conv.get("created_at") or "")[:10], |
|
len(msgs), reason)) |
|
continue |
|
|
|
date = (conv.get("created_at") or "")[:10] |
|
base = f"{date} {safe_name(title, conv['uuid'][:8])}" |
|
|
|
# same name on the same day happens; disambiguate deterministically |
|
key = base.lower() |
|
used_names[key] = used_names.get(key, 0) + 1 |
|
if used_names[key] > 1: |
|
base = f"{base} ({used_names[key]})" |
|
|
|
atts = [a for m in msgs for a in m.get("attachments") or [] |
|
if (a.get("extracted_content") or "").strip()] |
|
|
|
att_paths = {} |
|
if atts: |
|
folder = out / base |
|
chat_path = folder / CHAT_FILE |
|
att_dir = folder / ATT_DIR |
|
if not args.dry_run: |
|
att_dir.mkdir(parents=True, exist_ok=True) |
|
seen = {} |
|
for idx, att in enumerate(atts): |
|
fname = safe_name(att.get("file_name") or "", f"attachment-{idx + 1}", limit=120) |
|
if not os.path.splitext(fname)[1]: |
|
fname += ".md" |
|
low = fname.lower() |
|
seen[low] = seen.get(low, 0) + 1 |
|
if seen[low] > 1: |
|
stem, ext = os.path.splitext(fname) |
|
fname = f"{stem} ({seen[low]}){ext}" |
|
att_paths[id(att)] = f"{ATT_DIR}/{fname}" |
|
content = att["extracted_content"] |
|
if not args.dry_run: |
|
(att_dir / fname).write_text( |
|
content if content.endswith("\n") else content + "\n", encoding="utf-8") |
|
stats["attachments"] += 1 |
|
stats["folders"] += 1 |
|
rel = f"{base}/{CHAT_FILE}" |
|
else: |
|
chat_path = out / f"{base}.md" |
|
stats["flat"] += 1 |
|
rel = f"{base}.md" |
|
|
|
body = render_chat(conv, att_paths) |
|
if not args.dry_run: |
|
chat_path.parent.mkdir(parents=True, exist_ok=True) |
|
chat_path.write_text(body, encoding="utf-8") |
|
|
|
stats["messages"] += len(msgs) |
|
stats["bytes"] += len(body.encode("utf-8")) |
|
index_rows.append((date, title or "Untitled", rel, len(msgs), len(atts))) |
|
|
|
index_rows.sort() |
|
index = [ |
|
"# All chats", |
|
"", |
|
f"{len(index_rows)} chats, {stats['messages']} messages. Source: `{src.name}`.", |
|
"", |
|
"| Date | Chat | Messages | Attachments |", |
|
"| --- | --- | --- | --- |", |
|
] |
|
for date, title, rel, nmsg, natt in index_rows: |
|
safe_title = title.replace("|", "\\|").replace("[", "(").replace("]", ")") |
|
index.append(f"| {date} | {md_link(safe_title, rel)} | {nmsg} | {natt or ''} |") |
|
|
|
if empty_chats: |
|
index += ["", f"## Skipped empty chats: {len(empty_chats)}", "", |
|
"No files were written for these — they contain no readable text. " |
|
"The links open them in the web app so you can check and delete them.", ""] |
|
by_reason = {} |
|
for uuid, title, date, nmsg, reason in empty_chats: |
|
by_reason.setdefault(reason, []).append((date, title, nmsg, uuid)) |
|
for reason, rows in sorted(by_reason.items(), key=lambda kv: -len(kv[1])): |
|
index += [f"### {reason} — {len(rows)}", ""] |
|
for date, title, nmsg, uuid in sorted(rows): |
|
index.append(f"- {date} · {title or 'untitled'} · {nmsg} msg · " |
|
f"https://claude.ai/chat/{uuid}") |
|
index.append("") |
|
|
|
if not args.dry_run: |
|
(out / "_index.md").write_text("\n".join(index) + "\n", encoding="utf-8") |
|
|
|
prefix = "[dry-run] " if args.dry_run else "" |
|
print(f"\n{prefix}Done:") |
|
print(f" folders with attachments : {stats['folders']}") |
|
print(f" flat .md files : {stats['flat']}") |
|
print(f" attachment files : {stats['attachments']}") |
|
print(f" messages : {stats['messages']}") |
|
print(f" text volume : {stats['bytes'] / 1e6:.1f} MB") |
|
print(f" skipped empty chats : {len(empty_chats)}") |
|
return 0 |
|
|
|
|
|
if __name__ == "__main__": |
|
sys.exit(main()) |