GitHub Release Downloads

Shows total downloads for releases.

Dovrai installare un'estensione come Tampermonkey, Greasemonkey o Violentmonkey per installare questo script.

Dovrai installare un'estensione come Tampermonkey o Violentmonkey per installare questo script.

Dovrai installare un'estensione come Tampermonkey o Violentmonkey per installare questo script.

Dovrai installare un'estensione come Tampermonkey o Userscripts per installare questo script.

Dovrai installare un'estensione come ad esempio Tampermonkey per installare questo script.

Dovrai installare un gestore di script utente per installare questo script.

(Ho già un gestore di script utente, lasciamelo installare!)

Dovrai installare un'estensione come ad esempio Stylus per installare questo stile.

Dovrai installare un'estensione come ad esempio Stylus per installare questo stile.

Dovrai installare un'estensione come ad esempio Stylus per installare questo stile.

Dovrai installare un'estensione per la gestione degli stili utente per installare questo stile.

Dovrai installare un'estensione per la gestione degli stili utente per installare questo stile.

Dovrai installare un'estensione per la gestione degli stili utente per installare questo stile.

(Ho già un gestore di stile utente, lasciamelo installare!)

// ==UserScript==
// @name         GitHub Release Downloads
// @description  Shows total downloads for releases.
// @icon         https://github.githubassets.com/favicons/favicon-dark.svg
// @version      1.1
// @author       afkarxyz
// @namespace    https://github.com/afkarxyz/userscripts/
// @supportURL   https://github.com/afkarxyz/userscripts/issues
// @license      MIT
// @match        https://github.com/*
// @grant        GM_xmlhttpRequest
// @connect      api.codetabs.com
// @connect      api.cors.lol
// @connect      api.allorigins.win
// @connect      everyorigin.jwvbremen.nl
// @connect      api.github.com
// @run-at       document-start
// ==/UserScript==

