GitHub Advanced Search Builder

Advanced filter modal for GitHub search with OR/AND/NOT logic and native look.

Vous devrez installer une extension telle que Tampermonkey, Greasemonkey ou Violentmonkey pour installer ce script.

Vous devrez installer une extension telle que Tampermonkey pour installer ce script.

Vous devrez installer une extension telle que Tampermonkey ou Violentmonkey pour installer ce script.

Vous devrez installer une extension telle que Tampermonkey ou Userscripts pour installer ce script.

Vous devrez installer une extension telle que Tampermonkey pour installer ce script.

Vous devrez installer une extension de gestionnaire de script utilisateur pour installer ce script.

(J'ai déjà un gestionnaire de scripts utilisateur, laissez-moi l'installer !)

Vous devrez installer une extension telle que Stylus pour installer ce style.

Vous devrez installer une extension telle que Stylus pour installer ce style.

Vous devrez installer une extension telle que Stylus pour installer ce style.

Vous devrez installer une extension du gestionnaire de style pour utilisateur pour installer ce style.

Vous devrez installer une extension du gestionnaire de style pour utilisateur pour installer ce style.

Vous devrez installer une extension du gestionnaire de style pour utilisateur pour installer ce style.

(J'ai déjà un gestionnaire de style utilisateur, laissez-moi l'installer!)

// ==UserScript==
// @name         GitHub Advanced Search Builder
// @namespace    https://github.com/quantavil/userscript
// @version      1.8
// @description  Advanced filter modal for GitHub search with OR/AND/NOT logic and native look.
// @author       quantavil
// @match        https://github.com/*
// @license      MIT
// @icon         https://github.githubassets.com/favicons/favicon.svg
// @grant        none
// ==/UserScript==

