Greasy Fork

Greasy Fork is available in English.

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).

当前为 2025-09-10 提交的版本,查看 最新版本

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Greasemonkey 油猴子Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Userscripts ,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

// ==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();
})();