#!/usr/bin/env python3
import json
import os
import sys
from datetime import datetime
import re
def sanitize_filename(text):
"""Remove or replace characters that are invalid in filenames"""
if not text:
return "untitled"
# Replace invalid characters with underscores
text = re.sub(r'[<>:"/\\|?*]', '_', text)
# Remove any non-printable characters
text = ''.join(char for char in text if char.isprintable())
# Limit length to avoid filesystem issues
return text[:100].strip()
def convert_timestamp_to_filename(timestamp_str):
"""Convert ISO timestamp to yyyy-mm-dd format"""
try:
dt = datetime.fromisoformat(timestamp_str.replace('Z', '+00:00'))
return dt.strftime('%Y-%m-%d')
except:
return "unknown-date"
def convert_artifact_to_markdown(text):
"""Convert antArtifact tags to markdown code blocks and handle all types"""
import re
# First, let's find all artifact tags to analyze them
all_artifacts_pattern = r']*?)>(.*?)'
def analyze_artifact(match):
attributes = match.group(1)
content = match.group(2)
# Extract type attribute if present
type_match = re.search(r'type="([^"]+)"', attributes)
artifact_type = type_match.group(1) if type_match else None
# Extract language attribute if present
lang_match = re.search(r'language="([^"]+)"', attributes)
language = lang_match.group(1) if lang_match else None
# Extract title if present
title_match = re.search(r'title="([^"]+)"', attributes)
title = title_match.group(1) if title_match else None
# Add title as a comment if present
title_line = f"# {title}\n\n" if title else ""
# Handle all known types
if artifact_type == "application/vnd.ant.code" and language:
return f"\n```{language}\n{content}\n```\n"
elif artifact_type == "application/vnd.ant.mermaid":
return f"\n```mermaid\n{content}\n```\n"
elif artifact_type == "application/vnd.ant.react":
# React components - use jsx
return f"\n```jsx\n{content}\n```\n"
elif artifact_type == "application/vnd.ant.html":
# HTML artifacts
return f"\n```html\n{content}\n```\n"
elif artifact_type == "text/html":
# Plain HTML
return f"\n```html\n{content}\n```\n"
elif artifact_type == "text/markdown":
# Markdown content - just include as-is with a separator
return f"\n---\n\n{title_line}{content}\n\n---\n"
elif artifact_type == "image/svg+xml":
# SVG images
return f"\n```svg\n{content}\n```\n"
elif language:
# Has language but different/no type
return f"\n```{language}\n{content}\n```\n"
else:
# Default to plain code block
return f"\n```\n{content}\n```\n"
# Replace all artifacts
text = re.sub(all_artifacts_pattern, analyze_artifact, text, flags=re.DOTALL)
# Remove antThinking tags (these are internal thinking, not meant for output)
text = re.sub(r'.*?', '', text, flags=re.DOTALL)
return text
def extract_message_text(message):
"""Extract text from message, handling both direct text and content array"""
if 'text' in message and message['text']:
text = message['text']
elif 'content' in message and message['content']:
# Concatenate text from all content items
texts = []
for item in message['content']:
if isinstance(item, dict) and 'text' in item:
texts.append(item['text'])
text = '\n'.join(texts)
else:
return ""
# Convert artifacts to markdown code blocks
return convert_artifact_to_markdown(text)
def convert_conversation_to_markdown(conversation):
"""Convert a single conversation to markdown format"""
markdown_lines = []
# Add YAML frontmatter
markdown_lines.append("---")
markdown_lines.append(f"uuid: {conversation.get('uuid', 'unknown')}")
markdown_lines.append(f"name: {conversation.get('name', 'untitled')}")
markdown_lines.append(f"summary: {conversation.get('summary', 'No summary available')}")
markdown_lines.append(f"created_at: {conversation.get('created_at', 'unknown')}")
markdown_lines.append(f"updated_at: {conversation.get('updated_at', 'unknown')}")
markdown_lines.append("---")
markdown_lines.append("") # Blank line after frontmatter
# Add title if available
if conversation.get('name'):
markdown_lines.append(f"# {conversation['name']}\n")
# Process messages
for message in conversation.get('chat_messages', []):
sender = message.get('sender', 'unknown')
text = extract_message_text(message)
if not text:
continue
# Determine header based on sender
if sender.lower() == 'human':
markdown_lines.append("## User")
elif sender.lower() in ['assistant', 'claude']:
markdown_lines.append("## Assistant")
else:
markdown_lines.append(f"## {sender}")
markdown_lines.append(text)
markdown_lines.append("") # Add blank line between messages
return '\n'.join(markdown_lines)
def slugify(text):
"""Convert text to a URL-friendly slug"""
if not text:
return "untitled"
# Convert to lowercase
text = text.lower()
# Replace spaces with hyphens
text = re.sub(r'\s+', '-', text)
# Remove non-alphanumeric characters (except hyphens)
text = re.sub(r'[^a-z0-9-]', '', text)
# Remove multiple consecutive hyphens
text = re.sub(r'-+', '-', text)
# Strip leading/trailing hyphens
text = text.strip('-')
# Limit length
return text[:100] if text else "untitled"
def print_usage():
"""Print usage information"""
print("Usage: python3 convert_conversations.py [INPUT_FILE] [OUTPUT_DIR] [LIMIT]")
print()
print("Convert Claude.ai conversation exports to Markdown files")
print()
print("Arguments:")
print(" INPUT_FILE Path to conversations.json file (default: conversations.json)")
print(" OUTPUT_DIR Output directory for markdown files (default: output)")
print(" LIMIT Maximum number of conversations to convert (default: all)")
print()
print("Examples:")
print(" python3 convert_conversations.py")
print(" python3 convert_conversations.py conversations.json output")
print(" python3 convert_conversations.py conversations.json my_notes 100")
print(" python3 convert_conversations.py ~/Downloads/conversations.json ~/Documents/claude-notes")
def main():
# Parse command-line arguments
args = sys.argv[1:]
# Show help if requested
if args and args[0] in ['-h', '--help', 'help']:
print_usage()
sys.exit(0)
# Set defaults and parse positional arguments
input_file = 'conversations.json'
output_dir = 'output'
limit = None
if len(args) >= 1:
input_file = args[0]
if len(args) >= 2:
output_dir = args[1]
if len(args) >= 3:
try:
limit = int(args[2])
if limit <= 0:
print(f"Error: LIMIT must be a positive number, got {limit}")
sys.exit(1)
except ValueError:
print(f"Error: LIMIT must be a number, got '{args[2]}'")
sys.exit(1)
if len(args) > 3:
print("Warning: Extra arguments ignored")
print()
# Validate input file exists
if not os.path.exists(input_file):
print(f"Error: Input file '{input_file}' not found")
print()
print_usage()
sys.exit(1)
# Load the conversations
print(f"Loading conversations from: {input_file}")
try:
with open(input_file, 'r', encoding='utf-8') as f:
conversations = json.load(f)
except json.JSONDecodeError as e:
print(f"Error parsing JSON: {e}")
sys.exit(1)
except Exception as e:
print(f"Error reading file: {e}")
sys.exit(1)
# Validate it's a list
if not isinstance(conversations, list):
print("Error: JSON file should contain a list of conversations")
sys.exit(1)
total_conversations = len(conversations)
print(f"Found {total_conversations} conversations")
# Create output directory
os.makedirs(output_dir, exist_ok=True)
print(f"Output directory: {output_dir}")
# Apply limit if specified
if limit:
conversations_to_process = conversations[:limit]
print(f"Processing first {len(conversations_to_process)} conversations (limit: {limit})")
else:
conversations_to_process = conversations
print(f"Processing all {len(conversations_to_process)} conversations")
print("-" * 60)
# Process conversations
successful = 0
failed = 0
for i, conversation in enumerate(conversations_to_process):
try:
print(f"Processing conversation {i+1}/{len(conversations_to_process)}...", end='')
# Generate filename
timestamp = conversation.get('created_at', '')
date_time_str = convert_timestamp_to_filename(timestamp)
# Use the conversation name (which becomes H1 header) for the filename
title = conversation.get('name', 'untitled')
slugified_title = slugify(title)
filename = f"{date_time_str}-{slugified_title}.md"
filepath = os.path.join(output_dir, filename)
# Convert to markdown
markdown_content = convert_conversation_to_markdown(conversation)
# Write to file
with open(filepath, 'w', encoding='utf-8') as f:
f.write(markdown_content)
print(f" ✓ {filename}")
successful += 1
except Exception as e:
print(f" ✗ Failed: {e}")
failed += 1
# Print summary
print("-" * 60)
print(f"Conversion complete!")
print(f" Successfully converted: {successful}")
if failed > 0:
print(f" Failed: {failed}")
print(f" Output directory: {output_dir}/")
if limit and limit < total_conversations:
remaining = total_conversations - limit
print(f" Remaining conversations: {remaining} (use higher limit to convert more)")
if __name__ == "__main__":
main()