Claude Project Files Extractor

Download/extract all files from a Claude project as a single ZIP - Fixed filenames, PDF support, CSV handling

スクリプトをインストールするには、Tampermonkey, GreasemonkeyViolentmonkey のような拡張機能のインストールが必要です。

You will need to install an extension such as Tampermonkey to install this script.

スクリプトをインストールするには、TampermonkeyViolentmonkey のような拡張機能のインストールが必要です。

スクリプトをインストールするには、TampermonkeyUserscripts のような拡張機能のインストールが必要です。

このスクリプトをインストールするには、Tampermonkeyなどの拡張機能をインストールする必要があります。

このスクリプトをインストールするには、ユーザースクリプト管理ツールの拡張機能をインストールする必要があります。

(ユーザースクリプト管理ツールは設定済みなのでインストール!)

このスタイルをインストールするには、Stylusなどの拡張機能をインストールする必要があります。

このスタイルをインストールするには、Stylus などの拡張機能をインストールする必要があります。

このスタイルをインストールするには、Stylus tなどの拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

(ユーザースタイル管理ツールは設定済みなのでインストール!)

このスクリプトの質問や評価の投稿はこちら通報はこちらへお寄せください
// ==UserScript==
// @name         Claude Project Files Extractor
// @namespace    http://tampermonkey.net/
// @version      4.0.0
// @description  Download/extract all files from a Claude project as a single ZIP - Fixed filenames, PDF support, CSV handling
// @author       sharmanhall
// @match        https://claude.ai/*
// @icon         https://www.google.com/s2/favicons?sz=64&domain=claude.ai
// @grant        GM_download
// @grant        GM_xmlhttpRequest
// @license      MIT
// ==/UserScript==

