import json
import os
import re
from datetime import datetime
from tkinter import Tk, filedialog, messagebox, ttk, Label, Frame, StringVar, BooleanVar, colorchooser
from tkinterdnd2 import *
from pathlib import Path
def sanitize_filename(name):
name = re.sub(r'[\\/*?:"<>|]', "_", name)
name = re.sub(r'\s+', " ", name).strip()
return name[:140]
def get_message_text(msg):
if not msg: return ""
if isinstance(msg.get("text"), str): return msg["text"]
if isinstance(msg.get("content"), list):
texts = [item.get("text", str(item)) if isinstance(item, dict) else str(item) for item in msg["content"]]
return "\n\n".join(filter(None, texts))
return str(msg.get("content", ""))
def extract_project_name(conv):
project = conv.get("project") or conv.get("workspace") or conv.get("project_name")
if project and isinstance(project, dict):
project = project.get("name") or project.get("title")
return project if project else None
def convert_urls_to_links(text):
url_pattern = r'(https?://[^\s<>"\']+)'
return re.sub(url_pattern, r'\1', text)
class ClaudeExporterGUI:
def __init__(self):
self.root = TkinterDnD.Tk()
self.root.title("Claude Exported Data Offline Extractor & Browser")
self.root.geometry("852x612") # New initial size
self.root.minsize(680, 580)
self.root.resizable(True, True)
script_dir = os.path.dirname(os.path.abspath(__file__))
self.input_file = StringVar(value=os.path.join(script_dir, "conversations.json"))
self.output_dir = StringVar(value=script_dir)
self.export_md = BooleanVar(value=True)
self.export_html = BooleanVar(value=True)
self.user_color = StringVar(value="#E3F2FD")
self.claude_color = StringVar(value="#F1F8E9")
self.create_widgets()
def create_widgets(self):
main_frame = Frame(self.root, padx=25, pady=20)
main_frame.pack(fill="both", expand=True)
# Title
title = Label(main_frame, text="Claude Exported Data Offline Extractor & Browser",
font=("Arial", 16, "bold"))
title.pack(pady=(0, 10))
# Description
desc_text = "This is a tool for offline unpacking and browsing exported Claude conversations stored in conversations.json " \
"file which can be found in the zip file sent to your email after selecting " \
"\"Settings → Privacy → Export data\" in your Claude"
self.desc_label = Label(main_frame, text=desc_text, font=("Arial", 11),
justify="center", fg="black", wraplength=720)
self.desc_label.pack(pady=(0, 20))
# Input File
Label(main_frame, text="Input File (conversations.json) :").pack(anchor="w")
file_frame = Frame(main_frame)
file_frame.pack(fill="x", pady=4)
self.input_entry = ttk.Entry(file_frame, textvariable=self.input_file)
self.input_entry.pack(side="left", fill="x", expand=True)
ttk.Button(file_frame, text="Browse", command=self.browse_input).pack(side="right", padx=5)
self.input_entry.drop_target_register(DND_FILES)
self.input_entry.dnd_bind('<>', self.drop_file)
# Output Folder
Label(main_frame, text="Output Folder :").pack(anchor="w", pady=(10,0))
out_frame = Frame(main_frame)
out_frame.pack(fill="x", pady=4)
ttk.Entry(out_frame, textvariable=self.output_dir).pack(side="left", fill="x", expand=True)
ttk.Button(out_frame, text="Browse", command=self.browse_output).pack(side="right", padx=5)
# Export Options
opt_frame = Frame(main_frame)
opt_frame.pack(fill="x", pady=12)
ttk.Checkbutton(opt_frame, text="Export as Markdown (.md)", variable=self.export_md).pack(anchor="w")
ttk.Checkbutton(opt_frame, text="Export as HTML (with colors, navigation & clickable links)",
variable=self.export_html).pack(anchor="w", pady=4)
# Colors (Centered)
color_frame = Frame(main_frame)
color_frame.pack(pady=15)
Label(color_frame, text="User background:").pack(side="left", padx=8)
ttk.Button(color_frame, text="Pick", command=self.pick_user_color).pack(side="left", padx=5)
Label(color_frame, textvariable=self.user_color, width=10, background="#E3F2FD").pack(side="left", padx=8)
Label(color_frame, text="Claude background:").pack(side="left", padx=15)
ttk.Button(color_frame, text="Pick", command=self.pick_claude_color).pack(side="left", padx=5)
Label(color_frame, textvariable=self.claude_color, width=10, background="#F1F8E9").pack(side="left", padx=8)
# Start Button
ttk.Button(main_frame, text="Start Export", command=self.start_conversion).pack(pady=20, fill="x")
self.progress = ttk.Progressbar(main_frame, mode='determinate')
self.progress.pack(fill="x", pady=5)
self.status = Label(main_frame, text="", foreground="gray")
self.status.pack(pady=5)
# Make description wrap dynamically
self.root.bind("", self.on_resize)
def on_resize(self, event=None):
if hasattr(self, 'desc_label'):
width = self.root.winfo_width() - 80
self.desc_label.config(wraplength=max(400, width))
def drop_file(self, event):
path = event.data
if path.startswith('{') and path.endswith('}'):
path = path[1:-1]
if path.lower().endswith('.json'):
self.input_file.set(path)
self.status.config(text=f"Loaded: {Path(path).name}")
def pick_user_color(self):
color = colorchooser.askcolor()[1]
if color: self.user_color.set(color)
def pick_claude_color(self):
color = colorchooser.askcolor()[1]
if color: self.claude_color.set(color)
def browse_input(self):
file = filedialog.askopenfilename(filetypes=[("JSON files", "*.json")])
if file: self.input_file.set(file)
def browse_output(self):
folder = filedialog.askdirectory()
if folder: self.output_dir.set(folder)
def start_conversion(self):
if not os.path.exists(self.input_file.get()):
messagebox.showerror("Error", "conversations.json file not found!")
return
self.progress['value'] = 0
self.status.config(text="Processing...", foreground="blue")
self.root.update()
try:
success = self.convert()
messagebox.showinfo("Success", f"Successfully exported {success} conversations!")
self.status.config(text="Export completed successfully ✓", foreground="green")
except Exception as e:
messagebox.showerror("Error", f"An error occurred:\n{str(e)}")
self.status.config(text="Error during export", foreground="red")
finally:
self.progress['value'] = 0
# === Conversion methods (same as before) ===
def convert(self):
with open(self.input_file.get(), "r", encoding="utf-8") as f:
data = json.load(f)
convs = data.get("conversations", data) if isinstance(data, dict) else data
if not isinstance(convs, list):
convs = [convs]
out_dir = self.output_dir.get()
os.makedirs(out_dir, exist_ok=True)
success = 0
self.progress['maximum'] = len(convs)
months = {}
for idx, conv in enumerate(convs):
conv_number = f"{idx+1:03d}"
title = conv.get("title") or f"Conversation {conv_number}"
project_name = extract_project_name(conv)
created_at = conv.get("create_time") or conv.get("created_at")
date_str = "unknown_date"
month_key = "unknown"
if created_at:
try:
dt = datetime.fromisoformat(created_at.replace("Z", "+00:00"))
date_str = dt.strftime("%Y-%m-%d")
month_key = dt.strftime("%Y-%m")
except:
pass
display_title = f"Claude Exported Conversation {conv_number}"
if project_name:
display_title += f" - {project_name}"
if self.export_md.get():
self.save_markdown(conv, display_title, date_str, out_dir, project_name)
if self.export_html.get():
html_rel_path = self.save_html(conv, display_title, date_str, out_dir, month_key,
self.user_color.get(), self.claude_color.get())
if month_key not in months:
months[month_key] = []
months[month_key].append((display_title, date_str, html_rel_path))
success += 1
self.progress['value'] = idx + 1
self.root.update_idletasks()
if self.export_html.get():
self.create_main_html_index(out_dir, months)
return success
def save_markdown(self, conv, title, date_str, base_dir, project_name):
md = [f"# {title}\n", f"**Created:** {date_str}"]
if project_name:
md.append(f"**Project:** {project_name}")
md.append("\n---\n\n")
messages = conv.get("chat_messages") or conv.get("messages") or []
for msg in messages:
sender = msg.get("sender") or msg.get("role") or "unknown"
role = "You" if sender in ["human", "user"] else "Claude" if sender in ["assistant", "claude"] else sender.capitalize()
text = get_message_text(msg)
if text.strip():
md.append(f"### {role}\n")
md.append(f"{text}\n")
md.append("---\n")
safe_title = sanitize_filename(title)
filepath = os.path.join(base_dir, f"{date_str}_{safe_title}.md")
with open(filepath, "w", encoding="utf-8") as f:
f.write("\n".join(md))
def save_html(self, conv, title, date_str, base_dir, month_key, user_color, claude_color):
html = f"""
{title}
{title}
Created: {date_str}
"""
messages = conv.get("chat_messages") or conv.get("messages") or []
for msg in messages:
sender = msg.get("sender") or msg.get("role") or "unknown"
role = "You" if sender in ["human", "user"] else "Claude"
raw_text = get_message_text(msg)
text = convert_urls_to_links(raw_text.replace("\n", "
"))
css_class = "user" if role == "You" else "claude"
html += f''
html += ""
safe_title = sanitize_filename(title)
filepath = os.path.join(base_dir, f"{date_str}_{safe_title}.html")
with open(filepath, "w", encoding="utf-8") as f:
f.write(html)
return f"{date_str}_{safe_title}.html"
def create_main_html_index(self, base_dir, months):
index_html = """
Claude Archive Index
Claude Exported Conversations Archive
"""
for month in sorted(months.keys(), reverse=True):
index_html += f"{month}
"
for title, date, link in months[month]:
index_html += f'- {date} — {title}
'
index_html += "
"
index_html += ""
with open(os.path.join(base_dir, "INDEX.html"), "w", encoding="utf-8") as f:
f.write(index_html)
if __name__ == "__main__":
app = ClaudeExporterGUI()
app.root.mainloop()