diff options
| author | Yuval Adam <_@yuv.al> | 2025-09-18 10:38:28 +0200 |
|---|---|---|
| committer | Yuval Adam <_@yuv.al> | 2025-09-18 10:38:28 +0200 |
| commit | 57dbef04ebb37dd3e73c498163136e7e58467a63 (patch) | |
| tree | ebc0cca50e2eb1db2c329c3f93d7f5fd108c3142 | |
Initial commit
| -rw-r--r-- | LICENSE | 21 | ||||
| -rw-r--r-- | README.md | 199 | ||||
| -rw-r--r-- | convert.py | 297 |
3 files changed, 517 insertions, 0 deletions
@@ -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 |