;(() => {
    const proxyServices = [
        {
            name: "Direct GitHub API",
            url: "https://api.github.com/repos/",
            parseResponse: (response) => {
                return JSON.parse(response)
            },
        },
        {
            name: "CodeTabs Proxy",
            url: "https://api.codetabs.com/v1/proxy/?quest=https://api.github.com/repos/",
            parseResponse: (response) => {
                return JSON.parse(response)
            },
        },
        {
            name: "CORS.lol Proxy",
            url: "https://api.cors.lol/?url=https://api.github.com/repos/",
            parseResponse: (response) => {
                return JSON.parse(response)
            },
        },
        {
            name: "AllOrigins Proxy",
            url: "https://api.allorigins.win/get?url=https://api.github.com/repos/",
            parseResponse: (response) => {
                const parsed = JSON.parse(response)
                return JSON.parse(parsed.contents)
            },
        },
        {
            name: "EveryOrigin Proxy",
            url: "https://everyorigin.jwvbremen.nl/api/get?url=https://api.github.com/repos/",
            parseResponse: (response) => {
                const parsed = JSON.parse(response)
                return JSON.parse(parsed.html)
            },
        },
    ]

    async function fetchFromApi(proxyService, owner, repo, tag) {
        const apiUrl = `${proxyService.url}${owner}/${repo}/releases/tags/${tag}`

        return new Promise((resolve) => {
            if (typeof GM_xmlhttpRequest === "undefined") {
                resolve({ success: false, error: "GM_xmlhttpRequest is not defined" })
                return
            }
            GM_xmlhttpRequest({
                method: "GET",
                url: apiUrl,
                headers: {
                    Accept: "application/vnd.github.v3+json",
                },
                onload: (response) => {
                    if (response.responseText.includes("limit") && response.responseText.includes("API")) {
                        resolve({
                            success: false,
                            error: "Rate limit exceeded",
                            isRateLimit: true,
                        })
                        return
                    }

                    if (response.status >= 200 && response.status < 300) {
                        try {
                            const releaseData = proxyService.parseResponse(response.responseText)
                            resolve({ success: true, data: releaseData })
                        } catch (e) {
                            resolve({ success: false, error: "JSON parse error" })
                        }
                    } else {
                        resolve({
                            success: false,
                            error: `Status ${response.status}`,
                        })
                    }
                },
                onerror: () => {
                    resolve({ success: false, error: "Network error" })
                },
                ontimeout: () => {
                    resolve({ success: false, error: "Timeout" })
                },
            })
        })
    }

    async function getReleaseData(owner, repo, tag) {
        for (let i = 0; i < proxyServices.length; i++) {
            const proxyService = proxyServices[i]
            const result = await fetchFromApi(proxyService, owner, repo, tag)

            if (result.success) {
                return result.data
            }
        }
        return null
    }

    function createDownloadCounter() {
        const getThemeColor = () => {
            const isDarkTheme = document.documentElement.getAttribute('data-color-mode') === 'dark' || 
                               document.body.classList.contains('dark') ||
                               window.matchMedia('(prefers-color-scheme: dark)').matches
            return isDarkTheme ? '#3fb950' : '#1a7f37'
        }
        
        const downloadCounter = document.createElement('span')
        downloadCounter.className = 'download-counter-simple'
        downloadCounter.style.cssText = `
            margin-left: 8px;
            color: ${getThemeColor()};
            font-size: 14px;
            font-weight: 400;
            display: inline;
        `
        
        const downloadIcon = `
            <svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 384 512" fill="currentColor" style="margin-right: 2px; vertical-align: -2px;">
                <path d="M32 480c-17.7 0-32-14.3-32-32s14.3-32 32-32l320 0c17.7 0 32 14.3 32 32s-14.3 32-32 32L32 480zM214.6 342.6c-12.5 12.5-32.8 12.5-45.3 0l-128-128c-12.5-12.5-12.5-32.8 0-45.3s32.8-12.5 45.3 0L160 242.7 160 64c0-17.7 14.3-32 32-32s32 14.3 32 32l0 178.7 73.4-73.4c12.5-12.5 32.8-12.5 45.3 0s12.5 32.8 0 45.3l-128 128z"/>
            </svg>
        `        
        downloadCounter.innerHTML = `${downloadIcon}Loading...`
        
        return downloadCounter
    }

    function getCachedDownloads(owner, repo, tag) {
        const key = `ghdl_${owner}_${repo}_${tag}`
        const cached = localStorage.getItem(key)
        return cached ? parseInt(cached, 10) : null
    }

    function setCachedDownloads(owner, repo, tag, count) {
        const key = `ghdl_${owner}_${repo}_${tag}`
        if (localStorage.getItem(key) === null) {
            localStorage.setItem(key, count)
        }
    }

    function updateDownloadCounter(counter, totalDownloads, diff) {
        const formatNumber = (num) => {
            return num.toLocaleString('en-US')
        }
        const isDarkTheme = document.documentElement.getAttribute('data-color-mode') === 'dark' || 
            document.body.classList.contains('dark') ||
            window.matchMedia('(prefers-color-scheme: dark)').matches
        const diffColor = isDarkTheme ? '#888' : '#1f2328'
        const downloadIcon = `
            <svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 384 512" fill="currentColor" style="margin-right: 2px; vertical-align: -2px;">
                <path d="M32 480c-17.7 0-32-14.3-32-32s14.3-32 32-32l320 0c17.7 0 32 14.3 32 32s-14.3 32-32 32L32 480zM214.6 342.6c-12.5 12.5-32.8 12.5-45.3 0l-128-128c-12.5-12.5-12.5-32.8 0-45.3s32.8-12.5 45.3 0L160 242.7 160 64c0-17.7 14.3-32 32-32s32 14.3 32 32l0 178.7 73.4-73.4c12.5-12.5 32.8-12.5 45.3 0s12.5 32.8 0 45.3l-128 128z"/>
            </svg>
        `
        let diffText = ''
        if (typeof diff === 'number' && diff > 0) {
            diffText = ` <span class="download-diff" style="color:${diffColor};font-size:12px;">(+${formatNumber(diff)})</span>`
        }
        counter.innerHTML = `${downloadIcon}${formatNumber(totalDownloads)}${diffText}`
        counter.style.fontWeight = '600'
    }

    function setupThemeObserver(counter) {
        const getThemeColor = () => {
            const isDarkTheme = document.documentElement.getAttribute('data-color-mode') === 'dark' || 
                document.body.classList.contains('dark') ||
                window.matchMedia('(prefers-color-scheme: dark)').matches
            return isDarkTheme ? '#3fb950' : '#1a7f37'
        }
        const getDiffColor = () => {
            const isDarkTheme = document.documentElement.getAttribute('data-color-mode') === 'dark' || 
                document.body.classList.contains('dark') ||
                window.matchMedia('(prefers-color-scheme: dark)').matches
            return isDarkTheme ? '#888' : '#1f2328'
        }
        const updateCounterColor = () => {
            if (counter) {
                counter.style.color = getThemeColor()
                const diffSpan = counter.querySelector('.download-diff')
                if (diffSpan) {
                    diffSpan.style.color = getDiffColor()
                }
            }
        }
        
        const observer = new MutationObserver((mutations) => {
            mutations.forEach((mutation) => {
                if (mutation.type === 'attributes' && 
                    (mutation.attributeName === 'data-color-mode' || 
                     mutation.attributeName === 'class')) {
                    updateCounterColor()
                }
            })
        })
        
        observer.observe(document.documentElement, {
            attributes: true,
            attributeFilter: ['data-color-mode', 'class']
        })
        
        observer.observe(document.body, {
            attributes: true,
            attributeFilter: ['class']
        })
        
        const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)')
        mediaQuery.addEventListener('change', updateCounterColor)
    }

    async function addDownloadCounter() {
        if (isProcessing) {
            return
        }
        isProcessing = true
        const currentUrl = window.location.href
        const urlMatch = currentUrl.match(/github\.com\/([^\/]+)\/([^\/]+)\/releases\/tag\/([^\/\?]+)/)
        if (!urlMatch) {
            isProcessing = false
            return
        }
        const [, owner, repo, tag] = urlMatch
        const existingCounter = document.querySelector('.download-counter-simple')
        if (existingCounter) {
            isProcessing = false
            return
        }
        let attempts = 0
        const maxAttempts = 50
        const waitForBreadcrumb = () => {
            return new Promise((resolve) => {
                const checkBreadcrumb = () => {
                    const selectedBreadcrumb = document.querySelector('.breadcrumb-item-selected a')
                    if (selectedBreadcrumb) {
                        resolve(selectedBreadcrumb)
                        return
                    }
                    attempts++
                    if (attempts < maxAttempts) {
                        setTimeout(checkBreadcrumb, 100)
                    } else {
                        resolve(null)
                    }
                }
                checkBreadcrumb()
            })
        }
        const selectedBreadcrumb = await waitForBreadcrumb()
        if (!selectedBreadcrumb) {
            isProcessing = false
            return
        }
        const downloadCounter = createDownloadCounter()
        selectedBreadcrumb.appendChild(downloadCounter)
        setupThemeObserver(downloadCounter)
        try {
            const releaseData = await getReleaseData(owner, repo, tag)
            if (!releaseData) {
                downloadCounter.remove()
                isProcessing = false
                return
            }
            const totalDownloads = releaseData.assets.reduce((total, asset) => {
                return total + asset.download_count
            }, 0)
            const cached = getCachedDownloads(owner, repo, tag)
            let diff = null
            if (cached !== null && totalDownloads > cached) {
                diff = totalDownloads - cached
            }
            updateDownloadCounter(downloadCounter, totalDownloads, diff)
            setCachedDownloads(owner, repo, tag, totalDownloads)
        } catch (error) {
            downloadCounter.remove()
        } finally {
            isProcessing = false
        }
    }

    let navigationTimeout = null
    let lastUrl = window.location.href
    let isProcessing = false

    function handleNavigation() {
        const currentUrl = window.location.href
        
        if (navigationTimeout) {
            clearTimeout(navigationTimeout)
        }
        
        if (currentUrl === lastUrl && isProcessing) {
            return
        }
        
        lastUrl = currentUrl
        
        navigationTimeout = setTimeout(() => {
            const existingCounters = document.querySelectorAll('.download-counter-simple')
            existingCounters.forEach(counter => counter.remove())
            
            if (currentUrl.includes('/releases/tag/')) {
                addDownloadCounter()
            }
        }, 300)
    }

    function init() {
        if (document.readyState === 'loading') {
            document.addEventListener('DOMContentLoaded', handleNavigation)
        } else {
            handleNavigation()
        }
        
        document.addEventListener('turbo:load', handleNavigation)
        document.addEventListener('turbo:render', handleNavigation)
        document.addEventListener('turbo:frame-load', handleNavigation)
        
        document.addEventListener('pjax:end', handleNavigation)
        document.addEventListener('pjax:success', handleNavigation)
        
        window.addEventListener('popstate', handleNavigation)
        
        const originalPushState = history.pushState
        const originalReplaceState = history.replaceState
        
        history.pushState = function(...args) {
            originalPushState.apply(history, args)
            setTimeout(handleNavigation, 100)
        }
        
        history.replaceState = function(...args) {
            originalReplaceState.apply(history, args)  
            setTimeout(handleNavigation, 100)
        }
    }

    init()
})()