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.
// ==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, '<').replace(/>/g, '>'); 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, '&').replace(/</g, '<').replace(/>/g, '>'); } function escAttr(s) { return (s || '').replace(/&/g, '&').replace(/"/g, '"').replace(/</g, '<').replace(/>/g, '>'); } 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, '<').replace(/>/g, '>'); 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(); })();