Last active
May 8, 2026 02:38
-
-
Save mu-hun/eb5fd44a3f72f49cefc825f65c444c55 to your computer and use it in GitHub Desktop.
GitHub Copy Issue for @AdguardTeam Filters Maintenance
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // ==UserScript== | |
| // @name GitHub Copy Issue | |
| // @namespace https://github.com/AdguardTeam/ | |
| // @version 1.0.0 | |
| // @description Adds Copy Fix / Copy Upd buttons to GitHub issue pages | |
| // @author Mu-Hun | |
| // @match https://github.com/AdguardTeam/AdguardFilters/issues/* | |
| // @match https://github.com/issues/* | |
| // @run-at document-start | |
| // @downloadURL https://gist.githubusercontent.com/mu-hun/eb5fd44a3f72f49cefc825f65c444c55/raw/github-copy-issue.js | |
| // @updateURL https://gist.githubusercontent.com/mu-hun/eb5fd44a3f72f49cefc825f65c444c55/raw/github-copy-issue.js | |
| // @grant none | |
| // @license MIT | |
| // ==/UserScript== | |
| ;(function () { | |
| 'use strict' | |
| const SELECTORS = { | |
| title: '[data-testid="issue-title"]', | |
| header: '[data-testid="issue-header"]', | |
| number: | |
| '[data-testid="issue-title"] + span[class^="HeaderViewer-module__issueNumberText--"], [data-testid="issue-title"] ~ span', | |
| actionsAll: 'div[class^="HeaderMenu-module__menuActionsContainer--"]', | |
| } | |
| const CSS_PREFIX = 'gm-copy-issue' | |
| const COLOR_FIX = '#30A147' | |
| const COLOR_UPD = '#006EDB' | |
| const COLOR_TEXT = '#fff' | |
| const CONFIG = { debounce: 80, checkMs: 1200, initDelay: 40 } | |
| ;(function injectStyles() { | |
| const css = ` | |
| :root{ --gm-fix:${COLOR_FIX}; --gm-upd:${COLOR_UPD}; --gm-text:${COLOR_TEXT}; } | |
| .${CSS_PREFIX}-btn{ position:relative; display:inline-flex; align-items:center; gap:8px; padding:6px 12px; font-size:13px; border-radius:6px; border:1px solid rgba(0,0,0,0.12); color:var(--gm-text); cursor:pointer; box-shadow:0 1px 0 rgba(0,0,0,0.06); margin-left:8px; z-index:1; font-weight:600; font-family:inherit; line-height:1; overflow:visible; background-clip:padding-box; -webkit-font-smoothing:antialiased; } | |
| .${CSS_PREFIX}-btn svg{ display:block; fill:none; stroke:currentColor; } | |
| .${CSS_PREFIX}-btn .gm-btn-icon{ width:16px; height:16px; display:inline-flex; align-items:center; justify-content:center; flex:0 0 auto; line-height:0; } | |
| .${CSS_PREFIX}-btn .gm-btn-text{ display:inline-block; visibility:visible; } | |
| .${CSS_PREFIX}-btn .gm-btn-check{ position:absolute; left:50%; top:50%; transform:translate(-50%,-50%); visibility:hidden; display:inline-flex; align-items:center; justify-content:center; pointer-events:none; line-height:0; } | |
| .${CSS_PREFIX}-btn:focus{ outline:2px solid rgba(255,255,255,0.12); outline-offset:2px; box-shadow:0 2px 6px rgba(0,0,0,0.08); } | |
| .${CSS_PREFIX}-adguard-fix{ background:var(--gm-fix); } .${CSS_PREFIX}-adguard-upd{ background:var(--gm-upd); } | |
| .${CSS_PREFIX}-other-fix{ background:var(--gm-fix); } .${CSS_PREFIX}-other-upd{ background:var(--gm-upd); } | |
| /* Adaptive layout for title+metadata to avoid collapse */ | |
| div[class^="HeaderMetadata-module__titleAndMetadata--"]{ | |
| box-sizing:border-box !important; | |
| min-width:320px !important; | |
| max-width:calc(100% - 220px) !important; | |
| width:auto !important; | |
| } | |
| @media (max-width:760px){ | |
| div[class^="HeaderMetadata-module__titleAndMetadata--"]{ min-width:0 !important; max-width:100% !important; width:100% !important; } | |
| div[class^="HeaderMenu-module__menuActionsContainer--"]{ margin-top:8px !important; } | |
| } | |
| [data-gm-generated="true"]{} | |
| ` | |
| const style = document.createElement('style') | |
| style.setAttribute('data-gm-style', 'v2') | |
| style.textContent = css | |
| ;(document.head || document.documentElement).appendChild(style) | |
| })() | |
| let toastEl = null | |
| let toastTimer = null | |
| function showToast(text) { | |
| try { | |
| if (!toastEl) { | |
| toastEl = document.createElement('div') | |
| Object.assign(toastEl.style, { | |
| position: 'fixed', | |
| left: '16px', | |
| bottom: '16px', | |
| zIndex: 2147483647, | |
| padding: '8px 12px', | |
| background: 'rgba(0,0,0,0.78)', | |
| color: '#fff', | |
| borderRadius: '6px', | |
| fontSize: '13px', | |
| pointerEvents: 'none', | |
| transition: 'opacity 150ms', | |
| opacity: '0', | |
| }) | |
| document.body.appendChild(toastEl) | |
| } | |
| toastEl.textContent = text | |
| toastEl.style.opacity = '1' | |
| toastTimer && clearTimeout(toastTimer) | |
| toastTimer = setTimeout(() => { | |
| try { | |
| toastEl.style.opacity = '0' | |
| } catch {} | |
| }, 1200) | |
| } catch {} | |
| } | |
| function resolveIssueNumber(numberText) { | |
| if (!numberText) { | |
| const pathMatch = location.pathname.match(/\/issues\/(\d+)(?:$|\/)/) | |
| if (pathMatch) return pathMatch[1] | |
| const q = new URLSearchParams(location.search).get('issue') | |
| if (q) { | |
| const parts = q.split('|') | |
| if (parts[2]) return parts[2] | |
| } | |
| return '' | |
| } | |
| const m = String(numberText).match(/#?(\d+)/) | |
| return m && m[1] ? m[1] : '' | |
| } | |
| function buildCopyText({ mode, prefix, titleRaw, numberText }) { | |
| const title = (titleRaw || '').trim() | |
| if ('adguard' === mode) { | |
| const number = resolveIssueNumber(numberText) | |
| return number | |
| ? `${prefix} #${number} ${title}`.trim() | |
| : `${prefix} ${title}`.trim() | |
| } | |
| return `${prefix} ${title || location.hostname || ''} ${location.href || ''}`.trim() | |
| } | |
| const btnTemplate = document.createElement('template') | |
| btnTemplate.innerHTML = ` | |
| <button type="button" class="${CSS_PREFIX}-btn" data-gm-generated="true" aria-pressed="false"> | |
| <span class="gm-btn-icon"></span> | |
| <span class="gm-btn-text"></span> | |
| <span class="gm-btn-check"></span> | |
| </button> | |
| ` | |
| function filterIconSvg(size = 16, strokeWidth = 12) { | |
| return `<svg width="${size}" height="${size}" viewBox="0 0 256 256" xmlns="http://www.w3.org/2000/svg" aria-hidden="true"><path d="M112.001,229.05469A12.00583,12.00583,0,0,1,100,217.05176v-77.959a3.98926,3.98926,0,0,0-1.03955-2.68945l-65.75537-72.331A12,12,0,0,1,42.08447,44H213.91553a12,12,0,0,1,8.87939,20.07227l-65.75439,72.33007A3.98975,3.98975,0,0,0,156,139.09277v56.626a11.97406,11.97406,0,0,1-5.34424,9.98437l-31.99951,21.333A11.98627,11.98627,0,0,1,112.001,229.05469ZM42.08447,52A4.00015,4.00015,0,0,0,39.125,58.69141l65.75439,72.33007A11.971,11.971,0,0,1,108,139.09277v77.959a3.99924,3.99924,0,0,0,6.21826,3.32812l32.00049-21.333A3.99,3.99,0,0,0,148,195.71875v-56.626a11.97153,11.97153,0,0,1,3.12158-8.07226L216.875,58.69141A4.00015,4.00015,0,0,0,213.91553,52Z" fill="none" stroke="currentColor" stroke-width="${strokeWidth}" stroke-linecap="round" stroke-linejoin="round"/></svg>` | |
| } | |
| function checkIconSvg() { | |
| return '<svg width="16" height="16" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M3.5 8.2L6.5 11.2L12.5 4.8" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/></svg>' | |
| } | |
| function createButton({ role, mode, prefix, label, titleAttr }) { | |
| const btn = btnTemplate.content.firstElementChild.cloneNode(true) | |
| btn.dataset.gmRole = role | |
| btn.dataset.gmMode = mode | |
| btn.dataset.gmPrefix = prefix | |
| btn.title = titleAttr || label | |
| btn.setAttribute('aria-label', titleAttr || label) | |
| btn.querySelector('.gm-btn-icon').innerHTML = filterIconSvg(16, 12) | |
| btn.querySelector('.gm-btn-text').textContent = label | |
| btn.querySelector('.gm-btn-check').innerHTML = checkIconSvg() | |
| btn.classList.add(`${CSS_PREFIX}-${mode}-${prefix.toLowerCase()}`) | |
| return btn | |
| } | |
| function injectButtons(container) { | |
| if (!(container && container instanceof Element)) return | |
| const mode = 'adguard' | |
| const hasBtn = (role) => | |
| !!container.querySelector( | |
| `[data-gm-generated="true"][data-gm-role="${role}"][data-gm-mode="${mode}"]` | |
| ) | |
| if (!hasBtn('upd')) { | |
| container.appendChild( | |
| createButton({ | |
| role: 'upd', | |
| mode, | |
| prefix: 'Upd', | |
| label: 'Copy Upd', | |
| titleAttr: 'Copy: Upd #000000 example.com', | |
| }) | |
| ) | |
| } | |
| if (!hasBtn('fix')) { | |
| container.appendChild( | |
| createButton({ | |
| role: 'fix', | |
| mode, | |
| prefix: 'Fix', | |
| label: 'Copy Fix', | |
| titleAttr: 'Copy: Fix #000000 example.com', | |
| }) | |
| ) | |
| } | |
| } | |
| document.addEventListener( | |
| 'click', | |
| (e) => { | |
| const btn = e.target.closest('[data-gm-generated="true"]') | |
| if (!btn) return | |
| e.stopPropagation() | |
| e.preventDefault() | |
| const prefix = | |
| btn.dataset.gmPrefix || ('fix' === btn.dataset.gmRole ? 'Fix' : 'Upd') | |
| const mode = btn.dataset.gmMode || 'adguard' | |
| const titleEl = document.querySelector(SELECTORS.title) | |
| const numberEl = document.querySelector(SELECTORS.number) | |
| const text = buildCopyText({ | |
| mode, | |
| prefix, | |
| titleRaw: titleEl | |
| ? (titleEl.innerText || titleEl.textContent || '').trim() | |
| : '', | |
| numberText: numberEl | |
| ? (numberEl.innerText || numberEl.textContent || '').trim() | |
| : '', | |
| }) | |
| const btnText = btn.querySelector('.gm-btn-text') | |
| const btnIcon = btn.querySelector('.gm-btn-icon') | |
| const btnCheck = btn.querySelector('.gm-btn-check') | |
| btn._timer && clearTimeout(btn._timer) | |
| async function copyText(t) { | |
| if (!t) return showToast('Nothing to copy') | |
| try { | |
| if (navigator.clipboard?.writeText) { | |
| await navigator.clipboard.writeText(t) | |
| } else { | |
| const ta = document.createElement('textarea') | |
| ta.value = t | |
| ta.style.position = 'fixed' | |
| ta.style.left = '-9999px' | |
| document.body.appendChild(ta) | |
| ta.select() | |
| document.execCommand('copy') | |
| ta.remove() | |
| } | |
| showToast('Copied') | |
| } catch (err) { | |
| console.error('copy failed', err) | |
| showToast('Copy failed') | |
| } | |
| } | |
| copyText(text) | |
| btn._showing = true | |
| btn.style.filter = 'brightness(0.78)' | |
| btnText && (btnText.style.visibility = 'hidden') | |
| btnIcon && (btnIcon.style.visibility = 'hidden') | |
| btnCheck && (btnCheck.style.visibility = 'visible') | |
| btn.setAttribute('aria-pressed', 'true') | |
| btn._timer = setTimeout(() => { | |
| btn._showing = false | |
| btn.style.filter = '' | |
| btnText && (btnText.style.visibility = 'visible') | |
| btnIcon && (btnIcon.style.visibility = 'visible') | |
| btnCheck && (btnCheck.style.visibility = 'hidden') | |
| btn.removeAttribute('aria-pressed') | |
| btn._timer = null | |
| }, CONFIG.checkMs) | |
| }, | |
| true | |
| ) | |
| let observer = null | |
| let debounceTimer = null | |
| function scheduleCheck() { | |
| debounceTimer && clearTimeout(debounceTimer) | |
| debounceTimer = setTimeout(() => { | |
| debounceTimer = null | |
| const run = () => { | |
| try { | |
| document.querySelector(SELECTORS.title) | |
| const containers = Array.from( | |
| document.querySelectorAll(SELECTORS.actionsAll) | |
| ) | |
| const titleEl = document.querySelector(SELECTORS.title) | |
| const headerEl = | |
| document.querySelector(SELECTORS.header) || | |
| (titleEl && (titleEl.closest('div') || titleEl.parentElement)) | |
| if (headerEl && !containers.includes(headerEl)) | |
| containers.push(headerEl) | |
| containers.forEach(injectButtons) | |
| } catch (err) { | |
| console.error(err) | |
| } | |
| } | |
| 'requestIdleCallback' in window | |
| ? requestIdleCallback(run, { timeout: 200 }) | |
| : setTimeout(run, 20) | |
| }, CONFIG.debounce) | |
| } | |
| function startObserver() { | |
| if (observer) | |
| try { | |
| observer.disconnect() | |
| } catch {} | |
| observer = new MutationObserver((mutations) => { | |
| let changed = false | |
| for (const m of mutations) { | |
| if ('childList' === m.type && m.addedNodes && m.addedNodes.length) { | |
| changed = true | |
| break | |
| } | |
| if ('attributes' === m.type) { | |
| changed = true | |
| break | |
| } | |
| } | |
| if (changed) scheduleCheck() | |
| }) | |
| try { | |
| observer.observe(document.documentElement, { | |
| childList: true, | |
| subtree: true, | |
| attributes: true, | |
| attributeFilter: ['class', 'data-component'], | |
| }) | |
| } catch {} | |
| } | |
| function onNavigate() { | |
| startObserver() | |
| setTimeout(scheduleCheck, CONFIG.initDelay) | |
| } | |
| ;(function patchHistory() { | |
| try { | |
| const origPush = history.pushState | |
| const origReplace = history.replaceState | |
| history.pushState = function () { | |
| const result = origPush.apply(this, arguments) | |
| window.dispatchEvent(new Event('locationchange')) | |
| return result | |
| } | |
| history.replaceState = function () { | |
| const result = origReplace.apply(this, arguments) | |
| window.dispatchEvent(new Event('locationchange')) | |
| return result | |
| } | |
| window.addEventListener('popstate', () => | |
| window.dispatchEvent(new Event('locationchange')) | |
| ) | |
| } catch {} | |
| })() | |
| window.addEventListener('locationchange', onNavigate, true) | |
| document.addEventListener('pjax:end', onNavigate, true) | |
| document.addEventListener('turbo:load', onNavigate, true) | |
| ;(function init() { | |
| try { | |
| startObserver() | |
| setTimeout(scheduleCheck, CONFIG.initDelay) | |
| window.addEventListener('unload', () => { | |
| observer && observer.disconnect() | |
| document | |
| .querySelectorAll('[data-gm-generated="true"]') | |
| .forEach((el) => el.remove()) | |
| toastTimer && clearTimeout(toastTimer) | |
| }) | |
| } catch (err) { | |
| console.error('init', err) | |
| } | |
| })() | |
| })() |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment