Claude Project Downloader

A one-click project downloader for Claude.

// ==UserScript==
// @name         Claude Project Downloader
// @name:en      Claude Project Downloader
// @name:ja      Claudeプロジェクトダウンローダー
// @namespace    https://nomin.jp/
// @version      1.0
// @description  A one-click project downloader for Claude.
// @description:en A one-click project downloader for Claude.
// @description:ja ワンクリック式のClaudeプロジェクトダウンローダー。
// @author       nomin
// @license      All Rights Reserved
// @match        https://claude.ai/*
// @require      https://unpkg.com/fflate/umd/index.js
// @require      https://cdnjs.cloudflare.com/ajax/libs/FileSaver.js/2.0.5/FileSaver.min.js
// @grant        GM_addStyle
// @icon         data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyNCIgaGVpZ2h0PSIyNCIgdmlld0JveD0iMCAwIDI0IDI0IiBmaWxsPSJub25lIiBzdHJva2U9IiMxMTExMTEiIHN0cm9rZS13aWR0aD0iMiIgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIiBzdHJva2UtbGluZWpvaW49InJvdW5kIj48cGF0aCBkPSJNMjEgMTV2NGEyIDIgMCAwIDEtMiAySDVhMiAyIDAgMCAxLTItMnYtNCIvPjxwb2x5bGluZSBwb2ludHM9IjcgMTAgMTIgMTUgMTcgMTAiLz48bGluZSB4MT0iMTIiIHkxPSIxNSIgeDI9IjEyIiB5Mj0iMyIvPjwvc3ZnPg==
// ==/UserScript==

