Claude Project Downloader (NEW)

One-click downloader for all files in a Claude project. Handles both previewable text files and non-previewable binaries (e.g., .xlsx).

Size

30.3 KB

Version

1.1

Created

Jan 5, 2026

Updated

17 days ago

1// ==UserScript==
2// @name         Claude Project Downloader (NEW)
3// @namespace    https://tampermonkey.net
4// @version      1.1
5// @description  One-click downloader for all files in a Claude project. Handles both previewable text files and non-previewable binaries (e.g., .xlsx).
6// @author       sharmanhall
7// @license      All Rights Reserved
8// @match        https://claude.ai/*
9// @require      https://unpkg.com/fflate/umd/index.js
10// @require      https://cdnjs.cloudflare.com/ajax/libs/FileSaver.js/2.0.5/FileSaver.min.js
11// @grant        GM_addStyle
12// @connect      *
13// @icon         data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyNCIgaGVpZ2h0PSIyNCIgdmlld0JveD0iMCAwIDI0IDI0IiBmaWxsPSJub25lIiBzdHJva2U9IiMxMTExMTEiIHN0cm9rZS13aWR0aD0iMiIgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIiBzdHJva2UtbGluZWpvaW49InJvdW5kIj48cGF0aCBkPSJNMjEgMTV2NGEyIDIgMCAwIDEtMiAySDVhMiAyIDAgMCAxLTItMnYtNCIvPjxwb2x5bGluZSBwb2ludHM9IjcgMTAgMTIgMTUgMTcgMTAiLz48bGluZSB4MT0iMTIiIHkxPSIxNSIgeDI9IjEyIiB5Mj0iMyIvPjwvc3ZnPg==
14// @downloadURL https://update.greasyfork.org/scripts/548977/Claude%20Project%20Downloader%20%28NEW%29.user.js
15// @updateURL https://update.greasyfork.org/scripts/548977/Claude%20Project%20Downloader%20%28NEW%29.meta.js
16// ==/UserScript==
17
18(function () {
19  'use strict';
20
21  let isInitialized = false;
22
23  // -------- Utilities --------
24  const q = (sel, root = document) => root.querySelector(sel);
25  const qa = (sel, root = document) => Array.from(root.querySelectorAll(sel));
26
27  function waitForAny(selectors, timeout = 15000) {
28    // Resolve when ANY selector matches; reject if none appear within timeout
29    return new Promise((resolve, reject) => {
30      const start = performance.now();
31      const check = () => {
32        for (const sel of selectors) {
33          const el = q(sel);
34          if (el) return resolve({ el, selector: sel });
35        }
36        if (performance.now() - start >= timeout)
37          return reject(new Error(`None of selectors appeared: ${selectors.join(', ')}`));
38        requestAnimationFrame(check);
39      };
40      check();
41    });
42  }
43
44  function waitUntilGone(selector, timeout = 10000) {
45    return new Promise((resolve, reject) => {
46      const start = performance.now();
47      const loop = () => {
48        if (!q(selector)) return resolve();
49        if (performance.now() - start >= timeout)
50          return reject(new Error(`"${selector}" did not disappear`));
51        requestAnimationFrame(loop);
52      };
53      loop();
54    });
55  }
56
57  async function fetchBytes(url) {
58    const res = await fetch(url, { credentials: 'include' });
59    if (!res.ok) throw new Error(`Download failed (${res.status})`);
60    const buf = await res.arrayBuffer();
61    return new Uint8Array(buf);
62  }
63
64  // Try hard to find a download link/button in Claude's file viewer modal
65  function findDownloadLink() {
66    // Prefer anchors with href + download-ish text
67    const anchors = qa('a[href]');
68    const dlA = anchors.find(a =>
69      /download|save|export/i.test(a.textContent || '') ||
70      a.getAttribute('download') !== null ||
71      /\.([a-z0-9]{2,5})(\?|$)/i.test(a.getAttribute('href') || '')
72    );
73    if (dlA) return dlA;
74
75    // Some UIs use buttons that wrap an <a> inside, or have aria-label
76    const btns = qa('button, [role="button"]');
77    const dlB = btns.find(b => /download|save|export/i.test(b.textContent || b.getAttribute('aria-label') || ''));
78    if (dlB) {
79      const nestedA = q('a[href]', dlB);
80      if (nestedA) return nestedA;
81    }
82    return null;
83  }
84
85  function safeFileName(name, fallback = 'untitled') {
86    const cleaned = (name || fallback).replace(/[\\/:*?"<>|]/g, '_').trim();
87    return cleaned || fallback;
88  }
89
90  // -------- UI scaffold --------
91  function initializeDownloaderUI() {
92    if (typeof fflate === 'undefined' || typeof saveAs === 'undefined') return;
93    if (q('#downloader-corner-container')) return;
94
95    const ICONS = {
96      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>`,
97      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>`,
98      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>`,
99      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>`,
100      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>`
101    };
102
103    const corner = document.createElement('div');
104    corner.id = 'downloader-corner-container';
105    corner.innerHTML = `<button id="downloader-start-btn" class="downloader-btn"><span class="icon">${ICONS.DOWNLOAD}</span><span>Download project</span></button>`;
106    document.body.appendChild(corner);
107
108    const modal = document.createElement('div');
109    modal.id = 'downloader-modal-container';
110    modal.innerHTML = `
111      <div id="downloader-modal-card">
112        <div id="downloader-main-status"><span class="icon"></span><span class="text"></span></div>
113        <div id="downloader-progress-bar-container"><div class="progress-bar-fill"></div></div>
114        <div id="downloader-detail-status"></div>
115        <button id="downloader-cancel-btn">Cancel</button>
116      </div>
117    `;
118    document.body.appendChild(modal);
119
120    GM_addStyle(`
121      :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)}
122      @keyframes spin{from{transform:rotate(0)}to{transform:rotate(360deg)}}.animate-spin{animation:spin 1s linear infinite}
123      #downloader-corner-container{position:fixed;bottom:25px;right:25px;z-index:9998;display:none}
124      #downloader-corner-container.visible{display:block}
125      .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)}
126      .downloader-btn:hover{transform:translateY(-3px);background:rgba(40,40,40,.95)}
127      .downloader-btn .icon{display:flex;align-items:center;width:20px;height:20px}
128      #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)}
129      #downloader-modal-container.active{opacity:1;pointer-events:auto}
130      #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)}
131      #downloader-main-status{display:flex;align-items:center;gap:16px;font-size:20px;font-weight:600;color:var(--color-text)}
132      #downloader-progress-bar-container{width:100%;height:8px;background:rgba(255,255,255,.08);border-radius:4px;overflow:hidden}
133      .progress-bar-fill{width:0%;height:100%;background:var(--color-progress);transition:width .25s ease-out}
134      #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}
135      #downloader-cancel-btn{border:none;background:transparent;color:var(--color-cancel-text);padding:8px 16px;border-radius:8px;cursor:pointer}
136      #downloader-cancel-btn:hover{background:rgba(255,255,255,.1);color:#fff}
137    `);
138
139    const startBtn = q('#downloader-start-btn');
140    const modalIcon = q('#downloader-main-status .icon', modal);
141    const modalText = q('#downloader-main-status .text', modal);
142    const progressBar = q('.progress-bar-fill', modal);
143    const detail = q('#downloader-detail-status', modal);
144    const cancelBtn = q('#downloader-cancel-btn', modal);
145    let isCancelled = false;
146    let closeTimer = null;
147
148    function animateText(el, t) {
149      if (el.textContent === t) return;
150      el.style.opacity = '0';
151      setTimeout(() => { el.textContent = t; el.style.opacity = '1'; }, 150);
152    }
153
154    function setUI(state, main = '', sub = '', pct = 0) {
155      clearTimeout(closeTimer);
156      if (state === 'idle') { modal.classList.remove('active'); return; }
157      modal.classList.add('active');
158
159      const ICONS2 = {
160        processing: ICONS.SPINNER,
161        zipping: ICONS.SPINNER,
162        success: ICONS.SUCCESS,
163        error: ICONS.ERROR,
164        cancelled: ICONS.CANCEL
165      };
166      modalIcon.innerHTML = ICONS2[state] || '';
167      animateText(modalText, main);
168      animateText(detail, sub);
169      progressBar.style.width = `${pct}%`;
170      cancelBtn.style.display = state === 'processing' ? 'block' : 'none';
171
172      if (state === 'success') closeTimer = setTimeout(() => setUI('idle'), 2500);
173      if (state === 'cancelled') closeTimer = setTimeout(() => setUI('idle'), 1500);
174    }
175
176    cancelBtn.addEventListener('click', () => { isCancelled = true; });
177
178    startBtn.addEventListener('click', async () => {
179      try {
180        isCancelled = false;
181        setUI('processing', 'Preparing…', 'Scanning files…', 0);
182
183        // Find all file tiles/cards in the project Files panel
184        const fileButtons = qa('button.rounded-lg').filter(btn => btn.querySelector('h3'));
185        if (fileButtons.length === 0) throw new Error('No project files found.');
186
187        const collected = []; // {name, bytesUint8Array} or {name, text}
188        for (let i = 0; i < fileButtons.length; i++) {
189          if (isCancelled) throw new Error('cancelled');
190
191          const nameRaw = btnText(fileButtons[i].querySelector('h3')) || `untitled-${i + 1}`;
192          const fileName = safeFileName(nameRaw);
193          setUI('processing', 'Collecting files…', `${i + 1}/${fileButtons.length}: ${fileName}`, (i / fileButtons.length) * 100);
194
195          // Open the viewer
196          fileButtons[i].click();
197
198          // Wait for either: text preview, a "no preview" label, or any download link to appear
199          const candidates = [
200            // typical text preview container(s)
201            'div.whitespace-pre-wrap.break-all.font-mono',
202            'pre, code[class*="language-"]',
203            // "File previews are not supported…" banner/area
204            'div:has(> p), div[role="dialog"] p, [data-testid*="preview"] p',
205            // download link/button
206            'a[href][download], a[href*="."]',
207            'button:has(svg), [role="button"]'
208          ];
209
210          let previewText = null;
211          let fileBytes = null;
212          let usedUrl = null;
213
214          try {
215            await waitForAny(candidates, 12000);
216          } catch (e) {
217            // proceed; some dialogs render slowly, we’ll still attempt download link discovery
218          }
219
220          // 1) Try to read text preview
221          const textEl =
222            q('div.whitespace-pre-wrap.break-all.font-mono') ||
223            q('pre') || q('code[class*="language-"]');
224
225          if (textEl && (textEl.textContent || '').trim().length > 0) {
226            previewText = textEl.textContent;
227          } else {
228            // 2) If no text preview, try to find a download link
229            const link = findDownloadLink();
230            if (link) {
231              const href = link.getAttribute('href');
232              if (href) {
233                try {
234                  fileBytes = await fetchBytes(href);
235                } catch (err) {
236                  // CORS blocked or remote signed URL restricted — fallback to .url shortcut
237                  usedUrl = href;
238                }
239              }
240            } else {
241              // 3) Final fallback: capture the message shown by the dialog
242              const msg = qa('div[role="dialog"] p, [role="dialog"] div, div').map(n => n.textContent || '').find(t => /not supported|no preview/i.test(t));
243              if (msg) previewText = msg.trim();
244            }
245          }
246
247          // Close the viewer (try the “X” close if present)
248          const closeBtn = q('button:has(svg[aria-hidden="true"]), button[aria-label*="Close"], button[title*="Close"]') ||
249                           q('path[d^="M15.1465"]')?.closest('button');
250          if (closeBtn) {
251            closeBtn.click();
252            await waitUntilGone('div[role="dialog"]', 8000).catch(() => {});
253          }
254
255          // Store into our bundle
256          if (fileBytes) {
257            collected.push({ name: fileName, bytes: fileBytes });
258          } else if (previewText != null) {
259            collected.push({ name: fileName, text: previewText });
260          } else if (usedUrl) {
261            // .url (Internet Shortcut) – opens the real file when double-clicked on Windows; fine everywhere as a link placeholder
262            const urlTxt = `[InternetShortcut]\nURL=${usedUrl}\n`;
263            collected.push({ name: fileName + '.url', text: urlTxt });
264          } else {
265            const note = `No preview and no downloadable link were detected for "${fileName}".`;
266            collected.push({ name: fileName + '.txt', text: note });
267          }
268        }
269
270        // Build ZIP
271        setUI('zipping', 'Zipping…', 'Creating ZIP archive…', 100);
272        const filesToZip = {};
273        const encoder = new TextEncoder();
274        for (const f of collected) {
275          if (f.bytes) filesToZip[f.name] = f.bytes;
276          else filesToZip[f.name] = encoder.encode(f.text || '');
277        }
278        const zip = fflate.zipSync(filesToZip, { level: 6 });
279        const blob = new Blob([zip], { type: 'application/zip' });
280        saveAs(blob, 'claude_project_files.zip');
281        setUI('success', 'Done', 'Download complete');
282      } catch (err) {
283        if (err && String(err).toLowerCase().includes('cancelled')) {
284          setUI('cancelled', 'Cancelled', 'Operation aborted', 100);
285        } else {
286          console.error('[Claude Project Downloader] Error:', err);
287          setUI('error', 'Error', err?.message || 'Unknown error', 100);
288        }
289      }
290    });
291
292    // helper to keep the floating button visible only when a project page has files
293    function sentinel() {
294      const visible = !!(q('h2[id^="radix-"]') || q('button.rounded-lg h3'));
295      corner.classList.toggle('visible', visible);
296    }
297    setInterval(sentinel, 1000);
298
299    function btnText(el) { return (el?.textContent || '').trim(); }
300
301    isInitialized = true;
302  }
303
304  // init as the SPA navigates
305  function bootSentinel() {
306    if (!isInitialized) initializeDownloaderUI();
307  }
308  const obs = new MutationObserver(bootSentinel);
309  obs.observe(document.documentElement, { childList: true, subtree: true });
310  bootSentinel();
311})();
312// ==UserScript==
313// @name         Claude Project Downloader (NEW)
314// @namespace    https://tampermonkey.net
315// @version      1.1
316// @description  One-click downloader for all files in a Claude project. Handles both previewable text files and non-previewable binaries (e.g., .xlsx).
317// @author       sharmanhall
318// @license      All Rights Reserved
319// @match        https://claude.ai/*
320// @require      https://unpkg.com/fflate/umd/index.js
321// @require      https://cdnjs.cloudflare.com/ajax/libs/FileSaver.js/2.0.5/FileSaver.min.js
322// @grant        GM_addStyle
323// @connect      *
324// @icon         data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyNCIgaGVpZ2h0PSIyNCIgdmlld0JveD0iMCAwIDI0IDI0IiBmaWxsPSJub25lIiBzdHJva2U9IiMxMTExMTEiIHN0cm9rZS13aWR0aD0iMiIgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIiBzdHJva2UtbGluZWpvaW49InJvdW5kIj48cGF0aCBkPSJNMjEgMTV2NGEyIDIgMCAwIDEtMiAySDVhMiAyIDAgMCAxLTItMnYtNCIvPjxwb2x5bGluZSBwb2ludHM9IjcgMTAgMTIgMTUgMTcgMTAiLz48bGluZSB4MT0iMTIiIHkxPSIxNSIgeDI9IjEyIiB5Mj0iMyIvPjwvc3ZnPg==
325// ==/UserScript==
326
327(function () {
328  'use strict';
329
330  let isInitialized = false;
331
332  // -------- Utilities --------
333  const q = (sel, root = document) => root.querySelector(sel);
334  const qa = (sel, root = document) => Array.from(root.querySelectorAll(sel));
335
336  function waitForAny(selectors, timeout = 15000) {
337    // Resolve when ANY selector matches; reject if none appear within timeout
338    return new Promise((resolve, reject) => {
339      const start = performance.now();
340      const check = () => {
341        for (const sel of selectors) {
342          const el = q(sel);
343          if (el) return resolve({ el, selector: sel });
344        }
345        if (performance.now() - start >= timeout)
346          return reject(new Error(`None of selectors appeared: ${selectors.join(', ')}`));
347        requestAnimationFrame(check);
348      };
349      check();
350    });
351  }
352
353  function waitUntilGone(selector, timeout = 10000) {
354    return new Promise((resolve, reject) => {
355      const start = performance.now();
356      const loop = () => {
357        if (!q(selector)) return resolve();
358        if (performance.now() - start >= timeout)
359          return reject(new Error(`"${selector}" did not disappear`));
360        requestAnimationFrame(loop);
361      };
362      loop();
363    });
364  }
365
366  async function fetchBytes(url) {
367    const res = await fetch(url, { credentials: 'include' });
368    if (!res.ok) throw new Error(`Download failed (${res.status})`);
369    const buf = await res.arrayBuffer();
370    return new Uint8Array(buf);
371  }
372
373  // Try hard to find a download link/button in Claude's file viewer modal
374  function findDownloadLink() {
375    // Prefer anchors with href + download-ish text
376    const anchors = qa('a[href]');
377    const dlA = anchors.find(a =>
378      /download|save|export/i.test(a.textContent || '') ||
379      a.getAttribute('download') !== null ||
380      /\.([a-z0-9]{2,5})(\?|$)/i.test(a.getAttribute('href') || '')
381    );
382    if (dlA) return dlA;
383
384    // Some UIs use buttons that wrap an <a> inside, or have aria-label
385    const btns = qa('button, [role="button"]');
386    const dlB = btns.find(b => /download|save|export/i.test(b.textContent || b.getAttribute('aria-label') || ''));
387    if (dlB) {
388      const nestedA = q('a[href]', dlB);
389      if (nestedA) return nestedA;
390    }
391    return null;
392  }
393
394  function safeFileName(name, fallback = 'untitled') {
395    const cleaned = (name || fallback).replace(/[\\/:*?"<>|]/g, '_').trim();
396    return cleaned || fallback;
397  }
398
399  // -------- UI scaffold --------
400  function initializeDownloaderUI() {
401    if (typeof fflate === 'undefined' || typeof saveAs === 'undefined') return;
402    if (q('#downloader-corner-container')) return;
403
404    const ICONS = {
405      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>`,
406      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>`,
407      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>`,
408      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>`,
409      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>`
410    };
411
412    const corner = document.createElement('div');
413    corner.id = 'downloader-corner-container';
414    corner.innerHTML = `<button id="downloader-start-btn" class="downloader-btn"><span class="icon">${ICONS.DOWNLOAD}</span><span>Download project</span></button>`;
415    document.body.appendChild(corner);
416
417    const modal = document.createElement('div');
418    modal.id = 'downloader-modal-container';
419    modal.innerHTML = `
420      <div id="downloader-modal-card">
421        <div id="downloader-main-status"><span class="icon"></span><span class="text"></span></div>
422        <div id="downloader-progress-bar-container"><div class="progress-bar-fill"></div></div>
423        <div id="downloader-detail-status"></div>
424        <button id="downloader-cancel-btn">Cancel</button>
425      </div>
426    `;
427    document.body.appendChild(modal);
428
429    GM_addStyle(`
430      :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)}
431      @keyframes spin{from{transform:rotate(0)}to{transform:rotate(360deg)}}.animate-spin{animation:spin 1s linear infinite}
432      #downloader-corner-container{position:fixed;bottom:25px;right:25px;z-index:9998;display:none}
433      #downloader-corner-container.visible{display:block}
434      .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)}
435      .downloader-btn:hover{transform:translateY(-3px);background:rgba(40,40,40,.95)}
436      .downloader-btn .icon{display:flex;align-items:center;width:20px;height:20px}
437      #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)}
438      #downloader-modal-container.active{opacity:1;pointer-events:auto}
439      #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)}
440      #downloader-main-status{display:flex;align-items:center;gap:16px;font-size:20px;font-weight:600;color:var(--color-text)}
441      #downloader-progress-bar-container{width:100%;height:8px;background:rgba(255,255,255,.08);border-radius:4px;overflow:hidden}
442      .progress-bar-fill{width:0%;height:100%;background:var(--color-progress);transition:width .25s ease-out}
443      #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}
444      #downloader-cancel-btn{border:none;background:transparent;color:var(--color-cancel-text);padding:8px 16px;border-radius:8px;cursor:pointer}
445      #downloader-cancel-btn:hover{background:rgba(255,255,255,.1);color:#fff}
446    `);
447
448    const startBtn = q('#downloader-start-btn');
449    const modalIcon = q('#downloader-main-status .icon', modal);
450    const modalText = q('#downloader-main-status .text', modal);
451    const progressBar = q('.progress-bar-fill', modal);
452    const detail = q('#downloader-detail-status', modal);
453    const cancelBtn = q('#downloader-cancel-btn', modal);
454    let isCancelled = false;
455    let closeTimer = null;
456
457    function animateText(el, t) {
458      if (el.textContent === t) return;
459      el.style.opacity = '0';
460      setTimeout(() => { el.textContent = t; el.style.opacity = '1'; }, 150);
461    }
462
463    function setUI(state, main = '', sub = '', pct = 0) {
464      clearTimeout(closeTimer);
465      if (state === 'idle') { modal.classList.remove('active'); return; }
466      modal.classList.add('active');
467
468      const ICONS2 = {
469        processing: ICONS.SPINNER,
470        zipping: ICONS.SPINNER,
471        success: ICONS.SUCCESS,
472        error: ICONS.ERROR,
473        cancelled: ICONS.CANCEL
474      };
475      modalIcon.innerHTML = ICONS2[state] || '';
476      animateText(modalText, main);
477      animateText(detail, sub);
478      progressBar.style.width = `${pct}%`;
479      cancelBtn.style.display = state === 'processing' ? 'block' : 'none';
480
481      if (state === 'success') closeTimer = setTimeout(() => setUI('idle'), 2500);
482      if (state === 'cancelled') closeTimer = setTimeout(() => setUI('idle'), 1500);
483    }
484
485    cancelBtn.addEventListener('click', () => { isCancelled = true; });
486
487    startBtn.addEventListener('click', async () => {
488      try {
489        isCancelled = false;
490        setUI('processing', 'Preparing…', 'Scanning files…', 0);
491
492        // Find all file tiles/cards in the project Files panel
493        const fileButtons = qa('button.rounded-lg').filter(btn => btn.querySelector('h3'));
494        if (fileButtons.length === 0) throw new Error('No project files found.');
495
496        const collected = []; // {name, bytesUint8Array} or {name, text}
497        for (let i = 0; i < fileButtons.length; i++) {
498          if (isCancelled) throw new Error('cancelled');
499
500          const nameRaw = btnText(fileButtons[i].querySelector('h3')) || `untitled-${i + 1}`;
501          const fileName = safeFileName(nameRaw);
502          setUI('processing', 'Collecting files…', `${i + 1}/${fileButtons.length}: ${fileName}`, (i / fileButtons.length) * 100);
503
504          // Open the viewer
505          fileButtons[i].click();
506
507          // Wait for either: text preview, a "no preview" label, or any download link to appear
508          const candidates = [
509            // typical text preview container(s)
510            'div.whitespace-pre-wrap.break-all.font-mono',
511            'pre, code[class*="language-"]',
512            // "File previews are not supported…" banner/area
513            'div:has(> p), div[role="dialog"] p, [data-testid*="preview"] p',
514            // download link/button
515            'a[href][download], a[href*="."]',
516            'button:has(svg), [role="button"]'
517          ];
518
519          let previewText = null;
520          let fileBytes = null;
521          let usedUrl = null;
522
523          try {
524            await waitForAny(candidates, 12000);
525          } catch (e) {
526            // proceed; some dialogs render slowly, we’ll still attempt download link discovery
527          }
528
529          // 1) Try to read text preview
530          const textEl =
531            q('div.whitespace-pre-wrap.break-all.font-mono') ||
532            q('pre') || q('code[class*="language-"]');
533
534          if (textEl && (textEl.textContent || '').trim().length > 0) {
535            previewText = textEl.textContent;
536          } else {
537            // 2) If no text preview, try to find a download link
538            const link = findDownloadLink();
539            if (link) {
540              const href = link.getAttribute('href');
541              if (href) {
542                try {
543                  fileBytes = await fetchBytes(href);
544                } catch (err) {
545                  // CORS blocked or remote signed URL restricted — fallback to .url shortcut
546                  usedUrl = href;
547                }
548              }
549            } else {
550              // 3) Final fallback: capture the message shown by the dialog
551              const msg = qa('div[role="dialog"] p, [role="dialog"] div, div').map(n => n.textContent || '').find(t => /not supported|no preview/i.test(t));
552              if (msg) previewText = msg.trim();
553            }
554          }
555
556          // Close the viewer (try the “X” close if present)
557          const closeBtn = q('button:has(svg[aria-hidden="true"]), button[aria-label*="Close"], button[title*="Close"]') ||
558                           q('path[d^="M15.1465"]')?.closest('button');
559          if (closeBtn) {
560            closeBtn.click();
561            await waitUntilGone('div[role="dialog"]', 8000).catch(() => {});
562          }
563
564          // Store into our bundle
565          if (fileBytes) {
566            collected.push({ name: fileName, bytes: fileBytes });
567          } else if (previewText != null) {
568            collected.push({ name: fileName, text: previewText });
569          } else if (usedUrl) {
570            // .url (Internet Shortcut) – opens the real file when double-clicked on Windows; fine everywhere as a link placeholder
571            const urlTxt = `[InternetShortcut]\nURL=${usedUrl}\n`;
572            collected.push({ name: fileName + '.url', text: urlTxt });
573          } else {
574            const note = `No preview and no downloadable link were detected for "${fileName}".`;
575            collected.push({ name: fileName + '.txt', text: note });
576          }
577        }
578
579        // Build ZIP
580        setUI('zipping', 'Zipping…', 'Creating ZIP archive…', 100);
581        const filesToZip = {};
582        const encoder = new TextEncoder();
583        for (const f of collected) {
584          if (f.bytes) filesToZip[f.name] = f.bytes;
585          else filesToZip[f.name] = encoder.encode(f.text || '');
586        }
587        const zip = fflate.zipSync(filesToZip, { level: 6 });
588        const blob = new Blob([zip], { type: 'application/zip' });
589        saveAs(blob, 'claude_project_files.zip');
590        setUI('success', 'Done', 'Download complete');
591      } catch (err) {
592        if (err && String(err).toLowerCase().includes('cancelled')) {
593          setUI('cancelled', 'Cancelled', 'Operation aborted', 100);
594        } else {
595          console.error('[Claude Project Downloader] Error:', err);
596          setUI('error', 'Error', err?.message || 'Unknown error', 100);
597        }
598      }
599    });
600
601    // helper to keep the floating button visible only when a project page has files
602    function sentinel() {
603      const visible = !!(q('h2[id^="radix-"]') || q('button.rounded-lg h3'));
604      corner.classList.toggle('visible', visible);
605    }
606    setInterval(sentinel, 1000);
607
608    function btnText(el) { return (el?.textContent || '').trim(); }
609
610    isInitialized = true;
611  }
612
613  // init as the SPA navigates
614  function bootSentinel() {
615    if (!isInitialized) initializeDownloaderUI();
616  }
617  const obs = new MutationObserver(bootSentinel);
618  obs.observe(document.documentElement, { childList: true, subtree: true });
619  bootSentinel();
620})();
621
Claude Project Downloader (NEW) | Robomonkey