您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
One-click downloader for all files in a Claude project. Handles both previewable text files and non-previewable binaries (e.g., .xlsx).
// ==UserScript== // @name Claude Project Downloader (NEW) // @namespace https://tampermonkey.net // @version 1.1 // @description One-click downloader for all files in a Claude project. Handles both previewable text files and non-previewable binaries (e.g., .xlsx). // @author sharmanhall // @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 // @connect * // @icon data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyNCIgaGVpZ2h0PSIyNCIgdmlld0JveD0iMCAwIDI0IDI0IiBmaWxsPSJub25lIiBzdHJva2U9IiMxMTExMTEiIHN0cm9rZS13aWR0aD0iMiIgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIiBzdHJva2UtbGluZWpvaW49InJvdW5kIj48cGF0aCBkPSJNMjEgMTV2NGEyIDIgMCAwIDEtMiAySDVhMiAyIDAgMCAxLTItMnYtNCIvPjxwb2x5bGluZSBwb2ludHM9IjcgMTAgMTIgMTUgMTcgMTAiLz48bGluZSB4MT0iMTIiIHkxPSIxNSIgeDI9IjEyIiB5Mj0iMyIvPjwvc3ZnPg== // ==/UserScript== (function () { 'use strict'; let isInitialized = false; // -------- Utilities -------- const q = (sel, root = document) => root.querySelector(sel); const qa = (sel, root = document) => Array.from(root.querySelectorAll(sel)); function waitForAny(selectors, timeout = 15000) { // Resolve when ANY selector matches; reject if none appear within timeout return new Promise((resolve, reject) => { const start = performance.now(); const check = () => { for (const sel of selectors) { const el = q(sel); if (el) return resolve({ el, selector: sel }); } if (performance.now() - start >= timeout) return reject(new Error(`None of selectors appeared: ${selectors.join(', ')}`)); requestAnimationFrame(check); }; check(); }); } function waitUntilGone(selector, timeout = 10000) { return new Promise((resolve, reject) => { const start = performance.now(); const loop = () => { if (!q(selector)) return resolve(); if (performance.now() - start >= timeout) return reject(new Error(`"${selector}" did not disappear`)); requestAnimationFrame(loop); }; loop(); }); } async function fetchBytes(url) { const res = await fetch(url, { credentials: 'include' }); if (!res.ok) throw new Error(`Download failed (${res.status})`); const buf = await res.arrayBuffer(); return new Uint8Array(buf); } // Try hard to find a download link/button in Claude's file viewer modal function findDownloadLink() { // Prefer anchors with href + download-ish text const anchors = qa('a[href]'); const dlA = anchors.find(a => /download|save|export/i.test(a.textContent || '') || a.getAttribute('download') !== null || /\.([a-z0-9]{2,5})(\?|$)/i.test(a.getAttribute('href') || '') ); if (dlA) return dlA; // Some UIs use buttons that wrap an <a> inside, or have aria-label const btns = qa('button, [role="button"]'); const dlB = btns.find(b => /download|save|export/i.test(b.textContent || b.getAttribute('aria-label') || '')); if (dlB) { const nestedA = q('a[href]', dlB); if (nestedA) return nestedA; } return null; } function safeFileName(name, fallback = 'untitled') { const cleaned = (name || fallback).replace(/[\\/:*?"<>|]/g, '_').trim(); return cleaned || fallback; } // -------- UI scaffold -------- function initializeDownloaderUI() { if (typeof fflate === 'undefined' || typeof saveAs === 'undefined') return; if (q('#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 corner = document.createElement('div'); corner.id = 'downloader-corner-container'; corner.innerHTML = `<button id="downloader-start-btn" class="downloader-btn"><span class="icon">${ICONS.DOWNLOAD}</span><span>Download project</span></button>`; document.body.appendChild(corner); const modal = document.createElement('div'); modal.id = 'downloader-modal-container'; modal.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">Cancel</button> </div> `; document.body.appendChild(modal); GM_addStyle(` :root{--color-text:#FFF;--color-background:#111;--color-overlay:rgba(10,10,10,.75);--color-border:rgba(255,255,255,.15);--color-progress:#FFF;--color-cancel-text:rgba(255,255,255,.7);--curve:cubic-bezier(.2,.8,.2,1)} @keyframes spin{from{transform:rotate(0)}to{transform:rotate(360deg)}}.animate-spin{animation:spin 1s linear infinite} #downloader-corner-container{position:fixed;bottom:25px;right:25px;z-index:9998;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,.85);backdrop-filter:blur(10px);color:var(--color-text);padding:0 24px;cursor:pointer;height:54px;transition:all .3s var(--curve)} .downloader-btn:hover{transform:translateY(-3px);background:rgba(40,40,40,.95)} .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 .4s var(--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:18px;width:440px;border:1px solid var(--color-border)} #downloader-main-status{display:flex;align-items:center;gap:16px;font-size:20px;font-weight:600;color:var(--color-text)} #downloader-progress-bar-container{width:100%;height:8px;background:rgba(255,255,255,.08);border-radius:4px;overflow:hidden} .progress-bar-fill{width:0%;height:100%;background:var(--color-progress);transition:width .25s ease-out} #downloader-detail-status{height:20px;font-size:14px;color:rgba(255,255,255,.75);text-align:center;width:100%;white-space:nowrap;overflow:hidden;text-overflow:ellipsis} #downloader-cancel-btn{border:none;background:transparent;color:var(--color-cancel-text);padding:8px 16px;border-radius:8px;cursor:pointer} #downloader-cancel-btn:hover{background:rgba(255,255,255,.1);color:#fff} `); const startBtn = q('#downloader-start-btn'); const modalIcon = q('#downloader-main-status .icon', modal); const modalText = q('#downloader-main-status .text', modal); const progressBar = q('.progress-bar-fill', modal); const detail = q('#downloader-detail-status', modal); const cancelBtn = q('#downloader-cancel-btn', modal); let isCancelled = false; let closeTimer = null; function animateText(el, t) { if (el.textContent === t) return; el.style.opacity = '0'; setTimeout(() => { el.textContent = t; el.style.opacity = '1'; }, 150); } function setUI(state, main = '', sub = '', pct = 0) { clearTimeout(closeTimer); if (state === 'idle') { modal.classList.remove('active'); return; } modal.classList.add('active'); const ICONS2 = { processing: ICONS.SPINNER, zipping: ICONS.SPINNER, success: ICONS.SUCCESS, error: ICONS.ERROR, cancelled: ICONS.CANCEL }; modalIcon.innerHTML = ICONS2[state] || ''; animateText(modalText, main); animateText(detail, sub); progressBar.style.width = `${pct}%`; cancelBtn.style.display = state === 'processing' ? 'block' : 'none'; if (state === 'success') closeTimer = setTimeout(() => setUI('idle'), 2500); if (state === 'cancelled') closeTimer = setTimeout(() => setUI('idle'), 1500); } cancelBtn.addEventListener('click', () => { isCancelled = true; }); startBtn.addEventListener('click', async () => { try { isCancelled = false; setUI('processing', 'Preparing…', 'Scanning files…', 0); // Find all file tiles/cards in the project Files panel const fileButtons = qa('button.rounded-lg').filter(btn => btn.querySelector('h3')); if (fileButtons.length === 0) throw new Error('No project files found.'); const collected = []; // {name, bytesUint8Array} or {name, text} for (let i = 0; i < fileButtons.length; i++) { if (isCancelled) throw new Error('cancelled'); const nameRaw = btnText(fileButtons[i].querySelector('h3')) || `untitled-${i + 1}`; const fileName = safeFileName(nameRaw); setUI('processing', 'Collecting files…', `${i + 1}/${fileButtons.length}: ${fileName}`, (i / fileButtons.length) * 100); // Open the viewer fileButtons[i].click(); // Wait for either: text preview, a "no preview" label, or any download link to appear const candidates = [ // typical text preview container(s) 'div.whitespace-pre-wrap.break-all.font-mono', 'pre, code[class*="language-"]', // "File previews are not supported…" banner/area 'div:has(> p), div[role="dialog"] p, [data-testid*="preview"] p', // download link/button 'a[href][download], a[href*="."]', 'button:has(svg), [role="button"]' ]; let previewText = null; let fileBytes = null; let usedUrl = null; try { await waitForAny(candidates, 12000); } catch (e) { // proceed; some dialogs render slowly, we’ll still attempt download link discovery } // 1) Try to read text preview const textEl = q('div.whitespace-pre-wrap.break-all.font-mono') || q('pre') || q('code[class*="language-"]'); if (textEl && (textEl.textContent || '').trim().length > 0) { previewText = textEl.textContent; } else { // 2) If no text preview, try to find a download link const link = findDownloadLink(); if (link) { const href = link.getAttribute('href'); if (href) { try { fileBytes = await fetchBytes(href); } catch (err) { // CORS blocked or remote signed URL restricted — fallback to .url shortcut usedUrl = href; } } } else { // 3) Final fallback: capture the message shown by the dialog const msg = qa('div[role="dialog"] p, [role="dialog"] div, div').map(n => n.textContent || '').find(t => /not supported|no preview/i.test(t)); if (msg) previewText = msg.trim(); } } // Close the viewer (try the “X” close if present) const closeBtn = q('button:has(svg[aria-hidden="true"]), button[aria-label*="Close"], button[title*="Close"]') || q('path[d^="M15.1465"]')?.closest('button'); if (closeBtn) { closeBtn.click(); await waitUntilGone('div[role="dialog"]', 8000).catch(() => {}); } // Store into our bundle if (fileBytes) { collected.push({ name: fileName, bytes: fileBytes }); } else if (previewText != null) { collected.push({ name: fileName, text: previewText }); } else if (usedUrl) { // .url (Internet Shortcut) – opens the real file when double-clicked on Windows; fine everywhere as a link placeholder const urlTxt = `[InternetShortcut]\nURL=${usedUrl}\n`; collected.push({ name: fileName + '.url', text: urlTxt }); } else { const note = `No preview and no downloadable link were detected for "${fileName}".`; collected.push({ name: fileName + '.txt', text: note }); } } // Build ZIP setUI('zipping', 'Zipping…', 'Creating ZIP archive…', 100); const filesToZip = {}; const encoder = new TextEncoder(); for (const f of collected) { if (f.bytes) filesToZip[f.name] = f.bytes; else filesToZip[f.name] = encoder.encode(f.text || ''); } const zip = fflate.zipSync(filesToZip, { level: 6 }); const blob = new Blob([zip], { type: 'application/zip' }); saveAs(blob, 'claude_project_files.zip'); setUI('success', 'Done', 'Download complete'); } catch (err) { if (err && String(err).toLowerCase().includes('cancelled')) { setUI('cancelled', 'Cancelled', 'Operation aborted', 100); } else { console.error('[Claude Project Downloader] Error:', err); setUI('error', 'Error', err?.message || 'Unknown error', 100); } } }); // helper to keep the floating button visible only when a project page has files function sentinel() { const visible = !!(q('h2[id^="radix-"]') || q('button.rounded-lg h3')); corner.classList.toggle('visible', visible); } setInterval(sentinel, 1000); function btnText(el) { return (el?.textContent || '').trim(); } isInitialized = true; } // init as the SPA navigates function bootSentinel() { if (!isInitialized) initializeDownloaderUI(); } const obs = new MutationObserver(bootSentinel); obs.observe(document.documentElement, { childList: true, subtree: true }); bootSentinel(); })(); // ==UserScript== // @name Claude Project Downloader (NEW) // @namespace https://tampermonkey.net // @version 1.1 // @description One-click downloader for all files in a Claude project. Handles both previewable text files and non-previewable binaries (e.g., .xlsx). // @author sharmanhall // @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 // @connect * // @icon data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyNCIgaGVpZ2h0PSIyNCIgdmlld0JveD0iMCAwIDI0IDI0IiBmaWxsPSJub25lIiBzdHJva2U9IiMxMTExMTEiIHN0cm9rZS13aWR0aD0iMiIgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIiBzdHJva2UtbGluZWpvaW49InJvdW5kIj48cGF0aCBkPSJNMjEgMTV2NGEyIDIgMCAwIDEtMiAySDVhMiAyIDAgMCAxLTItMnYtNCIvPjxwb2x5bGluZSBwb2ludHM9IjcgMTAgMTIgMTUgMTcgMTAiLz48bGluZSB4MT0iMTIiIHkxPSIxNSIgeDI9IjEyIiB5Mj0iMyIvPjwvc3ZnPg== // ==/UserScript== (function () { 'use strict'; let isInitialized = false; // -------- Utilities -------- const q = (sel, root = document) => root.querySelector(sel); const qa = (sel, root = document) => Array.from(root.querySelectorAll(sel)); function waitForAny(selectors, timeout = 15000) { // Resolve when ANY selector matches; reject if none appear within timeout return new Promise((resolve, reject) => { const start = performance.now(); const check = () => { for (const sel of selectors) { const el = q(sel); if (el) return resolve({ el, selector: sel }); } if (performance.now() - start >= timeout) return reject(new Error(`None of selectors appeared: ${selectors.join(', ')}`)); requestAnimationFrame(check); }; check(); }); } function waitUntilGone(selector, timeout = 10000) { return new Promise((resolve, reject) => { const start = performance.now(); const loop = () => { if (!q(selector)) return resolve(); if (performance.now() - start >= timeout) return reject(new Error(`"${selector}" did not disappear`)); requestAnimationFrame(loop); }; loop(); }); } async function fetchBytes(url) { const res = await fetch(url, { credentials: 'include' }); if (!res.ok) throw new Error(`Download failed (${res.status})`); const buf = await res.arrayBuffer(); return new Uint8Array(buf); } // Try hard to find a download link/button in Claude's file viewer modal function findDownloadLink() { // Prefer anchors with href + download-ish text const anchors = qa('a[href]'); const dlA = anchors.find(a => /download|save|export/i.test(a.textContent || '') || a.getAttribute('download') !== null || /\.([a-z0-9]{2,5})(\?|$)/i.test(a.getAttribute('href') || '') ); if (dlA) return dlA; // Some UIs use buttons that wrap an <a> inside, or have aria-label const btns = qa('button, [role="button"]'); const dlB = btns.find(b => /download|save|export/i.test(b.textContent || b.getAttribute('aria-label') || '')); if (dlB) { const nestedA = q('a[href]', dlB); if (nestedA) return nestedA; } return null; } function safeFileName(name, fallback = 'untitled') { const cleaned = (name || fallback).replace(/[\\/:*?"<>|]/g, '_').trim(); return cleaned || fallback; } // -------- UI scaffold -------- function initializeDownloaderUI() { if (typeof fflate === 'undefined' || typeof saveAs === 'undefined') return; if (q('#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 corner = document.createElement('div'); corner.id = 'downloader-corner-container'; corner.innerHTML = `<button id="downloader-start-btn" class="downloader-btn"><span class="icon">${ICONS.DOWNLOAD}</span><span>Download project</span></button>`; document.body.appendChild(corner); const modal = document.createElement('div'); modal.id = 'downloader-modal-container'; modal.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">Cancel</button> </div> `; document.body.appendChild(modal); GM_addStyle(` :root{--color-text:#FFF;--color-background:#111;--color-overlay:rgba(10,10,10,.75);--color-border:rgba(255,255,255,.15);--color-progress:#FFF;--color-cancel-text:rgba(255,255,255,.7);--curve:cubic-bezier(.2,.8,.2,1)} @keyframes spin{from{transform:rotate(0)}to{transform:rotate(360deg)}}.animate-spin{animation:spin 1s linear infinite} #downloader-corner-container{position:fixed;bottom:25px;right:25px;z-index:9998;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,.85);backdrop-filter:blur(10px);color:var(--color-text);padding:0 24px;cursor:pointer;height:54px;transition:all .3s var(--curve)} .downloader-btn:hover{transform:translateY(-3px);background:rgba(40,40,40,.95)} .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 .4s var(--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:18px;width:440px;border:1px solid var(--color-border)} #downloader-main-status{display:flex;align-items:center;gap:16px;font-size:20px;font-weight:600;color:var(--color-text)} #downloader-progress-bar-container{width:100%;height:8px;background:rgba(255,255,255,.08);border-radius:4px;overflow:hidden} .progress-bar-fill{width:0%;height:100%;background:var(--color-progress);transition:width .25s ease-out} #downloader-detail-status{height:20px;font-size:14px;color:rgba(255,255,255,.75);text-align:center;width:100%;white-space:nowrap;overflow:hidden;text-overflow:ellipsis} #downloader-cancel-btn{border:none;background:transparent;color:var(--color-cancel-text);padding:8px 16px;border-radius:8px;cursor:pointer} #downloader-cancel-btn:hover{background:rgba(255,255,255,.1);color:#fff} `); const startBtn = q('#downloader-start-btn'); const modalIcon = q('#downloader-main-status .icon', modal); const modalText = q('#downloader-main-status .text', modal); const progressBar = q('.progress-bar-fill', modal); const detail = q('#downloader-detail-status', modal); const cancelBtn = q('#downloader-cancel-btn', modal); let isCancelled = false; let closeTimer = null; function animateText(el, t) { if (el.textContent === t) return; el.style.opacity = '0'; setTimeout(() => { el.textContent = t; el.style.opacity = '1'; }, 150); } function setUI(state, main = '', sub = '', pct = 0) { clearTimeout(closeTimer); if (state === 'idle') { modal.classList.remove('active'); return; } modal.classList.add('active'); const ICONS2 = { processing: ICONS.SPINNER, zipping: ICONS.SPINNER, success: ICONS.SUCCESS, error: ICONS.ERROR, cancelled: ICONS.CANCEL }; modalIcon.innerHTML = ICONS2[state] || ''; animateText(modalText, main); animateText(detail, sub); progressBar.style.width = `${pct}%`; cancelBtn.style.display = state === 'processing' ? 'block' : 'none'; if (state === 'success') closeTimer = setTimeout(() => setUI('idle'), 2500); if (state === 'cancelled') closeTimer = setTimeout(() => setUI('idle'), 1500); } cancelBtn.addEventListener('click', () => { isCancelled = true; }); startBtn.addEventListener('click', async () => { try { isCancelled = false; setUI('processing', 'Preparing…', 'Scanning files…', 0); // Find all file tiles/cards in the project Files panel const fileButtons = qa('button.rounded-lg').filter(btn => btn.querySelector('h3')); if (fileButtons.length === 0) throw new Error('No project files found.'); const collected = []; // {name, bytesUint8Array} or {name, text} for (let i = 0; i < fileButtons.length; i++) { if (isCancelled) throw new Error('cancelled'); const nameRaw = btnText(fileButtons[i].querySelector('h3')) || `untitled-${i + 1}`; const fileName = safeFileName(nameRaw); setUI('processing', 'Collecting files…', `${i + 1}/${fileButtons.length}: ${fileName}`, (i / fileButtons.length) * 100); // Open the viewer fileButtons[i].click(); // Wait for either: text preview, a "no preview" label, or any download link to appear const candidates = [ // typical text preview container(s) 'div.whitespace-pre-wrap.break-all.font-mono', 'pre, code[class*="language-"]', // "File previews are not supported…" banner/area 'div:has(> p), div[role="dialog"] p, [data-testid*="preview"] p', // download link/button 'a[href][download], a[href*="."]', 'button:has(svg), [role="button"]' ]; let previewText = null; let fileBytes = null; let usedUrl = null; try { await waitForAny(candidates, 12000); } catch (e) { // proceed; some dialogs render slowly, we’ll still attempt download link discovery } // 1) Try to read text preview const textEl = q('div.whitespace-pre-wrap.break-all.font-mono') || q('pre') || q('code[class*="language-"]'); if (textEl && (textEl.textContent || '').trim().length > 0) { previewText = textEl.textContent; } else { // 2) If no text preview, try to find a download link const link = findDownloadLink(); if (link) { const href = link.getAttribute('href'); if (href) { try { fileBytes = await fetchBytes(href); } catch (err) { // CORS blocked or remote signed URL restricted — fallback to .url shortcut usedUrl = href; } } } else { // 3) Final fallback: capture the message shown by the dialog const msg = qa('div[role="dialog"] p, [role="dialog"] div, div').map(n => n.textContent || '').find(t => /not supported|no preview/i.test(t)); if (msg) previewText = msg.trim(); } } // Close the viewer (try the “X” close if present) const closeBtn = q('button:has(svg[aria-hidden="true"]), button[aria-label*="Close"], button[title*="Close"]') || q('path[d^="M15.1465"]')?.closest('button'); if (closeBtn) { closeBtn.click(); await waitUntilGone('div[role="dialog"]', 8000).catch(() => {}); } // Store into our bundle if (fileBytes) { collected.push({ name: fileName, bytes: fileBytes }); } else if (previewText != null) { collected.push({ name: fileName, text: previewText }); } else if (usedUrl) { // .url (Internet Shortcut) – opens the real file when double-clicked on Windows; fine everywhere as a link placeholder const urlTxt = `[InternetShortcut]\nURL=${usedUrl}\n`; collected.push({ name: fileName + '.url', text: urlTxt }); } else { const note = `No preview and no downloadable link were detected for "${fileName}".`; collected.push({ name: fileName + '.txt', text: note }); } } // Build ZIP setUI('zipping', 'Zipping…', 'Creating ZIP archive…', 100); const filesToZip = {}; const encoder = new TextEncoder(); for (const f of collected) { if (f.bytes) filesToZip[f.name] = f.bytes; else filesToZip[f.name] = encoder.encode(f.text || ''); } const zip = fflate.zipSync(filesToZip, { level: 6 }); const blob = new Blob([zip], { type: 'application/zip' }); saveAs(blob, 'claude_project_files.zip'); setUI('success', 'Done', 'Download complete'); } catch (err) { if (err && String(err).toLowerCase().includes('cancelled')) { setUI('cancelled', 'Cancelled', 'Operation aborted', 100); } else { console.error('[Claude Project Downloader] Error:', err); setUI('error', 'Error', err?.message || 'Unknown error', 100); } } }); // helper to keep the floating button visible only when a project page has files function sentinel() { const visible = !!(q('h2[id^="radix-"]') || q('button.rounded-lg h3')); corner.classList.toggle('visible', visible); } setInterval(sentinel, 1000); function btnText(el) { return (el?.textContent || '').trim(); } isInitialized = true; } // init as the SPA navigates function bootSentinel() { if (!isInitialized) initializeDownloaderUI(); } const obs = new MutationObserver(bootSentinel); obs.observe(document.documentElement, { childList: true, subtree: true }); bootSentinel(); })();