(function() {
    'use strict';

    let isInitialized = false;

    function initializeDownloaderUI() {
        if (typeof fflate === 'undefined' || typeof saveAs === 'undefined') { return; }
        if (document.getElementById('downloader-corner-container')) return;

        const ICONS = {
            DOWNLOAD: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>`,
            SPINNER: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="animate-spin"><line x1="12" y1="2" x2="12" y2="6"/><line x1="12" y1="18" x2="12" y2="22"/><line x1="4.93" y1="4.93" x2="7.76" y2="7.76"/><line x1="16.24" y1="16.24" x2="19.07" y2="19.07"/><line x1="2" y1="12" x2="6" y2="12"/><line x1="18" y1="12" x2="22" y2="12"/><line x1="4.93" y1="19.07" x2="7.76" y2="16.24"/><line x1="16.24" y1="7.76" x2="19.07" y2="4.93"/></svg>`,
            SUCCESS: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>`,
            ERROR: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg>`,
            CANCEL: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>`
        };

        const cornerContainer = document.createElement('div');
        cornerContainer.id = 'downloader-corner-container';
        cornerContainer.innerHTML = `<button id="downloader-start-btn" class="downloader-btn"><span class="icon">${ICONS.DOWNLOAD}</span><span>プロジェクトをダウンロード</span></button>`;
        document.body.appendChild(cornerContainer);

        const modalContainer = document.createElement('div');
        modalContainer.id = 'downloader-modal-container';
        modalContainer.innerHTML = `
            <div id="downloader-modal-card">
                <div id="downloader-main-status"><span class="icon"></span><span class="text"></span></div>
                <div id="downloader-progress-bar-container"><div class="progress-bar-fill"></div></div>
                <div id="downloader-detail-status"></div>
                <button id="downloader-cancel-btn">キャンセル</button>
            </div>
        `;
        document.body.appendChild(modalContainer);

        GM_addStyle(`
            :root{--color-text:#FFFFFF;--color-background:#111111;--color-overlay:rgba(10,10,10,0.75);--color-border:rgba(255,255,255,0.15);--color-progress:#FFFFFF;--color-cancel-text:rgba(255,255,255,0.6);--transition-speed:0.6s;--transition-curve:cubic-bezier(0.2,0.8,0.2,1)}
            @keyframes spin{from{transform:rotate(0deg)}to{transform:rotate(360deg)}}.animate-spin{animation:spin 1s linear infinite}
            @keyframes fade-in-up{from{opacity:0;transform:translateY(15px)}to{opacity:1;transform:translateY(0)}}
            @keyframes progress-shimmer{0%{background-position:-200% 0}100%{background-position:200% 0}}
            #downloader-corner-container{position:fixed;bottom:25px;right:25px;z-index:9998;transition:opacity .3s,transform .3s;display:none}
            #downloader-corner-container.visible{display:block}
            .downloader-btn{display:flex;align-items:center;gap:12px;border:1px solid var(--color-border);border-radius:12px;background:rgba(30,30,30,0.8);backdrop-filter:blur(10px);color:var(--color-text);padding:0 24px;cursor:pointer;height:54px;box-shadow:0 8px 25px -5px rgba(0,0,0,0.2);transition:all .3s var(--transition-curve);font-size:16px;font-weight:500}
            .downloader-btn:hover{transform:translateY(-4px);box-shadow:0 12px 30px -8px rgba(0,0,0,0.3);background:rgba(40,40,40,0.9)}
            .downloader-btn .icon{display:flex;align-items:center;width:20px;height:20px}
            #downloader-modal-container{position:fixed;inset:0;z-index:9999;display:flex;justify-content:center;align-items:center;background:var(--color-overlay);backdrop-filter:blur(8px);opacity:0;pointer-events:none;transition:opacity var(--transition-speed) var(--transition-curve)}
            #downloader-modal-container.active{opacity:1;pointer-events:auto}
            #downloader-modal-card{display:flex;flex-direction:column;align-items:center;gap:18px;background:var(--color-background);padding:40px 56px;border-radius:20px;box-shadow:0 25px 50px -12px rgba(0,0,0,0.7);width:440px;border:1px solid var(--color-border);animation:fade-in-up .5s var(--transition-curve);cursor:default}
            #downloader-modal-container.is-dismissible #downloader-modal-card{cursor:pointer}
            #downloader-main-status{display:flex;align-items:center;gap:16px;font-size:20px;font-weight:500;color:var(--color-text)}
            #downloader-main-status .icon{display:flex;align-items:center;width:26px;height:26px}
            #downloader-progress-bar-container{width:100%;height:8px;background:rgba(255,255,255,0.08);border-radius:4px;overflow:hidden}
            .progress-bar-fill{width:0%;height:100%;background:var(--color-progress);background-image:linear-gradient(90deg,rgba(255,255,255,0) 0%,rgba(255,255,255,0.2) 50%,rgba(255,255,255,0) 100%);background-size:200% 100%;animation:progress-shimmer 2s linear infinite;transition:width .3s ease-out}
            #downloader-detail-status{height:20px;font-size:14px;color:rgba(255,255,255,0.7);text-align:center;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;width:100%;transition:opacity .3s}
            #downloader-cancel-btn{border:none;background:transparent;color:var(--color-cancel-text);padding:8px 16px;border-radius:8px;cursor:pointer;font-size:14px;transition:all .3s}
            #downloader-cancel-btn:hover{background:rgba(255,255,255,0.1);color:var(--color-text)}
        `);

        const startBtn = document.getElementById('downloader-start-btn');
        const modalIcon = modalContainer.querySelector('#downloader-main-status .icon');
        const modalText = modalContainer.querySelector('#downloader-main-status .text');
        const progressBarFill = modalContainer.querySelector('.progress-bar-fill');
        const detailStatus = modalContainer.querySelector('#downloader-detail-status');
        const cancelBtn = document.getElementById('downloader-cancel-btn');
        let isCancelled = false; let closeTimer = null;

        startBtn.addEventListener('click', startFullDownloadProcess);
        cancelBtn.addEventListener('click', () => { isCancelled = true; });
        modalContainer.addEventListener('click', (e) => {
            if (modalContainer.classList.contains('is-dismissible') && e.target === modalContainer) {
                clearTimeout(closeTimer); updateUI('idle');
            }
        });

        function animateText(element, newText) { if (element.textContent === newText) return; element.style.opacity = '0'; setTimeout(() => { element.textContent = newText; element.style.opacity = '1'; }, 200); }
        function updateUI(state, mainText = '', detailText = '', progress = 0) {
            clearTimeout(closeTimer); modalContainer.classList.remove('is-dismissible');
            if (state === 'idle') { modalContainer.classList.remove('active'); return; }
            modalContainer.classList.add('active');

            let icon = ''; let autoCloseDelay = null; cancelBtn.style.display = 'none';
            switch(state) {
                case 'processing': icon = ICONS.SPINNER; cancelBtn.style.display = 'block'; break;
                case 'zipping': icon = ICONS.SPINNER; break;
                case 'success': icon = ICONS.SUCCESS; progress = 100; mainText='成功'; detailText='ダウンロードが完了しました'; autoCloseDelay = 3000; break;
                case 'error': icon = ICONS.ERROR; progress = 100; mainText='エラー'; detailText=detailText||'不明なエラーが発生しました'; autoCloseDelay = 5000; break;
                case 'cancelled': icon = ICONS.CANCEL; progress = 100; mainText='キャンセル'; detailText='処理が中断されました'; autoCloseDelay = 1500; break;
            }
            modalIcon.innerHTML = icon;
            animateText(modalText, mainText); animateText(detailStatus, detailText);
            progressBarFill.style.width = `${progress}%`;
            if(autoCloseDelay !== null) { modalContainer.classList.add('is-dismissible'); closeTimer = setTimeout(() => updateUI('idle'), autoCloseDelay); }
        }

        async function startFullDownloadProcess() {
            isCancelled = false;
            updateUI('processing', '準備中', 'ファイル一覧をスキャン中...');
            try {
                const fileButtons = Array.from(document.querySelectorAll('button.rounded-lg')).filter(btn => btn.querySelector('h3.text-\\[12px\\]'));
                if (fileButtons.length === 0) throw new Error("対象のプロジェクトファイルが見つかりませんでした。");
                let collectedFiles = [];
                for (let i = 0; i < fileButtons.length; i++) {
                    if(isCancelled) throw new Error('cancelled');
                    const fileName = fileButtons[i].querySelector('h3')?.textContent.trim() || `untitled-${i+1}`;
                    updateUI('processing', 'ファイル収集中', `${i+1}/${fileButtons.length}: ${fileName}`, (i/fileButtons.length)*100);
                    fileButtons[i].click();
                    const contentContainer = await waitForElement('div.whitespace-pre-wrap.break-all.font-mono');
                    collectedFiles.push({ name: fileName, content: contentContainer.textContent });
                    const closeButton = document.querySelector('path[d^="M15.1465"]')?.closest('button');
                    if (closeButton) { closeButton.click(); await waitForElementToDisappear('div.whitespace-pre-wrap.break-all.font-mono'); }
                }
                updateUI('zipping', '圧縮処理中', 'ZIPファイルを生成しています...', 100);
                const filesToZip = {};
                const encoder = new TextEncoder();
                for (const file of collectedFiles) { filesToZip[file.name] = encoder.encode(file.content); }
                const zipData = fflate.zipSync(filesToZip, { level: 6 });
                const blob = new Blob([zipData], { type: "application/zip" });
                saveAs(blob, "claude_project_files.zip");
                updateUI('success');
            } catch (error) {
                console.error('Downloader Error:', error);
                if (isCancelled || error.message === 'cancelled') { updateUI('cancelled'); }
                else { updateUI('error', 'エラー', error.message); }
            }
        }
        isInitialized = true;
    }

    function sentinel() {
        if (!isInitialized) initializeDownloaderUI();
        const cornerContainer = document.getElementById('downloader-corner-container');
        if(!cornerContainer) return;
        const isProjectVisible = document.querySelector('h2[id^="radix-"]') || document.querySelector('button.rounded-lg h3.text-\\[12px\\]');
        cornerContainer.classList.toggle('visible', !!isProjectVisible);
    }
    
    setInterval(sentinel, 1000);
    
    function waitForElement(s,t=10000){return new Promise((r,j)=>{let i,m=()=>{let e=document.querySelector(s);if(e){clearInterval(i);clearTimeout(n);r(e)}};i=setInterval(m,100);let n=setTimeout(()=>{clearInterval(i);j(new Error(`Element "${s}" not found`))},t)})}
    function waitForElementToDisappear(s,t=10000){return new Promise((r,j)=>{let i,m=()=>{if(!document.querySelector(s)){clearInterval(i);clearTimeout(n);r()}};i=setInterval(m,100);let n=setTimeout(()=>{clearInterval(i);j(new Error(`"${s}" did not disappear`))},t)})}
})();