Files
DisplayRepoSizeGitHub.opens…/DownloadDir.os.js

246 lines
9.0 KiB
JavaScript

// ==UserScript==
// @name Download GitHub Directory
// @description Adds a rate-limit-friendly directory download option to GitHub.
// @match https://github.com/*/*
// @require https://cdn.jsdelivr.net/npm/jszip@3.10.1/dist/jszip.min.js
// ==/UserScript==
const ITEM_ATTR = 'data-openscript-download-dir';
const MENU_BUTTON = 'button[data-testid="tree-overflow-menu-anchor"]';
let active = false;
const getPat = () => {
const source = typeof OpenScript !== 'undefined' ? OpenScript.env : typeof env !== 'undefined' ? env : {};
const [, value] = Object.entries(source || {}).find(([key, val]) =>
/^(GH_PAT|GITHUB_PAT|GITHUB_TOKEN|PAT)$/i.test(key) && val
) || [];
return value ? String(value).trim() : '';
};
const decode = value => {
try { return decodeURIComponent(value); }
catch { return value; }
};
const getView = () => {
try {
const parts = location.pathname.split('/').slice(1).map(decode);
if (parts[2] !== 'tree') return null;
const ref = document.querySelector('#ref-picker-repos-header-ref-selector')?.textContent.trim();
const tail = parts.slice(3).join('/');
if (!ref || tail === ref || !tail.startsWith(`${ref}/`)) return null;
const app = document.querySelector('react-app[app-name="code-view"]');
const payload = JSON.parse(app?.querySelector('script[type="application/json"]')?.textContent || '{}').payload;
const info = payload?.codeViewLayoutRoute?.repo;
const commit = document.querySelector('a[aria-label^="Commit "][href*="/commit/"]')
?.getAttribute('href')?.match(/\/commit\/([0-9a-f]{40,64})(?:$|[/?#])/i)?.[1];
return {
owner: parts[0], repo: parts[1], ref,
path: tail.slice(ref.length + 1), oid: commit || ref,
private: info?.private === true,
};
} catch {
return null;
}
};
const api = async (view, path, accept = 'application/vnd.github+json') => {
const pat = getPat();
const res = await fetch(
`https://api.github.com/repos/${encodeURIComponent(view.owner)}/${encodeURIComponent(view.repo)}${path}`,
{ headers: {
Accept: accept,
'X-GitHub-Api-Version': '2022-11-28',
...(pat && { Authorization: `Bearer ${pat}` }),
} },
);
if (res.ok) return res;
if (res.status === 403 && res.headers.get('X-RateLimit-Remaining') === '0')
throw new Error('GitHub API rate limit reached. Add GH_PAT in OpenScript or try again after the reset.');
if (res.status === 404 && !pat)
throw new Error('Directory data is unavailable. Private repositories require GH_PAT in OpenScript.');
throw new Error(`GitHub API request failed (${res.status}).`);
};
const tree = async (view, sha, recursive = false) =>
(await api(view, `/git/trees/${encodeURIComponent(sha)}${recursive ? '?recursive=1' : ''}`)).json();
const walkTree = async (view) => {
let sha = view.oid;
for (const name of view.path.split('/')) {
const data = await tree(view, sha);
const next = data.tree?.find(item => item.type === 'tree' && item.path === name);
if (!next) throw new Error('Could not locate this directory in the repository tree.');
sha = next.sha;
}
const files = [];
const queue = [{ sha, path: '' }];
while (queue.length) {
const current = queue.shift();
const data = await tree(view, current.sha);
for (const item of data.tree || []) {
const path = current.path ? `${current.path}/${item.path}` : item.path;
if (item.type === 'tree') queue.push({ sha: item.sha, path });
else if (item.type === 'blob') files.push({ ...item, path: `${view.path}/${path}`, relative: path });
}
}
return files;
};
const listFiles = async view => {
const data = await tree(view, view.oid, true);
if (data.truncated) return walkTree(view);
const prefix = `${view.path}/`;
return (data.tree || [])
.filter(item => item.type === 'blob' && item.path?.startsWith(prefix))
.map(item => ({ ...item, relative: item.path.slice(prefix.length) }));
};
const fileData = async (view, file) => {
if (!view.private) {
const path = file.path.split('/').map(encodeURIComponent).join('/');
try {
const raw = await fetch(
`https://raw.githubusercontent.com/${encodeURIComponent(view.owner)}/${encodeURIComponent(view.repo)}/${view.oid}/${path}`
);
if (raw.ok) return raw.arrayBuffer();
} catch {}
}
return (await api(view, `/git/blobs/${encodeURIComponent(file.sha)}`, 'application/vnd.github.raw+json')).arrayBuffer();
};
const notice = (message, error = false) => {
const box = document.createElement('div');
box.className = `flash ${error ? 'flash-error' : 'flash-success'}`;
box.setAttribute('role', error ? 'alert' : 'status');
box.textContent = message;
Object.assign(box.style, {
position: 'fixed', top: '72px', right: '16px', zIndex: 2147483647,
maxWidth: '420px', boxShadow: 'var(--shadow-floating-small)',
});
document.body.append(box);
setTimeout(() => box.remove(), error ? 9000 : 5000);
};
const saveZip = async (view, setLabel) => {
if (view.private && !getPat())
throw new Error('Private repositories require GH_PAT in OpenScript.');
if (!globalThis.JSZip) throw new Error('JSZip did not load. Re-save the script to refresh its @require cache.');
setLabel('Reading directory…');
const files = await listFiles(view);
if (!files.length) throw new Error('This directory has no downloadable files.');
const root = view.path.split('/').at(-1);
const zip = new JSZip();
let done = 0;
let failure;
const queue = [...files];
const worker = async () => {
while (queue.length && !failure) {
const file = queue.shift();
try {
zip.file(`${root}/${file.relative}`, await fileData(view, file));
setLabel(`Downloading ${++done}/${files.length}`);
} catch (error) {
failure ||= error;
queue.length = 0;
}
}
};
await Promise.all(Array.from({ length: Math.min(6, files.length) }, worker));
if (failure) throw failure;
setLabel('Creating ZIP…');
const blob = await zip.generateAsync({ type: 'blob', compression: 'DEFLATE', compressionOptions: { level: 6 } });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
const safe = `${view.repo}-${view.path}-${view.oid.slice(0, 7)}`.replace(/[^\w.-]+/g, '-');
link.href = url;
link.download = `${safe}.zip`;
link.click();
setTimeout(() => URL.revokeObjectURL(url), 60000);
notice(`Downloaded ${root} (${files.length.toLocaleString()} files).`);
};
const download = async (item, label) => {
if (active) return notice('Another directory download is already running.', true);
const view = getView();
if (!view) return notice('Could not read the current GitHub directory.', true);
active = true;
item.setAttribute('aria-disabled', 'true');
const setLabel = text => { if (label.isConnected) label.textContent = text; };
try {
await saveZip(view, setLabel);
} catch (error) {
notice(error?.message || 'Directory download failed.', true);
} finally {
active = false;
item.removeAttribute('aria-disabled');
setLabel('Download directory');
}
};
const inject = () => {
const view = getView();
const anchor = document.querySelector(MENU_BUTTON);
if (!view || !anchor) return;
const labelledBy = anchor.getAttribute('aria-labelledby');
const menu = [...document.querySelectorAll('ul[role="menu"]')].find(el =>
el.getAttribute('aria-labelledby') === labelledBy
);
if (!menu || menu.querySelector(`[${ITEM_ATTR}]`)) return;
const source = [...menu.children].find(el =>
el.getAttribute('role') === 'menuitem' && /Copy permalink/i.test(el.textContent)
) || menu.querySelector(':scope > li[role="menuitem"]');
if (!source) return;
const item = source.cloneNode(true);
item.setAttribute(ITEM_ATTR, '');
item.setAttribute('aria-label', 'Download directory');
item.setAttribute('tabindex', '-1');
item.removeAttribute('aria-labelledby');
item.removeAttribute('aria-keyshortcuts');
item.querySelectorAll('[id]').forEach(el => el.removeAttribute('id'));
item.querySelector('[data-component="ActionList.TrailingVisual"]')?.remove();
const label = item.querySelector('[data-component="ActionList.Item.Label"]');
if (!label) return;
label.textContent = 'Download directory';
item.addEventListener('click', event => {
event.preventDefault();
event.stopPropagation();
download(item, label);
});
const deletion = [...menu.children].find(el => el.querySelector('a[href*="/tree/delete/"]'));
const divider = [...menu.children].find(el => el.dataset.component === 'ActionList.Divider');
if (deletion) menu.insertBefore(item, deletion);
else if (divider) divider.after(item);
else menu.prepend(item);
};
let scheduled;
const run = () => {
clearTimeout(scheduled);
scheduled = setTimeout(inject, 30);
};
['turbo:load', 'turbo:render', 'pjax:end', 'popstate'].forEach(event =>
window.addEventListener(event, run)
);
const start = () => {
new MutationObserver(run).observe(document.documentElement, { childList: true, subtree: true });
run();
};
if (document.documentElement) start();
else window.addEventListener('DOMContentLoaded', start, { once: true });