GitHub Advanced Search

Advanced filter modal for GitHub search with release detection

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

You will need to install an extension such as Tampermonkey to install this 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 Advanced Search 
// @namespace    https://github.com/quantavil/userscript
// @version      5.2
// @description  Advanced filter modal for GitHub search with release detection
// @match        https://github.com/*
// @license      MIT
// @icon         https://github.githubassets.com/favicons/favicon.svg
// @grant        GM_registerMenuCommand
// ==/UserScript==

(function () {
    'use strict';

    /* =========================================================================
       CONSTANTS & CONFIG
       ========================================================================= */
    const CONFIG = {
        ids: {
            modal: 'gh-adv-search-modal',
            style: 'gh-adv-search-style',
            toggleBtn: 'gh-adv-toggle-btn'
        },
        selectors: {
            results: '[data-testid="results-list"]',
            resultItem: '[data-testid="results-list"] > div, .repo-list-item, .Box-row',
            resultLink: '.search-title a, a[href^="/"]'
        }
    };

    const FIELDS = [
        {
            section: 'CORE',
            items: [
                {
                    id: 'type', label: 'Type', type: 'select', options: [
                        { v: 'repositories', l: 'Repositories' },
                        { v: 'code', l: 'Code' },
                        { v: 'issues', l: 'Issues' },
                        { v: 'pullrequests', l: 'Pull Requests' },
                        { v: 'discussions', l: 'Discussions' },
                        { v: 'users', l: 'Users' }
                    ]
                },
                {
                    id: 'sort', label: 'Sort', type: 'select', options: [
                        { v: '', l: 'Best Match' },
                        { v: 'stars', l: 'Most Stars' },
                        { v: 'forks', l: 'Most Forks' },
                        { v: 'updated', l: 'Recently Updated' }
                    ]
                }
            ]
        },
        {
            section: 'LOGIC & OPTIONS',
            items: [
                { id: 'and', label: 'And', placeholder: 'rust async', type: 'text' },
                { id: 'or', label: 'Or', placeholder: 'react, vue', type: 'text' },
                { id: 'hide_keys', label: 'Hide words', placeholder: 'spam, bot', type: 'text' },
                { id: 'releases', label: 'Only with releases', type: 'checkbox' },
                { id: 'scanrepo', label: 'Scan repositories', type: 'checkbox' }
            ]
        },
        {
            section: 'FILTERS',
            items: [
                { id: 'repo', label: 'Repo', placeholder: 'facebook/react', meta: 'repo' },
                { id: 'lang', label: 'Language', placeholder: 'python, -html', meta: 'language' },
                { id: 'ext', label: 'Extension', placeholder: 'md', meta: 'extension' },
                { id: 'stars', label: 'Stars', placeholder: '>500', meta: 'stars' },
                { id: 'forks', label: 'Forks', placeholder: '>100', meta: 'forks' },
                { id: 'size', label: 'Size (KB)', placeholder: '>1000', meta: 'size' },
                { id: 'created', label: 'Created', placeholder: '>2023-01', meta: 'created' },
                { id: 'pushed', label: 'Pushed', placeholder: '>2024-01-01', meta: 'pushed' }
            ]
        }
    ];

    /* =========================================================================
       THEME & STYLES
       ========================================================================= */
    function injectStyles() {
        if (document.getElementById(CONFIG.ids.style)) return;

        const css = `
            :root {
                --gs-bg: var(--color-canvas-overlay, #ffffff);
                --gs-surface: var(--color-canvas-subtle, #f6f8fa);
                --gs-border: var(--color-border-default, #d0d7de);
                --gs-border-focus: var(--color-accent-emphasis, #0969da);
                --gs-text: var(--color-fg-default, #1F2328);
                --gs-muted: var(--color-fg-muted, #656d76);
                --gs-accent: var(--color-accent-fg, #0969da);
                --gs-accent-hover: var(--color-accent-emphasis, #0969da);
                --gs-green: var(--color-success-fg, #1a7f37);
                --gs-red: var(--color-danger-fg, #cf222e);
                --gs-font: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif;
                --gs-radius: 6px;
            }

            #${CONFIG.ids.toggleBtn} {
                position: fixed; bottom: 20px; right: 20px; width: 36px; height: 36px;
                background: var(--gs-bg); border: 1px solid var(--gs-border);
                border-radius: 50%; cursor: pointer; z-index: 9997;
                display: flex; align-items: center; justify-content: center;
                box-shadow: 0 4px 12px rgba(0,0,0,0.15); transition: all 0.2s;
            }
            #${CONFIG.ids.toggleBtn}:hover { border-color: var(--gs-accent); transform: scale(1.05); }
            #${CONFIG.ids.toggleBtn} svg { width: 16px; height: 16px; fill: var(--gs-text); }

            #${CONFIG.ids.modal} {
                position: fixed; bottom: 64px; right: 20px; width: 320px;
                max-height: calc(100vh - 80px); background: var(--gs-bg);
                border: 1px solid var(--gs-border); border-radius: var(--gs-radius);
                box-shadow: 0 8px 24px rgba(0,0,0,0.15); z-index: 9999;
                display: none; flex-direction: column; font-family: var(--gs-font);
                font-size: 12px; color: var(--gs-text);
            }
            #${CONFIG.ids.modal}[data-visible="true"] { display: flex; }

            .gs-header { padding: 10px 12px; display: flex; justify-content: space-between; align-items: center; border-bottom: 1px solid var(--gs-border); }
            .gs-header-title { display: flex; align-items: center; gap: 6px; font-weight: 600; font-size: 13px; }
            .gs-header-title svg { width: 14px; height: 14px; fill: var(--gs-text); }
            .gs-close { background: none; border: none; color: var(--gs-muted); cursor: pointer; padding: 4px; border-radius: 4px; display: flex; align-items: center; justify-content: center; }
            .gs-close:hover { background: var(--gs-surface); color: var(--gs-text); }

            .gs-body { padding: 10px 12px; overflow-y: auto; }
            .gs-section { margin-bottom: 12px; }
            .gs-section:last-child { margin-bottom: 0; }
            .gs-section-title { font-weight: 600; font-size: 10px; text-transform: uppercase; color: var(--gs-muted); margin-bottom: 8px; }
            
            .gs-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
            .gs-grid.full { grid-template-columns: 1fr; }

            .gs-field label { display: block; font-size: 10px; font-weight: 600; margin-bottom: 4px; color: var(--gs-text); }
            .gs-check-container { display: flex; align-items: center; gap: 6px; padding-top: 18px; }
            .gs-check-container input { cursor: pointer; accent-color: var(--gs-accent); margin: 0; }
            .gs-check-container label { margin-bottom: 0; cursor: pointer; }

            .gs-input { width: 100%; background: var(--gs-surface); border: 1px solid var(--gs-border); border-radius: 4px; padding: 6px 8px; color: var(--gs-text); font-size: 12px; box-sizing: border-box; outline: none; }
            .gs-input:focus { border-color: var(--gs-border-focus); box-shadow: 0 0 0 2px rgba(9, 105, 218, 0.3); }
            select.gs-input { cursor: pointer; }

            .gs-footer { padding: 10px 12px; border-top: 1px solid var(--gs-border); display: flex; gap: 8px; }
            .gs-btn { flex: 1; padding: 6px 10px; border: 1px solid var(--gs-border); background: var(--gs-surface); border-radius: 4px; font-weight: 600; font-size: 12px; cursor: pointer; color: var(--gs-text); }
            .gs-btn:hover { background: var(--gs-border); }
            .gs-btn.primary { background: var(--gs-accent); border: none; color: #fff; }
            .gs-btn.primary:hover { background: var(--gs-accent-hover); }

            .gh-release-tag { display: inline-flex; align-items: center; gap: 4px; padding: 2px 6px; margin-top: 4px; font-size: 10px; font-weight: 600; background: var(--gs-surface); border-radius: 4px; border: 1px solid var(--gs-border); color: var(--gs-text) !important; text-decoration: none !important; }
            .gh-release-tag.loading { opacity: 0.7; }
            .gh-release-tag.has-release { color: var(--gs-green) !important; border-color: var(--gs-green); }
            .gh-release-tag.no-release { color: var(--gs-red) !important; border-color: var(--gs-red); }
            .gh-filtered-item { display: none !important; }
            .gh-filtered-tag { display: inline-block; padding: 2px 6px; margin-top: 4px; font-size: 10px; font-weight: 600; color: var(--gs-red); border: 1px solid var(--gs-red); border-radius: 4px; background: var(--gs-surface); }
            
            .gs-overlay { position: fixed; inset: 0; background: transparent; z-index: 9998; display: none; }
            .gs-overlay[data-visible="true"] { display: block; }
        `;

        const style = document.createElement('style');
        style.id = CONFIG.ids.style;
        style.textContent = css;
        document.head.appendChild(style);
    }

    /* =========================================================================
       LOGIC: QUERY BUILDER 
       ========================================================================= */
    class QueryBuilder {
        static clean = str => (str ? (str.match(/(\"[^\"]*\"|[^, ]+)/g) || []) : []);

        static buildUrl(data) {
            const parts = [...this.clean(data.and)];
            const orTerms = this.clean(data.or);
            if (orTerms.length) parts.push(orTerms.length === 1 ? orTerms[0] : `(${orTerms.join(' OR ')})`);

            data.meta.forEach(m => {
                const val = m.value.trim();
                if (!val) return;

                if (m.key === 'language') {
                    this.clean(val).forEach(t => {
                        let prefix = t.startsWith('-') ? '-' : '';
                        t = t.replace(/^-/, '');
                        parts.push(`${prefix}language:${t.includes(' ') ? `"${t}"` : t}`);
                    });
                } else {
                    let v = val;
                    if (['stars', 'forks', 'size'].includes(m.key) && !/^[<>=]|\.\./.test(v)) v = `>=${v}`;
                    parts.push(`${m.key}:${v.includes(' ') ? `"${v}"` : v}`);
                }
            });

            let url = `https://github.com/search?q=${encodeURIComponent(parts.join(' '))}&type=${data.type}`;
            if (data.sort) url += `&s=${data.sort}&o=desc`;
            if (data.releasesOnly) url += '&userscript_has_release=1';
            if (data.hideKeys) url += `&userscript_hide_keys=${encodeURIComponent(data.hideKeys)}`;
            return url;
        }

        static parseCurrent() {
            const params = new URLSearchParams(window.location.search);
            const state = {
                type: (params.get('type') || 'repositories').toLowerCase(),
                sort: params.get('s') || '',
                releasesOnly: params.get('userscript_has_release') === '1',
                hideKeys: params.get('userscript_hide_keys') || '',
                and: '', or: '', meta: {}
            };
            let q = params.get('q') || '';

            FIELDS.find(s => s.section === 'FILTERS').items.forEach(i => {
                q = q.replace(new RegExp(`(?<!-)(?:^|\\s)${i.meta}:("[^"]*"|\\S+)`, 'gi'), (_, v) => {
                    if (v.startsWith('"') && v.endsWith('"')) v = v.slice(1, -1);
                    if (['stars', 'forks', 'size'].includes(i.meta) && v.startsWith('>=')) v = v.substring(2);
                    state.meta[i.id] = state.meta[i.id] ? `${state.meta[i.id]}, ${v}` : v;
                    return '';
                });

                if (i.meta === 'language') {
                    q = q.replace(new RegExp(`(?:^|\\s)-language:("[^"]*"|\\S+)`, 'gi'), (_, v) => {
                        if (v.startsWith('"') && v.endsWith('"')) v = v.slice(1, -1);
                        state.meta[i.id] = state.meta[i.id] ? `${state.meta[i.id]}, -${v}` : `-${v}`;
                        return '';
                    });
                }
            });

            const orMatch = q.match(/\(([^)]+)\)/);
            if (orMatch && orMatch[1].includes(' OR ')) {
                state.or = orMatch[1].split(' OR ').join(', ');
                q = q.replace(orMatch[0], '');
            }
            state.and = q.replace(/\s+/g, ' ').trim();
            return state;
        }
    }

    /* =========================================================================
       LOGIC: RELEASE DETECTION
       ========================================================================= */
    const formatRelDate = d => {
        try {
            const diff = Math.floor((new Date() - new Date(d)) / 86400000);
            return diff < 1 ? 'today' : diff === 1 ? 'yesterday' : diff < 7 ? `${diff}d ago` : diff < 30 ? `${Math.floor(diff/7)}w ago` : diff < 365 ? `${Math.floor(diff/30)}mo ago` : `${Math.floor(diff/365)}y ago`;
        } catch { return ''; }
    };

    const createBadge = (status, data = null) => {
        const b = document.createElement('a');
        b.className = `gh-release-tag ${status === 'checking' ? 'loading' : status}`;
        if (status === 'checking') {
            b.textContent = 'Checking…'; b.href = '#'; b.onclick = e => e.preventDefault();
        } else if (status === 'has-release' && data) {
            b.textContent = `${data.tag}${data.date ? ` · ${formatRelDate(data.date)}` : ''}`;
            b.href = data.url; b.target = '_blank';
            b.title = data.date ? `Released: ${new Date(data.date).toLocaleDateString()}` : data.tag;
        } else {
            b.textContent = 'No Release'; b.href = '#'; b.onclick = e => e.preventDefault();
        }
        return b;
    };

    const fetchReleaseInfo = async (owner, repo) => {
        const key = `gh-rel-${owner}-${repo}`;
        try {
            const cached = JSON.parse(localStorage.getItem(key));
            if (cached && Date.now() - cached.ts < 86400000) return cached.info;
        } catch {}

        try {
            const ctrl = new AbortController();
            const tid = setTimeout(() => ctrl.abort(), 10000);
            const res = await fetch(`/${owner}/${repo}/releases/latest`, { signal: ctrl.signal });
            clearTimeout(tid);

            if (!res.ok) {
                localStorage.setItem(key, JSON.stringify({ ts: Date.now(), info: null }));
                return null;
            }

            const doc = new DOMParser().parseFromString(await res.text(), 'text/html');
            let tag = decodeURIComponent(res.url.match(/\/releases\/tag\/([^/?#]+)/)?.[1] || '');
            if (!tag) tag = doc.title.match(/Release (.+?) ·/)?.[1] || doc.querySelector('h1.d-inline')?.textContent.trim();
            if (!tag) throw new Error();

            const date = doc.querySelector('relative-time, time[datetime]')?.getAttribute('datetime');
            const info = { tag, date, url: `/${owner}/${repo}/releases/tag/${encodeURIComponent(tag)}` };
            localStorage.setItem(key, JSON.stringify({ ts: Date.now(), info }));
            return info;
        } catch { return null; }
    };

    const processQueue = async (items, concurrency, task) => {
        const q = [...items];
        await Promise.all(Array.from({ length: concurrency }, async () => {
            while (q.length) try { await task(q.shift()); } catch {}
        }));
    };

    const processItem = async (item, filterOnly) => {
        const link = item.querySelector(CONFIG.selectors.resultLink);
        const parts = link?.getAttribute('href')?.split('/').filter(Boolean);
        if (!parts || parts.length < 2) return;
        
        const insertTarget = item.querySelector('ul') || item;
        const container = document.createElement('div');
        container.appendChild(createBadge('checking'));
        insertTarget.parentNode.insertBefore(container, insertTarget.nextSibling);

        const info = await fetchReleaseInfo(parts[0], parts[1]);
        container.innerHTML = '';

        if (info) {
            container.appendChild(createBadge('has-release', info));
        } else {
            if (filterOnly) {
                item.classList.add('gh-filtered-item');
                const t = document.createElement('span');
                t.className = 'gh-filtered-tag'; t.textContent = 'Filtered (No Release)';
                container.appendChild(t);
            } else {
                container.appendChild(createBadge('no-release'));
            }
        }
    };

    const processSearchResults = () => {
        if (!window.location.pathname.startsWith('/search')) return;
        
        const shouldScan = localStorage.getItem('gh-adv-scan') !== 'false';
        const params = new URLSearchParams(window.location.search);
        const filterOnly = params.get('userscript_has_release') === '1';
        const hideKeys = params.get('userscript_hide_keys') || '';
        const keywords = hideKeys.split(',').map(k => k.trim().toLowerCase()).filter(Boolean);
        
        // Return early if neither action is needed
        if (!shouldScan && keywords.length === 0) return;
        
        const items = Array.from(document.querySelectorAll(CONFIG.selectors.resultItem)).filter(i => !i.dataset.releaseProcessed);
        items.forEach(i => i.dataset.releaseProcessed = 'true');
        
        const toProcessRel = [];
        items.forEach(item => {
            if (keywords.length) {
                const text = item.textContent.toLowerCase();
                if (keywords.some(k => text.includes(k))) {
                    item.classList.add('gh-filtered-item');
                    return;
                }
            }
            if (shouldScan) {
                toProcessRel.push(item);
            }
        });

        if (toProcessRel.length) processQueue(toProcessRel, 3, i => processItem(i, filterOnly));
    };

    /* =========================================================================
       UI: MODAL 
       ========================================================================= */
    let modalEl = null;
    let overlayEl = null;

    function toggleModal(show) {
        if (!modalEl) createUI();
        const v = show === undefined ? modalEl.dataset.visible !== 'true' : show;
        modalEl.dataset.visible = overlayEl.dataset.visible = v;
        if (v) {
            loadStateToUI();
            modalEl.querySelector('input, select')?.focus();
        }
    }

    const createUI = () => {
        if (document.getElementById(CONFIG.ids.modal)) return;

        overlayEl = Object.assign(document.createElement('div'), { className: 'gs-overlay', onclick: () => toggleModal(false) });
        document.body.appendChild(overlayEl);

        modalEl = Object.assign(document.createElement('div'), { id: CONFIG.ids.modal });

        let html = `
            <div class="gs-header">
                <span class="gs-header-title"><svg viewBox="0 0 16 16"><path d="M10.68 11.74a6 6 0 1 1 1.06-1.06l3.04 3.04a.75.75 0 1 1-1.06 1.06l-3.04-3.04ZM11 6.5a4.5 4.5 0 1 0-9 0 4.5 4.5 0 0 0 9 0Z"/></svg> Search Filter</span>
                <button class="gs-close" data-close><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line></svg></button>
            </div>
            <div class="gs-body">
        `;

        FIELDS.forEach(s => {
            html += `<div class="gs-section"><div class="gs-section-title">${s.section}</div><div class="gs-grid ${s.items.length === 1 ? 'full' : ''}">`;
            s.items.forEach(f => {
                if (f.type === 'checkbox') {
                    html += `<div class="gs-field gs-check-container"><input type="checkbox" id="gh-field-${f.id}"><label for="gh-field-${f.id}">${f.label}</label></div>`;
                } else {
                    html += `<div class="gs-field"><label>${f.label}</label>${f.type === 'select'
                        ? `<select id="gh-field-${f.id}" class="gs-input">${f.options.map(o => `<option value="${o.v}">${o.l}</option>`).join('')}</select>`
                        : `<input id="gh-field-${f.id}" type="text" class="gs-input" placeholder="${f.placeholder || ''}">`}</div>`;
                }
            });
            html += `</div></div>`;
        });

        html += `</div><div class="gs-footer"><button data-clear class="gs-btn">Clear</button><button data-search class="gs-btn primary">Search</button></div>`;
        
        modalEl.innerHTML = html;
        document.body.appendChild(modalEl);

        if (!document.getElementById(CONFIG.ids.toggleBtn)) {
            const btn = Object.assign(document.createElement('button'), {
                id: CONFIG.ids.toggleBtn, type: 'button', title: 'Search Filter',
                innerHTML: `<svg viewBox="0 0 16 16"><path d="M10.68 11.74a6 6 0 1 1 1.06-1.06l3.04 3.04a.75.75 0 1 1-1.06 1.06l-3.04-3.04ZM11 6.5a4.5 4.5 0 1 0-9 0 4.5 4.5 0 0 0 9 0Z"/></svg>`,
                onclick: () => toggleModal()
            });
            document.body.appendChild(btn);
        }

        modalEl.querySelector('[data-close]').onclick = () => toggleModal(false);
        modalEl.querySelector('[data-clear]').onclick = () => {
            modalEl.querySelectorAll('input, select').forEach(el => el.type === 'checkbox' ? el.checked = false : el.value = '');
            modalEl.querySelector('#gh-field-scanrepo')?.dispatchEvent(new Event('change'));
        };
        modalEl.querySelector('[data-search]').onclick = executeSearch;

        const scanCheck = modalEl.querySelector('#gh-field-scanrepo');
        const relCheck = modalEl.querySelector('#gh-field-releases');
        if (scanCheck && relCheck) {
            scanCheck.addEventListener('change', () => {
                relCheck.disabled = !scanCheck.checked;
                relCheck.parentElement.style.opacity = scanCheck.checked ? '1' : '0.5';
                if (!scanCheck.checked) relCheck.checked = false;
            });
        }

        modalEl.addEventListener('keydown', e => e.key === 'Enter' && executeSearch());
        document.addEventListener('keydown', e => e.key === 'Escape' && modalEl.dataset.visible === 'true' && toggleModal(false));
    };

    const loadStateToUI = () => {
        const state = QueryBuilder.parseCurrent();
        const setVal = (id, val) => { const el = document.getElementById(`gh-field-${id}`); if (el) el.value = val || ''; };

        setVal('type', state.type); setVal('sort', state.sort);
        setVal('and', state.and); setVal('or', state.or);
        setVal('hide_keys', state.hideKeys);
        Object.entries(state.meta).forEach(([id, val]) => setVal(id, val));

        const relCheck = document.getElementById('gh-field-releases');
        if (relCheck) relCheck.checked = state.releasesOnly;

        const scanCheck = document.getElementById('gh-field-scanrepo');
        if (scanCheck) {
            scanCheck.checked = localStorage.getItem('gh-adv-scan') !== 'false';
            scanCheck.dispatchEvent(new Event('change'));
        }
    };

    const executeSearch = () => {
        const getVal = id => document.getElementById(`gh-field-${id}`)?.value || '';
        const scanCheck = document.getElementById('gh-field-scanrepo');
        if (scanCheck) localStorage.setItem('gh-adv-scan', scanCheck.checked);

        const data = {
            type: getVal('type'), sort: getVal('sort'), and: getVal('and'), or: getVal('or'), meta: [],
            hideKeys: getVal('hide_keys'),
            releasesOnly: document.getElementById('gh-field-releases')?.checked || false
        };

        FIELDS.find(s => s.section === 'FILTERS').items.forEach(i => {
            const val = getVal(i.id);
            if (val) data.meta.push({ key: i.meta, value: val });
        });

        window.location.href = QueryBuilder.buildUrl(data);
    };

    /* =========================================================================
       INIT
       ========================================================================= */
    const init = () => {
        injectStyles();
        createUI();
        if (typeof GM_registerMenuCommand === 'function') GM_registerMenuCommand("Search Filter", () => toggleModal());

        processSearchResults();
        let dt;
        new MutationObserver(() => {
            clearTimeout(dt);
            dt = setTimeout(processSearchResults, 200);
        }).observe(document.body, { childList: true, subtree: true });

        document.addEventListener('turbo:render', processSearchResults);
    };

    init();

})();