// ==UserScript== // @name Custom DuckDuckGo Bangs // @namespace https://github.com/JMcrafter26/userscripts // @version 1.8.1 // @description Add your own !bangs to DuckDuckGo (and other search engines) without touching built-in ones // @author Cufiy // @license AGPL-3.0 // @match https://duckduckgo.com/* // @match https://*.duckduckgo.com/* // @match https://*.google.com/search* // @match https://*.bing.com/search* // @match https://*.yahoo.com/search* // @match https://search.yahoo.com/search* // @match https://*.startpage.com/sp/search* // @match https://*.startpage.com/do/dsearch* // @match https://search.brave.com/search* // @match https://*.ecosia.org/search* // @match https://*.qwant.com/?* // @match https://kagi.com/search* // @grant GM_getValue // @grant GM_setValue // @grant GM_registerMenuCommand // @grant GM_xmlhttpRequest // @grant GM_notification // @connect duckduckgo.com // @connect raw.githubusercontent.com // @connect gist.githubusercontent.com // @connect * // @run-at document-start // ==/UserScript== (function () { 'use strict'; const OWN_KEY = 'customBangs'; const LISTS_KEY = 'externalLists'; const SETTINGS_KEY = 'cbSettings'; // {checkCollisions, syncInterval, enableOtherEngines, useDdgOfficial} const DDG_CACHE_KEY = 'ddgOfficialCache'; // ---------- Storage helpers ---------- function getOwn() { try { return JSON.parse(GM_getValue(OWN_KEY, '[]')); } catch (e) { return []; } } function saveOwn(list) { GM_setValue(OWN_KEY, JSON.stringify(list)); } function getLists() { try { return JSON.parse(GM_getValue(LISTS_KEY, '[]')); } catch (e) { return []; } } function saveLists(list) { GM_setValue(LISTS_KEY, JSON.stringify(list)); } function getSettings() { const defaults = { checkCollisions: true, syncInterval: 24, enableOtherEngines: true, useDdgOfficial: true }; try { return Object.assign(defaults, JSON.parse(GM_getValue(SETTINGS_KEY, '{}'))); } catch (e) { return defaults; } } function saveSettings(s) { GM_setValue(SETTINGS_KEY, JSON.stringify(s)); } function getDdgCache() { try { return JSON.parse(GM_getValue(DDG_CACHE_KEY, '{"bangs":[],"lastFetched":null}')); } catch (e) { return { bangs: [], lastFetched: null }; } } function saveDdgCache(c) { GM_setValue(DDG_CACHE_KEY, JSON.stringify(c)); } function uid() { return Date.now().toString(36) + Math.random().toString(36).slice(2, 7); } function triggerDownload(filename, text) { const el = document.createElement('a'); el.setAttribute('href', 'data:application/json;charset=utf-8,' + encodeURIComponent(text)); el.setAttribute('download', filename); el.style.display = 'none'; document.body.appendChild(el); el.click(); document.body.removeChild(el); } // ---------- Shared parser ---------- function normalizeRemoteBangs(rawText) { const data = JSON.parse(rawText); if (!Array.isArray(data)) throw new Error('Expected a JSON array'); return data.map(item => { let trig, name, url; let aliases = []; if (item.t !== undefined && item.u !== undefined) { trig = String(item.t); name = item.s || item.t; url = item.u; if (item.a) aliases = Array.isArray(item.a) ? item.a : [item.a]; } else if (item.trigger !== undefined && item.url !== undefined) { trig = String(item.trigger); name = item.name || item.trigger; url = item.url; if (item.aliases) aliases = Array.isArray(item.aliases) ? item.aliases : [item.aliases]; } else { return null; } return { trigger: trig.replace(/^!/, '').toLowerCase(), name, url, aliases: aliases.map(a => String(a).replace(/^!/, '').toLowerCase()) }; }).filter(Boolean); } // ---------- Matching & redirect ---------- function findMatch(trigger, isDDG) { const t = trigger.toLowerCase(); // 1. Personal bangs (check primary and aliases, must be enabled) const own = getOwn().find(b => b.enabled !== false && (b.trigger.toLowerCase() === t || (b.aliases && b.aliases.some(a => a.toLowerCase() === t))) ); if (own) return { source: 'own', bang: own }; // 2. External lists (check primary and aliases) for (const list of getLists()) { if (!list.enabled) continue; const found = (list.bangs || []).find(b => b.trigger.toLowerCase() === t || (b.aliases && b.aliases.some(a => a.toLowerCase() === t)) ); if (found) return { source: 'list', listName: list.name, bang: found }; } // 3. Official DDG bangs const settings = getSettings(); if (!isDDG && settings.enableOtherEngines && settings.useDdgOfficial) { const ddgCache = getDdgCache(); const foundOfficial = (ddgCache.bangs || []).find(b => b.trigger.toLowerCase() === t); if (foundOfficial) return { source: 'official', bang: foundOfficial }; } return null; } function buildTarget(url, rest) { return url.includes('{{{s}}}') ? url.replace(/\{\{\{s\}\}\}/g, encodeURIComponent(rest)) : url + encodeURIComponent(rest); } function tryRedirect() { const isDDG = location.hostname.includes('duckduckgo.com'); const settings = getSettings(); if (!isDDG && !settings.enableOtherEngines) return false; const params = new URLSearchParams(location.search); const q = ['q', 'p', 'query', 'text'].map(k => params.get(k)).find(Boolean); if (!q) return false; const match = q.match(/(^|\s)!(\S+)/); if (!match) return false; const hit = findMatch(match[2], isDDG); if (!hit) return false; const rest = q.replace(match[0], ' ').trim().replace(/\s+/g, ' '); location.replace(buildTarget(hit.bang.url, rest)); return true; } if (tryRedirect()) return; // ---------- Remote sync ---------- function syncList(id, { silent = false } = {}) { return new Promise((resolve, reject) => { const lists = getLists(); const list = lists.find(l => l.id === id); if (!list) return reject(new Error('list not found')); GM_xmlhttpRequest({ method: 'GET', url: list.url, nocache: true, onload: (res) => { try { const parsed = normalizeRemoteBangs(res.responseText); const fresh = getLists(); const target = fresh.find(l => l.id === id); if (!target) return resolve(null); target.bangs = parsed; target.lastSync = new Date().toISOString(); saveLists(fresh); if (!silent) GM_notification({ text: `${target.name}: synced ${parsed.length} bangs`, title: 'Custom Bangs' }); resolve(parsed); } catch (e) { if (!silent) GM_notification({ text: `${list.name}: sync failed — ${e.message}`, title: 'Custom Bangs' }); reject(e); } }, onerror: (e) => { if (!silent) GM_notification({ text: `${list.name}: sync request failed`, title: 'Custom Bangs' }); reject(e); } }); }); } function fetchDdgOfficial({ silent = false } = {}) { return new Promise((resolve, reject) => { GM_xmlhttpRequest({ method: 'GET', url: 'https://duckduckgo.com/bang.js', nocache: true, onload: (res) => { try { const parsed = normalizeRemoteBangs(res.responseText); saveDdgCache({ bangs: parsed, lastFetched: new Date().toISOString() }); if (!silent) GM_notification({ text: `Cached ${parsed.length} official DDG bangs`, title: 'Custom Bangs' }); resolve(parsed); } catch (e) { if (!silent) GM_notification({ text: 'Fetching official DDG bangs failed: ' + e.message, title: 'Custom Bangs' }); reject(e); } }, onerror: (e) => { if (!silent) GM_notification({ text: 'Fetching official DDG bangs failed', title: 'Custom Bangs' }); reject(e); } }); }); } // ---------- Collision helpers ---------- function officialTriggerMap() { return new Map(getDdgCache().bangs.map(b => [b.trigger.toLowerCase(), b])); } function ownCollisions(bang) { if (!getSettings().checkCollisions || bang.enabled === false) return []; const offMap = officialTriggerMap(); const colls = []; if (offMap.has(bang.trigger.toLowerCase())) colls.push(bang.trigger); if (bang.aliases) { for (const a of bang.aliases) { if (offMap.has(a.toLowerCase())) colls.push(a); } } return colls; } function listStats(list, allLists, ownBangs) { const settings = getSettings(); const idx = allLists.findIndex(l => l.id === list.id); const higherSets = allLists.slice(0, idx).filter(l => l.enabled).map(l => new Set( (l.bangs || []).flatMap(b => [b.trigger.toLowerCase(), ...(b.aliases || []).map(a => a.toLowerCase())]) )); const ownSet = new Set(); ownBangs.forEach(b => { if (b.enabled !== false) { ownSet.add(b.trigger.toLowerCase()); if (b.aliases) b.aliases.forEach(a => ownSet.add(a.toLowerCase())); } }); const official = settings.checkCollisions ? officialTriggerMap() : null; let shadowed = 0, officialCollisions = 0; for (const b of (list.bangs || [])) { const triggers = [b.trigger.toLowerCase(), ...(b.aliases || []).map(a => a.toLowerCase())]; let isShadowed = false; let isColliding = false; for (const t of triggers) { if (ownSet.has(t) || higherSets.some(s => s.has(t))) isShadowed = true; if (official && official.has(t)) isColliding = true; } if (isShadowed) shadowed++; if (isColliding) officialCollisions++; } return { shadowed, officialCollisions, total: (list.bangs || []).length }; } // ---------- UI ---------- const STYLE = ` .cb-overlay{position:fixed;inset:0;background:rgba(0,0,0,.6);z-index:999999;display:flex; align-items:flex-start;justify-content:center;padding:16px;overflow:auto;font-family:-apple-system,system-ui,sans-serif;box-sizing:border-box;} .cb-modal{background:#181818;color:#eee;width:100%;max-width:820px;border-radius:12px;padding:24px; box-shadow:0 10px 40px rgba(0,0,0,.5);position:relative;box-sizing:border-box;margin-bottom:40px;} .cb-modal h2{margin:0 0 16px;font-size:20px;} .cb-row{display:flex;gap:8px;margin-bottom:10px;align-items:center;flex-wrap:wrap;} .cb-row input, .cb-row select{flex:1;background:#111;border:1px solid #333;color:#eee;border-radius:8px;padding:8px 10px;font-size:14px;box-sizing:border-box;} .cb-row input.cb-trigger{flex:0 0 160px;} .cb-list{max-height:360px;overflow:auto;margin-bottom:16px;border-radius:8px;} /* Group styles */ .cb-group{border:1px solid #2a2a2a;border-radius:8px;margin-bottom:12px;background:#141414;overflow:hidden;} .cb-group-header{display:flex;align-items:center;padding:10px 12px;background:#1a1a1a;border-bottom:1px solid #2a2a2a;} .cb-group-title{flex:1;display:flex;align-items:center;gap:8px;cursor:pointer;user-select:none;} .cb-group-title h4{margin:0;font-size:14px;color:#eee;font-weight:600;} .cb-group-actions{display:flex;gap:6px;flex-wrap:wrap;} .cb-group.collapsed .cb-group-header{border-bottom:none;} .cb-group.collapsed .cb-group-content{display:none;} .cb-toggle-icon{color:#888;font-size:10px;width:12px;text-align:center;} .cb-item{display:flex;gap:8px;align-items:center;padding:10px;border-bottom:1px solid #2a2a2a;} .cb-item:last-child{border-bottom:none;} .cb-item.disabled{opacity:0.45;} .cb-item-info{display:flex;flex:1;align-items:center;overflow:hidden;gap:8px;} .cb-item-actions{display:flex;gap:6px;flex-shrink:0;} .cb-item .cb-trig{color:#7ab7ff;font-weight:600;flex-shrink:0;white-space:nowrap;} .cb-item .cb-name{flex:1;color:#ccc;font-size:13px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;display:flex;align-items:center;} .cb-item .cb-url{flex:2;color:#888;font-size:12px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;} .cb-cat-badge{background:#333;padding:3px 6px;border-radius:4px;font-size:11px;margin-right:6px;color:#bbb;white-space:nowrap;} .cb-warn{color:#e0b040;font-size:12px;cursor:help;} .cb-btn{background:#2a2a2a;color:#eee;border:1px solid #3a3a3a;border-radius:8px;padding:8px 12px; font-size:13px;cursor:pointer;white-space:nowrap;box-sizing:border-box;} .cb-btn:hover{background:#333;} .cb-btn.danger{color:#ff8080;} .cb-btn.primary{background:#3574f0;border-color:#3574f0;} .cb-btn:disabled{opacity:.35;cursor:default;} .cb-section{margin-top:20px;padding-top:16px;border-top:1px solid #2a2a2a;} .cb-section h3{margin:0 0 4px;font-size:14px;color:#aaa;text-transform:uppercase;letter-spacing:.05em;} .cb-section .cb-hint{color:#777;font-size:12px;margin-bottom:10px;} .cb-section textarea{width:100%;min-height:120px;background:#111;border:1px solid #333;color:#eee; border-radius:8px;padding:8px;font-family:monospace;font-size:12px;box-sizing:border-box;} .cb-flex{display:flex;gap:8px;flex-wrap:wrap;margin-top:8px;align-items:center;} .cb-close{position:absolute;top:16px;right:20px;cursor:pointer;color:#888;font-size:24px;background:none;border:none;} .cb-small{color:#888;font-size:12px;margin-top:4px;} .cb-listrow{display:flex;gap:8px;align-items:center;padding:10px;border-bottom:1px solid #2a2a2a;flex-wrap:wrap;} .cb-listrow:last-child{border-bottom:none;} .cb-listrow.disabled{opacity:.45;} .cb-listrow .cb-lname{flex:1;min-width:0;} .cb-listrow .cb-lname .cb-ltitle{font-size:14px;color:#eee;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;} .cb-copy-url:hover { text-decoration: underline; color: #fff; } .cb-listrow .cb-lname .cb-lmeta{font-size:11px;color:#888;} .cb-order-badge{width:22px;height:22px;border-radius:6px;background:#222;display:flex;align-items:center; justify-content:center;font-size:11px;color:#888;flex-shrink:0;} .cb-toggle{width:18px;height:18px;flex-shrink:0;} @media (min-width: 651px) { #cb-in-trigger { flex: 0 0 160px; } #cb-in-name { flex: 0 0 200px; } #cb-in-group { flex: 0 0 100px; } #cb-in-category { max-width: 160px; flex: 0 0 160px; } } @media (max-width: 650px) { .cb-overlay { padding: 0px; } .cb-modal { padding: 16px; } .cb-list { max-height: 420px; } .cb-row { flex-direction: column; align-items: stretch; } .cb-row input.cb-trigger { flex: 1; } .cb-item { flex-direction: column; align-items: flex-start; gap: 10px; } .cb-item-info { flex-direction: column; align-items: flex-start; width: 100%; gap: 6px; } .cb-item .cb-url { width: 100%; white-space: normal; word-break: break-all; } .cb-item-actions { width: 100%; justify-content: space-between; } .cb-item-actions button { flex: 1; text-align: center; } .cb-listrow { flex-direction: column; align-items: flex-start; gap: 12px; } .cb-listrow > div:first-child { display: flex; align-items: center; gap: 8px; width: 100%; } .cb-listrow .cb-lname { width: 100%; } .cb-listrow .cb-btn { width: 100%; } .cb-listrow-move { width: 100%; } .cb-flex { flex-direction: column; align-items: stretch; } .cb-flex button { width: 100%; } .cb-flex .cb-small { text-align: center; } .cb-group-header { flex-direction: column; align-items: flex-start; gap: 10px; } .cb-group-actions { width: 100%; justify-content: space-between; } } `; function injectStyle() { if (document.getElementById('cb-style')) return; const s = document.createElement('style'); s.id = 'cb-style'; s.textContent = STYLE; document.head.appendChild(s); } function escapeHtml(str) { const d = document.createElement('div'); d.textContent = str == null ? '' : String(str); return d.innerHTML; } function openManager() { injectStyle(); let editBangId = null; let collapsedGroups = new Set(); // To persist toggle state across renders while modal is open const overlay = document.createElement('div'); overlay.className = 'cb-overlay'; overlay.innerHTML = `

