summaryrefslogtreecommitdiff
path: root/convert.py
diff options
context:
space:
mode:
Diffstat (limited to 'convert.py')
-rw-r--r--convert.py297
1 files changed, 297 insertions, 0 deletions
diff --git a/convert.py b/convert.py
new file mode 100644
index 0000000..0792f50
--- /dev/null
+++ b/convert.py
@@ -0,0 +1,297 @@
+#!/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'<antArtifact\s+([^>]*?)>(.*?)</antArtifact>'
+
+ 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'<antThinking>.*?</antThinking>', '', 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() \ No newline at end of file