(function() {
    'use strict';

    // ============================================================
    // CHANGELOG v4.0.0
    // ============================================================
    // - Complete rewrite of filename extraction logic
    // - Fixed duplicate extension bug (.md.md.md -> .md)
    // - Added PDF export via modal download link
    // - Added CSV handling with fallback to unexportable status
    // - Clean collision handling with __2, __3 suffixes
    // - Comprehensive metadata with export method tracking
    // - Verbose logging throughout
    // ============================================================

    // ============================================================
    // SELECTOR MAP (matched to provided DOM snippets)
    // ============================================================
    // FILE_GRID_CONTAINER: ul.grid - contains all file cards
    // TEXT_FILE_CARD: [data-testid="file-thumbnail"] button - clickable text file card
    // PDF_FILE_CARD: div[data-testid$=".pdf"] button, .group\/thumbnail div[data-testid] button - PDF thumbnails
    // FILENAME_H3: h3.text-\[12px\] - filename text in text cards
    // TYPE_BADGE: p.uppercase.truncate - file type indicator (md, txt, pdf, csv)
    // LINE_COUNT: p.text-\[10px\] - shows "X lines"
    // PDF_IMG_ALT: img[alt$=".pdf"] - PDF thumbnail image with filename in alt
    // MODAL_DIALOG: [role="dialog"] - opened modal
    // PDF_DOWNLOAD_LINK: a[href*="/document_pdf"] - PDF download link in modal
    // MODAL_CONTENT: pre code, pre, .whitespace-pre-wrap - text content in modal
    // MODAL_CLOSE: button with X icon, first button in modal header
    // ============================================================

    const CONFIG = {
        SCROLL_WAIT_MS: 1500,
        MODAL_WAIT_MS: 2000,
        MODAL_CONTENT_WAIT_MS: 1000,
        BETWEEN_FILES_MS: 800,
        MAX_SCROLL_ATTEMPTS: 20,
        MIN_CONTENT_LENGTH: 10
    };

    const LOG_PREFIX = '[Claude Exporter]';

    // Logging utilities
    const log = {
        info: (msg, ...args) => console.log(`${LOG_PREFIX} ℹ️ ${msg}`, ...args),
        success: (msg, ...args) => console.log(`${LOG_PREFIX} ✅ ${msg}`, ...args),
        warn: (msg, ...args) => console.warn(`${LOG_PREFIX} ⚠️ ${msg}`, ...args),
        error: (msg, ...args) => console.error(`${LOG_PREFIX} ❌ ${msg}`, ...args),
        debug: (msg, ...args) => console.log(`${LOG_PREFIX} 🔍 ${msg}`, ...args),
        file: (domName, normalizedName, type, strategy, status) => {
            const emoji = status === 'success' ? '✅' : status === 'failed' ? '❌' : '⚠️';
            console.log(`${LOG_PREFIX} ${emoji} FILE: "${domName}" → "${normalizedName}" [${type}] via ${strategy} = ${status}`);
        }
    };

    // ============================================================
    // FILENAME NORMALIZATION
    // ============================================================

    /**
     * Normalize a filename for cross-platform compatibility
     * - Removes illegal characters for Windows/macOS
     * - Collapses duplicate extensions
     * - Handles collision suffixes properly
     */
    function normalizeFilename(rawName) {
        if (!rawName || typeof rawName !== 'string') {
            return 'unnamed_file';
        }

        let name = rawName.trim();

        // Step 1: Remove illegal characters (Windows/macOS)
        // Illegal: \ / : * ? " < > | and control chars (0x00-0x1F)
        name = name.replace(/[\\/:*?"<>|]/g, '_');
        name = name.replace(/[\x00-\x1F]/g, '');

        // Step 2: Collapse multiple spaces/underscores
        name = name.replace(/\s+/g, ' ');
        name = name.replace(/_+/g, '_');
        name = name.replace(/[ _]+/g, '_');

        // Step 3: Trim trailing dots and spaces (Windows issue)
        name = name.replace(/[. ]+$/, '');
        name = name.replace(/^[. ]+/, '');

        // Step 4: Fix duplicate extensions
        name = collapseDuplicateExtensions(name);

        // Step 5: Ensure we have something
        if (!name || name === '_') {
            name = 'unnamed_file';
        }

        return name;
    }

    /**
     * Collapse duplicate extensions like .md.md.md -> .md
     * Also handles cases like .csvcsv -> .csv
     */
    function collapseDuplicateExtensions(filename) {
        // Known extensions to check for duplicates
        const extensions = ['md', 'txt', 'csv', 'json', 'xml', 'pdf', 'docx', 'doc', 'xlsx', 'xls', 'srt', 'html', 'htm'];

        let result = filename;

        for (const ext of extensions) {
            // Pattern: .ext.ext.ext... at end of filename -> .ext
            const repeatedExtPattern = new RegExp(`(\\.${ext})+$`, 'gi');
            result = result.replace(repeatedExtPattern, `.${ext}`);

            // Pattern: extextSelect_file or similar garbage
            const garbagePattern = new RegExp(`\\.${ext}${ext}[A-Za-z_]*`, 'gi');
            result = result.replace(garbagePattern, `.${ext}`);
        }

        // Handle weird patterns like "txt9_.csv" -> remove the garbage
        result = result.replace(/\d+_\.(csv|txt|md)$/i, '.$1');

        return result;
    }

    /**
     * Get the extension from a filename (lowercase, without dot)
     */
    function getExtension(filename) {
        const match = filename.match(/\.([a-zA-Z0-9]+)$/);
        return match ? match[1].toLowerCase() : null;
    }

    /**
     * Check if filename already has a valid extension
     */
    function hasValidExtension(filename) {
        const validExts = ['md', 'txt', 'csv', 'json', 'xml', 'pdf', 'docx', 'doc', 'xlsx', 'xls', 'srt', 'html', 'htm', 'js', 'py', 'ts', 'jsx', 'tsx', 'css', 'scss'];
        const ext = getExtension(filename);
        return ext && validExts.includes(ext);
    }

    /**
     * Add extension only if needed
     */
    function ensureExtension(filename, detectedType) {
        if (hasValidExtension(filename)) {
            return filename;
        }

        // Map type badge to extension
        const typeToExt = {
            'md': 'md',
            'txt': 'txt',
            'text': 'txt',
            'csv': 'csv',
            'pdf': 'pdf',
            'docx': 'docx',
            'doc': 'doc',
            'xlsx': 'xlsx',
            'xls': 'xls',
            'json': 'json',
            'xml': 'xml',
            'html': 'html'
        };

        const ext = typeToExt[detectedType?.toLowerCase()] || 'txt';
        return `${filename}.${ext}`;
    }

    // ============================================================
    // COLLISION HANDLING
    // ============================================================

    /**
     * Handle filename collisions by adding __2, __3, etc. BEFORE extension
     */
    function handleCollision(filename, usedNames) {
        if (!usedNames.has(filename.toLowerCase())) {
            usedNames.add(filename.toLowerCase());
            return filename;
        }

        const ext = getExtension(filename);
        const base = ext ? filename.slice(0, -(ext.length + 1)) : filename;

        let counter = 2;
        let newName;
        do {
            newName = ext ? `${base}__${counter}.${ext}` : `${base}__${counter}`;
            counter++;
        } while (usedNames.has(newName.toLowerCase()));

        usedNames.add(newName.toLowerCase());
        return newName;
    }

    // ============================================================
    // JSZip LOADER
    // ============================================================

    function loadJSZip() {
        return new Promise((resolve, reject) => {
            if (typeof JSZip !== 'undefined') {
                log.debug('JSZip already loaded');
                resolve();
                return;
            }

            log.info('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 = () => {
                setTimeout(() => {
                    if (typeof JSZip !== 'undefined') {
                        log.success('JSZip loaded');
                        resolve();
                    } else {
                        reject(new Error('JSZip loaded but not available'));
                    }
                }, 300);
            };
            script.onerror = () => reject(new Error('Failed to load JSZip'));
            document.head.appendChild(script);
        });
    }

    // ============================================================
    // DOM UTILITIES
    // ============================================================

    function sleep(ms) {
        return new Promise(resolve => setTimeout(resolve, ms));
    }

    async function waitForElement(selector, parent = document, timeout = 5000) {
        const startTime = Date.now();
        while (Date.now() - startTime < timeout) {
            const el = parent.querySelector(selector);
            if (el) return el;
            await sleep(100);
        }
        return null;
    }

    async function waitForModal(timeout = CONFIG.MODAL_WAIT_MS) {
        const startTime = Date.now();
        while (Date.now() - startTime < timeout) {
            const modal = document.querySelector('[role="dialog"]');
            if (modal && modal.offsetHeight > 0) {
                await sleep(CONFIG.MODAL_CONTENT_WAIT_MS);
                return modal;
            }
            await sleep(100);
        }
        return null;
    }

    async function closeModal() {
        log.debug('Closing modal...');

        // Method 1: Find close button (X button in header)
        const closeSelectors = [
            '[role="dialog"] button[type="button"]:first-of-type',
            '[role="dialog"] button svg[viewBox="0 0 20 20"]',
            'button[aria-label*="close" i]',
            'button[aria-label*="Close" i]'
        ];

        for (const selector of closeSelectors) {
            try {
                const btn = document.querySelector(selector);
                if (btn) {
                    const clickTarget = btn.closest('button') || btn;
                    clickTarget.click();
                    await sleep(300);
                    if (!document.querySelector('[role="dialog"]')) {
                        log.debug('Modal closed via button');
                        return true;
                    }
                }
            } catch (e) { /* continue */ }
        }

        // Method 2: Escape key
        for (let i = 0; i < 3; i++) {
            document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
            await sleep(200);
            if (!document.querySelector('[role="dialog"]')) {
                log.debug('Modal closed via Escape');
                return true;
            }
        }

        // Method 3: Click overlay
        const overlay = document.querySelector('.fixed.z-modal.inset-0');
        if (overlay) {
            const rect = overlay.getBoundingClientRect();
            overlay.click();
            await sleep(300);
        }

        const closed = !document.querySelector('[role="dialog"]');
        log.debug(closed ? 'Modal closed' : 'Failed to close modal');
        return closed;
    }

    // ============================================================
    // FILE DISCOVERY
    // ============================================================

    /**
     * Find all file cards in the project, handling lazy loading
     */
    async function discoverAllFiles(statusCallback) {
        log.info('Discovering files...');
        statusCallback?.('Scanning for files...');

        const fileGrid = document.querySelector('ul.grid');
        if (!fileGrid) {
            log.error('File grid not found');
            return [];
        }

        let lastCount = 0;
        let stableCount = 0;

        // Scroll to load all files
        for (let attempt = 0; attempt < CONFIG.MAX_SCROLL_ATTEMPTS; attempt++) {
            fileGrid.scrollTop = fileGrid.scrollHeight;
            window.scrollTo(0, document.body.scrollHeight);
            await sleep(CONFIG.SCROLL_WAIT_MS);

            const currentCount = fileGrid.querySelectorAll(':scope > div').length;
            log.debug(`Scroll attempt ${attempt + 1}: found ${currentCount} file cards`);

            if (currentCount === lastCount) {
                stableCount++;
                if (stableCount >= 3) {
                    log.info(`File count stable at ${currentCount}`);
                    break;
                }
            } else {
                stableCount = 0;
            }
            lastCount = currentCount;
        }

        // Now collect all file cards
        const fileCards = [];
        const cardContainers = fileGrid.querySelectorAll(':scope > div');

        for (const container of cardContainers) {
            const fileInfo = extractFileInfo(container);
            if (fileInfo) {
                fileCards.push({ element: container, ...fileInfo });
            }
        }

        log.success(`Discovered ${fileCards.length} files`);
        return fileCards;
    }

    /**
     * Extract file information from a card element
     */
    function extractFileInfo(cardContainer) {
        // Check for PDF first (has data-testid on inner div or img with .pdf alt)
        const pdfTestId = cardContainer.querySelector('div[data-testid$=".pdf"]');
        const pdfImg = cardContainer.querySelector('img[alt$=".pdf"]');

        if (pdfTestId || pdfImg) {
            // It's a PDF
            let filename;
            if (pdfTestId) {
                filename = pdfTestId.getAttribute('data-testid');
            } else if (pdfImg) {
                filename = pdfImg.getAttribute('alt');
            }

            if (filename) {
                return {
                    domFilename: filename,
                    type: 'pdf',
                    isPdf: true,
                    lineCount: null
                };
            }
        }

        // Check for text-based file (has [data-testid="file-thumbnail"])
        const fileThumbnail = cardContainer.querySelector('[data-testid="file-thumbnail"]');
        if (fileThumbnail) {
            const h3 = fileThumbnail.querySelector('h3');
            const typeBadge = fileThumbnail.querySelector('p.uppercase');
            const lineCountEl = fileThumbnail.querySelector('p.text-\\[10px\\]');

            if (h3) {
                const filename = h3.textContent.trim();
                const type = typeBadge?.textContent?.trim()?.toLowerCase() || 'txt';
                const lineCount = lineCountEl?.textContent?.trim() || null;

                return {
                    domFilename: filename,
                    type: type,
                    isPdf: false,
                    lineCount: lineCount
                };
            }
        }

        // Fallback: try to find any filename
        const anyH3 = cardContainer.querySelector('h3');
        const anyTypeBadge = cardContainer.querySelector('p.uppercase');
        if (anyH3) {
            return {
                domFilename: anyH3.textContent.trim(),
                type: anyTypeBadge?.textContent?.trim()?.toLowerCase() || 'txt',
                isPdf: false,
                lineCount: null
            };
        }

        return null;
    }

    // ============================================================
    // CONTENT EXTRACTION
    // ============================================================

    /**
     * Extract text content from an open modal
     */
    function extractTextContent(modal) {
        const contentSelectors = [
            'pre code',
            'pre',
            '.whitespace-pre-wrap',
            '.font-mono',
            '.overflow-auto pre',
            '[class*="content"]'
        ];

        for (const selector of contentSelectors) {
            const el = modal.querySelector(selector);
            if (el && el.textContent.trim().length > CONFIG.MIN_CONTENT_LENGTH) {
                return el.textContent;
            }
        }

        // Fallback: get modal body text, filtering UI elements
        const allText = modal.textContent || '';
        const lines = allText.split('\n')
            .map(l => l.trim())
            .filter(l => l.length > 3)
            .filter(l => !l.match(/^(Close|Download|Export|PDF|Select|Cancel|OK|\d+\s*lines?|View|Edit|pages?)$/i))
            .filter(l => !l.includes('claude.ai'))
            .filter(l => l.length < 500);

        return lines.join('\n');
    }

    /**
     * Extract PDF download URL from modal
     */
    function extractPdfUrl(modal) {
        // Look for the download link
        const downloadLink = modal.querySelector('a[href*="/document_pdf"]');
        if (downloadLink) {
            return downloadLink.href;
        }

        // Try to find any API file link
        const anyApiLink = modal.querySelector('a[href*="/api/"][href*="/files/"]');
        if (anyApiLink) {
            return anyApiLink.href;
        }

        return null;
    }

    /**
     * Extract CSV content - try download URL first, then table reconstruction
     */
    async function extractCsvContent(modal, fileInfo) {
        // Check if there's a download link (similar to PDF)
        const downloadLink = modal.querySelector('a[href*="/files/"]');
        if (downloadLink && downloadLink.href) {
            return { method: 'download_url', url: downloadLink.href };
        }

        // Try to find table content
        const table = modal.querySelector('table');
        if (table) {
            const rows = [];
            table.querySelectorAll('tr').forEach(tr => {
                const cells = [];
                tr.querySelectorAll('td, th').forEach(cell => {
                    cells.push(cell.textContent.trim().replace(/,/g, ';'));
                });
                if (cells.length > 0) {
                    rows.push(cells.join(','));
                }
            });
            if (rows.length > 0) {
                return { method: 'table_reconstruction', content: rows.join('\n') };
            }
        }

        // Try text content that looks like CSV
        const textContent = extractTextContent(modal);
        if (textContent && textContent.includes(',')) {
            return { method: 'text_scrape', content: textContent };
        }

        return { method: 'unexportable', reason: 'No download URL, table, or CSV-like content found' };
    }

    // ============================================================
    // FILE EXPORT
    // ============================================================

    /**
     * Export a single file and return metadata
     */
    async function exportFile(fileCard, usedNames, statusCallback) {
        const { element, domFilename, type, isPdf, lineCount } = fileCard;

        log.debug(`Processing: "${domFilename}" (type: ${type}, isPdf: ${isPdf})`);

        // Normalize the filename
        let normalizedName = normalizeFilename(domFilename);
        normalizedName = ensureExtension(normalizedName, type);
        normalizedName = handleCollision(normalizedName, usedNames);

        const metadata = {
            originalDomFilename: domFilename,
            normalizedFilename: normalizedName,
            detectedType: type,
            sourceUrl: null,
            exportMethod: null,
            status: 'pending',
            error: null,
            lineCount: lineCount
        };

        statusCallback?.(`Exporting: ${domFilename}`);

        try {
            // Click to open the file
            const clickTarget = element.querySelector('button') || element;
            clickTarget.scrollIntoView({ behavior: 'instant', block: 'center' });
            await sleep(200);
            clickTarget.click();

            const modal = await waitForModal();
            if (!modal) {
                throw new Error('Modal did not open');
            }

            let content = null;

            if (isPdf || type === 'pdf') {
                // Handle PDF
                const pdfUrl = extractPdfUrl(modal);
                if (pdfUrl) {
                    metadata.sourceUrl = pdfUrl;
                    metadata.exportMethod = 'pdf_download_url';

                    // Fetch the PDF
                    const response = await fetch(pdfUrl, { credentials: 'include' });
                    if (!response.ok) {
                        throw new Error(`PDF fetch failed: ${response.status}`);
                    }
                    const blob = await response.blob();
                    content = blob;
                    metadata.status = 'success';
                    log.file(domFilename, normalizedName, 'pdf', 'download_url', 'success');
                } else {
                    throw new Error('Could not find PDF download URL');
                }

            } else if (type === 'csv') {
                // Handle CSV
                const csvResult = await extractCsvContent(modal, fileCard);
                metadata.exportMethod = csvResult.method;

                if (csvResult.method === 'download_url') {
                    metadata.sourceUrl = csvResult.url;
                    const response = await fetch(csvResult.url, { credentials: 'include' });
                    if (!response.ok) {
                        throw new Error(`CSV fetch failed: ${response.status}`);
                    }
                    content = await response.text();
                    metadata.status = 'success';
                    log.file(domFilename, normalizedName, 'csv', 'download_url', 'success');

                } else if (csvResult.method === 'table_reconstruction' || csvResult.method === 'text_scrape') {
                    content = csvResult.content;
                    metadata.status = 'success';
                    log.file(domFilename, normalizedName, 'csv', csvResult.method, 'success');

                } else {
                    metadata.status = 'unexportable';
                    metadata.error = csvResult.reason;
                    metadata.exportMethod = 'unexportable';
                    log.file(domFilename, normalizedName, 'csv', 'unexportable', 'failed');
                }

            } else {
                // Handle text-based files (md, txt, docx, etc.)
                content = extractTextContent(modal);

                if (content && content.length > CONFIG.MIN_CONTENT_LENGTH) {
                    metadata.exportMethod = 'text_scrape';
                    metadata.status = 'success';
                    log.file(domFilename, normalizedName, type, 'text_scrape', 'success');
                } else {
                    throw new Error(`Content too short (${content?.length || 0} chars)`);
                }
            }

            await closeModal();
            await sleep(CONFIG.BETWEEN_FILES_MS);

            return { metadata, content, filename: normalizedName };

        } catch (error) {
            metadata.status = 'failed';
            metadata.error = error.message;
            metadata.exportMethod = metadata.exportMethod || 'failed';
            log.file(domFilename, normalizedName, type, 'error', 'failed');
            log.error(`Failed to export "${domFilename}": ${error.message}`);

            await closeModal();
            await sleep(CONFIG.BETWEEN_FILES_MS);

            return { metadata, content: null, filename: normalizedName };
        }
    }

    // ============================================================
    // ZIP CREATION
    // ============================================================

    async function createAndDownloadZip(exportedFiles, projectName, statusCallback) {
        log.info('Creating ZIP archive...');
        statusCallback?.('Creating ZIP...');

        const zip = new JSZip();
        const allMetadata = [];

        let successCount = 0;
        let failedCount = 0;
        let pdfCount = 0;
        let csvCount = 0;
        let unexportableCount = 0;

        for (const { metadata, content, filename } of exportedFiles) {
            allMetadata.push(metadata);

            if (content !== null) {
                if (content instanceof Blob) {
                    zip.file(filename, content);
                    pdfCount++;
                } else {
                    zip.file(filename, content);
                    if (metadata.detectedType === 'csv') csvCount++;
                }
                successCount++;
            } else {
                if (metadata.status === 'unexportable') {
                    unexportableCount++;
                } else {
                    failedCount++;
                }
            }
        }

        // Add metadata JSON
        const metadataJson = {
            exportDate: new Date().toISOString(),
            projectTitle: projectName,
            url: window.location.href,
            exporterVersion: '4.0.0',
            summary: {
                total: exportedFiles.length,
                exported: successCount,
                failed: failedCount,
                unexportable: unexportableCount,
                pdfExported: pdfCount,
                csvExported: csvCount
            },
            files: allMetadata
        };

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

        // Generate and download
        log.info('Generating ZIP blob...');
        const zipBlob = await zip.generateAsync({
            type: 'blob',
            compression: 'DEFLATE',
            compressionOptions: { level: 6 }
        });

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

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

        // Final summary
        log.success('='.repeat(50));
        log.success('EXPORT COMPLETE');
        log.success('='.repeat(50));
        log.info(`Total files:       ${exportedFiles.length}`);
        log.info(`Exported:          ${successCount}`);
        log.info(`Failed:            ${failedCount}`);
        log.info(`Unexportable:      ${unexportableCount}`);
        log.info(`PDFs exported:     ${pdfCount}`);
        log.info(`CSVs exported:     ${csvCount}`);
        log.success('='.repeat(50));

        return { zipFilename, ...metadataJson.summary };
    }

    // ============================================================
    // MAIN EXPORT FUNCTION
    // ============================================================

    async function exportProject() {
        const button = document.querySelector('#claude-export-btn');
        const updateStatus = (msg) => {
            if (button) button.textContent = `🔄 ${msg}`;
            log.info(msg);
        };

        try {
            updateStatus('Loading JSZip...');
            await loadJSZip();

            updateStatus('Discovering files...');
            const fileCards = await discoverAllFiles(updateStatus);

            if (fileCards.length === 0) {
                updateStatus('No files found!');
                log.error('No files found in project');
                setTimeout(() => {
                    if (button) button.textContent = '📁 Export Project Files';
                }, 3000);
                return;
            }

            updateStatus(`Found ${fileCards.length} files, exporting...`);

            const usedNames = new Set();
            const exportedFiles = [];

            for (let i = 0; i < fileCards.length; i++) {
                updateStatus(`Exporting ${i + 1}/${fileCards.length}: ${fileCards[i].domFilename}`);
                const result = await exportFile(fileCards[i], usedNames, updateStatus);
                exportedFiles.push(result);
            }

            // Get project name
            const projectName = getProjectTitle();

            updateStatus('Creating ZIP...');
            const summary = await createAndDownloadZip(exportedFiles, projectName, updateStatus);

            updateStatus(`✅ Exported ${summary.exported}/${summary.total} files`);
            setTimeout(() => {
                if (button) button.textContent = '📁 Export Project Files';
            }, 5000);

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

    function getProjectTitle() {
        // Try various title selectors
        const selectors = ['h1', '[data-testid*="title"]', '.text-xl', '.text-2xl'];
        for (const sel of selectors) {
            const el = document.querySelector(sel);
            if (el && el.textContent.trim() && el.textContent.trim() !== 'Claude') {
                return el.textContent.trim();
            }
        }

        // Fallback to URL
        const urlMatch = window.location.pathname.match(/\/project\/([^\/]+)/);
        if (urlMatch) return urlMatch[1];

        return 'Claude_Project';
    }

    // ============================================================
    // UI BUTTON
    // ============================================================

    function addExportButton() {
        const existing = document.querySelector('#claude-export-btn');
        if (existing) existing.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;
        `;

        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);
        log.success('Export button added');
    }

    // ============================================================
    // INITIALIZATION
    // ============================================================

    function init() {
        log.info('Claude Project Files Exporter v4.0.0 initialized');

        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();

})();