Custom Bangs

Your own bangs (always highest priority)

External bang lists

Checked in order below, after your own bangs. Drag priority with ↑/↓.

Settings & Sync

Backup & Restore (Everything)

`; document.documentElement.appendChild(overlay); const ownListEl = overlay.querySelector('#cb-own-list'); const listsEl = overlay.querySelector('#cb-lists'); const ddgStatusEl = overlay.querySelector('#cb-ddg-status'); const collisionToggle = overlay.querySelector('#cb-collision-toggle'); const engineToggle = overlay.querySelector('#cb-engine-toggle'); const ddgOfficialToggle = overlay.querySelector('#cb-ddg-official-toggle'); const ddgOfficialLabel = overlay.querySelector('label[for="cb-ddg-official-toggle"]'); const syncIntervalSel = overlay.querySelector('#cb-sync-interval'); const urlInput = overlay.querySelector('#cb-in-url'); // Init settings const settings = getSettings(); collisionToggle.checked = settings.checkCollisions; syncIntervalSel.value = settings.syncInterval; engineToggle.checked = settings.enableOtherEngines; ddgOfficialToggle.checked = settings.useDdgOfficial; function updateDdgOfficialState() { ddgOfficialToggle.disabled = !engineToggle.checked; ddgOfficialLabel.style.opacity = engineToggle.checked ? '1' : '0.5'; if (!engineToggle.checked) ddgOfficialToggle.checked = false; } updateDdgOfficialState(); collisionToggle.addEventListener('change', () => { saveSettings(Object.assign(getSettings(), { checkCollisions: collisionToggle.checked })); renderOwn(); renderLists(); }); engineToggle.addEventListener('change', () => { saveSettings(Object.assign(getSettings(), { enableOtherEngines: engineToggle.checked })); updateDdgOfficialState(); saveSettings(Object.assign(getSettings(), { useDdgOfficial: ddgOfficialToggle.checked })); }); ddgOfficialToggle.addEventListener('change', () => { saveSettings(Object.assign(getSettings(), { useDdgOfficial: ddgOfficialToggle.checked })); }); syncIntervalSel.addEventListener('change', () => { saveSettings(Object.assign(getSettings(), { syncInterval: parseInt(syncIntervalSel.value, 10) })); }); urlInput.addEventListener('paste', (e) => { if (urlInput.value.trim() !== '') return; const pastedText = (e.clipboardData || window.clipboardData).getData('text'); if (!pastedText || pastedText.includes('{{{s}}}')) return; try { let urlObj = new URL(pastedText); const queryParamsToLookFor = ['q', 'query', 'search', 's', 'term', 'keyword', 'k', 'p', 'text']; const magicKeywords = ['custombangs', '!custombangs', 'bangs', '!bangs', 'bang', '!bang']; let modified = false; let extractedQuery = ''; for (const [key, val] of urlObj.searchParams.entries()) { if (magicKeywords.includes(val.toLowerCase())) { extractedQuery = val; urlObj.searchParams.set(key, '{{{s}}}'); modified = true; break; } } if (!modified) { for (const param of queryParamsToLookFor) { if (urlObj.searchParams.has(param)) { extractedQuery = urlObj.searchParams.get(param); urlObj.searchParams.set(param, '{{{s}}}'); modified = true; break; } } } if (!modified) { const searchPathMatch = urlObj.pathname.match(/\/search\/([^\/]+)/); if (searchPathMatch) { extractedQuery = decodeURIComponent(searchPathMatch[1]); urlObj.pathname = urlObj.pathname.replace(searchPathMatch[1], '{{{s}}}'); modified = true; } } if (!modified) { let tempUrl = urlObj.toString(); for (const kw of magicKeywords) { const encodedKw = encodeURIComponent(kw); if (tempUrl.includes(encodedKw)) { extractedQuery = kw; tempUrl = tempUrl.replace(encodedKw, '{{{s}}}'); modified = true; break; } else if (tempUrl.includes(kw)) { extractedQuery = kw; tempUrl = tempUrl.replace(kw, '{{{s}}}'); modified = true; break; } } if (modified) urlObj = new URL(tempUrl); } if (modified) { e.preventDefault(); urlInput.value = urlObj.toString().replace(/(?:%7B){3}s(?:%7D){3}/gi, '{{{s}}}'); const exampleInput = overlay.querySelector('#cb-in-example'); if (exampleInput && extractedQuery && exampleInput.value.trim() === '') { exampleInput.value = extractedQuery; } } } catch (err) {} }); function renderDdgStatus() { const cache = getDdgCache(); ddgStatusEl.textContent = cache.lastFetched ? `${cache.bangs.length} bangs cached · last fetched ${new Date(cache.lastFetched).toLocaleString()}` : 'Not fetched yet'; } renderDdgStatus(); overlay.querySelector('#cb-ddg-refresh').addEventListener('click', (e) => { e.target.disabled = true; fetchDdgOfficial().then(() => { renderDdgStatus(); renderOwn(); renderLists(); }) .finally(() => { e.target.disabled = false; }); }); // ---- Own bangs (Grouped) ---- function renderOwn() { const own = getOwn(); if (!own.length) { ownListEl.innerHTML = '
No custom bangs yet.
'; return; } const groups = {}; own.forEach(b => { const gName = b.group && b.group.trim() ? b.group : 'Default'; if (!groups[gName]) groups[gName] = []; groups[gName].push(b); }); ownListEl.innerHTML = Object.entries(groups).map(([gName, items]) => { const isCollapsed = collapsedGroups.has(gName); const allDisabled = items.every(b => b.enabled === false); const itemsHtml = items.map(b => { const colls = ownCollisions(b); const warn = colls.length ? `` : ''; const catBadge = b.category ? `${escapeHtml(b.category)}` : ''; let triggersDisplay = `!${escapeHtml(b.trigger)}`; if (b.aliases && b.aliases.length > 0) { triggersDisplay += `, ${b.aliases.map(a => `!${escapeHtml(a)}`).join(', ')}`; } return `
${triggersDisplay} ${catBadge}${escapeHtml(b.name)} ${escapeHtml(b.url)} ${warn}
`; }).join(''); return `
${isCollapsed ? '▶' : '▼'}

