Claude Project Files Extractor

Download/extract all files from a Claude project as a single ZIP

// ==UserScript==
// @name         Claude Project Files Extractor
// @namespace    http://tampermonkey.net/
// @version      3.0
// @description  Download/extract all files from a Claude project as a single ZIP
// @author       sharmanhall
// @match        https://claude.ai/*
// @icon         https://www.google.com/s2/favicons?sz=64&domain=claude.ai
// @grant        none
// @license      MIT
// ==/UserScript==

(function() {
    'use strict';

    // Load JSZip from CDN first
    function loadJSZip() {
        return new Promise((resolve, reject) => {
            if (typeof JSZip !== 'undefined') {
                console.log('JSZip already available');
                resolve();
                return;
            }

            console.log('Loading JSZip from CDN...');
            const script = document.createElement('script');
            script.src = 'https://cdnjs.cloudflare.com/ajax/libs/jszip/3.10.1/jszip.min.js';
            script.onload = () => {
                console.log('JSZip script loaded');
                setTimeout(() => {
                    if (typeof JSZip !== 'undefined') {
                        console.log('JSZip is now available');
                        resolve();
                    } else {
                        reject(new Error('JSZip loaded but not available'));
                    }
                }, 500);
            };
            script.onerror = () => {
                console.error('Failed to load JSZip');
                reject(new Error('Failed to load JSZip'));
            };
            document.head.appendChild(script);
        });
    }

    // Helper function to wait for modal to appear
    async function waitForModal(timeout = 5000) {
        const startTime = Date.now();
        while (Date.now() - startTime < timeout) {
            const modal = document.querySelector('[role="dialog"]');
            if (modal && modal.offsetHeight > 0) {
                // Wait a bit more for content to load
                await new Promise(resolve => setTimeout(resolve, 1000));
                return modal;
            }
            await new Promise(resolve => setTimeout(resolve, 100));
        }
        return null;
    }

    // Helper function to wait for modal to close
    async function waitForModalClose(timeout = 3000) {
        const startTime = Date.now();
        while (Date.now() - startTime < timeout) {
            const modal = document.querySelector('[role="dialog"]');
            if (!modal || modal.offsetHeight === 0) return true;
            await new Promise(resolve => setTimeout(resolve, 100));
        }
        return false;
    }

    // Function to close modal
    async function closeModal() {
        console.log('🔄 Attempting to close modal...');

        // Try multiple close methods
        const closeSelectors = [
            'button[aria-label*="close"]',
            'button[aria-label*="Close"]',
            '[data-testid*="close"]',
            'button[title*="close"]',
            'button[title*="Close"]',
            '.modal button:last-child',
            '[role="dialog"] button:first-child',
            '[role="dialog"] button[type="button"]'
        ];

        for (const selector of closeSelectors) {
            const buttons = document.querySelectorAll(selector);
            for (const btn of buttons) {
                try {
                    console.log(`Trying close button: ${selector}`);
                    btn.click();
                    await new Promise(resolve => setTimeout(resolve, 300));
                    if (await waitForModalClose(1000)) {
                        console.log('✅ Modal closed successfully');
                        return true;
                    }
                } catch (e) {
                    console.log('Close button failed:', e);
                }
            }
        }

        // Press Escape multiple times
        for (let i = 0; i < 3; i++) {
            document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
            document.dispatchEvent(new KeyboardEvent('keyup', { key: 'Escape', bubbles: true }));
            await new Promise(resolve => setTimeout(resolve, 200));
        }

        // Click outside modal
        const modal = document.querySelector('[role="dialog"]');
        if (modal) {
            const rect = modal.getBoundingClientRect();
            document.elementFromPoint(rect.left - 10, rect.top)?.click();
        }

        const closed = await waitForModalClose();
        console.log(closed ? '✅ Modal closed' : '❌ Failed to close modal');
        return closed;
    }

    // Better file name extraction
    function extractFileName(element) {
        const text = element.textContent.trim();
        console.log('🔍 Analyzing element text:', text);

        // Look for common file patterns
        const patterns = [
            // Pattern: filename.ext followed by size info
            /^(.+\.(?:pdf|txt|md|json|xml|csv|doc|docx|xlsx?))\s*\d+\s*lines?/i,
            // Pattern: filename followed by extension indicator
            /^(.+?)\s*\d+\s*lines?\s*(pdf|txt|text|md|json|xml|csv)/i,
            // Pattern: clear filename at start
            /^([^0-9]+?)(?:\s*\d+\s*lines?|$)/i
        ];

        for (const pattern of patterns) {
            const match = text.match(pattern);
            if (match) {
                let filename = match[1].trim();
                console.log('📝 Extracted filename:', filename);
                return filename;
            }
        }

        // Fallback: take first meaningful part
        const words = text.split(/\s+/).filter(word =>
            word.length > 2 &&
            !word.match(/^\d+$/) &&
            !word.match(/^(lines?|pdf|txt|text|md|json|xml|csv)$/i)
        );

        if (words.length > 0) {
            const filename = words.slice(0, 3).join(' ');
            console.log('📝 Fallback filename:', filename);
            return filename;
        }

        return 'Unknown_File';
    }

    // Better file type detection
    function detectFileType(filename, content) {
        const lower = filename.toLowerCase();

        // Check filename extension first
        if (lower.includes('.pdf')) return 'pdf.txt';
        if (lower.includes('.json')) return 'json';
        if (lower.includes('.xml')) return 'xml';
        if (lower.includes('.md')) return 'md';
        if (lower.includes('.csv')) return 'csv';
        if (lower.includes('.xlsx') || lower.includes('.xls')) return 'xlsx.txt';
        if (lower.includes('.doc')) return 'doc.txt';
        if (lower.includes('.eml')) return 'eml.txt';

        // Check content patterns
        if (content.includes('{') && content.includes('}') && content.includes('"')) return 'json';
        if (content.includes('<') && content.includes('>')) return 'xml';
        if (content.includes('##') || content.includes('**')) return 'md';
        if (content.includes(',') && content.split('\n').length > 1) return 'csv';

        return 'txt';
    }

    // Better content extraction from modal
    function extractContentFromModal(modal) {
        console.log('📖 Extracting content from modal...');

        // Try different content containers
        const contentSelectors = [
            'pre code',
            'pre',
            '.whitespace-pre-wrap',
            '.font-mono',
            '.overflow-auto pre',
            '.text-sm.whitespace-pre-wrap',
            '[class*="content"]',
            '.modal-body',
            '.dialog-content'
        ];

        for (const selector of contentSelectors) {
            const element = modal.querySelector(selector);
            if (element && element.textContent.trim().length > 50) {
                console.log(`✅ Found content in: ${selector}`);
                return element.textContent.trim();
            }
        }

        // Fallback: get all text but filter out UI elements
        const allText = modal.textContent;
        const lines = allText.split('\n')
            .map(line => line.trim())
            .filter(line => line.length > 3)
            .filter(line => !line.match(/^(Close|Download|Export|PDF|TEXT|Select|Cancel|OK|\d+\s*lines?|View|Edit)$/i))
            .filter(line => !line.includes('claude.ai'))
            .filter(line => line.length < 200); // Remove very long UI text

        const content = lines.join('\n').trim();
        console.log(`📄 Extracted ${content.length} characters using fallback method`);
        return content;
    }

    // Find file elements in the project knowledge panel
    function findFileElements() {
        console.log('🔍 Searching for file elements...');

        // Look for elements in the project knowledge area
        const knowledgePanel = document.querySelector('[class*="project"], [class*="knowledge"]') || document;

        // Find clickable elements that look like files
        const selectors = [
            'button[class*="cursor-pointer"]',
            'div[class*="cursor-pointer"]',
            '[role="button"]',
            '.clickable',
            'button[type="button"]'
        ];

        const fileElements = [];

        for (const selector of selectors) {
            const elements = knowledgePanel.querySelectorAll(selector);

            for (const element of elements) {
                const text = element.textContent.trim();

                // Check if this looks like a file
                if (text.includes('lines') ||
                    text.match(/\.(pdf|txt|md|json|xml|csv|doc|docx|xlsx|eml)/i) ||
                    (text.length > 10 && text.length < 200 &&
                     !text.includes('claude.ai') &&
                     !text.match(/^(Export|Download|Close|Cancel|OK|Edit|View|Settings)$/i))) {

                    console.log(`📄 Found potential file: ${text.substring(0, 50)}...`);
                    fileElements.push(element);
                }
            }
        }

        console.log(`✅ Found ${fileElements.length} file elements`);
        return fileElements;
    }

    // Extract project knowledge files
    async function extractProjectFiles() {
        const files = [];

        console.log('🔍 Looking for project knowledge files...');

        const fileElements = findFileElements();

        for (let i = 0; i < fileElements.length; i++) {
            const element = fileElements[i];

            try {
                const rawFilename = extractFileName(element);
                console.log(`\n📄 Processing file ${i + 1}/${fileElements.length}: ${rawFilename}`);

                // Click the element
                console.log('⚡ Clicking element...');
                element.scrollIntoView();
                await new Promise(resolve => setTimeout(resolve, 500));
                element.click();

                // Wait for modal to appear
                console.log('⏳ Waiting for modal...');
                const modal = await waitForModal();

                if (!modal) {
                    console.log('❌ No modal appeared, skipping...');
                    continue;
                }

                // Extract content
                const content = extractContentFromModal(modal);

                if (content.length < 50) {
                    console.log('❌ Content too short, skipping...');
                    await closeModal();
                    continue;
                }

                // Determine file type and create filename
                const fileType = detectFileType(rawFilename, content);
                const cleanFilename = rawFilename
                    .replace(/[^a-zA-Z0-9\s\-_\.]/g, '_')
                    .replace(/\s+/g, '_')
                    .replace(/_+/g, '_')
                    .trim();

                const finalFilename = `${cleanFilename}.${fileType}`;

                console.log(`✅ Extracted ${content.length} characters`);
                console.log(`📁 Final filename: ${finalFilename}`);

                files.push({
                    filename: finalFilename,
                    content: content,
                    originalName: rawFilename
                });

                // Close modal and wait
                await closeModal();
                await new Promise(resolve => setTimeout(resolve, 1000));

            } catch (error) {
                console.error(`❌ Error processing file: ${error}`);
                await closeModal();
                await new Promise(resolve => setTimeout(resolve, 500));
            }
        }

        console.log(`\n🎉 Successfully extracted ${files.length} files`);
        return files;
    }

    // Create and download ZIP
    async function createZIP(files, projectName) {
        try {
            console.log('📦 Creating ZIP with JSZip...');

            if (typeof JSZip === 'undefined') {
                throw new Error('JSZip not available');
            }

            const zip = new JSZip();

            // Add each file to ZIP
            files.forEach((file, index) => {
                console.log(`📁 Adding to ZIP [${index + 1}]: ${file.filename}`);
                zip.file(file.filename, file.content);
            });

            // Add metadata
            const metadata = {
                exportDate: new Date().toISOString(),
                projectTitle: projectName,
                url: window.location.href,
                fileCount: files.length,
                files: files.map(f => ({
                    filename: f.filename,
                    originalName: f.originalName,
                    size: f.content.length
                }))
            };

            zip.file('_export_metadata.json', JSON.stringify(metadata, null, 2));

            console.log('🔄 Generating ZIP blob...');

            // Generate ZIP
            const zipBlob = await zip.generateAsync({
                type: "blob",
                compression: "DEFLATE",
                compressionOptions: { level: 6 }
            });

            console.log(`✅ ZIP created! Size: ${zipBlob.size} bytes`);

            // Download ZIP
            const timestamp = new Date().toISOString().replace(/[:.]/g, '-').substring(0, 16);
            const filename = `${projectName.replace(/[^a-zA-Z0-9]/g, '_')}_export_${timestamp}.zip`;

            const url = URL.createObjectURL(zipBlob);
            const link = document.createElement('a');
            link.href = url;
            link.download = filename;
            document.body.appendChild(link);
            link.click();
            document.body.removeChild(link);
            URL.revokeObjectURL(url);

            return true;

        } catch (error) {
            console.error('❌ ZIP creation failed:', error);
            return false;
        }
    }

    // Download individual files as fallback
    function downloadIndividualFiles(files, projectName) {
        console.log('📥 Falling back to individual downloads...');

        files.forEach((file, index) => {
            setTimeout(() => {
                const blob = new Blob([file.content], { type: 'text/plain' });
                const url = URL.createObjectURL(blob);
                const link = document.createElement('a');
                link.href = url;
                link.download = file.filename;
                document.body.appendChild(link);
                link.click();
                document.body.removeChild(link);
                URL.revokeObjectURL(url);
                console.log(`📥 Downloaded: ${file.filename}`);
            }, index * 500); // Stagger downloads
        });
    }

    // Get project title
    function getProjectTitle() {
        const titleSelectors = [
            'h1',
            '[data-testid*="title"]',
            '.text-xl',
            '.text-2xl',
            '.font-bold',
            'title'
        ];

        for (const selector of titleSelectors) {
            const element = document.querySelector(selector);
            if (element && element.textContent.trim()) {
                const title = element.textContent.trim();
                if (title !== 'Claude' && title.length > 2) {
                    return title;
                }
            }
        }

        // Fallback: extract from URL
        const urlMatch = window.location.pathname.match(/\/([^\/]+)$/);
        if (urlMatch) {
            return urlMatch[1].replace(/[-_]/g, ' ');
        }

        return 'Claude_Project';
    }

    // Main export function
    async function exportProject() {
        const button = document.querySelector('#claude-export-btn');

        try {
            // Update button status
            const updateStatus = (msg) => {
                if (button) button.textContent = `🔄 ${msg}`;
                console.log(`\n🚀 ${msg}`);
            };

            updateStatus('Loading ZIP library...');
            await loadJSZip();

            updateStatus('Scanning for files...');
            const files = await extractProjectFiles();

            if (files.length === 0) {
                updateStatus('❌ No files found');
                setTimeout(() => {
                    if (button) button.textContent = '📁 Export Project Files';
                }, 3000);
                return;
            }

            const projectName = getProjectTitle();
            updateStatus(`Creating ZIP (${files.length} files)...`);

            const zipSuccess = await createZIP(files, projectName);

            if (zipSuccess) {
                updateStatus(`✅ ZIP exported! (${files.length} files)`);
                setTimeout(() => {
                    if (button) button.textContent = '📁 Export Project Files';
                }, 3000);
            } else {
                updateStatus('ZIP failed - downloading individual files...');
                downloadIndividualFiles(files, projectName);
                setTimeout(() => {
                    if (button) button.textContent = '📁 Export Project Files';
                }, 3000);
            }

        } catch (error) {
            console.error('💥 Export failed:', error);
            if (button) button.textContent = '❌ Export Failed';
            setTimeout(() => {
                if (button) button.textContent = '📁 Export Project Files';
            }, 3000);
        }
    }

    // Add export button with better styling
    function addExportButton() {
        const existingButton = document.querySelector('#claude-export-btn');
        if (existingButton) existingButton.remove();

        const button = document.createElement('button');
        button.id = 'claude-export-btn';
        button.textContent = '📁 Export Project Files';
        button.style.cssText = `
            position: fixed;
            bottom: 20px;
            right: 20px;
            padding: 12px 20px;
            background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
            color: white;
            border: none;
            border-radius: 8px;
            cursor: pointer;
            z-index: 10000;
            font-size: 14px;
            font-weight: 600;
            box-shadow: 0 4px 15px rgba(0,0,0,0.2);
            transition: all 0.3s ease;
            min-width: 200px;
            text-align: center;
        `;

        // Add hover effect
        button.addEventListener('mouseenter', () => {
            button.style.transform = 'translateY(-2px)';
            button.style.boxShadow = '0 6px 20px rgba(0,0,0,0.3)';
        });

        button.addEventListener('mouseleave', () => {
            button.style.transform = 'translateY(0)';
            button.style.boxShadow = '0 4px 15px rgba(0,0,0,0.2)';
        });

        button.addEventListener('click', exportProject);
        document.body.appendChild(button);

        console.log('✅ Export button added');
    }

    // Initialize
    function init() {
        console.log('🚀 Claude Project Files Extractor - Fixed v3.0');

        if (document.readyState === 'loading') {
            document.addEventListener('DOMContentLoaded', addExportButton);
        } else {
            addExportButton();
        }

        // Re-add button on navigation
        let currentUrl = location.href;
        const observer = new MutationObserver(() => {
            if (location.href !== currentUrl) {
                currentUrl = location.href;
                setTimeout(addExportButton, 1000);
            }
        });

        observer.observe(document.body, { childList: true, subtree: true });
    }

    init();

})();