(function () {
    'use strict';

    // Config
    const TRIGGER_ID = 'gh-adv-search-btn';
    const MODAL_ID = 'gh-adv-search-modal';

    // Icons
    const FILTER_ICON = `<svg aria-hidden="true" height="16" viewBox="0 0 16 16" version="1.1" width="16" fill="currentColor"><path d="M.75 3h14.5a.75.75 0 0 1 0 1.5H.75a.75.75 0 0 1 0-1.5ZM3 7.75A.75.75 0 0 1 3.75 7h8.5a.75.75 0 0 1 0 1.5h-8.5A.75.75 0 0 1 3 7.75Zm3 4.75a.75.75 0 0 1 .75-.75h2.5a.75.75 0 0 1 0 1.5h-2.5a.75.75 0 0 1-.75-.75Z"></path></svg>`;

    function createUI() {
        if (document.getElementById(TRIGGER_ID)) return;

        // Find the global search input container
        const headerSearch = document.querySelector('.header-search-wrapper, .AppHeader-search');
        if (!headerSearch) return;

        // Create Trigger Button
        const btn = document.createElement('button');
        btn.id = TRIGGER_ID;
        btn.className = 'btn btn-sm ml-2';
        btn.style.display = 'inline-flex';
        btn.style.alignItems = 'center';
        btn.style.gap = '4px';
        btn.innerHTML = `${FILTER_ICON} Filter`;
        btn.title = "Advanced Search Builder (Ctrl+Shift+F)";

        // Insert Button
        if (headerSearch.parentNode) {
            headerSearch.parentNode.insertBefore(btn, headerSearch.nextSibling);
        }

        // Create Modal (Hidden by default)
        const modal = document.createElement('div');
        modal.id = MODAL_ID;
        modal.style.cssText = `
            position: fixed;
            top: 50%;
            left: 50%;
            transform: translate(-50%, -50%);
            width: 95%;
            max-width: 500px;
            max-height: 90vh;
            overflow-y: auto;
            z-index: 9999;
            background-color: var(--bgColor-default, #fff);
            border: 1px solid var(--borderColor-default, #d0d7de);
            border-radius: 6px;
            box-shadow: var(--shadow-large, 0 8px 24px rgba(140,149,159,0.2));
            display: none;
            padding: 16px;
            font-family: -apple-system,BlinkMacSystemFont,"Segoe UI","Noto Sans",Helvetica,Arial,sans-serif;
            color: var(--fgColor-default, #24292f);
            box-sizing: border-box;
        `;

        // Add responsive grid style
        const style = document.createElement('style');
        style.innerHTML = `
            #${MODAL_ID} .responsive-grid {
                display: grid;
                grid-template-columns: 1fr 1fr;
                gap: 10px;
            }
            @media (max-width: 480px) {
                #${MODAL_ID} .responsive-grid {
                    grid-template-columns: 1fr;
                }
                #${MODAL_ID} {
                    top: 10px;
                    transform: translateX(-50%);
                }
            }
        `;
        document.head.appendChild(style);

        modal.innerHTML = `
            <div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:16px;">
                <h3 style="margin:0; font-size:16px;">Advanced Search</h3>
                <button id="${MODAL_ID}-close" class="btn-octicon" type="button">
                   <svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor"><path d="M3.72 3.72a.75.75 0 0 1 1.06 0L8 6.94l3.22-3.22a.749.749 0 0 1 1.275.326.749.749 0 0 1-.215.734L9.06 8l3.22 3.22a.749.749 0 0 1-.326 1.275.749.749 0 0 1-.734-.215L8 9.06l-3.22 3.22a.751.751 0 0 1-1.042-.018.751.751 0 0 1-.018-1.042L6.94 8 3.72 4.78a.75.75 0 0 1 0-1.06Z"></path></svg>
                </button>
            </div>

            <form id="${MODAL_ID}-form">
                <div style="display:grid; grid-template-columns: 1fr 1fr; gap: 10px; margin-bottom:12px;">
                    <div>
                        <label style="display:block; font-size:12px; font-weight:600; margin-bottom:4px;">Search Type</label>
                        <select id="sel-type" class="form-select select-sm" style="width:100%;">
                            <option value="repositories">Repositories</option>
                            <option value="code">Code</option>
                            <option value="issues">Issues</option>
                            <option value="pullrequests">Pull Requests</option>
                            <option value="discussions">Discussions</option>
                            <option value="users">Users</option>
                        </select>
                    </div>
                    <div>
                        <label style="display:block; font-size:12px; font-weight:600; margin-bottom:4px;">Sort By</label>
                        <select id="sel-sort" class="form-select select-sm" style="width:100%;">
                            <option value="">Best Match</option>
                            <option value="stars">Most Stars</option>
                            <option value="forks">Most Forks</option>
                            <option value="updated">Recently Updated</option>
                        </select>
                    </div>
                </div>

                <div class="form-group" style="margin-bottom:12px;">
                    <label style="display:block; font-size:12px; font-weight:600; margin-bottom:4px;">Must contain ALL (AND)</label>
                    <input type="text" id="inp-and" class="form-control input-sm input-block" placeholder="rust async tokio" style="width:100%;">
                </div>

                <div class="form-group" style="margin-bottom:12px;">
                    <label style="display:block; font-size:12px; font-weight:600; margin-bottom:4px;">Must contain ONE OF (OR)</label>
                    <input type="text" id="inp-or" class="form-control input-sm input-block" placeholder="api, library" style="width:100%;">
                </div>

                <div class="form-group" style="margin-bottom:12px;">
                    <label style="display:block; font-size:12px; font-weight:600; margin-bottom:4px; color:var(--fgColor-danger, #cf222e);">Exclude (NOT)</label>
                    <input type="text" id="inp-not" class="form-control input-sm input-block" placeholder="deprecated" style="width:100%;">
                </div>

                <hr style="border:0; border-top:1px solid var(--borderColor-muted); margin: 12px 0;">

                <div class="responsive-grid">
                     <div>
                        <label style="display:block; font-size:12px; font-weight:600;">Owner/User</label>
                        <input type="text" id="inp-user" class="form-control input-sm" placeholder="e.g. facebook" style="width:100%;">
                    </div>
                    <div>
                        <label style="display:block; font-size:12px; font-weight:600;">Repository</label>
                        <input type="text" id="inp-repo" class="form-control input-sm" placeholder="e.g. react" style="width:100%;">
                    </div>
                    <div>
                        <label style="display:block; font-size:12px; font-weight:600;">Language</label>
                        <input type="text" id="inp-lang" class="form-control input-sm" placeholder="e.g. python" style="width:100%;">
                    </div>
                    <div>
                        <label style="display:block; font-size:12px; font-weight:600;">Extension</label>
                        <input type="text" id="inp-ext" class="form-control input-sm" placeholder="e.g. md" style="width:100%;">
                    </div>
                    <div>
                        <label style="display:block; font-size:12px; font-weight:600;">Created Date</label>
                        <input type="text" id="inp-created" class="form-control input-sm" placeholder="e.g. >2023-01-01" style="width:100%;">
                    </div>
                    <div>
                        <label style="display:block; font-size:12px; font-weight:600;">Pushed Date</label>
                        <input type="text" id="inp-pushed" class="form-control input-sm" placeholder="e.g. >2024-01-01" style="width:100%;">
                    </div>
                    <div>
                        <label style="display:block; font-size:12px; font-weight:600;">Stars (>=)</label>
                        <input type="number" id="inp-stars" class="form-control input-sm" style="width:100%;">
                    </div>
                    <div>
                        <label style="display:block; font-size:12px; font-weight:600;">Forks (>=)</label>
                        <input type="number" id="inp-forks" class="form-control input-sm" style="width:100%;">
                    </div>
                    <div>
                        <label style="display:block; font-size:12px; font-weight:600;">Size (KB)</label>
                        <input type="text" id="inp-size" class="form-control input-sm" placeholder="e.g. >1000" style="width:100%;">
                    </div>
                    <div>
                        <label style="display:block; font-size:12px; font-weight:600;">Topics</label>
                        <input type="text" id="inp-topics" class="form-control input-sm" placeholder="e.g. machine-learning" style="width:100%;">
                    </div>
                </div>

                 <div style="margin-top:12px;">
                    <label style="display:block; font-size:12px; font-weight:600;">In Path</label>
                    <input type="text" id="inp-path" class="form-control input-sm" placeholder="src/main" style="width:100%;">
                </div>

                <div style="margin-top:16px; display:flex; justify-content:space-between; align-items:center;">
                    <button type="button" id="${MODAL_ID}-clear" class="btn btn-sm btn-muted">Clear all</button>
                    <button type="submit" class="btn btn-primary btn-sm">Search</button>
                </div>
            </form>

        `;

        document.body.appendChild(modal);

        // Events
        btn.addEventListener('click', (e) => {
            e.preventDefault();
            const isOpening = modal.style.display !== 'block';
            modal.style.display = isOpening ? 'block' : 'none';
            if (isOpening) {
                populateFieldsFromURL();
                document.getElementById('inp-and').focus();
            }
        });

        document.getElementById(`${MODAL_ID}-close`).addEventListener('click', () => {
            modal.style.display = 'none';
        });

        document.getElementById(`${MODAL_ID}-form`).addEventListener('submit', (e) => {
            e.preventDefault();
            executeSearch();
        });

        document.getElementById(`${MODAL_ID}-clear`).addEventListener('click', () => {
            const ids = ['inp-and', 'inp-or', 'inp-not', 'inp-user', 'inp-repo', 'inp-lang', 'inp-ext', 'inp-stars', 'inp-forks', 'inp-path', 'inp-topics', 'inp-created', 'inp-pushed', 'inp-size', 'sel-type', 'sel-sort'];
            ids.forEach(id => {
                const el = document.getElementById(id);
                if (el) {
                    if (el.tagName === 'SELECT') el.selectedIndex = 0;
                    else el.value = '';
                }
            });
        });

        // Close on escape
        document.addEventListener('keydown', (e) => {
            if (e.key === 'Escape') modal.style.display = 'none';
            if (e.ctrlKey && e.shiftKey && e.key === 'F') {
                modal.style.display = 'block';
                populateFieldsFromURL();
                document.getElementById('inp-and').focus();
            }
        });
    }

    function populateFieldsFromURL() {
        const params = new URLSearchParams(window.location.search);
        const query = params.get('q');
        const type = params.get('type');
        const sort = params.get('s');

        // Reset fields
        const allIds = ['inp-and', 'inp-or', 'inp-not', 'inp-user', 'inp-repo', 'inp-lang', 'inp-ext', 'inp-stars', 'inp-forks', 'inp-path', 'inp-topics', 'inp-created', 'inp-pushed', 'inp-size', 'sel-type', 'sel-sort'];
        allIds.forEach(id => {
            const el = document.getElementById(id);
            if (el) {
                if (el.tagName === 'SELECT') el.selectedIndex = 0;
                else el.value = '';
            }
        });

        if (type) document.getElementById('sel-type').value = type;
        if (sort) document.getElementById('sel-sort').value = sort;

        if (!query) return;

        let remainingQuery = query;

        // 1. Extract metadata filters
        const metadataMap = {
            'user': 'inp-user',
            'repo': 'inp-repo',
            'language': 'inp-lang',
            'extension': 'inp-ext',
            'stars': 'inp-stars',
            'forks': 'inp-forks',
            'path': 'inp-path',
            'topic': 'inp-topics',
            'created': 'inp-created',
            'pushed': 'inp-pushed',
            'size': 'inp-size'
        };

        for (const [key, id] of Object.entries(metadataMap)) {
            const regex = new RegExp(`${key}:(\\S+)`, 'i');
            const match = remainingQuery.match(regex);
            if (match) {
                let val = match[1];
                if (key === 'stars' || key === 'forks') {
                    val = val.replace('>=', '');
                }
                document.getElementById(id).value = val;
                remainingQuery = remainingQuery.replace(match[0], '');
            }
        }

        // 2. Extract OR groups: (A OR B OR C)
        const orMatch = remainingQuery.match(/\(([^)]+ OR [^)]+)\)/i);
        if (orMatch) {
            const terms = orMatch[1].split(/\s+OR\s+/i);
            document.getElementById('inp-or').value = terms.join(', ');
            remainingQuery = remainingQuery.replace(orMatch[0], '');
        }

        // 3. Extract NOT terms: -term
        const notTerms = [];
        remainingQuery = remainingQuery.replace(/-(\S+)/g, (match, term) => {
            notTerms.push(term);
            return '';
        });
        if (notTerms.length > 0) {
            document.getElementById('inp-not').value = notTerms.join(', ');
        }

        // 4. Remaining goes to AND
        const andVal = remainingQuery.trim().replace(/\s+/g, ' ');
        if (andVal) {
            document.getElementById('inp-and').value = andVal;
        }
    }

    function executeSearch() {
        let queryParts = [];
        const getVal = (id) => document.getElementById(id).value.trim();

        // Helper to split by space, comma, or semicolon
        const parseList = (val) => val.split(/[\s,;]+/).filter(t => t.length > 0);

        // 1. Handle AND
        const andVal = getVal('inp-and');
        if (andVal) queryParts.push(andVal);

        // 2. Handle OR
        const orVal = getVal('inp-or');
        if (orVal) {
            const terms = parseList(orVal);
            if (terms.length > 1) queryParts.push(`(${terms.join(' OR ')})`);
            else if (terms.length === 1) queryParts.push(terms[0]);
        }

        // 3. Handle NOT
        const notVal = getVal('inp-not');
        if (notVal) {
            const terms = parseList(notVal);
            terms.forEach(t => queryParts.push(`-${t}`));
        }

        // 4. Metadata
        const metadata = {
            'user': 'inp-user',
            'repo': 'inp-repo',
            'language': 'inp-lang',
            'extension': 'inp-ext',
            'stars': 'inp-stars',
            'forks': 'inp-forks',
            'path': 'inp-path',
            'topic': 'inp-topics',
            'created': 'inp-created',
            'pushed': 'inp-pushed',
            'size': 'inp-size'
        };

        for (const [key, id] of Object.entries(metadata)) {
            let val = getVal(id);
            if (val) {
                // Auto-add >= to stars/forks if missing and only a number
                if ((key === 'stars' || key === 'forks') && !val.match(/[<>=]/)) val = `>=${val}`;
                queryParts.push(`${key}:${val}`);
            }
        }

        const type = document.getElementById('sel-type').value;
        const sort = document.getElementById('sel-sort').value;

        // Construct final URL
        const finalQuery = encodeURIComponent(queryParts.join(' '));
        let url = `https://github.com/search?q=${finalQuery}&type=${type}`;
        if (sort) url += `&s=${sort}&o=desc`;

        window.location.href = url;
    }

    // Init and Observe for Turbo/PJAX
    createUI();
    const observer = new MutationObserver(() => {
        if (!document.getElementById(TRIGGER_ID)) createUI();
    });
    observer.observe(document.body, { childList: true, subtree: true });

})();