${escapeHtml(gName)}

${items.length} bang${items.length === 1 ? '' : 's'}
${itemsHtml}
`; }).join(''); } ownListEl.addEventListener('click', (e) => { const groupTitle = e.target.closest('.cb-group-title'); if (groupTitle) { const gName = groupTitle.closest('.cb-group').dataset.group; if (collapsedGroups.has(gName)) collapsedGroups.delete(gName); else collapsedGroups.add(gName); renderOwn(); return; } const btn = e.target.closest('button'); if (!btn) return; let own = getOwn(); const groupEl = e.target.closest('.cb-group'); const gName = groupEl ? groupEl.dataset.group : null; const action = btn.dataset.action; if (action === 'toggle-group') { const items = own.filter(b => (b.group && b.group.trim() ? b.group : 'Default') === gName); const allDisabled = items.every(b => b.enabled === false); own.forEach(b => { if ((b.group && b.group.trim() ? b.group : 'Default') === gName) b.enabled = allDisabled; }); saveOwn(own); renderOwn(); renderLists(); } else if (action === 'delete-group') { if (!confirm(`Delete group "${gName}" and all its bangs?`)) return; own = own.filter(b => (b.group && b.group.trim() ? b.group : 'Default') !== gName); saveOwn(own); renderOwn(); renderLists(); } else if (action === 'edit-group') { const newName = prompt('Enter new group name:', gName); if (newName !== null && newName.trim() !== '') { own.forEach(b => { if ((b.group && b.group.trim() ? b.group : 'Default') === gName) b.group = newName.trim(); }); if (collapsedGroups.has(gName)) { collapsedGroups.delete(gName); collapsedGroups.add(newName.trim()); } saveOwn(own); renderOwn(); renderLists(); } } else if (action === 'export-group') { const items = own.filter(b => (b.group && b.group.trim() ? b.group : 'Default') === gName); const exportData = items.map(b => ({ name: b.name, trigger: b.trigger, aliases: b.aliases || [], url: b.url, category: b.category })); triggerDownload(gName.replace(/[^a-z0-9]/gi, '_').toLowerCase() + "_bangs.json", JSON.stringify(exportData, null, 2)); } else { const item = e.target.closest('.cb-item'); if (!item) return; const id = item.dataset.id; const bang = own.find(b => b.id === id); if (action === 'delete') { if (editBangId === id) overlay.querySelector('#cb-cancel-edit').click(); saveOwn(own.filter(b => b.id !== id)); renderOwn(); renderLists(); } else if (action === 'test') { const q = bang.example || 'test'; window.open(buildTarget(bang.url, q), '_blank'); } else if (action === 'edit') { editBangId = bang.id; let trigVal = `!${bang.trigger}`; if (bang.aliases && bang.aliases.length) { trigVal += ', ' + bang.aliases.map(a => `!${a}`).join(', '); } overlay.querySelector('#cb-in-trigger').value = trigVal; overlay.querySelector('#cb-in-name').value = bang.name || ''; overlay.querySelector('#cb-in-group').value = bang.group || ''; overlay.querySelector('#cb-in-category').value = bang.category || ''; overlay.querySelector('#cb-in-url').value = bang.url || ''; overlay.querySelector('#cb-in-example').value = bang.example || ''; overlay.querySelector('#cb-add').textContent = 'Save Edit'; overlay.querySelector('#cb-cancel-edit').style.display = 'inline-block'; } } }); overlay.querySelector('#cb-cancel-edit').addEventListener('click', () => { editBangId = null; overlay.querySelector('#cb-in-trigger').value = ''; overlay.querySelector('#cb-in-name').value = ''; overlay.querySelector('#cb-in-group').value = ''; overlay.querySelector('#cb-in-category').value = ''; overlay.querySelector('#cb-in-url').value = ''; overlay.querySelector('#cb-in-example').value = ''; overlay.querySelector('#cb-add').textContent = 'Add'; overlay.querySelector('#cb-cancel-edit').style.display = 'none'; }); overlay.querySelector('#cb-add').addEventListener('click', () => { const rawTrigger = overlay.querySelector('#cb-in-trigger').value.trim(); const name = overlay.querySelector('#cb-in-name').value.trim(); const group = overlay.querySelector('#cb-in-group').value.trim(); const category = overlay.querySelector('#cb-in-category').value; const url = overlay.querySelector('#cb-in-url').value.trim(); const example = overlay.querySelector('#cb-in-example').value.trim(); const triggerParts = rawTrigger.split(',').map(s => s.trim().replace(/^!/, '').toLowerCase()).filter(Boolean); if (!triggerParts.length || !url) { alert('Bang command and Bang URL are required.'); return; } if (!url.includes('{{{s}}}') && !confirm('URL has no {{{s}}} placeholder — the query will just be appended. Continue?')) return; const trigger = triggerParts[0]; const aliases = triggerParts.slice(1); // Pre-save Collision Check const list = getOwn(); const allLists = getLists(); const ddgCache = getDdgCache(); const checkSettings = getSettings(); let warnings = []; for (const t of triggerParts) { if (list.some(b => b.id !== editBangId && (b.trigger.toLowerCase() === t || (b.aliases || []).some(a => a.toLowerCase() === t)))) { warnings.push(`!${t} is already used in your custom bangs.`); } for (const extList of allLists) { if (extList.enabled && (extList.bangs || []).some(b => b.trigger.toLowerCase() === t || (b.aliases && b.aliases.some(a => a.toLowerCase() === t)))) { warnings.push(`!${t} overrides external list "${extList.name}".`); } } if (checkSettings.checkCollisions && (ddgCache.bangs || []).some(b => b.trigger.toLowerCase() === t)) { warnings.push(`!${t} overrides an official DuckDuckGo bang.`); } } if (warnings.length > 0) { if (!confirm(warnings.join('\n') + '\n\nDo you want to save anyway?')) return; } if (editBangId) { const idx = list.findIndex(b => b.id === editBangId); if (idx >= 0) { const currentEnabled = list[idx].enabled !== false; list[idx] = { id: editBangId, name, trigger, aliases, url, example, category, group, enabled: currentEnabled }; } editBangId = null; overlay.querySelector('#cb-add').textContent = 'Add'; overlay.querySelector('#cb-cancel-edit').style.display = 'none'; } else { const existingIdx = list.findIndex(b => b.trigger.toLowerCase() === trigger.toLowerCase()); const entry = { id: existingIdx >= 0 ? list[existingIdx].id : uid(), name, trigger, aliases, url, example, category, group, enabled: true }; if (existingIdx >= 0) list[existingIdx] = entry; else list.push(entry); } // If they created a new group while it was collapsed, unfold it for them automatically if (group && collapsedGroups.has(group)) collapsedGroups.delete(group); saveOwn(list); overlay.querySelector('#cb-in-trigger').value = ''; overlay.querySelector('#cb-in-name').value = ''; overlay.querySelector('#cb-in-group').value = ''; overlay.querySelector('#cb-in-category').value = ''; overlay.querySelector('#cb-in-url').value = ''; overlay.querySelector('#cb-in-example').value = ''; renderOwn(); renderLists(); }); overlay.querySelector('#cb-test').addEventListener('click', () => { const url = overlay.querySelector('#cb-in-url').value.trim(); const example = overlay.querySelector('#cb-in-example').value.trim() || 'test'; if (!url) return; window.open(buildTarget(url, example), '_blank'); }); // ---- Global Import/Export ---- overlay.querySelector('#cb-export-all').addEventListener('click', () => { const payload = { own: getOwn(), lists: getLists(), settings: getSettings() }; overlay.querySelector('#cb-json').value = JSON.stringify(payload, null, 2); }); overlay.querySelector('#cb-import-all').addEventListener('click', () => { const raw = overlay.querySelector('#cb-json').value.trim(); if (!raw) return; try { const parsed = JSON.parse(raw); if (Array.isArray(parsed)) throw new Error('This looks like a list of bangs (v1 export). Please wrap it in {"own": [...]} or re-export from the new version.'); if (!parsed.own || !parsed.lists) throw new Error('Invalid format. Expected "own" and "lists" arrays.'); if (!confirm(`This will OVERWRITE your entire Custom Bangs configuration (including external lists and settings). Continue?`)) return; saveOwn(parsed.own); saveLists(parsed.lists); if (parsed.settings) saveSettings(Object.assign(getSettings(), parsed.settings)); const newSettings = getSettings(); overlay.querySelector('#cb-collision-toggle').checked = newSettings.checkCollisions; overlay.querySelector('#cb-sync-interval').value = newSettings.syncInterval; overlay.querySelector('#cb-engine-toggle').checked = newSettings.enableOtherEngines; overlay.querySelector('#cb-ddg-official-toggle').checked = newSettings.useDdgOfficial; updateDdgOfficialState(); renderOwn(); renderLists(); alert('Data imported successfully!'); overlay.querySelector('#cb-json').value = ''; } catch (e) { alert('Import failed: ' + e.message); } }); // ---- External lists ---- function renderLists() { const lists = getLists(); const own = getOwn(); if (!lists.length) { listsEl.innerHTML = '
No external lists added yet.
'; return; } listsEl.innerHTML = lists.map((l, i) => { const stats = listStats(l, lists, own); const collisionText = getSettings().checkCollisions ? ` · ${stats.officialCollisions} collide with official DDG bangs` : ''; return `
${i + 1}
${escapeHtml(l.name)}
${stats.total} bangs · ${stats.shadowed} shadowed by higher priority${collisionText}${l.lastSync ? ' · synced ' + new Date(l.lastSync).toLocaleString() : ' · never synced'}
`; }).join(''); } listsEl.addEventListener('click', (e) => { const copyTitle = e.target.closest('.cb-copy-url'); if (copyTitle) { const urlToCopy = copyTitle.dataset.url; if (urlToCopy) { navigator.clipboard.writeText(urlToCopy).then(() => { const origText = copyTitle.textContent; copyTitle.textContent = 'Copied!'; setTimeout(() => { copyTitle.textContent = origText; }, 1000); }).catch(err => console.error('Failed to copy', err)); } return; } const btn = e.target.closest('[data-action]'); if (!btn) return; const row = e.target.closest('.cb-listrow'); const id = row.dataset.id; const lists = getLists(); const idx = lists.findIndex(l => l.id === id); if (idx === -1) return; const action = btn.dataset.action; if (action === 'toggle') { lists[idx].enabled = btn.checked; saveLists(lists); renderLists(); renderOwn(); } else if (action === 'up' && idx > 0) { [lists[idx - 1], lists[idx]] = [lists[idx], lists[idx - 1]]; saveLists(lists); renderLists(); renderOwn(); } else if (action === 'down' && idx < lists.length - 1) { [lists[idx + 1], lists[idx]] = [lists[idx], lists[idx + 1]]; saveLists(lists); renderLists(); renderOwn(); } else if (action === 'remove') { if (!confirm(`Remove "${lists[idx].name}"?`)) return; lists.splice(idx, 1); saveLists(lists); renderLists(); renderOwn(); } else if (action === 'export') { triggerDownload(lists[idx].name.replace(/[^a-z0-9]/gi, '_').toLowerCase() + "_bangs.json", JSON.stringify(lists[idx].bangs, null, 2)); } else if (action === 'sync') { btn.disabled = true; syncList(id).then(() => { renderLists(); renderOwn(); }).finally(() => { btn.disabled = false; }); } }); overlay.querySelector('#cb-list-add').addEventListener('click', () => { const name = overlay.querySelector('#cb-list-name').value.trim(); const url = overlay.querySelector('#cb-list-url').value.trim(); if (!name || !url) { alert('Name and URL are required.'); return; } const lists = getLists(); const entry = { id: uid(), name, url, enabled: true, bangs: [], lastSync: null }; lists.push(entry); saveLists(lists); overlay.querySelector('#cb-list-name').value = ''; overlay.querySelector('#cb-list-url').value = ''; renderLists(); syncList(entry.id).then(() => { renderLists(); renderOwn(); }); }); overlay.querySelector('.cb-close').addEventListener('click', () => overlay.remove()); overlay.addEventListener('click', (e) => { if (e.target === overlay) overlay.remove(); }); document.addEventListener('keydown', function esc(e) { if (e.key === 'Escape') { overlay.remove(); document.removeEventListener('keydown', esc); } }); renderOwn(); renderLists(); } // ---------- Menu commands & Floating UI ---------- function registerMenu() { GM_registerMenuCommand('⚙️ Manage Custom Bangs', openManager); GM_registerMenuCommand('🔄 Sync all external lists', () => { const lists = getLists(); if (!lists.length) { alert('No external lists added yet.'); return; } Promise.all(lists.map(l => syncList(l.id, { silent: true }))) .then(() => GM_notification({ text: `Synced ${lists.length} list(s)`, title: 'Custom Bangs' })); }); } function injectFloatingButton() { if (location.hostname.includes('duckduckgo.com') && location.pathname === '/bangs') { if (document.getElementById('cb-floating-btn')) return; const btn = document.createElement('button'); btn.id = 'cb-floating-btn'; btn.textContent = '⚙️ Custom Bangs'; Object.assign(btn.style, { position: 'fixed', bottom: '24px', right: '24px', zIndex: '99999', background: '#3574f0', color: '#fff', border: 'none', borderRadius: '24px', padding: '12px 20px', fontSize: '14px', fontWeight: 'bold', boxShadow: '0 4px 12px rgba(0,0,0,0.3)', cursor: 'pointer', fontFamily: '-apple-system, system-ui, sans-serif' }); btn.addEventListener('mouseenter', () => btn.style.background = '#4a84f4'); btn.addEventListener('mouseleave', () => btn.style.background = '#3574f0'); btn.addEventListener('click', openManager); document.body.appendChild(btn); } } if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', () => { registerMenu(); injectFloatingButton(); }); } else { registerMenu(); injectFloatingButton(); } // ---------- Background auto-sync ---------- const syncHours = getSettings().syncInterval; if (syncHours > 0) { const syncIntervalMs = syncHours * 60 * 60 * 1000; for (const list of getLists()) { if (list.enabled && (!list.lastSync || Date.now() - new Date(list.lastSync).getTime() > syncIntervalMs)) { syncList(list.id, { silent: true }).catch(() => {}); } } if (getSettings().checkCollisions || (getSettings().enableOtherEngines && getSettings().useDdgOfficial)) { const cache = getDdgCache(); if (!cache.lastFetched || Date.now() - new Date(cache.lastFetched).getTime() > syncIntervalMs) { fetchDdgOfficial({ silent: true }).catch(() => {}); } } } })();