summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorYuval Adam <_@yuv.al>2025-09-18 10:38:28 +0200
committerYuval Adam <_@yuv.al>2025-09-18 10:38:28 +0200
commit57dbef04ebb37dd3e73c498163136e7e58467a63 (patch)
treeebc0cca50e2eb1db2c329c3f93d7f5fd108c3142
Initial commit
-rw-r--r--LICENSE21
-rw-r--r--README.md199
-rw-r--r--convert.py297
3 files changed, 517 insertions, 0 deletions
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..5453cb2
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2024 Claude to Markdown Contributors
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE. \ No newline at end of file
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..984b176
--- /dev/null
+++ b/README.md
@@ -0,0 +1,199 @@
+# Claude to Markdown
+
+Convert your Claude.ai conversation exports into beautifully formatted Markdown files for use in Obsidian, Logseq, or any markdown-based note-taking system.
+
+## Features
+
+✨ **Complete Conversion**
+- Converts Claude JSON exports to individual Markdown files
+- Preserves full conversation history with User/Assistant structure
+- Maintains conversation metadata in YAML frontmatter
+
+📝 **Smart Formatting**
+- Handles all Claude artifact types (code, React, HTML, Mermaid diagrams, SVG)
+- Converts artifacts to properly syntax-highlighted code blocks
+- Removes internal thinking blocks for cleaner output
+- Preserves formatting and structure of conversations
+
+🗂️ **Organized Output**
+- Creates descriptive filenames: `yyyy-mm-dd-slugified-title.md`
+- Includes metadata (UUID, name, summary, timestamps) in frontmatter
+- Perfect for searching, filtering, and linking in your knowledge base
+
+## Installation
+
+1. Clone this repository:
+```bash
+git clone https://github.com/yuvadm/claude-to-markdown
+cd claude-to-markdown
+```
+
+2. Ensure you have Python 3.6+ installed:
+```bash
+python --version
+```
+
+## Usage
+
+### Basic Usage
+
+1. Export your Claude conversations:
+ - Go to [Claude.ai](https://claude.ai)
+ - Navigate to Settings → Account
+ - Click "Export data"
+ - Download and extract the ZIP file
+ - Locate `conversations.json` in the extracted files
+
+2. Run the converter:
+```bash
+# Convert all conversations using defaults
+python convert_conversations.py
+
+# Specify input file and output directory
+python convert_conversations.py conversations.json my_claude_notes
+
+# Convert only first 100 conversations
+python convert_conversations.py conversations.json output 100
+
+# Show help
+python convert_conversations.py --help
+```
+
+### Command Line Arguments
+
+```
+python convert_conversations.py [INPUT_FILE] [OUTPUT_DIR] [LIMIT]
+```
+
+| Argument | Description | Default |
+|----------|-------------|---------|
+| `INPUT_FILE` | Path to conversations.json | `conversations.json` |
+| `OUTPUT_DIR` | Output directory for markdown files | `output` |
+| `LIMIT` | Max number of conversations to convert | All |
+
+### Examples
+
+```bash
+# Use all defaults (input: conversations.json, output: output/)
+python convert_conversations.py
+
+# Custom input and output paths
+python convert_conversations.py ~/Downloads/conversations.json ~/Documents/claude-notes
+
+# Process only first 50 conversations for testing
+python convert_conversations.py conversations.json test_output 50
+
+# Convert from a different location
+python convert_conversations.py /path/to/claude_export.json /path/to/notes
+```
+
+3. Import to your note-taking app:
+ - **Obsidian**: Copy the contents of `output/` to your vault
+ - **Logseq**: Import the markdown files to your graph
+ - **Notion**: Import as markdown (may need formatting adjustments)
+ - **VS Code**: Open the folder directly to browse with markdown preview
+ - Or use any markdown-compatible application
+
+## Output Format
+
+Each conversation is converted to a Markdown file with:
+
+### Filename
+```
+2024-03-15-understanding-quantum-computing.md
+```
+
+### Content Structure
+```markdown
+---
+uuid: 4dc7f4d6-7290-4b41-8bea-453c7dfeaa9f
+name: Understanding Quantum Computing
+summary: Discussion about quantum computing principles
+created_at: 2024-03-15T10:30:00.000Z
+updated_at: 2024-03-15T11:45:00.000Z
+---
+
+# Understanding Quantum Computing
+
+## User
+Can you explain quantum computing?
+
+## Assistant
+Quantum computing is a revolutionary approach to computation...
+```
+
+## Artifact Handling
+
+The converter intelligently handles various Claude artifact types:
+
+| Artifact Type | Converted To |
+|--------------|--------------|
+| `application/vnd.ant.code` | Code block with language syntax |
+| `application/vnd.ant.react` | JSX code block |
+| `application/vnd.ant.html` | HTML code block |
+| `application/vnd.ant.mermaid` | Mermaid diagram block |
+| `text/markdown` | Inline markdown |
+| `image/svg+xml` | SVG code block |
+
+## Tips for Different Platforms
+
+### Obsidian
+- Install the Mermaid plugin for diagram support
+- Use Graph View to visualize conversation relationships
+- Search by frontmatter fields using Dataview plugin
+
+### Logseq
+- Import files to see them in the journal view by date
+- Use page properties (frontmatter) for queries
+- Tag conversations for easy filtering
+
+### VS Code
+- Install a markdown preview extension
+- Use the file explorer to browse by date
+- Search across all files with Ctrl/Cmd+Shift+F
+
+## Privacy & Security
+
+- All processing happens locally on your machine
+- No data is sent to external servers
+- Your conversations remain private
+- Consider the sensitivity of your data before sharing converted files
+
+## Troubleshooting
+
+**Issue**: Script says `conversations.json not found`
+- **Solution**: Specify the correct path to your JSON file as the first argument
+
+**Issue**: Some conversations are missing
+- **Solution**: Conversations without content are skipped. Check the console output for the total processed
+
+**Issue**: Special characters in filenames
+- **Solution**: The script automatically sanitizes filenames for all operating systems
+
+## Contributing
+
+Contributions are welcome! Please feel free to submit a Pull Request. Some areas for improvement:
+
+- [ ] Add support for custom output directories
+- [ ] Include conversation statistics in a summary file
+- [ ] Add options for different filename formats
+- [ ] Support for incremental updates
+- [ ] Export to other formats (Notion, Roam, etc.)
+
+## License
+
+MIT License - See LICENSE file for details
+
+## Acknowledgments
+
+- Built for the Claude.ai community
+- Inspired by the need to preserve AI conversations for future reference
+- Designed to work with any markdown-based workflow
+
+## Contact
+
+For issues, questions, or suggestions, please open an issue on GitHub.
+
+---
+
+Made with ❤️ for knowledge preservation \ No newline at end of file
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