GitHub Repo Exporter — Releases · Issues · PRs · Discussions

Export all of a GitHub repo's release notes into a single page, or generate a complete index of every issue, PR & discussion — as HTML or Markdown. No more endless scrolling through paginated release histories. Perfect for feeding a full project overview to an LLM, finding related discussions, or offline reference.

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

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.

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

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

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         GitHub Repo Exporter — Releases · Issues · PRs · Discussions
// @namespace    https://greasyfork.org/en/users/1462137-piknockyou
// @version      5.6.4
// @author       Piknockyou (vibe-coded)
// @license      AGPL-3.0
// @description  Export all of a GitHub repo's release notes into a single page, or generate a complete index of every issue, PR & discussion — as HTML or Markdown. No more endless scrolling through paginated release histories. Perfect for feeding a full project overview to an LLM, finding related discussions, or offline reference.
// @match        *://github.com/*/*
// @icon         https://www.google.com/s2/favicons?sz=64&domain=github.com
// @grant        GM_openInTab
// @grant        GM_getValue
// @grant        GM_setValue
// @require      https://cdn.jsdelivr.net/npm/[email protected]/lib/marked.umd.js
// @run-at       document-idle
// ==/UserScript==

(function () {
  'use strict';

  const SCRIPT_NAME = 'GitHub Repo Lister';
  let lastUrl = '';

  // ════════════════════════════════════════════════════════════════════════════
  // SHARED UTILITIES
  // ════════════════════════════════════════════════════════════════════════════
  function log(...a) { console.log(`[GHExporter]`, ...a); }

  function sanitize(s) {
    return (s || 'export').replace(/·/g, '-').replace(/[<>:"/\\|?*]/g, '_').replace(/\s+/g, ' ').trim().substring(0, 200) || 'export';
  }

  function dlBlob(content, name, mime) {
    const b = new Blob([content], { type: `${mime};charset=utf-8` });
    const u = URL.createObjectURL(b);
    Object.assign(document.createElement('a'), { href: u, download: name }).click();
    URL.revokeObjectURL(u);
  }

  function openBlob(content, mime) {
    const b = new Blob([content], { type: `${mime};charset=utf-8` });
    const u = URL.createObjectURL(b);
    if (typeof GM_openInTab === 'function') GM_openInTab(u, { active: true, setParent: true });
    else window.open(u, '_blank');
    setTimeout(() => URL.revokeObjectURL(u), 15000);
  }

  function repoFullName() {
    const p = location.pathname.split('/').filter(Boolean);
    return p.length >= 2 ? `${p[0]}/${p[1]}` : null;
  }

  function fmtReset(ts) {
    return ts ? new Date(ts * 1000).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' }) : '?';
  }

  function isRepoPage() {
    const p = location.pathname.split('/').filter(Boolean);
    // Must have at least user/repo, and not be a special top-level page
    if (p.length < 2) return false;
    const special = ['settings', 'organizations', 'orgs', 'login', 'signup', 'explore', 'topics',
      'trending', 'collections', 'events', 'sponsors', 'notifications', 'new', 'codespaces', 'marketplace'];
    return !special.includes(p[0]);
  }

  // ── Token ──
  const TOKEN_KEY = 'ghExporterToken';
  function getToken() { return (GM_getValue(TOKEN_KEY, '') || '').trim(); }
  function setToken(t) { GM_setValue(TOKEN_KEY, (t || '').trim()); }

  // ── Prefs (GM storage) ──
  const PREF_KEY = 'ghExporterPrefs';
  function loadPrefs() {
    try { return JSON.parse(GM_getValue(PREF_KEY, '{}'));
    } catch { return {}; }
  }
  function savePrefs(p) { GM_setValue(PREF_KEY, JSON.stringify(p)); }
  function getPref(k, def) { return loadPrefs()[k] ?? def; }
  function setPref(k, v) { const p = loadPrefs(); p[k] = v; savePrefs(p); }

  // ════════════════════════════════════════════════════════════════════════════
  // RELEASES API MODULE
  // ════════════════════════════════════════════════════════════════════════════
  const ReleasesAPI = (() => {
    let html = '', md = '', items = [];
    let running = false, rlRemain = null, rlReset = null;
    let debugLog = [], debugEnabled = false;

    function dbg(msg, data) {
      if (!debugEnabled) return;
      const ts = new Date().toISOString().slice(11, 23);
      const entry = data !== undefined ? `[${ts}] ${msg} ${JSON.stringify(data)}` : `[${ts}] ${msg}`;
      debugLog.push(entry);
      log(msg, data !== undefined ? data : '');
    }

    function setDebug(on) { debugEnabled = on; }
    function getDebugLog() { return debugLog.join('\n'); }
    function clearDebugLog() { debugLog = []; }

    function authHeaders() {
      const h = { Accept: 'application/vnd.github+json' };
      const t = getToken();
      if (t) h.Authorization = `Bearer ${t}`;
      return h;
    }

    function fmtBytes(bytes) {
      if (!bytes || bytes === 0) return '';
      if (bytes < 1024) return bytes + ' B';
      if (bytes < 1048576) return (bytes / 1024).toFixed(1) + ' KB';
      if (bytes < 1073741824) return (bytes / 1048576).toFixed(1) + ' MB';
      return (bytes / 1073741824).toFixed(2) + ' GB';
    }

    async function fetchAll(cb) {
      const repo = repoFullName();
      if (!repo) return false;
      running = true;
      items = [];
      let page = 1;

      while (running) {
        const url = `https://api.github.com/repos/${repo}/releases?per_page=100&page=${page}`;
        dbg(`→ GET ${url}`);
        cb(`Fetching page ${page}…`);

        let res;
        try { res = await fetch(url, { headers: authHeaders() }); }
        catch (err) {
          dbg(`✗ Network error: ${err.message}`);
          running = false;
          return false;
        }

        rlRemain = parseInt(res.headers.get('x-ratelimit-remaining') || '0');
        rlReset = parseInt(res.headers.get('x-ratelimit-reset') || '0');
        dbg(`← HTTP ${res.status} | rate-limit remaining: ${rlRemain} | reset: ${fmtReset(rlReset)}`);

        if (res.status === 403 && rlRemain <= 0) {
          dbg('⚠ Rate limited (403 + remaining=0). Stopping.');
          running = false;
          cb('Rate limited.');
          return false;
        }
        if (!res.ok) {
          const body = await res.text();
          dbg(`✗ Non-OK response. Body:`, body.substring(0, 500));
          running = false;
          return false;
        }

        const data = await res.json();
        if (!Array.isArray(data)) {
          dbg('✗ Response is not an array.', data);
          running = false;
          return false;
        }
        dbg(`  Received ${data.length} releases (page ${page})`);
        items.push(...data);
        cb(`Fetched ${items.length} releases…`);

        if (data.length < 100) break;
        page++;
        await new Promise(r => setTimeout(r, 280));
      }

      running = false;
      if (items.length === 0) { cb('No releases found.'); return false; }
      build();
      cb(`Done · ${items.length} releases`);
      return true;
    }

    function build() {
      const repo = repoFullName() || '?/?';
      buildHtml(repo);
      buildMd(repo);
    }

    function autoLinkRefs(src, repo) {
      if (!src || !repo) return src;
      // GitHub auto-links: #123 → issue/PR link, @user → profile link
      // But don't touch ones inside markdown links [...](#123) or code blocks
      let result = src;
      // Auto-link #123 (issue/PR references) — only when not preceded by [ or ( or `/`
      result = result.replace(/(^|[^[(\w/])#(\d+)\b/gm, (match, prefix, num) => {
        return `${prefix}[#${num}](https://github.com/${repo}/issues/${num})`;
      });
      // Auto-link @username — only simple alphanumeric+hyphen usernames, not emails
      result = result.replace(/(^|[^[(\w/])@([a-zA-Z0-9](?:[a-zA-Z0-9-]*[a-zA-Z0-9])?)\b/gm, (match, prefix, user) => {
        // Skip common non-username patterns
        if (/^(param|media|import|charset|font-face|keyframes|supports|layer)$/i.test(user)) return match;
        return `${prefix}[@${user}](https://github.com/${user})`;
      });
      // Auto-link full SHA hashes (40 hex chars) to commits
      result = result.replace(/(^|[^[(\w/])([0-9a-f]{40})\b/gm, (match, prefix, sha) => {
        return `${prefix}[\`${sha.slice(0, 7)}\`](https://github.com/${repo}/commit/${sha})`;
      });
      // Auto-link cross-repo references: owner/repo#123
      result = result.replace(/(^|[^[(\w])([a-zA-Z0-9._-]+\/[a-zA-Z0-9._-]+)#(\d+)\b/gm, (match, prefix, xrepo, num) => {
        if (xrepo === repo) return match; // Already handled above
        return `${prefix}[${xrepo}#${num}](https://github.com/${xrepo}/issues/${num})`;
      });
      return result;
    }

    function renderMd(src, repo) {
      if (!src) return '';
      try {
        if (typeof marked !== 'undefined') {
          const renderer = typeof marked.parse === 'function' ? marked : marked.marked;
          const linked = autoLinkRefs(src, repo);
          return renderer.parse(linked, { breaks: true, gfm: true });
        }
      } catch (e) { log('marked parse error:', e); }
      // Fallback: escaped pre-wrap
      return `<pre style="white-space:pre-wrap">${escHtml(src)}</pre>`;
    }

    function buildHtml(repo) {
      const title = `Releases — ${repo}`;
      let body = '';

      for (const rel of items) {
        const tag = rel.tag_name || '';
        const name = rel.name || tag;
        const author = rel.author?.login || '';
        const date = rel.published_at ? rel.published_at.slice(0, 10) : '';
        const flags = [rel.draft ? '📝 Draft' : '', rel.prerelease ? '🧪 Pre-release' : ''].filter(Boolean).join(' · ');
        const safeTitle = (name || '').replace(/</g, '&lt;').replace(/>/g, '&gt;');
        const bodyHtml = rel.body ? renderMd(rel.body, repo) : '<em>No release notes.</em>';

        let assetsHtml = '';
        const allAssets = buildAssetList(rel);
        if (allAssets.length > 0) {
          const rows = allAssets.map(a => {
            const sha = a.digest ? a.digest.replace(/^sha256:/i, '') : '';
            const shaShort = sha ? `${sha.slice(0, 8)}…${sha.slice(-6)}` : '';
            const shaCell = sha ? `<code title="${escAttr(sha)}">${shaShort}</code>` : '';
            const dlCount = a.download_count != null ? a.download_count.toLocaleString() : '';
            return `<tr>
              <td>${a.type}</td>
              <td><a href="${a.url}" target="_blank">${escHtml(a.name)}</a></td>
              <td style="text-align:right">${fmtBytes(a.size)}</td>
              <td style="text-align:right">${dlCount}</td>
              <td>${a.uploaded}</td>
              <td>${shaCell}</td>
            </tr>`;
          }).join('');
          assetsHtml = `<details open><summary style="cursor:pointer;font-weight:600;margin:8px 0">Assets (${allAssets.length})</summary>
            <table style="width:100%;border-collapse:collapse;font-size:13px;margin-top:4px">
            <thead><tr style="border-bottom:2px solid #30363d;text-align:left">
              <th style="padding:4px 8px">Type</th><th style="padding:4px 8px">File</th>
              <th style="padding:4px 8px;text-align:right">Size</th>
              <th style="padding:4px 8px;text-align:right">Downloads</th>
              <th style="padding:4px 8px">Uploaded</th><th style="padding:4px 8px">SHA256</th>
            </tr></thead><tbody>${rows}</tbody></table></details>`;
        }

        const reactionsHtml = buildReactionsHtml(rel.reactions);

        body += `<div style="border:1px solid #30363d;border-radius:8px;padding:16px;margin-bottom:16px">
          <h2 style="margin:0 0 4px"><a href="${rel.html_url}" target="_blank" style="color:#58a6ff;text-decoration:none">${safeTitle}</a>
            <code style="font-size:14px;background:#21262d;padding:2px 6px;border-radius:4px;margin-left:8px">${escHtml(tag)}</code></h2>
          <div style="font-size:13px;color:#8b949e;margin-bottom:8px">${[author, date, flags].filter(Boolean).join(' · ')}</div>
          <div class="markdown-body" style="font-size:14px;line-height:1.6;margin-bottom:12px">${bodyHtml}</div>
          ${assetsHtml}${reactionsHtml}
        </div>`;
      }

      html = `<!DOCTYPE html><html lang="en"><head><meta charset="utf-8"><title>${title}</title>
<style>body{font-family:system-ui;margin:40px;background:#0d1117;color:#c9d1d9;line-height:1.5}
a{color:#58a6ff;text-decoration:none}a:hover{text-decoration:underline}
h1{font-size:22px;margin-bottom:16px}
table{border-collapse:collapse}td,th{padding:4px 8px;border-bottom:1px solid #21262d}
tr:hover{background:#161b22}code{font-size:12px;background:#21262d;padding:1px 4px;border-radius:3px}
pre{background:#161b22;border:1px solid #30363d;border-radius:6px;padding:12px;overflow-x:auto;font-size:13px;line-height:1.45}
pre code{background:none;padding:0;font-size:inherit}
.markdown-body h1,.markdown-body h2,.markdown-body h3,.markdown-body h4{margin:16px 0 8px;color:#f0f6fc;border-bottom:1px solid #21262d;padding-bottom:4px}
.markdown-body h1{font-size:1.6em}.markdown-body h2{font-size:1.3em}.markdown-body h3{font-size:1.1em}
.markdown-body ul,.markdown-body ol{padding-left:24px;margin:8px 0}
.markdown-body li{margin:4px 0}
.markdown-body blockquote{border-left:3px solid #30363d;padding-left:12px;color:#8b949e;margin:8px 0}
.markdown-body p{margin:8px 0}
.markdown-body img{max-width:100%;border-radius:6px}
.markdown-body hr{border:none;border-top:1px solid #30363d;margin:16px 0}
.reactions{display:flex;gap:6px;margin-top:8px;font-size:13px}
.reactions span{background:#21262d;padding:2px 8px;border-radius:12px}</style>
</head><body><h1>${title}</h1>${body}</body></html>`;
    }

    function buildMd(repo) {
      const lines = [`# Releases — ${repo}`, ''];

      for (const rel of items) {
        const tag = rel.tag_name || '';
        const name = rel.name || tag;
        const author = rel.author?.login || '';
        const date = rel.published_at ? rel.published_at.slice(0, 10) : '';
        const flags = [rel.draft ? 'Draft' : '', rel.prerelease ? 'Pre-release' : ''].filter(Boolean).join(', ');

        lines.push(`## ${name}${tag ? ` (\`${tag}\`)` : ''}`);
        const meta = [author, date, flags].filter(Boolean).join(' · ');
        if (meta) lines.push(`*${meta}*`);
        lines.push('');

        if (rel.body) {
          lines.push(rel.body.trim());
          lines.push('');
        }

        const allAssets = buildAssetList(rel);
        if (allAssets.length > 0) {
          lines.push('### Assets', '');
          lines.push('| Type | File | Size | Downloads | Uploaded | SHA256 |');
          lines.push('|:-----|:-----|-----:|----------:|:---------|:-------|');

          for (const a of allAssets) {
            const sha = a.digest ? a.digest.replace(/^sha256:/i, '') : '';
            const shaShort = sha ? `${sha.slice(0, 8)}…${sha.slice(-6)}` : '';
            const shaCell = sha ? `\`${shaShort}\`` : '';
            const dlCount = a.download_count != null ? a.download_count.toLocaleString() : '';
            const fileCell = a.url ? `[${a.name}](${a.url})` : a.name;
            lines.push(`| ${a.type} | ${fileCell} | ${fmtBytes(a.size)} | ${dlCount} | ${a.uploaded} | ${shaCell} |`);
          }
          lines.push('');
        }

        // Reactions
        if (rel.reactions && rel.reactions.total_count > 0) {
          const emojis = { '+1': '👍', '-1': '👎', laugh: '😄', hooray: '🎉', confused: '😕', heart: '❤️', rocket: '🚀', eyes: '👀' };
          const parts = Object.entries(emojis).filter(([k]) => rel.reactions[k] > 0).map(([k, e]) => `${e} ${rel.reactions[k]}`);
          if (parts.length) lines.push(`Reactions: ${parts.join(' · ')}`, '');
        }

        lines.push('---', '');
      }

      md = lines.join('\n');
    }

    function buildAssetList(rel) {
      const assets = [];

      // Uploaded assets
      for (const a of (rel.assets || [])) {
        assets.push({
          type: guessAssetType(a.name),
          name: a.name,
          size: a.size,
          download_count: a.download_count,
          uploaded: a.created_at ? a.created_at.slice(0, 10) : '',
          digest: a.digest || null,
          url: a.browser_download_url || '',
        });
      }

      // Source archives
      if (rel.zipball_url) {
        assets.push({ type: 'Source', name: `Source code (zip)`, size: null, download_count: null, uploaded: '', digest: null, url: rel.zipball_url });
      }
      if (rel.tarball_url) {
        assets.push({ type: 'Source', name: `Source code (tar.gz)`, size: null, download_count: null, uploaded: '', digest: null, url: rel.tarball_url });
      }

      return assets;
    }

    function guessAssetType(name) {
      const n = name.toLowerCase();
      if (/\.(sig|asc|gpg)$/i.test(n)) return 'Signature';
      if (/sha\d*sum|checksum|\.sha\d+$/i.test(n)) return 'Checksum';
      if (/\.sigstore$/i.test(n)) return 'Attestation';
      if (/\.(exe|msi|msix)$/i.test(n)) return 'Installer';
      if (/\.(dmg|pkg)$/i.test(n)) return 'macOS';
      if (/\.(deb|rpm|appimage|snap|flatpak)$/i.test(n)) return 'Linux';
      if (/\.(tar\.gz|tar\.bz2|tar\.xz|tgz|zip|7z|rar)$/i.test(n)) return 'Archive';
      if (/\.(apk|aab|ipa)$/i.test(n)) return 'Mobile';
      return 'Asset';
    }

    function escHtml(s) { return (s || '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;'); }
    function escAttr(s) { return (s || '').replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;').replace(/>/g, '&gt;'); }

    function buildReactionsHtml(reactions) {
      if (!reactions || !reactions.total_count) return '';
      const emojis = { '+1': '👍', '-1': '👎', laugh: '😄', hooray: '🎉', confused: '😕', heart: '❤️', rocket: '🚀', eyes: '👀' };
      const parts = Object.entries(emojis).filter(([k]) => reactions[k] > 0).map(([k, e]) => `<span>${e} ${reactions[k]}</span>`);
      return parts.length ? `<div class="reactions">${parts.join('')}</div>` : '';
    }

    function cancel() { running = false; }

    function state() {
      return { count: items.length, running, rlRemain, rlReset, hasData: html.length > 0, finished: !running && items.length > 0 };
    }

    return {
      fetchAll, cancel, setDebug, getDebugLog, clearDebugLog, state,
      getHtml: () => html, getMd: () => md,
      hasData: () => html.length > 0,
      reset: () => { items = []; html = ''; md = ''; running = false; rlRemain = null; rlReset = null; },
    };
  })();



  // ════════════════════════════════════════════════════════════════════════════
  // ISSUES / PRs / DISCUSSIONS MODULE
  // ════════════════════════════════════════════════════════════════════════════
  const Issues = (() => {
    let items = [], lowest = Infinity, highest = 0, running = false;
    let rlRemain = null, rlReset = null, curPage = 1;
    let rangeFilter = null;
    let html = '', md = '';
    let rateLimited = false, gqlBatchIndex = 0, discussionCursor = null;

    const CFG = { perPage: 100, delayMs: 280, maxPages: 500 };
    let debugLog = [];
    let debugEnabled = false;

    function dbg(msg, data) {
      if (!debugEnabled) return;
      const ts = new Date().toISOString().slice(11, 23);
      const entry = data !== undefined ? `[${ts}] ${msg} ${JSON.stringify(data)}` : `[${ts}] ${msg}`;
      debugLog.push(entry);
      log(msg, data !== undefined ? data : '');
    }

    function setDebug(on) { debugEnabled = on; }
    function getDebugLog() { return debugLog.join('\n'); }
    function clearDebugLog() { debugLog = []; }

    function authHeaders() {
      const h = { Accept: 'application/vnd.github+json' };
      const t = getToken();
      if (t) h.Authorization = `Bearer ${t}`;
      return h;
    }

    function apiUrl(page, type) {
      const r = repoFullName();
      if (!r) return null;
      const [o, n] = r.split('/');
      const endpoint = type === 'pulls' ? 'pulls' : 'issues';
      const u = new URL(`https://api.github.com/repos/${o}/${n}/${endpoint}`);
      u.searchParams.set('state', 'all');
      u.searchParams.set('per_page', CFG.perPage);
      u.searchParams.set('page', page);
      u.searchParams.set('sort', 'created');
      u.searchParams.set('direction', 'asc');
      return u.toString();
    }

    function build(opts) {
      const repo = repoFullName() || '?/?';
      const selParts = [];
      if (!opts || opts.issues) selParts.push('Issues');
      if (!opts || opts.prs) selParts.push('Pull Requests');
      if (!opts || opts.discussions) selParts.push('Discussions');
      const selLabel = selParts.length === 3 ? 'Issues, Pull Requests & Discussions'
        : selParts.length === 2 ? `${selParts[0]} & ${selParts[1]}`
        : selParts[0] || 'Items';
      const title = `${selLabel} Index — ${repo}`;

      const typed = items.map(i => {
        if (i.pull_request) return { ...i, _k: 'pr', _l: 'Pull Request' };
        if (i.discussion) return { ...i, _k: 'discussion', _l: 'DISCUSSION' };
        return { ...i, _k: 'issue', _l: 'ISSUE' };
      }).sort((a, b) => a.number - b.number);

      const c = { issue: 0, pr: 0, discussion: 0 };
      typed.forEach(i => c[i._k]++);
      const summary = `Total: ${items.length} (Issues: ${c.issue} · Pull Requests: ${c.pr} · Discussions: ${c.discussion})`;

      // HTML
      const rows = typed.map(i => {
        const isDsc = i._k === 'discussion';
        const st = isDsc
          ? (i.isAnswered ? { l: 'ANSWERED', c: 'closed' } : { l: 'OPEN', c: 'open' })
          : i.state === 'open' ? { l: 'OPEN', c: 'open' }
          : i.state_reason === 'not_planned' ? { l: 'NOT PLANNED', c: 'not-planned' }
          : { l: 'CLOSED', c: 'closed' };
        const labels = (i.labels || []).map(l => typeof l === 'object' ? l.name : l).join(', ');
        const meta = [i._l, st.l,
          `${new Date(i.created_at).toISOString().slice(0, 10)}`,
          labels ? labels : ''
        ].filter(Boolean).join(' · ');
        const safe = (i.title || '').replace(/</g, '&lt;').replace(/>/g, '&gt;');
        return `<li class="item ${st.c} ${i._k}"><a href="${i.html_url}" target="_blank">#${i.number} · ${safe}</a><div class="meta">${meta}</div></li>`;
      }).join('');

      html = `<!DOCTYPE html><html lang="en"><head><meta charset="utf-8"><title>${title}</title>
<style>body{font-family:system-ui;margin:40px;background:#0d1117;color:#c9d1d9;line-height:1.5}
a{color:#58a6ff;text-decoration:none}h1{font-size:22px;margin-bottom:8px}
.summary{color:#8b949e;margin-bottom:20px}ul{list-style:none;padding:0}
li{padding:10px 0;border-bottom:1px solid #30363d}.meta{font-size:13px;color:#8b949e;margin-top:4px}
.open a{color:#3fb950}.pr a{color:#58a6ff}.discussion a{color:#8957e5}
.closed a,.not-planned a{color:#f85149}
.note{background:#161b22;border:1px solid #30363d;border-radius:6px;padding:10px 14px;margin-bottom:16px;font-size:12px;color:#8b949e}</style></head><body>
<h1>${title}</h1>
<div class="note">ℹ️ This is an index listing — titles, status, and links only. Full content (descriptions, comments, diffs) is not included.</div>
<div class="summary">Repository: <strong>${repo}</strong><br>${summary}</div>
<ul>${rows}</ul></body></html>`;

      // MD
      const mdl = [`# ${title}`, '', `> ℹ️ This is an index listing — titles, status, and links only. Full content (descriptions, comments, diffs) is not included.`, '', `Repository: \`${repo}\``, `${summary}`, ''];
      typed.forEach(i => {
        const isDsc = i._k === 'discussion';
        const stLabel = isDsc ? (i.isAnswered ? 'ANSWERED' : 'OPEN')
          : i.state === 'open' ? 'OPEN'
          : i.state_reason === 'not_planned' ? 'NOT PLANNED' : 'CLOSED';
        mdl.push(`- [#${i.number} ${i.title}](${i.html_url}) — ${i._l} · **${stLabel}**`);
      });
      mdl.push('');
      md = mdl.join('\n');
    }

    async function fetchIssuesPRs(cb, fetchIssues, fetchPRs) {
      // When a range is set and token available, use GraphQL batch (exact, no pagination waste)
      if (rangeFilter && getToken()) {
        dbg('Range + token detected → using GraphQL batch lookup');
        await fetchByGraphQL(cb, fetchIssues, fetchPRs);
        return;
      }
      if (rangeFilter && !getToken()) {
        dbg('Range set but no token → falling back to REST (GraphQL requires auth)');
      }

      // REST approach
      const endpoints = [];
      if (fetchIssues && fetchPRs) {
        endpoints.push({ type: 'issues', wantIssues: true, wantPRs: true });
      } else if (fetchIssues) {
        endpoints.push({ type: 'issues', wantIssues: true, wantPRs: false });
      } else if (fetchPRs) {
        endpoints.push({ type: 'pulls', wantIssues: false, wantPRs: true });
      }

      for (const ep of endpoints) {
        let page = ep.type === 'pulls' ? 1 : curPage;
        let emptyStreak = 0;
        const maxEmptyPages = 3;

        dbg(`Endpoint: /${ep.type} | wantIssues=${ep.wantIssues} wantPRs=${ep.wantPRs}`);

        while (running && page <= CFG.maxPages) {
          const url = apiUrl(page, ep.type);
          cb();
          dbg(`→ GET ${url}`);
          let res;
          try { res = await fetch(url, { headers: authHeaders(), cache: 'no-store' }); }
          catch (err) {
            dbg(`✗ Network error: ${err.message}`);
            await new Promise(r => setTimeout(r, 5000));
            continue;
          }

          rlRemain = parseInt(res.headers.get('x-ratelimit-remaining') || '0');
          rlReset = parseInt(res.headers.get('x-ratelimit-reset') || '0');
          dbg(`← HTTP ${res.status} | rate-limit remaining: ${rlRemain} | reset: ${fmtReset(rlReset)}`);

          if (res.status === 403 && rlRemain <= 0) {
            dbg('⚠ Rate limited (403 + remaining=0). Pausing — resume after reset.');
            rateLimited = true;
            curPage = page; // Save position for resume
            running = false; cb(); return;
          }
          if (!res.ok) {
            const body = await res.text();
            dbg(`✗ Non-OK response. Body:`, body.substring(0, 500));
            break;
          }

          const data = await res.json();
          if (!Array.isArray(data)) {
            dbg('✗ Response is not an array. Stopping.', data);
            break;
          }
          dbg(`  Received ${data.length} items (page ${page})`);

          let added = 0, skipped = { dup: 0, range: 0, filter: 0 };
          for (const i of data) {
            if (items.some(e => e.number === i.number)) { skipped.dup++; continue; }
            if (!inRange(i.number)) { skipped.range++; continue; }
            const isPR = !!i.pull_request;
            if (ep.type === 'issues') {
              if (isPR && !ep.wantPRs) { skipped.filter++; continue; }
              if (!isPR && !ep.wantIssues) { skipped.filter++; continue; }
            }
            if (ep.type === 'pulls' && !i.pull_request) {
              i.pull_request = { url: i.url };
            }
            items.push(i);
            if (i.number < lowest) lowest = i.number;
            if (i.number > highest) highest = i.number;
            added++;
          }

          let pageLowest = Infinity;
          for (const i of data) { if (i.number < pageLowest) pageLowest = i.number; }

          dbg(`  Added: ${added} | Skipped — dup: ${skipped.dup}, range: ${skipped.range}, filter: ${skipped.filter} | page lowest: #${pageLowest}`);

          if (added === 0 && data.length > 0 && skipped.dup === data.length) {
            dbg('  All items were duplicates. Stopping.');
            break;
          }

          if (added === 0 && data.length > 0) {
            emptyStreak++;
            dbg(`  0 items matched (streak: ${emptyStreak}/${maxEmptyPages})`);
            if (emptyStreak >= maxEmptyPages) {
              dbg('  Stopping: too many consecutive empty pages.');
              break;
            }
          } else {
            emptyStreak = 0;
          }

          build(fetchOpts);
          cb();
          if (data.length < CFG.perPage) break;
          page++;
          if (ep.type !== 'pulls') curPage = page;
          await new Promise(r => setTimeout(r, CFG.delayMs));
        }
      }
    }

    async function fetchByGraphQL(cb, fetchIssues, fetchPRs) {
      const numbers = [];
      for (const [lo, hi] of rangeFilter) {
        for (let n = lo; n <= hi; n++) numbers.push(n);
      }
      dbg(`GraphQL batch: ${numbers.length} numbers to look up (#${numbers[0]}–#${numbers[numbers.length - 1]}), starting at index ${gqlBatchIndex}`);

      const repo = repoFullName();
      if (!repo) return;
      const [owner, name] = repo.split('/');
      const batchSize = 50;

      for (let i = gqlBatchIndex; i < numbers.length && running; i += batchSize) {
        const batch = numbers.slice(i, i + batchSize);
        const batchNum = Math.floor(i / batchSize) + 1;
        const totalBatches = Math.ceil(numbers.length / batchSize);
        cb(`GraphQL batch ${batchNum}/${totalBatches} (#${batch[0]}–#${batch[batch.length - 1]})…`);

        const fields = batch.map(n =>
          `i${n}: issueOrPullRequest(number: ${n}) {
            __typename
            ... on Issue { number title state stateReason url createdAt updatedAt labels(first:20) { nodes { name } } }
            ... on PullRequest { number title state url createdAt updatedAt labels(first:20) { nodes { name } } }
          }`
        ).join('\n');

        const query = `{ repository(owner: "${owner}", name: "${name}") {\n${fields}\n} }`;
        dbg(`→ POST /graphql (batch ${batchNum}/${totalBatches}: #${batch[0]}–#${batch[batch.length - 1]})`);

        let res;
        try {
          res = await fetch('https://api.github.com/graphql', {
            method: 'POST',
            headers: authHeaders(),
            body: JSON.stringify({ query }),
          });
        } catch (err) {
          dbg(`✗ Network error: ${err.message}`);
          await new Promise(r => setTimeout(r, 5000));
          continue;
        }

        rlRemain = parseInt(res.headers.get('x-ratelimit-remaining') || '0');
        rlReset = parseInt(res.headers.get('x-ratelimit-reset') || '0');
        dbg(`← HTTP ${res.status} | rate-limit remaining: ${rlRemain} | reset: ${fmtReset(rlReset)}`);

        if (res.status === 403 && rlRemain <= 0) {
          dbg('⚠ Rate limited. Pausing — resume after reset.');
          gqlBatchIndex = i; // Save position for resume
          rateLimited = true;
          running = false; cb(); return;
        }
        if (!res.ok) {
          const body = await res.text();
          dbg(`✗ Non-OK response. Body:`, body.substring(0, 500));
          break;
        }

        const j = await res.json();
        if (j.errors) {
          const realErrors = j.errors.filter(e => e.type !== 'NOT_FOUND');
          if (realErrors.length) dbg('✗ GraphQL errors:', realErrors);
          const notFound = j.errors.filter(e => e.type === 'NOT_FOUND').length;
          if (notFound) dbg(`  ${notFound} numbers not found (gaps/deleted)`);
        }

        const repoData = j.data?.repository || {};
        let added = 0, skippedType = 0;
        for (const key of Object.keys(repoData)) {
          const item = repoData[key];
          if (!item) continue;

          const isIssue = item.__typename === 'Issue';
          const isPR = item.__typename === 'PullRequest';
          if (isIssue && !fetchIssues) { skippedType++; continue; }
          if (isPR && !fetchPRs) { skippedType++; continue; }
          if (items.some(e => e.number === item.number)) continue;

          const normalized = {
            number: item.number,
            title: item.title,
            state: item.state === 'OPEN' ? 'open' : 'closed',
            state_reason: item.stateReason === 'NOT_PLANNED' ? 'not_planned' : null,
            html_url: item.url,
            created_at: item.createdAt,
            updated_at: item.updatedAt,
            labels: (item.labels?.nodes || []).map(l => ({ name: l.name })),
          };
          if (isPR) normalized.pull_request = { url: item.url };

          items.push(normalized);
          if (item.number < lowest) lowest = item.number;
          if (item.number > highest) highest = item.number;
          added++;
        }

        dbg(`  Added: ${added} | Skipped (type filter): ${skippedType}`);
        gqlBatchIndex = i + batchSize; // Track progress
        build(fetchOpts);
        cb();
        await new Promise(r => setTimeout(r, CFG.delayMs));
      }
    }

    async function fetchDiscussions(cb) {
      const r = repoFullName();
      if (!r) return;
      const [owner, name] = r.split('/');

      let after = discussionCursor;
      dbg(`Starting discussions fetch for ${owner}/${name} (GraphQL)${after ? ` resuming from cursor` : ''}`);

      do {
        const q = `{repository(owner:"${owner}",name:"${name}"){discussions(first:${CFG.perPage}${after ? `,after:"${after}"` : ''}){pageInfo{endCursor hasNextPage}edges{node{number title url createdAt updatedAt isAnswered labels(first:100){edges{node{name}}}}}}}}`;
        dbg(`→ POST /graphql (discussions, after=${after || 'null'})`);
        let res;
        try { res = await fetch('https://api.github.com/graphql', { method: 'POST', headers: authHeaders(), body: JSON.stringify({ query: q }) }); }
        catch (err) {
          dbg(`✗ Network error: ${err.message}`);
          await new Promise(r => setTimeout(r, 5000));
          continue;
        }

        rlRemain = parseInt(res.headers.get('x-ratelimit-remaining') || '0');
        rlReset = parseInt(res.headers.get('x-ratelimit-reset') || '0');
        dbg(`← HTTP ${res.status} | rate-limit remaining: ${rlRemain} | reset: ${fmtReset(rlReset)}`);

        if (res.status === 403 && rlRemain <= 0) {
          dbg('⚠ Rate limited (403 + remaining=0). Pausing — resume after reset.');
          discussionCursor = after; // Save cursor for resume
          rateLimited = true;
          running = false; cb(); return;
        }
        if (!res.ok) {
          const body = await res.text();
          dbg(`✗ Non-OK response. Body:`, body.substring(0, 500));
          break;
        }

        const j = await res.json();
        if (j.errors) {
          dbg('✗ GraphQL errors:', j.errors);
          break;
        }
        const edges = j.data?.repository?.discussions?.edges || [];
        dbg(`  Received ${edges.length} discussions`);

        for (const e of edges) {
          const i = e.node;
          i.html_url = i.url; i.discussion = true; i.created_at = i.createdAt; i.updated_at = i.updatedAt;
          i.labels = i.labels.edges.map(x => x.node.name);
          if (items.some(x => x.number === i.number)) continue;
          if (!inRange(i.number)) continue;
          items.push(i);
          if (i.number < lowest) lowest = i.number;
          if (i.number > highest) highest = i.number;
        }
        build(fetchOpts); cb();
        after = j.data?.repository?.discussions?.pageInfo?.endCursor;
        discussionCursor = after; // Track cursor for resume
        if (!j.data?.repository?.discussions?.pageInfo?.hasNextPage) break;
        await new Promise(r => setTimeout(r, CFG.delayMs));
      } while (running && after);
    }

    let fetchOpts = {};

    async function run(cb, opts) {
      fetchOpts = opts;
      const resuming = rateLimited && items.length > 0;
      rateLimited = false;
      running = true;
      dbg(resuming ? '═══ RESUMING EXPORT ═══' : '═══ EXPORT START ═══');
      dbg('Options:', { issues: opts.issues, prs: opts.prs, discussions: opts.discussions });
      dbg('Range filter:', rangeFilter ? JSON.stringify(rangeFilter) : 'none (all items)');
      dbg('Token:', getToken() ? 'set' : 'not set');
      dbg('Repo:', repoFullName());
      if (resuming) dbg(`Resuming with ${items.length} items already fetched (page ${curPage}, gqlBatch ${gqlBatchIndex})`);
      cb();
      if (opts.issues || opts.prs) await fetchIssuesPRs(cb, opts.issues, opts.prs);
      if (running && opts.discussions) await fetchDiscussions(cb);
      running = false; build(fetchOpts);
      dbg(`═══ EXPORT DONE · ${items.length} items ═══`);
      cb();
    }

    function cancel() { running = false; }

    function setRange(rangeStr) {
      rangeFilter = parseRange(rangeStr);
      if (debugEnabled) dbg('Range set:', rangeFilter ? JSON.stringify(rangeFilter) : 'none');
    }

    function parseRange(str) {
      if (str === null || str === undefined || !String(str).trim()) return null;
      const parts = String(str).split(',').map(s => s.trim()).filter(Boolean);
      if (parts.length === 0) return null;
      const ranges = [];
      for (const part of parts) {
        const m = part.match(/^(\d+)\s*-\s*(\d+)$/);
        if (m) {
          const lo = parseInt(m[1], 10);
          const hi = parseInt(m[2], 10);
          if (!isNaN(lo) && !isNaN(hi)) ranges.push([Math.min(lo, hi), Math.max(lo, hi)]);
        } else {
          const n = parseInt(part, 10);
          if (!isNaN(n) && n > 0) ranges.push([n, n]);
        }
      }
      return ranges.length > 0 ? ranges : null;
    }

    function inRange(num) {
      if (!rangeFilter) return true;
      return rangeFilter.some(([lo, hi]) => num >= lo && num <= hi);
    }

    function state() {
      return { count: items.length, lowest, highest, running, rlRemain, rlReset, hasData: html.length > 0, finished: !running && items.length > 0, rateLimited };
    }

    function fileSlug() {
      const parts = [];
      if (!fetchOpts || (!fetchOpts.issues && !fetchOpts.prs && !fetchOpts.discussions)) {
        parts.push('items');
      } else {
        if (fetchOpts.issues) parts.push('issues');
        if (fetchOpts.prs) parts.push('prs');
        if (fetchOpts.discussions) parts.push('discussions');
      }
      return parts.join('-');
    }

    return {
      run, cancel, setRange, setDebug, getDebugLog, clearDebugLog, state, fileSlug,
      getHtml: () => html, getMd: () => md,
      isRateLimited: () => rateLimited,
      reset: () => { items = []; lowest = Infinity; highest = 0; html = ''; md = ''; curPage = 1; rangeFilter = null; rlRemain = null; rlReset = null; fetchOpts = {}; rateLimited = false; gqlBatchIndex = 0; discussionCursor = null; },
    };
  })();

  // ════════════════════════════════════════════════════════════════════════════
  // UI MODULE
  // ════════════════════════════════════════════════════════════════════════════
  const UI = (() => {
    const ID = 'ghe-root';

    function injectCSS() {
      if (document.getElementById('ghe-css')) return;
      const s = document.createElement('style');
      s.id = 'ghe-css';
      s.textContent = `
#ghe-root{position:fixed;bottom:20px;right:20px;z-index:999999;font-size:13px}
#ghe-toggle{background:#238636;color:#fff;padding:8px 12px;border-radius:8px;cursor:pointer;box-shadow:0 4px 12px rgba(0,0,0,.4);user-select:none;font-weight:600;font-size:13px}
#ghe-panel{background:#0d1117;border:1px solid #30363d;border-radius:8px;padding:8px;min-width:340px;max-width:90vw;box-shadow:0 8px 32px rgba(0,0,0,.6);color:#c9d1d9;display:none;max-height:calc(100vh - 40px);overflow-y:auto;box-sizing:border-box;color-scheme:dark}
#ghe-panel input[type="checkbox"]{accent-color:#238636}
.ghe-hdr{display:flex;justify-content:space-between;align-items:center;margin-bottom:8px;font-size:13px}
.ghe-close{cursor:pointer;color:#8b949e;font-size:16px;padding:2px 6px}
.ghe-sec{margin-bottom:6px;padding:10px 12px;border:1px solid #30363d;border-radius:6px}
.ghe-sec h3{margin:0 0 6px;font-size:13px;color:#f0f6fc}
.ghe-btn{padding:5px 8px;border:none;border-radius:5px;cursor:pointer;color:#fff;font-size:12px;white-space:nowrap}
.ghe-btn:disabled{opacity:.4;cursor:not-allowed}
.ghe-g{background:#238636}.ghe-b{background:#1f6feb}.ghe-p{background:#8957e5}.ghe-gr{background:#6e7781}
.ghe-row{display:flex;gap:3px;margin-top:6px;flex-wrap:wrap}
.ghe-row .ghe-btn{flex:1;text-align:center;min-width:0}
.ghe-st{color:#8b949e;font-size:11px;margin-top:4px;min-height:1.4em}
.ghe-warn{color:#f85149}
.ghe-inp{width:60px;padding:3px;background:#21262d;border:1px solid #30363d;border-radius:4px;color:#fff;font-size:12px}
.ghe-fw{width:100%}
.ghe-chk{display:flex;flex-wrap:wrap;gap:4px 10px;margin-bottom:6px}
.ghe-chk label{display:flex;align-items:center;gap:4px;font-size:12px;color:#c9d1d9;cursor:pointer;white-space:nowrap}
.ghe-chk input{margin:0;cursor:pointer}
#ghe-panel.ghe-light{background:#ffffff;border-color:#d0d7de;color:#1f2328;color-scheme:light}
#ghe-panel.ghe-light .ghe-sec{border-color:#d0d7de}
#ghe-panel.ghe-light .ghe-sec h3{color:#1f2328}
#ghe-panel.ghe-light .ghe-st{color:#656d76}
#ghe-panel.ghe-light .ghe-close{color:#656d76}
#ghe-panel.ghe-light .ghe-inp{background:#f6f8fa;border-color:#d0d7de;color:#1f2328}
#ghe-panel.ghe-light .ghe-chk label{color:#1f2328}
#ghe-panel.ghe-light .ghe-hdr strong{color:#1f2328}
#ghe-panel.ghe-light #ghe-token-det summary{color:#656d76}
#ghe-panel.ghe-light a{color:#0969da}
#ghe-panel.ghe-light textarea{background:#f6f8fa!important;color:#1f2328!important;border-color:#d0d7de!important}
#ghe-panel.ghe-light code{background:#eaeef2!important;color:#1f2328!important}
#ghe-panel.ghe-light #ghe-token-inp{background:#f6f8fa!important;border-color:#d0d7de!important;color:#1f2328!important}
#ghe-panel.ghe-light .ghe-btn.ghe-gr{background:#d0d7de!important;color:#1f2328!important}
`;
      document.head.appendChild(s);
    }

    function create() {
      remove();
      injectCSS();

      const root = document.createElement('div');
      root.id = ID;

      const toggle = document.createElement('div');
      toggle.id = 'ghe-toggle';
      toggle.textContent = '📋 Repo Lister';

      const panel = document.createElement('div');
      panel.id = 'ghe-panel';

      panel.innerHTML = `
<div class="ghe-hdr"><strong style="font-size:12px">${SCRIPT_NAME}</strong><div style="display:flex;align-items:center;gap:4px"><button id="ghe-theme" class="ghe-btn ghe-gr" style="font-size:11px;padding:2px 6px" title="Toggle light/dark mode">🌙</button><a id="ghe-kofi" href="https://ko-fi.com/piknockyou" target="_blank" rel="noopener noreferrer" class="ghe-btn ghe-gr" style="font-size:11px;padding:2px 6px;text-decoration:none;color:#fff" title="Support this script on Ko-Fi">☕</a><span class="ghe-close" id="ghe-x">✕</span></div></div>
<div style="font-size:11px;color:#8b949e;margin-bottom:6px">${repoFullName() || ''}</div>

<div class="ghe-sec" style="padding:8px 12px">
  <details id="ghe-token-det">
    <summary style="cursor:pointer;font-size:12px;color:#8b949e;user-select:none">🔑 GitHub Token <span id="ghe-token-badge" style="font-size:11px"></span></summary>
    <div style="margin-top:6px">
      <div style="font-size:11px;color:#8b949e;margin-bottom:4px">Required for Discussions & higher rate limits (60→5k/hr).<br>
      <a href="https://github.com/settings/tokens" target="_blank" style="color:#58a6ff">Settings → Tokens</a> · scope: <code style="background:#21262d;padding:1px 4px;border-radius:3px;font-size:11px">public_repo</code></div>
      <div style="display:flex;gap:4px;align-items:center">
        <input id="ghe-token-inp" type="password" placeholder="ghp_…" style="flex:1;padding:4px 6px;background:#21262d;border:1px solid #30363d;border-radius:4px;color:#c9d1d9;font-family:monospace;font-size:12px" value="${getToken() ? '••••••••' : ''}">
        <button id="ghe-token-save" class="ghe-btn ghe-g" style="font-size:11px;padding:4px 8px">Save</button>
        <button id="ghe-token-clear" class="ghe-btn ghe-gr" style="font-size:11px;padding:4px 8px">Clear</button>
      </div>
      <div id="ghe-token-st" class="ghe-st" style="margin-top:4px"></div>
    </div>
  </details>
</div>

<div class="ghe-sec" id="ghe-rel-sec">
  <h3>📦 Releases</h3>
  <div id="ghe-rel-st" class="ghe-st" style="display:flex;justify-content:space-between;align-items:center"><span id="ghe-rel-st-txt">No data yet.</span><label style="display:flex;align-items:center;gap:3px;font-size:11px;color:#8b949e;cursor:pointer;white-space:nowrap;flex-shrink:0"><input type="checkbox" id="ghe-rel-dbg" ${getPref('relDebug', false) ? 'checked' : ''} style="margin:0;cursor:pointer"> 🪵 Log</label></div>
  <div id="ghe-rel-rl" class="ghe-st">Rate limit: not checked yet.</div>
  <div id="ghe-rel-dbg-wrap" style="display:none;margin-top:4px">
    <textarea id="ghe-rel-dbg-log" readonly style="width:100%;height:100px;background:#161b22;color:#8b949e;border:1px solid #30363d;border-radius:4px;font-family:monospace;font-size:10px;padding:4px;resize:vertical;box-sizing:border-box"></textarea>
    <div class="ghe-row" style="margin-top:6px"><button id="ghe-rel-dbg-clear" class="ghe-btn ghe-gr" style="font-size:11px">Clear</button></div>
  </div>
  <button id="ghe-rel-go" class="ghe-btn ghe-g ghe-fw" style="margin-top:6px">Start</button>
  <div class="ghe-row" style="margin-top:6px">
    <button id="ghe-rel-open" class="ghe-btn ghe-b" title="Open list in new tab" disabled>🔗 HTML</button>
    <button id="ghe-rel-save-h" class="ghe-btn ghe-b" title="Save list as HTML" disabled>💾 HTML</button>
    <button id="ghe-rel-save-m" class="ghe-btn ghe-p" title="Save list as Markdown" disabled>💾 MD</button>
  </div>
</div>

<div class="ghe-sec" id="ghe-iss-sec">
  <div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:6px">
    <h3 style="margin:0">📋 Issues · Pull Requests · Discussions</h3>
  </div>
  <div class="ghe-chk">
    <label><input type="checkbox" id="ghe-chk-iss" ${getPref('issues', true) ? 'checked' : ''}> Issues</label>
    <label><input type="checkbox" id="ghe-chk-pr" ${getPref('prs', true) ? 'checked' : ''}> Pull Requests</label>
    <label title="Requires a GitHub token — set one via 🔑 above"><input type="checkbox" id="ghe-chk-dsc" ${getPref('discussions', true) ? 'checked' : ''}> Discussions<span style="font-size:10px;margin-left:1px">🔑</span></label>
  </div>
  <div style="display:flex;align-items:center;gap:4px;font-size:12px;margin-bottom:4px;white-space:nowrap">
    <span style="flex-shrink:0">Range: #</span>
    <input id="ghe-iss-from" type="text" class="ghe-inp" style="width:90px;flex-shrink:0" placeholder="e.g. 1-500" title="Single number, range (1-500), or comma-separated (1,5,10-50)">
  </div>
  <div id="ghe-iss-pr" class="ghe-st" style="display:flex;justify-content:space-between;align-items:center"><span id="ghe-iss-pr-txt">No data yet.</span><label style="display:flex;align-items:center;gap:3px;font-size:11px;color:#8b949e;cursor:pointer;white-space:nowrap;flex-shrink:0"><input type="checkbox" id="ghe-chk-dbg" ${getPref('debug', false) ? 'checked' : ''} style="margin:0;cursor:pointer"> 🪵 Log</label></div>
  <div id="ghe-iss-rl" class="ghe-st">Rate limit: not checked yet.</div>
  <div id="ghe-dbg-wrap" style="display:none;margin-top:4px">
    <textarea id="ghe-dbg-log" readonly style="width:100%;height:100px;background:#161b22;color:#8b949e;border:1px solid #30363d;border-radius:4px;font-family:monospace;font-size:10px;padding:4px;resize:vertical;box-sizing:border-box"></textarea>
    <div class="ghe-row" style="margin-top:6px"><button id="ghe-dbg-clear" class="ghe-btn ghe-gr" style="font-size:11px">Clear</button></div>
  </div>
  <button id="ghe-iss-go" class="ghe-btn ghe-g ghe-fw" style="margin-top:6px">Start</button>
  <div class="ghe-row" style="margin-top:6px">
    <button id="ghe-iss-open" class="ghe-btn ghe-b" title="Open list in new tab" disabled>🔗 HTML</button>
    <button id="ghe-iss-dl-h" class="ghe-btn ghe-b" title="Save list as HTML" disabled>💾 HTML</button>
    <button id="ghe-iss-dl-m" class="ghe-btn ghe-p" title="Save list as Markdown" disabled>💾 MD</button>
  </div>
</div>`;



      root.appendChild(toggle);
      root.appendChild(panel);
      document.body.appendChild(root);

      // Toggle
      const flip = () => {
        const open = panel.style.display === 'block';
        panel.style.display = open ? 'none' : 'block';
        toggle.style.display = open ? 'block' : 'none';
        if (!open) {
          // Recalculate max-height so panel fits between top of viewport and its position
          const rootRect = root.getBoundingClientRect();
          const availableHeight = window.innerHeight - (window.innerHeight - rootRect.bottom) - 20;
          panel.style.maxHeight = availableHeight + 'px';
          // Lock width after first layout to prevent flicker during fetching
          requestAnimationFrame(() => {
            const w = panel.offsetWidth;
            panel.style.width = w + 'px';
          });
        } else {
          // Release width lock when closing so it can recalculate on next open
          panel.style.width = '';
        }
      };
      toggle.onclick = flip;
      panel.querySelector('#ghe-x').onclick = flip;

      // ── Theme wiring ──
      const themeBtn = panel.querySelector('#ghe-theme');
      const applyTheme = (light) => {
        panel.classList.toggle('ghe-light', light);
        themeBtn.textContent = light ? '☀️' : '🌙';
        themeBtn.title = light ? 'Switch to dark mode' : 'Switch to light mode';
      };
      applyTheme(getPref('lightMode', false));
      themeBtn.onclick = () => {
        const next = !getPref('lightMode', false);
        setPref('lightMode', next);
        applyTheme(next);
      };

      // ── Ko-Fi wiring ──
      panel.querySelector('#ghe-kofi').addEventListener('click', (e) => { e.stopPropagation(); });

      // ── Token wiring ──
      wireToken();

      // ── All releases wiring ──
      wireAllReleases();

      // ── Issues wiring ──
      wireIssues();
    }

    function wireToken() {
      const inp = document.getElementById('ghe-token-inp');
      const badge = document.getElementById('ghe-token-badge');
      const st = document.getElementById('ghe-token-st');

      function updateBadge() {
        const has = !!getToken();
        badge.textContent = has ? '✅' : '(not set)';
        badge.style.color = has ? '#3fb950' : '#f85149';
      }
      updateBadge();

      document.getElementById('ghe-token-save').onclick = () => {
        const v = inp.value.trim();
        if (!v || v === '••••••••') { st.textContent = 'Enter a token first.'; return; }
        setToken(v);
        inp.value = '••••••••';
        inp.type = 'password';
        st.textContent = 'Token saved.';
        st.className = 'ghe-st';
        updateBadge();
        setTimeout(() => { st.textContent = ''; }, 3000);
      };

      document.getElementById('ghe-token-clear').onclick = () => {
        setToken('');
        inp.value = '';
        st.textContent = 'Token cleared.';
        st.className = 'ghe-st';
        updateBadge();
        setTimeout(() => { st.textContent = ''; }, 3000);
      };

      // Show/hide on focus
      inp.onfocus = () => { if (inp.value === '••••••••') { inp.value = ''; inp.type = 'text'; } };
      inp.onblur = () => { if (!inp.value && getToken()) { inp.value = '••••••••'; inp.type = 'password'; } };
    }

    function wireAllReleases() {
      const sec = document.getElementById('ghe-rel-sec');
      if (!sec) return;

      const btnGo = sec.querySelector('#ghe-rel-go');
      const btnOpen = sec.querySelector('#ghe-rel-open');
      const btnSaveH = sec.querySelector('#ghe-rel-save-h');
      const btnSaveM = sec.querySelector('#ghe-rel-save-m');
      const st = sec.querySelector('#ghe-rel-st-txt');
      const rl = sec.querySelector('#ghe-rel-rl');
      const dbgChk = sec.querySelector('#ghe-rel-dbg');
      const dbgWrap = sec.querySelector('#ghe-rel-dbg-wrap');
      const dbgLog = sec.querySelector('#ghe-rel-dbg-log');
      const dbgClear = sec.querySelector('#ghe-rel-dbg-clear');
      const dlBtns = [btnOpen, btnSaveH, btnSaveM];

      // Debug
      dbgChk.onchange = function () {
        setPref('relDebug', this.checked);
        ReleasesAPI.setDebug(this.checked);
        dbgWrap.style.display = this.checked ? 'block' : 'none';
        if (this.checked) scrollToBottom();
      };
      ReleasesAPI.setDebug(dbgChk.checked);
      dbgWrap.style.display = dbgChk.checked ? 'block' : 'none';
      dbgClear.onclick = () => { ReleasesAPI.clearDebugLog(); dbgLog.value = ''; };

      function scrollToBottom() {
        requestAnimationFrame(() => {
          const p = document.getElementById('ghe-panel');
          if (p) p.scrollTop = p.scrollHeight;
        });
      }

      function refreshDbg() {
        if (dbgChk.checked) {
          dbgLog.value = ReleasesAPI.getDebugLog();
          dbgLog.scrollTop = dbgLog.scrollHeight;
          scrollToBottom();
        }
      }

      function enableDl(on) { dlBtns.forEach(b => b.disabled = !on); }

      function updateUI() {
        const s = ReleasesAPI.state();
        if (s.rlRemain !== null) {
          rl.textContent = s.rlRemain <= 0
            ? `⚠ Rate limited — reset ${fmtReset(s.rlReset)}`
            : `Rate limit: ${s.rlRemain} remaining`;
          rl.className = s.rlRemain <= 0 ? 'ghe-st ghe-warn' : 'ghe-st';
        }
        if (s.running) {
          btnGo.textContent = 'Cancel';
          btnGo.disabled = false;
        } else if (s.finished) {
          btnGo.textContent = '↺ New Export';
          btnGo.disabled = false;
        } else {
          btnGo.textContent = 'Start';
          btnGo.disabled = false;
        }
        enableDl(s.hasData);
        refreshDbg();
      }

      async function doFetch() {
        const s = ReleasesAPI.state();
        if (s.running) {
          ReleasesAPI.cancel();
          updateUI();
          return;
        }
        ReleasesAPI.reset();
        btnGo.disabled = true;
        btnGo.textContent = '⏳ Fetching…';
        enableDl(false);
        const ok = await ReleasesAPI.fetchAll(msg => { st.textContent = msg; updateUI(); });
        btnGo.disabled = false;
        if (ok) {
          st.textContent = `Done · ${ReleasesAPI.state().count} releases`;
          btnGo.textContent = '↺ New Export';
          enableDl(true);
        } else {
          btnGo.textContent = 'Start';
        }
        updateUI();
      }

      btnGo.onclick = doFetch;

      btnOpen.onclick = () => {
        if (ReleasesAPI.hasData()) openBlob(ReleasesAPI.getHtml(), 'text/html');
      };
      btnSaveH.onclick = () => {
        if (ReleasesAPI.hasData()) dlBlob(ReleasesAPI.getHtml(), `${sanitize(repoFullName() || document.title)}-releases.html`, 'text/html');
      };
      btnSaveM.onclick = () => {
        if (ReleasesAPI.hasData()) dlBlob(ReleasesAPI.getMd(), `${sanitize(repoFullName() || document.title)}-releases.md`, 'text/markdown');
      };

      updateUI();
    }

    function wireIssues() {
      const sec = document.getElementById('ghe-iss-sec');
      if (!sec) return;

      // Persist checkbox changes & update start button state
      const chkIss = sec.querySelector('#ghe-chk-iss');
      const chkPr = sec.querySelector('#ghe-chk-pr');
      const chkDsc = sec.querySelector('#ghe-chk-dsc');
      const chkDbg = sec.querySelector('#ghe-chk-dbg');
      const dbgWrap = sec.querySelector('#ghe-dbg-wrap');
      const dbgLog = sec.querySelector('#ghe-dbg-log');
      const dbgClear = sec.querySelector('#ghe-dbg-clear');
      const goBtn = sec.querySelector('#ghe-iss-go');

      function updateGoEnabled() {
        const any = chkIss.checked || chkPr.checked || chkDsc.checked;
        const s = Issues.state();
        goBtn.disabled = !any && !s.running;
        if (!any && !s.running) goBtn.title = 'Select at least one category';
        else goBtn.title = '';
      }

      ['iss', 'pr', 'dsc'].forEach((k, i) => {
        const keys = ['issues', 'prs', 'discussions'];
        sec.querySelector(`#ghe-chk-${k}`).onchange = function () {
          setPref(keys[i], this.checked);
          updateGoEnabled();
        };
      });

      // Debug checkbox
      chkDbg.onchange = function () {
        setPref('debug', this.checked);
        Issues.setDebug(this.checked);
        dbgWrap.style.display = this.checked ? 'block' : 'none';
        if (this.checked) scrollPanelToBottom();
      };
      Issues.setDebug(chkDbg.checked);
      dbgWrap.style.display = chkDbg.checked ? 'block' : 'none';

      function scrollPanelToBottom() {
        requestAnimationFrame(() => {
          const p = document.getElementById('ghe-panel');
          if (p) p.scrollTop = p.scrollHeight;
        });
      }

      dbgClear.onclick = () => { Issues.clearDebugLog(); dbgLog.value = ''; };

        function refreshDebugLog() {
        if (chkDbg.checked) {
          dbgLog.value = Issues.getDebugLog();
          dbgLog.scrollTop = dbgLog.scrollHeight;
          scrollPanelToBottom();
        }
      }

      const updateUI = () => {
        const s = Issues.state();
        const rl = sec.querySelector('#ghe-iss-rl');
        const pr = sec.querySelector('#ghe-iss-pr-txt');

        if (s.rlRemain !== null) {
          rl.textContent = s.rlRemain <= 0
            ? `⚠ Rate limited — reset ${fmtReset(s.rlReset)}`
            : `Rate limit: ${s.rlRemain} remaining`;
          rl.className = s.rlRemain <= 0 ? 'ghe-st ghe-warn' : 'ghe-st';
        }
        if (s.count > 0) {
          const range = s.lowest === s.highest ? `#${s.lowest}` : `#${s.lowest} – #${s.highest}`;
          if (s.running) {
            pr.textContent = `Fetching… ${s.count} items so far (${range})`;
          } else {
            pr.textContent = `Done · ${s.count} items (${range})`;
          }
        } else {
          pr.textContent = s.running ? 'Fetching…' : 'No data yet';
        }

        if (s.running) {
          goBtn.textContent = 'Cancel';
          goBtn.disabled = false;
        } else if (s.rateLimited) {
          goBtn.textContent = '▶ Resume';
          goBtn.disabled = false;
          goBtn.title = `Rate limited — resume after ${fmtReset(s.rlReset)}`;
        } else if (s.finished) {
          goBtn.textContent = '↺ New Export';
          updateGoEnabled();
        } else {
          goBtn.textContent = 'Start';
          updateGoEnabled();
        }

        refreshDebugLog();
      };

      // Auto-set range on input change
      const rangeInput = sec.querySelector('#ghe-iss-from');
      rangeInput.addEventListener('input', () => {
        const val = rangeInput.value.trim();
        Issues.setRange(val);
      });

      const issBtnOpen = sec.querySelector('#ghe-iss-open');
      const issBtnDlH = sec.querySelector('#ghe-iss-dl-h');
      const issBtnDlM = sec.querySelector('#ghe-iss-dl-m');

      const updateDlButtons = () => {
        const has = Issues.state().hasData;
        issBtnOpen.disabled = !has;
        issBtnDlH.disabled = !has;
        issBtnDlM.disabled = !has;
      };

      issBtnOpen.onclick = () => {
        if (Issues.state().hasData) openBlob(Issues.getHtml(), 'text/html');
      };
      issBtnDlH.onclick = () => {
        if (Issues.state().hasData) dlBlob(Issues.getHtml(), `${sanitize(repoFullName() || document.title)}-${Issues.fileSlug()}.html`, 'text/html');
      };
      issBtnDlM.onclick = () => {
        const md = Issues.getMd();
        if (md) dlBlob(md, `${sanitize(repoFullName() || document.title)}-${Issues.fileSlug()}.md`, 'text/markdown');
      };

      // Wrap updateUI to also update download button states
      const wrappedUpdateUI = () => { updateUI(); updateDlButtons(); };

      sec.querySelector('#ghe-iss-go').onclick = () => {
        const s = Issues.state();
        if (s.running) {
          Issues.cancel();
          wrappedUpdateUI();
        } else {
          if (s.finished && !Issues.isRateLimited()) {
            Issues.reset();
          }
          // Always apply current range value before starting
          Issues.setRange(rangeInput.value.trim());
          Issues.run(wrappedUpdateUI, {
            issues: sec.querySelector('#ghe-chk-iss').checked,
            prs: sec.querySelector('#ghe-chk-pr').checked,
            discussions: sec.querySelector('#ghe-chk-dsc').checked,
          });
        }
      };

      updateGoEnabled();
      updateUI();
      updateDlButtons();
    }

    function remove() {
      const el = document.getElementById(ID);
      if (el) el.remove();
    }

    return { create, remove };
  })();

  // ════════════════════════════════════════════════════════════════════════════
  // NAVIGATION
  // ════════════════════════════════════════════════════════════════════════════
  function handleNav() {
    const url = location.href;
    if (url === lastUrl) return;
    lastUrl = url;

    if (isRepoPage()) {
      // Reset releases data on navigation
      ReleasesAPI.reset();
      UI.create();
    } else {
      UI.remove();
    }
  }

  function setup() {
    document.addEventListener('turbo:load', handleNav);
    document.addEventListener('turbo:render', handleNav);
    document.addEventListener('turbo:frame-load', handleNav);
    window.addEventListener('popstate', () => setTimeout(handleNav, 100));
    setInterval(() => { if (location.href !== lastUrl) handleNav(); }, 1000);
  }

  handleNav();
  setup();
})();