Greasy Fork is available in English.

Claude Chat Downloader

Add download button to save Claude AI conversations in TXT, MD, or JSON format

// ==UserScript==
// @name         Claude Chat Downloader
// @namespace    http://tampermonkey.net/
// @version      2.0 alpha
// @description  Add download button to save Claude AI conversations in TXT, MD, or JSON format
// @author       Papa Casper
// @homepage     https://papacasper.com
// @repository   https://github.com/PapaCasper
// @source       https://github.com/PapaCasper/claude-downloader
// @supportURL   https://github.com/PapaCasper/claude-downloader/issues
// @match        https://claude.ai/chat/*
// @match        https://claude.ai/chats/*
// @match        https://claude.ai/project/*
// @match        https://claude.ai/projects/*
// @grant        GM_xmlhttpRequest
// @license      MIT
// ==/UserScript==

(function() {
    'use strict';

    const API_BASE_URL = 'https://claude.ai/api';

    const styles = `
        .claude-download-button {
            display: flex;
            padding: 0.5rem 0.75rem;
            border-radius: 0.5rem;
            font-size: 0.875rem;
            color: var(--text-200);
            cursor: pointer;
            align-items: center;
            justify-content: space-between;
            border: 1px solid var(--border-300);
            background-color: transparent;
            transition: all 0.2s ease;
            flex: 1;
            min-width: 85px;
            margin-right: 0.5rem;
            position: relative;
        }
        .claude-download-button:last-child {
            margin-right: 0;
        }
        .claude-download-button:hover {
            background-color: var(--bg-500, rgba(39, 39, 42, 0.4));
            color: var(--text-100);
            border-color: var(--text-200);
            transform: translateY(-1px);
            box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
        }
        .claude-download-button:active {
            transform: translateY(0);
        }
        .claude-download-button svg {
            width: 1.25rem;
            height: 1.25rem;
            margin-left: 0.5rem;
        }
    `;

    // Add styles
    const styleSheet = document.createElement('style');
    styleSheet.textContent = styles;
    document.head.appendChild(styleSheet);

    // API Request Function
    function apiRequest(method, endpoint, data = null, headers = {}) {
        return new Promise((resolve, reject) => {
            GM_xmlhttpRequest({
                method: method,
                url: `${API_BASE_URL}${endpoint}`,
                headers: {
                    'Content-Type': 'application/json',
                    ...headers,
                },
                data: data ? JSON.stringify(data) : null,
                onload: (response) => {
                    if (response.status >= 200 && response.status < 300) {
                        resolve(JSON.parse(response.responseText));
                    } else {
                        reject(new Error(`API request failed with status ${response.status}`));
                    }
                },
                onerror: (error) => {
                    reject(error);
                },
            });
        });
    }

    // Get Organization ID
    async function getOrganizationId() {
        const organizations = await apiRequest('GET', '/organizations');
        return organizations[0].uuid;
    }

    // Get Conversation History
    async function getConversationHistory(orgId, id) {
        const isProject = window.location.pathname.includes('/project/');
        const endpoint = isProject ? 
            `/organizations/${orgId}/projects/${id}` :
            `/organizations/${orgId}/chat_conversations/${id}`;
        return await apiRequest('GET', endpoint);
    }

    // Format conversion
    function convertToFormat(data, format) {
        const isProject = window.location.pathname.includes('/project/');
        
        if (format === 'json') {
            return JSON.stringify(data, null, 2);
        } else if (format === 'txt') {
            const messages = isProject ? data.conversations[0].chat_messages : data.chat_messages;
            return messages.map(message => {
                const sender = message.sender === 'human' ? 'User' : 'Claude';
                return `${sender}:\n${message.text}\n\n`;
            }).join('');
        } else if (format === 'md') {
            let content = `# ${isProject ? 'Claude Project Export' : 'Claude Chat Export'}\n\n`;
            content += `*Exported on ${new Date().toLocaleString()}*\n\n---\n\n`;
            
            const messages = isProject ? data.conversations[0].chat_messages : data.chat_messages;
            messages.forEach(message => {
                const sender = message.sender === 'human' ? 'User' : 'Claude';
                const text = message.text.replace(/```(\w*)\n([\s\S]*?)```/g, (match, lang, code) => {
                    return `\`\`\`${lang}\n${code.trim()}\`\`\`\n`;
                });
                content += `### ${sender}\n\n${text}\n\n---\n\n`;
            });
            
            return content;
        }
    }

    // Download Function
    async function downloadChat(format) {
        try {
            const orgId = await getOrganizationId();
            const id = window.location.pathname.split('/').pop();
            const data = await getConversationHistory(orgId, id);
            
            const content = convertToFormat(data, format);
            const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
            const prefix = window.location.pathname.includes('/project/') ? 'claude-project' : 'claude-chat';
            const filename = `${prefix}-${timestamp}.${format}`;

            const blob = new Blob([content], { type: 'text/plain' });
            const url = URL.createObjectURL(blob);
            const a = document.createElement('a');
            a.href = url;
            a.download = filename;
            document.body.appendChild(a);
            a.click();
            document.body.removeChild(a);
            URL.revokeObjectURL(url);
        } catch (error) {
            console.error('Error downloading conversation:', error);
            alert('Error downloading conversation. Please try again.');
        }
    }

    // Create and add download buttons
    function addDownloadButton() {
        if (document.querySelector('.claude-download-container')) return;

        // Create the buttons container with row layout
        const buttonsContainer = document.createElement('div');
        buttonsContainer.className = 'flex flex-row gap-2 mt-2';

        // Create format buttons
        const formats = [
            { id: 'txt', label: 'TXT', title: 'Download as plain text file' },
            { id: 'md', label: 'MD', title: 'Download as markdown file with formatting' },
            { id: 'json', label: 'JSON', title: 'Download complete conversation data' }
        ];

        formats.forEach(format => {
            const formatButton = document.createElement('button');
            formatButton.className = 'claude-download-button';
            formatButton.title = format.title;
            formatButton.innerHTML = `
                <span>${format.label}</span>
                <svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" viewBox="0 0 256 256">
                    <path d="M224,152v56a16,16,0,0,1-16,16H48a16,16,0,0,1-16-16V152a8,8,0,0,1,16,0v56H208V152a8,8,0,0,1,16,0ZM117.66,154.34a8,8,0,0,0,11.31,0l40-40a8,8,0,0,0-11.31-11.31L136,124.69V40a8,8,0,0,0-16,0v84.69L98.34,103a8,8,0,0,0-11.31,11.31Z"/>
                </svg>
            `;
            formatButton.addEventListener('click', () => downloadChat(format.id));
            buttonsContainer.appendChild(formatButton);
        });

        // Find the chat controls header and insert after it
        const chatControlsHeader = document.querySelector('.font-styrene-display.flex-1.text-lg');
        if (chatControlsHeader) {
            const headerParent = chatControlsHeader.closest('div');
            headerParent.parentNode.insertBefore(buttonsContainer, headerParent.nextSibling);
        }
    }

    // Observer setup
    const observer = new MutationObserver((mutations) => {
        const shouldAddButton = mutations.some(mutation => 
            Array.from(mutation.addedNodes).some(node => 
                node.nodeType === 1 && 
                (node.matches('.px-5.pb-4.pt-3') || node.querySelector('.px-5.pb-4.pt-3'))
            )
        );

        if (shouldAddButton) {
            addDownloadButton();
        }
    });

    function startObserver() {
        const targetNode = document.documentElement;
        if (targetNode) {
            observer.observe(targetNode, {
                childList: true,
                subtree: true
            });
            // Initial check
            addDownloadButton();
        } else {
            setTimeout(startObserver, 500);
        }
    }

    startObserver();
})();