Greasy Fork

来自缓存

Greasy Fork is available in English.

GitHub 增强

为 GitHub 增加额外的功能。

当前为 2024-09-29 提交的版本,查看 最新版本

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Greasemonkey 油猴子Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Userscripts ,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

// ==UserScript==
// @name         GitHub Plus
// @name:zh-CN   GitHub 增强
// @namespace    http://tampermonkey.net/
// @version      0.1.1
// @description  Enhance GitHub with additional features.
// @description:zh-CN 为 GitHub 增加额外的功能。
// @author       PRO-2684
// @match        https://github.com/*
// @run-at       document-start
// @icon         http://github.com/favicon.ico
// @license      gpl-3.0
// @grant        GM_setValue
// @grant        GM_getValue
// @grant        GM_deleteValue
// @grant        GM_registerMenuCommand
// @grant        GM_unregisterMenuCommand
// @grant        GM_addValueChangeListener
// @require      https://update.greasyfork.icu/scripts/470224/1449525/Tampermonkey%20Config.js
// ==/UserScript==

(function() {
    'use strict';
    /**
     * The color used for logging. Matches the color of the GitHub.
     * @type {string}
     */
    const themeColor = "#f78166";
    /**
     * Regular expression to match the expanded assets URL. (https://github.com/<username>/<repo>/releases/expanded_assets/<version>)
     */
    const expandedAssetsRegex = /https:\/\/github\.com\/([^/]+)\/([^/]+)\/releases\/expanded_assets\/([^/]+)/;
    /**
     * Data about the release. Maps `owner`, `repo` and `version` to the details of a release. Details are `Promise` objects if exist.
     */
    let releaseData = {};
    /**
     * Rate limit data for the GitHub API.
     * @type {Object}
     * @property {number} limit The maximum number of requests that the consumer is permitted to make per hour.
     * @property {number} remaining The number of requests remaining in the current rate limit window.
     * @property {number} reset The time at which the current rate limit window resets in UTC epoch seconds.
     */
    let rateLimit = {
        limit: -1,
        remaining: -1,
        reset: -1
    };

    const configDesc = {
        $default: {
            value: true,
            input: "current",
            processor: "not",
            formatter: "boolean",
            autoClose: false
        },
        token: {
            name: "Personal Access Token",
            title: "Your personal access token for GitHub API, starting with `github_pat_` (used for increasing rate limit)",
            value: "",
            input: "prompt",
            processor: "same",
            formatter: "normal"
        },
        debug: {
            name: "Debug",
            title: "Enable debug mode",
            value: false
        },
        releaseDownloads: {
            name: "Release Downloads",
            title: "Show how many times a release asset has been downloaded"
        },
        releaseUploader: {
            name: "Release Uploader",
            title: "Show who uploaded a release asset"
        }
    };
    const config = new GM_config(configDesc);

    /**
     * Log the given arguments if debug mode is enabled.
     * @param {...any} args The arguments to log.
     */
    function log(...args) {
        if (config.get("debug")) console.log("%c[GitHub Plus]%c", `color:${themeColor};`, "color: unset;", ...args);
    }
    /**
     * Warn the given arguments.
     * @param {...any} args The arguments to warn.
     */
    function warn(...args) {
        console.warn("%c[GitHub Plus]%c", `color:${themeColor};`, "color: unset;", ...args);
    }
    /**
     * Fetch the given URL with the personal access token, if given. Also updates rate limit.
     * @param {string} url The URL to fetch.
     * @param {RequestInit} options The options to pass to `fetch`.
     * @returns {Promise<Response>} The response from the fetch.
     */
    async function fetchWithToken(url, options) {
        const token = config.get("token");
        if (token) {
            if (!options) options = {};
            if (!options.headers) options.headers = {};
            options.headers.accept = "application/vnd.github+json";
            options.headers["X-GitHub-Api-Version"] = "2022-11-28";
            options.headers.Authorization = `Bearer ${token}`;
        }
        const r = await fetch(url, options);
        // Update rate limit
        rateLimit.limit = parseInt(r.headers.get("X-RateLimit-Limit"));
        rateLimit.remaining = parseInt(r.headers.get("X-RateLimit-Remaining"));
        rateLimit.reset = parseInt(r.headers.get("X-RateLimit-Reset"));
        const resetDate = new Date(rateLimit.reset * 1000).toLocaleString();
        log(`Rate limit: remaining ${rateLimit.remaining}/${rateLimit.limit}, resets at ${resetDate}`);
        if (r.status === 403 || r.status === 429) { // If we get 403 or 429, we've hit the rate limit.
            throw new Error(`Rate limit exceeded! Will reset at ${resetDate}`);
        } else if (rateLimit.remaining === 0) {
            warn(`Rate limit has been exhausted! Will reset at ${resetDate}`);
        }
        return r;
    }
    /**
     * Get the release data for the given owner, repo and version.
     * @param {string} owner The owner of the repository.
     * @param {string} repo The repository name.
     * @param {string} version The version tag of the release.
     * @returns {Promise<Object>} The release data, which resolves to an object mapping download link to details.
     */
    async function getReleaseData(owner, repo, version) {
        if (!releaseData[owner]) releaseData[owner] = {};
        if (!releaseData[owner][repo]) releaseData[owner][repo] = {};
        if (!releaseData[owner][repo][version]) {
            const promise = fetchWithToken(`https://api.github.com/repos/${owner}/${repo}/releases/tags/${version}`).then(
                response => response.json()
            ).then(data => {
                log(`Fetched release data for ${owner}/${repo}@${version}:`, data);
                const assets = {};
                for (const asset of data.assets) {
                    assets[asset.browser_download_url] = {
                        downloads: asset.download_count,
                        uploader: {
                            name: asset.uploader.login,
                            url: asset.uploader.html_url
                        }
                    };
                }
                log(`Processed release data for ${owner}/${repo}@${version}:`, assets);
                return assets;
            });
            releaseData[owner][repo][version] = promise;
        }
        return releaseData[owner][repo][version];
    }
    /**
     * Create a link to the uploader's profile.
     * @param {Object} uploader The uploader information.
     * @param {string} uploader.name The name of the uploader.
     * @param {string} uploader.url The URL to the uploader's profile.
     */
    function createUploaderLink(uploader) {
        const link = document.createElement("a");
        link.textContent = "@" + uploader.name;
        link.href = uploader.url;
        link.title = `Uploaded by @${uploader.name}`;
        link.setAttribute("class", "color-fg-muted text-sm-left flex-auto ml-md-3 nowrap");
        return link;
    }
    /**
     * Create a span element with the given download count.
     * @param {number} downloads The download count.
     */
    function createDownloadCount(downloads) {
        const downloadCount = document.createElement("span");
        downloadCount.textContent = `${downloads} DL`;
        downloadCount.title = `${downloads} downloads`;
        downloadCount.setAttribute("class", "color-fg-muted text-sm-left flex-shrink-0 flex-grow-0 ml-md-3 nowrap");
        return downloadCount;
    }
    /**
     * Adding additional info (download count) to the release entries under the given element.
     * @param {HTMLElement} el The element to search for release entries.
     * @param {Object} info Additional information about the release (owner, repo, version).
     * @param {string} info.owner The owner of the repository.
     * @param {string} info.repo The repository name.
     * @param {string} info.version The version of the release.
     */
    function addAdditionalInfoToRelease(el, info) {
        const entries = el.querySelectorAll("ul > li");
        entries.forEach(async entry => {
            const icon = entry.children[0].querySelector("svg.octicon-package");
            if (!icon) return; // Not a release entry
            const downloadLink = entry.children[0].querySelector("a")?.href;
            const statistics = entry.children[1];
            const assetInfo = (await getReleaseData(info.owner, info.repo, info.version))?.[downloadLink];
            if (!assetInfo) return;
            const size = statistics.querySelector("span.flex-auto");
            size.classList.remove("flex-auto");
            size.classList.add("flex-shrink-0", "flex-grow-0");
            if (config.get("releaseDownloads")) {
                const downloadCount = createDownloadCount(assetInfo.downloads);
                statistics.prepend(downloadCount);
            }
            if (config.get("releaseUploader")) {
                const uploaderLink = createUploaderLink(assetInfo.uploader);
                statistics.prepend(uploaderLink);
            }
        });
    }
    /**
     * Handle the `include-fragment-replace` event.
     * @param {CustomEvent} event The event object.
     */
    function onFragmentReplace(event) {
        const self = event.target;
        const src = self.src;
        const match = expandedAssetsRegex.exec(src);
        if (!match) return;
        const [_, owner, repo, version] = match;
        const info = { owner, repo, version };
        const fragment = event.detail.fragment;
        log("Found expanded assets:", fragment);
        for (const child of fragment.children) {
            addAdditionalInfoToRelease(child, info);
        }
    }
    /**
     * Find all release entries and setup listeners to show the download count.
     */
    function setupListeners() {
        if (!config.get("releaseDownloads") && !config.get("releaseUploader")) return; // No need to run
        // IncludeFragmentElement: https://github.com/github/include-fragment-element/blob/main/src/include-fragment-element.ts
        const fragments = document.querySelectorAll('[data-hpc] details[data-view-component="true"] include-fragment');
        fragments.forEach(fragment => {
            fragment.addEventListener("include-fragment-replace", onFragmentReplace, { once: true });
        });
    }
    document.addEventListener("DOMContentLoaded", setupListeners);
    // Examine event listeners on `document`, and you can see the event listeners for the `turbo:*` events. (Remember to check `Framework Listeners`)
    document.addEventListener("turbo:load", setupListeners);
    // Other possible approaches and reasons against them:
    // - Use `MutationObserver` - Not efficient
    // - Patch `IncludeFragmentElement.prototype.fetch`, just like GitHub itself did at `https://github.githubassets.com/assets/app/assets/modules/github/include-fragment-element-hacks.ts`
    //   - Monkey-patching
    //   - If using regex to modify the response, it would be tedious to maintain
    //   - If using `DOMParser`, the same HTML would be parsed twice
    if (!config.get("releaseDownloads") && !config.get("releaseUploader")) return; // No need to run
    document.head.appendChild(document.createElement("style")).textContent = `
        /* Making more room for the additional info */
        @media (min-width: 1012px) {
            .col-lg-9 {
                width: 60%; /* Originally ~75% */
            }
        }
        .nowrap {
            overflow: hidden;
            text-overflow: ellipsis;
            white-space: nowrap;
        }
    `;
})();