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.

이 스크립트를 설치하려면 Tampermonkey, Greasemonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램을 설치해야 합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Userscripts와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 유저 스크립트 관리자 확장 프로그램이 필요합니다.

(이미 유저 스크립트 관리자가 설치되어 있습니다. 설치를 진행합니다!)

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

(이미 유저 스타일 관리자가 설치되어 있습니다. 설치를 진행합니다!)

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