Pawchive Expand All

Adds "Expand All" and "Download All" buttons. Expands thumbnails to full images in a smooth queue with retries; downloads originals sequentially in gallery order with proper numbered filenames.

Du musst eine Erweiterung wie Tampermonkey, Greasemonkey oder Violentmonkey installieren, um dieses Skript zu installieren.

You will need to install an extension such as Tampermonkey to install this script.

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Userscripts to install this script.

You will need to install an extension such as Tampermonkey to install this script.

Sie müssten eine Skript Manager Erweiterung installieren damit sie dieses Skript installieren können

(Ich habe schon ein Skript Manager, Lass mich es installieren!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(I already have a user style manager, let me install it!)

// ==UserScript==
// @name         Pawchive Expand All
// @namespace    pawchive-expand-all
// @version      1.2
// @description  Adds "Expand All" and "Download All" buttons. Expands thumbnails to full images in a smooth queue with retries; downloads originals sequentially in gallery order with proper numbered filenames.
// @author       BlueAnivia
// @match        https://pawchive.pw/*
// @match        https://*.pawchive.pw/*
// @icon         https://www.google.com/s2/favicons?sz=64&domain=pawchive.pw
// @run-at       document-idle
// @license      MIT
// @grant        GM_xmlhttpRequest
// @connect      pawchive.pw
// @connect      *
// ==/UserScript==

(function () {
  'use strict';

  // ---------- config ----------
  const CONCURRENCY = 3;      // how many full images load at once (expand)
  const MAX_RETRIES = 4;      // retry attempts per image
  const RETRY_DELAY = 1500;   // ms base delay between retries (doubles each attempt)
  const LOAD_TIMEOUT = 30000; // ms before a stalled load counts as failed
  const DL_GAP = 400;         // ms pause between saved files (keeps browser download order stable)

  // ---------- state ----------
  let expanded = false;
  let queue = [];
  let active = 0;
  let done = 0;
  let total = 0;
  let btn = null;

  let downloading = false;
  let dlBtn = null;

  // ---------- UI ----------
  const BTN_BASE = {
    position: 'fixed',
    right: '20px',
    zIndex: '99999',
    padding: '10px 16px',
    font: '600 14px/1 system-ui, sans-serif',
    color: '#fff',
    background: '#4a5568',
    border: 'none',
    borderRadius: '8px',
    cursor: 'pointer',
    boxShadow: '0 2px 8px rgba(0,0,0,.35)',
  };

  function styleButton(b) {
    Object.assign(b.style, BTN_BASE);
    b.addEventListener('mouseenter', () => (b.style.background = '#2d3748'));
    b.addEventListener('mouseleave', () => (b.style.background = '#4a5568'));
  }

  function ensureButton() {
    if (!btn || !document.body.contains(btn)) {
      btn = document.createElement('button');
      btn.id = 'pawchive-expand-all-btn';
      btn.textContent = 'Expand all';
      styleButton(btn);
      btn.style.bottom = '64px';
      btn.addEventListener('click', () => {
        if (!expanded) expandAll();
        else collapseAll();
      });
      document.body.appendChild(btn);
      setProgress();
    }
    if (!dlBtn || !document.body.contains(dlBtn)) {
      dlBtn = document.createElement('button');
      dlBtn.id = 'pawchive-download-all-btn';
      dlBtn.textContent = 'Download all';
      styleButton(dlBtn);
      dlBtn.style.bottom = '20px';
      dlBtn.addEventListener('click', () => {
        if (!downloading) downloadAll();
      });
      document.body.appendChild(dlBtn);
    }
  }

  function setProgress() {
    if (!btn) return;
    if (!expanded) {
      btn.textContent = 'Expand all';
    } else if (done < total) {
      btn.textContent = `Loading ${done}/${total}…`;
    } else {
      btn.textContent = 'Collapse all';
    }
  }

  // ---------- helpers ----------
  function getThumbLinks() {
    return [...document.querySelectorAll('a.fileThumb.image-link')].filter((a) =>
      /\.(jpe?g|png|gif|webp|avif)(\?|$)/i.test(a.href)
    );
  }

  // -- gallery title -> safe filename base --
  function getGalleryTitle() {
    const h1 = document.querySelector('h1.post__title');
    let text = '';
    if (h1) {
      const spans = [...h1.querySelectorAll('span')];
      text = spans.length
        ? spans.map((s) => s.textContent.trim()).filter(Boolean).join(' ')
        : h1.textContent;
    }
    text = (text || document.title || 'gallery').replace(/\s+/g, ' ').trim();
    // strip characters that are illegal or troublesome in filenames
    text = text
      .replace(/[\\/:*?"<>|]/g, '-')  // Windows-illegal chars
      .replace(/[\u0000-\u001f]/g, '') // control chars
      .replace(/\s*-\s*/g, ' - ')
      .replace(/\s+/g, ' ')
      .trim()
      .replace(/[. ]+$/, '');          // no trailing dots/spaces
    if (text.length > 150) text = text.slice(0, 150).trim();
    return text || 'gallery';
  }

  function getExtension(url) {
    const m = url.match(/\.(jpe?g|png|gif|webp|avif)(?:\?|$)/i);
    return m ? m[1].toLowerCase().replace('jpeg', 'jpg') : 'jpg';
  }

  function padNum(n, width) {
    return String(n).padStart(width, '0');
  }

  // ---------- expand: load with retry ----------
  function loadWithRetry(anchor, attempt = 0) {
    return new Promise((resolve) => {
      const img = new Image();
      let settled = false;

      const timer = setTimeout(() => {
        if (settled) return;
        settled = true;
        img.src = '';
        fail();
      }, LOAD_TIMEOUT);

      function fail() {
        clearTimeout(timer);
        if (attempt < MAX_RETRIES) {
          const delay = RETRY_DELAY * Math.pow(2, attempt);
          setTimeout(() => loadWithRetry(anchor, attempt + 1).then(resolve), delay);
        } else {
          resolve(null);
        }
      }

      img.onload = () => {
        if (settled) return;
        settled = true;
        clearTimeout(timer);
        resolve(img);
      };
      img.onerror = () => {
        if (settled) return;
        settled = true;
        fail();
      };

      img.src = attempt === 0
        ? anchor.href
        : anchor.href + (anchor.href.includes('?') ? '&' : '?') + '_r=' + attempt;
    });
  }

  function pump() {
    while (active < CONCURRENCY && queue.length) {
      const anchor = queue.shift();
      active++;
      loadWithRetry(anchor).then((img) => {
        active--;
        done++;
        // anchor may have been removed by SPA navigation while loading
        if (img && expanded && document.body.contains(anchor)) {
          const thumb = anchor.querySelector('img[data-src]');
          if (thumb) thumb.style.display = 'none';
          img.style.maxWidth = '100%';
          img.className = 'expanded-full';
          img.style.opacity = '0';
          img.style.transition = 'opacity .25s';
          anchor.appendChild(img);
          requestAnimationFrame(() => (img.style.opacity = '1'));
          anchor.addEventListener('click', collapseClick);
        }
        setProgress();
        pump();
      });
    }
  }

  function collapseClick(e) {
    const anchor = e.currentTarget;
    const full = anchor.querySelector('img.expanded-full');
    if (full) {
      e.preventDefault();
      e.stopPropagation();
      full.remove();
      const thumb = anchor.querySelector('img[data-src]');
      if (thumb) thumb.style.display = '';
      anchor.removeEventListener('click', collapseClick);
    }
  }

  function expandAll() {
    const links = getThumbLinks().filter((a) => !a.querySelector('img.expanded-full'));
    queue = links;
    total = links.length;
    done = 0;
    expanded = true;
    setProgress();
    pump();
  }

  function collapseAll() {
    expanded = false;
    queue = [];
    total = 0;
    done = 0;
    getThumbLinks().forEach((a) => {
      const full = a.querySelector('img.expanded-full');
      if (full) full.remove();
      const thumb = a.querySelector('img[data-src]');
      if (thumb) thumb.style.display = '';
      a.removeEventListener('click', collapseClick);
    });
    setProgress();
  }

  // ---------- download all ----------

  // Fetch the ORIGINAL file (anchor.href = full-size source, not the thumbnail).
  // Prefers GM_xmlhttpRequest (works cross-origin for CDN/image subdomains),
  // falls back to fetch() if the grant isn't available.
  function fetchBlob(url) {
    if (typeof GM_xmlhttpRequest === 'function') {
      return new Promise((resolve, reject) => {
        GM_xmlhttpRequest({
          method: 'GET',
          url,
          responseType: 'blob',
          timeout: LOAD_TIMEOUT,
          onload: (r) => {
            if (r.status >= 200 && r.status < 300 && r.response) resolve(r.response);
            else reject(new Error('HTTP ' + r.status));
          },
          onerror: () => reject(new Error('network error')),
          ontimeout: () => reject(new Error('timeout')),
        });
      });
    }
    return fetch(url, { credentials: 'include' }).then((r) => {
      if (!r.ok) throw new Error('HTTP ' + r.status);
      return r.blob();
    });
  }

  async function fetchBlobWithRetry(url) {
    for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
      const u = attempt === 0
        ? url
        : url + (url.includes('?') ? '&' : '?') + '_r=' + attempt;
      try {
        return await fetchBlob(u);
      } catch (e) {
        if (attempt === MAX_RETRIES) throw e;
        await new Promise((res) => setTimeout(res, RETRY_DELAY * Math.pow(2, attempt)));
      }
    }
    throw new Error('unreachable');
  }

  function saveBlob(blob, filename) {
    const url = URL.createObjectURL(blob);
    const a = document.createElement('a');
    a.href = url;
    a.download = filename;
    a.style.display = 'none';
    document.body.appendChild(a);
    a.click();
    a.remove();
    setTimeout(() => URL.revokeObjectURL(url), 30000);
  }

  async function downloadAll() {
    const links = getThumbLinks(); // DOM order == gallery order
    if (!links.length) {
      dlBtn.textContent = 'Nothing to download';
      setTimeout(() => (dlBtn.textContent = 'Download all'), 2000);
      return;
    }

    downloading = true;
    const title = getGalleryTitle();
    const width = Math.max(2, String(links.length).length); // 01, 02… or 001…
    let failed = 0;

    // Strictly sequential: guarantees files hit the disk in gallery order
    // and the page number in each filename matches its position.
    for (let i = 0; i < links.length; i++) {
      dlBtn.textContent = `Downloading ${i + 1}/${links.length}…`;
      const href = links[i].href; // original full-quality file
      const filename = `${title} - ${padNum(i + 1, width)}.${getExtension(href)}`;
      try {
        const blob = await fetchBlobWithRetry(href);
        saveBlob(blob, filename);
      } catch (e) {
        failed++;
        console.warn(`[Pawchive DL] failed: ${filename}`, e);
      }
      await new Promise((res) => setTimeout(res, DL_GAP));
    }

    downloading = false;
    dlBtn.textContent = failed
      ? `Done (${failed} failed)`
      : 'Done!';
    setTimeout(() => {
      if (!downloading) dlBtn.textContent = 'Download all';
    }, 4000);
  }

  // reset state when we land on a new page/gallery
  function onNavigate() {
    expanded = false;
    queue = [];
    total = 0;
    done = 0;
    downloading = false;
    ensureButton();
    if (dlBtn) dlBtn.textContent = 'Download all';
  }

  // ---------- navigation handling ----------

  // 1. SPA navigation: hook pushState/replaceState + popstate (back/forward)
  let lastUrl = location.href;
  function checkUrl() {
    if (location.href !== lastUrl) {
      lastUrl = location.href;
      // give the SPA a moment to render the new gallery
      setTimeout(onNavigate, 300);
    }
  }

  const origPush = history.pushState;
  history.pushState = function (...args) {
    origPush.apply(this, args);
    checkUrl();
  };
  const origReplace = history.replaceState;
  history.replaceState = function (...args) {
    origReplace.apply(this, args);
    checkUrl();
  };
  window.addEventListener('popstate', checkUrl);

  // 2. bfcache: page restored from back-forward cache without re-running scripts
  window.addEventListener('pageshow', (e) => {
    if (e.persisted) onNavigate();
    else ensureButton();
  });

  // 3. safety net: if the site rebuilds <body> content and nukes the buttons,
  //    a light observer puts them back (also catches URL changes some routers do silently)
  const observer = new MutationObserver(() => {
    checkUrl();
    if (
      !document.getElementById('pawchive-expand-all-btn') ||
      !document.getElementById('pawchive-download-all-btn')
    ) {
      ensureButton();
    }
  });
  observer.observe(document.body, { childList: true, subtree: false });

  ensureButton();
})();