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