// ==UserScript== // @name GitHub Advanced Search // @namespace https://github.com/quantavil/userscript // @version 4.2 // @description Advanced filter modal for GitHub search // @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', items: [ { id: 'and', label: 'AND', placeholder: 'rust async', type: 'text' }, { id: 'or', label: 'OR', placeholder: 'react, vue', type: 'text' }, ] }, { section: 'META', items: [ { id: 'user', label: 'Owner', placeholder: 'facebook', meta: 'user' }, { id: 'repo', label: 'Repository', placeholder: 'react', meta: 'repo' }, { id: 'lang', label: 'Language', placeholder: 'python', meta: 'language' }, { id: 'ext', label: 'Extension', placeholder: 'md', meta: 'extension' }, { id: 'path', label: 'Path', placeholder: 'src/', meta: 'path' }, { 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 — MIMIMAL BRUTAL ========================================================================= */ function injectStyles() { if (document.getElementById(CONFIG.ids.style)) return; const css = ` /* Light theme by default */ :root { --brutal-bg: #ffffff; --brutal-fg: #1a1a1a; --brutal-accent: #e11d48; --brutal-border: #1a1a1a; --brutal-muted: #6b7280; --brutal-shadow: 4px 4px 0 #1a1a1a; --brutal-font: system-ui, -apple-system, sans-serif; } /* --- Floating Toggle Button --- */ #${CONFIG.ids.toggleBtn} { position: fixed; top: 50%; right: 0; transform: translateY(-50%); width: 28px; height: 44px; background: var(--brutal-fg); border: none; border-radius: 6px 0 0 6px; cursor: pointer; z-index: 9997; display: flex; align-items: center; justify-content: center; opacity: 0.25; transition: opacity 0.15s, width 0.15s; } #${CONFIG.ids.toggleBtn}:hover, #${CONFIG.ids.toggleBtn}:focus { opacity: 1; width: 36px; } #${CONFIG.ids.toggleBtn} svg { width: 16px; height: 16px; fill: var(--brutal-bg); } @media (hover: none) { #${CONFIG.ids.toggleBtn} { opacity: 0.5; } } /* --- Modal Panel (Right Side) --- */ #${CONFIG.ids.modal} { position: fixed; top: 60px; right: 16px; bottom: auto; left: auto; transform: none; width: 420px; max-width: calc(100vw - 32px); max-height: calc(100vh - 80px); background: var(--brutal-bg); border: 2px solid var(--brutal-border); box-shadow: var(--brutal-shadow); z-index: 9999; display: none; flex-direction: column; font-family: var(--brutal-font); font-size: 13px; } #${CONFIG.ids.modal}[data-visible="true"] { display: flex; animation: brutal-slide 0.12s ease-out; } @keyframes brutal-slide { from { opacity: 0; transform: translateX(8px); } to { opacity: 1; transform: translateX(0); } } .brutal-header { background: var(--brutal-border); color: var(--brutal-bg); padding: 8px 12px; font-weight: 700; text-transform: uppercase; letter-spacing: 1px; display: flex; justify-content: space-between; align-items: center; font-size: 11px; } .brutal-close { cursor: pointer; font-size: 18px; line-height: 1; opacity: 0.7; font-weight: 400; } .brutal-close:hover { opacity: 1; } .brutal-body { padding: 12px; overflow-y: auto; scrollbar-width: none; -ms-overflow-style: none; } .brutal-body::-webkit-scrollbar { display: none; } .brutal-section { margin-bottom: 12px; } .brutal-section:last-child { margin-bottom: 0; } .brutal-section-title { font-weight: 700; font-size: 9px; text-transform: uppercase; letter-spacing: 1.5px; color: var(--brutal-muted); margin-bottom: 8px; padding-bottom: 4px; border-bottom: 1px solid var(--brutal-border); } .brutal-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; } .brutal-grid.full { grid-template-columns: 1fr; } .brutal-field label { display: block; font-size: 9px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 3px; color: var(--brutal-fg); } .brutal-input { width: 100%; background: var(--brutal-bg); border: 2px solid var(--brutal-border); padding: 6px 8px; color: var(--brutal-fg); font-family: inherit; font-size: 12px; box-sizing: border-box; } .brutal-input:focus { outline: none; border-color: var(--brutal-accent); } .brutal-input::placeholder { color: var(--brutal-muted); } .brutal-footer { padding: 10px 12px; border-top: 2px solid var(--brutal-border); display: flex; gap: 8px; } .brutal-btn { flex: 1; padding: 8px; border: 2px solid var(--brutal-border); background: var(--brutal-bg); font-family: inherit; font-weight: 700; font-size: 10px; text-transform: uppercase; letter-spacing: 0.5px; cursor: pointer; color: var(--brutal-fg); transition: transform 0.05s, box-shadow 0.05s; } .brutal-btn:hover { transform: translate(-2px, -2px); box-shadow: 4px 4px 0 var(--brutal-border); } .brutal-btn:active { transform: translate(0, 0); box-shadow: none; } .brutal-btn.primary { background: var(--brutal-border); color: var(--brutal-bg); } /* --- Release Badge (Brutal Enhanced) --- */ .gh-release-tag { display: inline-flex; align-items: center; padding: 4px 8px; margin-top: 6px; font-size: 11px; font-family: var(--brutal-font); font-weight: 700; border: 2px solid var(--brutal-border); background: var(--brutal-bg); color: var(--brutal-fg) !important; text-decoration: none !important; box-shadow: 2px 2px 0 var(--brutal-border); transition: transform 0.1s; } .gh-release-tag:hover { transform: translate(-1px, -1px); box-shadow: 3px 3px 0 var(--brutal-border); text-decoration: none !important; } .gh-release-tag.loading { opacity: 0.5; cursor: wait; border-style: dashed; } /* Success (Has Release) - GREEN */ .gh-release-tag.has-release { color: #15803d !important; border-color: #15803d; box-shadow: 2px 2px 0 #15803d; } .gh-release-tag.has-release:hover { background: #f0fdf4; box-shadow: 3px 3px 0 #15803d; } /* Failure (No Release) - RED */ .gh-release-tag.no-release { color: #b91c1c !important; border-color: #b91c1c; box-shadow: 2px 2px 0 #b91c1c; } .gh-release-tag.no-release:hover { background: #fef2f2; box-shadow: 3px 3px 0 #b91c1c; } /* --- Checkbox --- */ .brutal-check-row { display: flex; align-items: center; gap: 8px; } .brutal-check { width: 16px; height: 16px; accent-color: var(--brutal-accent); cursor: pointer; border: 2px solid var(--brutal-border); } .brutal-check-label { font-size: 11px; font-weight: 700; text-transform: uppercase; cursor: pointer; } /* --- Overlay --- */ .brutal-overlay { position: fixed; inset: 0; background: rgba(0, 0, 0, 0.4); backdrop-filter: blur(2px); z-index: 9998; display: none; } .brutal-overlay[data-visible="true"] { display: block; } /* Mobile responsive */ @media (max-width: 480px) { #${CONFIG.ids.modal} { right: 0; left: 0; bottom: 0; width: 100%; max-width: none; top: auto; max-height: 85vh; border-left: none; border-right: none; border-bottom: none; } } `; 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) { if (!str) return []; // Parse tokens respecting quotes: matches quoted strings OR non-space/comma sequences const matches = str.match(/("[^"]*"|[^, ]+)/g); return matches ? matches : []; } static buildUrl(data) { const parts = []; const andTerms = this.clean(data.and); if (andTerms.length) parts.push(...andTerms); const orTerms = this.clean(data.or); if (orTerms.length) { parts.push(orTerms.length === 1 ? orTerms[0] : `(${orTerms.join(' OR ')})`); } data.meta.forEach(m => { let val = m.value.trim(); if (!val) return; // Auto-add >= for specific numeric fields if missing operator if (['stars', 'forks', 'size'].includes(m.key)) { if (!val.match(/^[<>=]/) && !val.includes('..')) val = `>=${val}`; } parts.push(`${m.key}:${val}`); }); const query = encodeURIComponent(parts.join(' ')); let url = `https://github.com/search?q=${query}&type=${data.type}`; if (data.sort) url += `&s=${data.sort}&o=desc`; if (data.releasesOnly) url += '&userscript_has_release=1'; return url; } static parseCurrent() { const params = new URLSearchParams(window.location.search); const rawQ = params.get('q') || ''; const type = params.get('type') || ''; const sort = params.get('s') || ''; const releasesOnly = params.get('userscript_has_release') === '1'; const state = { type, sort, releasesOnly, and: '', or: '', meta: {} }; let q = rawQ; // 1. Extract Meta fields (key:value) - supports quoted values FIELDS.find(s => s.section === 'META').items.forEach(item => { const regex = new RegExp(`\\b${item.meta}:("[^"]*"|\\S+)`, 'gi'); q = q.replace(regex, (match, val) => { // Strip auto >= for display if (['stars', 'forks', 'size'].includes(item.meta) && val.startsWith('>=')) { val = val.substring(2); } state.meta[item.id] = val; return ''; // Remove from q }); }); // 2. Extract OR groups: (a OR b) const orMatch = q.match(/\(([^)]+)\)/); if (orMatch && orMatch[1].includes(' OR ')) { state.or = orMatch[1].split(' OR ').join(', '); q = q.replace(orMatch[0], ''); } // 3. Remaining is AND state.and = q.replace(/\s+/g, ' ').trim(); return state; } } /* ========================================================================= LOGIC: RELEASE DETECTION ========================================================================= */ const CACHE_PREFIX = 'gh-release-cache-'; const CACHE_TTL = 24 * 60 * 60 * 1000; // 24 hours function getReleaseCache(owner, repo) { try { const key = `${CACHE_PREFIX}${owner}-${repo}`; const cached = localStorage.getItem(key); if (!cached) return null; const data = JSON.parse(cached); if (Date.now() - data.timestamp > CACHE_TTL) { localStorage.removeItem(key); return null; } return data.info; } catch (e) { return null; } } function setReleaseCache(owner, repo, info) { try { const key = `${CACHE_PREFIX}${owner}-${repo}`; const data = { timestamp: Date.now(), info: info }; localStorage.setItem(key, JSON.stringify(data)); } catch (e) { } } function formatRelativeDate(dateStr) { const date = new Date(dateStr); const now = new Date(); const diffMs = now - date; const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24)); if (diffDays === 0) return 'today'; if (diffDays === 1) return 'yesterday'; if (diffDays < 7) return `${diffDays}d ago`; if (diffDays < 30) return `${Math.floor(diffDays / 7)}w ago`; if (diffDays < 365) return `${Math.floor(diffDays / 30)}mo ago`; return `${Math.floor(diffDays / 365)}y ago`; } function createReleaseBadge(status, data = null) { const badge = document.createElement('a'); badge.className = `gh-release-tag`; if (status === 'checking') { badge.textContent = 'Checking...'; badge.classList.add('loading'); badge.href = '#'; badge.onclick = (e) => e.preventDefault(); } else if (status === 'has-release' && data) { const dateText = data.date ? ` (${formatRelativeDate(data.date)})` : ''; badge.textContent = `${data.tag}${dateText}`; badge.href = data.url; badge.target = '_blank'; badge.title = data.date ? `Released: ${new Date(data.date).toLocaleDateString()}` : data.tag; badge.classList.add('has-release'); } else { badge.textContent = 'NO RELEASE'; badge.classList.add('no-release'); badge.href = '#'; badge.onclick = (e) => e.preventDefault(); } return badge; } async function fetchReleaseInfo(owner, repo) { const cached = getReleaseCache(owner, repo); if (cached) return cached; try { const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), 10000); const res = await fetch(`https://github.com/${owner}/${repo}/releases/latest`, { method: 'GET', redirect: 'follow', signal: controller.signal }); clearTimeout(timeoutId); if (res.status === 404 || !res.ok) { setReleaseCache(owner, repo, null); return null; } const htmlText = await res.text(); const parser = new DOMParser(); const doc = parser.parseFromString(htmlText, 'text/html'); let tag = null; const finalUrl = res.url; const tagMatch = finalUrl.match(/\/releases\/tag\/([^/?#]+)/); if (tagMatch) { tag = decodeURIComponent(tagMatch[1]); } else { const title = doc.title; const titleMatch = title.match(/Release (.+?) ·/); if (titleMatch) tag = titleMatch[1]; } if (!tag) { const header = doc.querySelector('h1.d-inline'); if (header) tag = header.textContent.trim(); } if (!tag) return null; let date = null; const timeEl = doc.querySelector('relative-time'); if (timeEl) { date = timeEl.getAttribute('datetime'); } else { const anyTime = doc.querySelector('time[datetime]'); if (anyTime) date = anyTime.getAttribute('datetime'); } const info = { tag, date, url: `https://github.com/${owner}/${repo}/releases/tag/${encodeURIComponent(tag)}` }; setReleaseCache(owner, repo, info); return info; } catch (e) { return null; } } async function processQueue(items, concurrency, task) { const queue = [...items]; const workers = []; const worker = async () => { while (queue.length > 0) { const item = queue.shift(); try { await task(item); } catch (e) { } } }; for (let i = 0; i < concurrency; i++) { workers.push(worker()); } await Promise.all(workers); } async function processItem(item, filterOnly) { const link = item.querySelector(CONFIG.selectors.resultLink); if (!link) return; const path = link.getAttribute('href'); const parts = path.split('/').filter(Boolean); if (parts.length < 2) return; const [owner, repo] = parts; const metaList = item.querySelector('ul'); const insertTarget = metaList || item; // Container is still used to keep structure clean, but no specific asset classes needed const badgeContainer = document.createElement('div'); badgeContainer.style.marginTop = '6px'; const checkingBadge = createReleaseBadge('checking'); badgeContainer.appendChild(checkingBadge); insertTarget.parentNode.insertBefore(badgeContainer, insertTarget.nextSibling); const releaseInfo = await fetchReleaseInfo(owner, repo); badgeContainer.innerHTML = ''; if (releaseInfo) { const releaseBadge = createReleaseBadge('has-release', releaseInfo); badgeContainer.appendChild(releaseBadge); } else { if (filterOnly) { item.style.display = 'none'; badgeContainer.style.display = 'none'; } else { const noReleaseBadge = createReleaseBadge('no-release'); badgeContainer.appendChild(noReleaseBadge); } } } async function processSearchResults() { if (!window.location.pathname.startsWith('/search')) return; const params = new URLSearchParams(window.location.search); const filterOnly = params.get('userscript_has_release') === '1'; const allItems = Array.from(document.querySelectorAll(CONFIG.selectors.resultItem)); const itemsToProcess = allItems.filter(item => !item.dataset.releaseProcessed); itemsToProcess.forEach(item => item.dataset.releaseProcessed = 'true'); await processQueue(itemsToProcess, 3, item => processItem(item, filterOnly)); } /* ========================================================================= UI: MODAL ========================================================================= */ let modalEl = null; let overlayEl = null; function toggleModal(show) { if (!modalEl) createUI(); // If show is undefined, toggle. If defined, set. const visible = (show === undefined) ? (modalEl.dataset.visible !== 'true') : show; modalEl.dataset.visible = visible; overlayEl.dataset.visible = visible; if (visible) { loadStateToUI(); const firstInput = modalEl.querySelector('input, select'); if (firstInput) firstInput.focus(); } } function createUI() { if (document.getElementById(CONFIG.ids.modal)) return; // Overlay overlayEl = document.createElement('div'); overlayEl.className = 'brutal-overlay'; overlayEl.onclick = () => toggleModal(false); document.body.appendChild(overlayEl); // Modal modalEl = document.createElement('div'); modalEl.id = CONFIG.ids.modal; let html = `
FILTER ×
`; FIELDS.forEach(section => { html += `
${section.section}
`; section.items.forEach(field => { const dangerStyle = field.danger ? 'style="color:var(--brutal-accent)"' : ''; html += `
${field.type === 'select' ? `` : ``}
`; }); html += `
`; }); html += `
`; modalEl.innerHTML = html; document.body.appendChild(modalEl); // Floating toggle button if (!document.getElementById(CONFIG.ids.toggleBtn)) { const toggleBtn = document.createElement('button'); toggleBtn.id = CONFIG.ids.toggleBtn; toggleBtn.type = 'button'; toggleBtn.title = 'Search Filter'; toggleBtn.innerHTML = ``; toggleBtn.onclick = () => toggleModal(); document.body.appendChild(toggleBtn); } // Events 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('[data-search]').onclick = executeSearch; // Keydown modalEl.addEventListener('keydown', (e) => { if (e.key === 'Enter') executeSearch(); }); document.addEventListener('keydown', e => { if (e.key === 'Escape' && modalEl.dataset.visible === 'true') toggleModal(false); }); } function 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); Object.entries(state.meta).forEach(([id, val]) => setVal(id, val)); const relCheck = document.getElementById('gh-field-releases'); if (relCheck) relCheck.checked = state.releasesOnly; } function executeSearch() { const getVal = id => document.getElementById(`gh-field-${id}`).value; const data = { type: getVal('type'), sort: getVal('sort'), and: getVal('and'), or: getVal('or'), meta: [], releasesOnly: document.getElementById('gh-field-releases').checked }; FIELDS.find(s => s.section === 'META').items.forEach(item => { const val = getVal(item.id); if (val) data.meta.push({ key: item.meta, value: val }); }); window.location.href = QueryBuilder.buildUrl(data); } /* ========================================================================= INIT ========================================================================= */ function init() { injectStyles(); createUI(); // Register Menu if (typeof GM_registerMenuCommand === 'function') { GM_registerMenuCommand("Search Filter", () => toggleModal()); } // Process existing results processSearchResults(); // Observers let debounceTimer; // Watch for page changes (GitHub uses Turbo/PJAX) const observer = new MutationObserver(() => { clearTimeout(debounceTimer); debounceTimer = setTimeout(() => { processSearchResults(); }, 200); }); observer.observe(document.body, { childList: true, subtree: true }); // Turbo event listener for cleaner navigation handling document.addEventListener('turbo:render', () => { processSearchResults(); }); } init(); })();