Custom DuckDuckGo Bangs

Add your own !bangs to DuckDuckGo (and other search engines) without touching built-in ones

スクリプトをインストールするには、Tampermonkey, GreasemonkeyViolentmonkey のような拡張機能のインストールが必要です。

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

スクリプトをインストールするには、TampermonkeyViolentmonkey のような拡張機能のインストールが必要です。

スクリプトをインストールするには、TampermonkeyUserscripts のような拡張機能のインストールが必要です。

このスクリプトをインストールするには、Tampermonkeyなどの拡張機能をインストールする必要があります。

このスクリプトをインストールするには、ユーザースクリプト管理ツールの拡張機能をインストールする必要があります。

(ユーザースクリプト管理ツールは設定済みなのでインストール!)

このスタイルをインストールするには、Stylusなどの拡張機能をインストールする必要があります。

このスタイルをインストールするには、Stylus などの拡張機能をインストールする必要があります。

このスタイルをインストールするには、Stylus tなどの拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

(ユーザースタイル管理ツールは設定済みなのでインストール!)

このスクリプトの質問や評価の投稿はこちら通報はこちらへお寄せください
// ==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 = `
      <div class="cb-modal">
        <button class="cb-close" title="Close">×</button>
        <h2>Custom Bangs</h2>

        <div class="cb-section" style="margin-top:0;border-top:none;padding-top:0;">
          <h3>Your own bangs <span style="color:#666;">(always highest priority)</span></h3>
          <div class="cb-list" id="cb-own-list" style="border:none;"></div>
          <div class="cb-row">
            <input class="cb-trigger" id="cb-in-trigger" placeholder="!gh, !gith" title="Main bang, and optionally comma separated aliases" />
            <input id="cb-in-name" placeholder="Site name, e.g. GitHub" />
              <input id="cb-in-group" placeholder="Group (e.g. Sync Gist)" title="Organize bangs for grouped exporting" />
              <select id="cb-in-category">
                <option value="">Category</option>

                <option value="Tech:Blogs">Tech: Blogs</option>
                <option value="Tech:Blogs (intl)">Tech: Blogs (intl)</option>
                <option value="Tech:Chakra">Tech: Chakra</option>
                <option value="Tech:Cryptocurrency">Tech: Cryptocurrency</option>
                <option value="Tech:Companies">Tech: Companies</option>
                <option value="Tech:Design">Tech: Design</option>
                <option value="Tech:Domains">Tech: Domains</option>
                <option value="Tech:Downloads">Tech: Downloads</option>
                <option value="Tech:Downloads (add-ons)">Tech: Downloads (add-ons)</option>
                <option value="Tech:Downloads (apps)">Tech: Downloads (apps)</option>
                <option value="Tech:Downloads (code)">Tech: Downloads (code)</option>
                <option value="Tech:Downloads (intl)">Tech: Downloads (intl)</option>
                <option value="Tech:Downloads (software)">Tech: Downloads (software)</option>
                <option value="Tech:Programming">Tech: Programming</option>
                <option value="Tech:Startups">Tech: Startups</option>
                <option value="Tech:Sysadmin">Tech: Sysadmin</option>

                <option value="Shopping:Big box/department">Shopping: Big box/department</option>
                <option value="Shopping:Online">Shopping: Online</option>
                <option value="Shopping:Online (deals)">Shopping: Online (deals)</option>
                <option value="Shopping:Online (intl)">Shopping: Online (intl)</option>
                <option value="Shopping:Online (marketplace)">Shopping: Online (marketplace)</option>
                <option value="Shopping:Services">Shopping: Services</option>
                <option value="Shopping:Tech">Shopping: Tech</option>
                <option value="Shopping:Tech (domains)">Shopping: Tech (domains)</option>

                <option value="Research:Academic">Research: Academic</option>
                <option value="Research:Academic (math/cs)">Research: Academic (math/cs)</option>
                <option value="Research:Academic (biology)">Research: Academic (biology)</option>
                <option value="Research:Food">Research: Food</option>
                <option value="Research:Government">Research: Government</option>
                <option value="Research:Health">Research: Health</option>
                <option value="Research:Law">Research: Law</option>
                <option value="Research:Learning">Research: Learning</option>
                <option value="Research:Learning (intl)">Research: Learning (intl)</option>
                <option value="Research:Local">Research: Local</option>
                <option value="Research:Real Estate">Research: Real Estate</option>
                <option value="Research:Reference">Research: Reference</option>
                <option value="Research:Reference (fun)">Research: Reference (fun)</option>
                <option value="Research:Reference (religion)">Research: Reference (religion)</option>
                <option value="Research:Reference (science)">Research: Reference (science)</option>
                <option value="Research:Reference (words intl)">Research: Reference (words intl)</option>
                <option value="Research:Reference (words)">Research: Reference (words)</option>
                <option value="Research:Topical">Research: Topical</option>
                <option value="Research:Travel">Research: Travel</option>

                <option value="Online Services:Events">Online Services: Events</option>
                <option value="Online Services:Google">Online Services: Google</option>
                <option value="Online Services:Jobs">Online Services: Jobs</option>
                <option value="Online Services:Maps">Online Services: Maps</option>
                <option value="Online Services:Search">Online Services: Search</option>
                <option value="Online Services:Search (DDG)">Online Services: Search (DDG)</option>
                <option value="Online Services:Search (non-US)">Online Services: Search (non-US)</option>
                <option value="Online Services:Search (Private)">Online Services: Search (Private)</option>
                <option value="Online Services:Search (Real-time)">Online Services: Search (Real-time)</option>
                <option value="Online Services:Social">Online Services: Social</option>
                <option value="Online Services:Social (intl)">Online Services: Social (intl)</option>
                <option value="Online Services:Social news/links">Online Services: Social news/links</option>
                <option value="Online Services:Tools">Online Services: Tools</option>
                <option value="Online Services:Tools (fundraising)">Online Services: Tools (fundraising)</option>
                <option value="Online Services:Tools (URLs)">Online Services: Tools (URLs)</option>
                <option value="Online Services:Tracking">Online Services: Tracking</option>

                <option value="News:Aggregators">News: Aggregators</option>
                <option value="News:Broadcast">News: Broadcast</option>
                <option value="News:Business">News: Business</option>
                <option value="News:International">News: International</option>
                <option value="News:Magazine">News: Magazine</option>
                <option value="News:Magazine (car)">News: Magazine (car)</option>
                <option value="News:Magazine (fashion)">News: Magazine (fashion)</option>
                <option value="News:Newspaper">News: Newspaper</option>
                <option value="News:Newspaper (intl)">News: Newspaper (intl)</option>
                <option value="News:Online">News: Online</option>
                <option value="News:Specialty">News: Specialty</option>
                <option value="News:Weather">News: Weather</option>

                <option value="Multimedia:Books">Multimedia: Books</option>
                <option value="Multimedia:Docs">Multimedia: Docs</option>
                <option value="Multimedia:General">Multimedia: General</option>
                <option value="Multimedia:Images">Multimedia: Images</option>
                <option value="Multimedia:Music">Multimedia: Music</option>
                <option value="Multimedia:Music (Folk)">Multimedia: Music (Folk)</option>
                <option value="Multimedia:Music (Lyrics)">Multimedia: Music (Lyrics)</option>
                <option value="Multimedia:Video">Multimedia: Video</option>

                <option value="Entertainment:Audio">Entertainment: Audio</option>
                <option value="Entertainment:Blogs">Entertainment: Blogs</option>
                <option value="Entertainment:Blogs (intl)">Entertainment: Blogs (intl)</option>
                <option value="Entertainment:Comics">Entertainment: Comics</option>
                <option value="Entertainment:Events">Entertainment: Events</option>
                <option value="Entertainment:Forum">Entertainment: Forum</option>
                <option value="Entertainment:Games (general)">Entertainment: Games (general)</option>
                <option value="Entertainment:Games (Minecraft)">Entertainment: Games (Minecraft)</option>
                <option value="Entertainment:Games (offline)">Entertainment: Games (offline)</option>
                <option value="Entertainment:Games (Pokemon)">Entertainment: Games (Pokemon)</option>
                <option value="Entertainment:Games (specific)">Entertainment: Games (specific)</option>
                <option value="Entertainment:Games (WOW)">Entertainment: Games (WOW)</option>
                <option value="Entertainment:Misc">Entertainment: Misc</option>
                <option value="Entertainment:Movies">Entertainment: Movies</option>
                <option value="Entertainment:Radio">Entertainment: Radio</option>
                <option value="Entertainment:Sports">Entertainment: Sports</option>
                <option value="Entertainment:TV">Entertainment: TV</option>

                <option value="Translation:Google">Translation: Google</option>
                <option value="Translation:Google Translate">Translation: Google Translate</option>
                <option value="Translation:Microsoft">Translation: Microsoft</option>
                
              </select>
          </div>
          <div class="cb-row">
            <input id="cb-in-url" placeholder="https://github.com/search?q={{{s}}}" title="Paste a search URL here and we'll automatically replace the query with {{{s}}}!" />
          </div>
          <div class="cb-row">
            <input id="cb-in-example" placeholder="Example search, e.g. userscripts" />
            <button class="cb-btn" id="cb-test">Test</button>
            <button class="cb-btn primary" id="cb-add">Add</button>
            <button class="cb-btn" id="cb-cancel-edit" style="display:none;">Cancel</button>
          </div>
        </div>

        <div class="cb-section">
          <h3>External bang lists</h3>
          <div class="cb-hint">Checked in order below, after your own bangs. Drag priority with ↑/↓.</div>
          <div class="cb-list" id="cb-lists" style="border:1px solid #2a2a2a;"></div>
          <div class="cb-row">
            <input id="cb-list-name" placeholder="List name, e.g. Someone's gist" style="flex:1;" />
            <input id="cb-list-url" placeholder="https://gist.githubusercontent.com/.../raw/bangs.json" style="flex:2;" />
            <button class="cb-btn primary" id="cb-list-add">Add list</button>
          </div>
        </div>

        <div class="cb-section">
          <h3>Settings & Sync</h3>
          
          <div class="cb-row" style="margin-bottom:12px; flex-direction: row; flex-wrap: nowrap; align-items: center;">
            <input type="checkbox" id="cb-engine-toggle" style="flex:0 0 auto;width:18px;height:18px;" />
            <label for="cb-engine-toggle" style="font-size:13px;color:#ccc;line-height:1.4;cursor:pointer;">Enable Custom Bangs on other search engines (Google, Bing, Yahoo, Startpage, etc.)</label>
          </div>
          <div class="cb-row" style="margin-bottom:12px; flex-direction: row; flex-wrap: nowrap; align-items: center; padding-left: 26px;">
            <input type="checkbox" id="cb-ddg-official-toggle" style="flex:0 0 auto;width:18px;height:18px;" />
            <label for="cb-ddg-official-toggle" style="font-size:13px;color:#ccc;line-height:1.4;cursor:pointer;">Use official DuckDuckGo bangs on other search engines</label>
          </div>

          <div class="cb-row" style="margin-bottom:12px; flex-direction: row; flex-wrap: nowrap; align-items: center;">
            <input type="checkbox" id="cb-collision-toggle" style="flex:0 0 auto;width:18px;height:18px;" />
            <label for="cb-collision-toggle" style="font-size:13px;color:#ccc;line-height:1.4;cursor:pointer;">Warn when a bang collides with DuckDuckGo's official list</label>
          </div>

          <div class="cb-row" style="margin-bottom:12px; flex-direction: row; flex-wrap: nowrap; align-items: center; max-width: 350px;">
            <label for="cb-sync-interval" style="font-size:13px;color:#ccc;white-space:nowrap;">Auto-sync interval:</label>
            <select id="cb-sync-interval" style="margin-left: 10px;">
              <option value="0">Never (Manual only)</option>
              <option value="12">Every 12 Hours</option>
              <option value="24">Daily</option>
              <option value="168">Weekly</option>
            </select>
          </div>

          <div class="cb-flex">
            <button class="cb-btn" id="cb-ddg-refresh">Refresh official DDG bang cache</button>
            <span class="cb-small" id="cb-ddg-status"></span>
          </div>
        </div>

        <div class="cb-section">
          <h3>Backup & Restore (Everything)</h3>
          <textarea id="cb-json" placeholder='{"own": [], "lists": [], "settings": {}}'></textarea>
          <div class="cb-flex">
            <button class="cb-btn" id="cb-export-all">Export Everything</button>
            <button class="cb-btn danger" id="cb-import-all">Import Everything (Overwrites!)</button>
          </div>
        </div>
      </div>
    `;
    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 = '<div style="padding:16px;color:#666;font-size:13px;border:1px solid #2a2a2a;border-radius:8px;">No custom bangs yet.</div>';
        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
            ? `<span class="cb-warn" title="Overrides official DDG bang(s): ${colls.map(c => '!'+escapeHtml(c)).join(', ')}" onclick="alert('Overrides official DDG bang(s): ${colls.map(c => '!'+escapeHtml(c)).join(', ')}')">⚠</span>`
            : '';
          const catBadge = b.category ? `<span class="cb-cat-badge">${escapeHtml(b.category)}</span>` : '';
          
          let triggersDisplay = `!${escapeHtml(b.trigger)}`;
          if (b.aliases && b.aliases.length > 0) {
            triggersDisplay += `, ${b.aliases.map(a => `!${escapeHtml(a)}`).join(', ')}`;
          }

          return `
          <div class="cb-item ${b.enabled === false ? 'disabled' : ''}" data-id="${b.id}">
            <div class="cb-item-info">
              <span class="cb-trig" title="Triggers">${triggersDisplay}</span>
              <span class="cb-name">${catBadge}${escapeHtml(b.name)}</span>
              <span class="cb-url">${escapeHtml(b.url)}</span>
              ${warn}
            </div>
            <div class="cb-item-actions">
              <button class="cb-btn" data-action="test">Test</button>
              <button class="cb-btn" data-action="edit">Edit</button>
              <button class="cb-btn danger" data-action="delete">Delete</button>
            </div>
          </div>`;
        }).join('');

        return `
        <div class="cb-group ${isCollapsed ? 'collapsed' : ''}" data-group="${escapeHtml(gName)}">
          <div class="cb-group-header">
             <div class="cb-group-title">
               <span class="cb-toggle-icon">${isCollapsed ? '▶' : '▼'}</span>
               <h4>${escapeHtml(gName)}</h4>
               <span class="cb-lmeta" style="font-size:11px;color:#888;">${items.length} bang${items.length === 1 ? '' : 's'}</span>
             </div>
             <div class="cb-group-actions">
               <button class="cb-btn" data-action="toggle-group">${allDisabled ? 'Enable Group' : 'Disable Group'}</button>
               <button class="cb-btn" data-action="edit-group">Edit Name</button>
               <button class="cb-btn" data-action="export-group">Export</button>
               <button class="cb-btn danger" data-action="delete-group">Delete</button>
             </div>
          </div>
          <div class="cb-group-content">
             ${itemsHtml}
          </div>
        </div>`;
      }).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 = '<div style="padding:16px;color:#666;font-size:13px;">No external lists added yet.</div>';
        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 `
        <div class="cb-listrow ${l.enabled ? '' : 'disabled'}" data-id="${l.id}">
          <div>
            <span class="cb-order-badge">${i + 1}</span>
            <input type="checkbox" class="cb-toggle" data-action="toggle" ${l.enabled ? 'checked' : ''} title="Enabled" />
          </div>
          <div class="cb-lname">
            <div class="cb-ltitle cb-copy-url" style="cursor:pointer;" title="Click to copy URL" data-url="${escapeHtml(l.url)}">${escapeHtml(l.name)}</div>
            <div class="cb-lmeta">${stats.total} bangs · ${stats.shadowed} shadowed by higher priority${collisionText}${l.lastSync ? ' · synced ' + new Date(l.lastSync).toLocaleString() : ' · never synced'}</div>
          </div>
          <div class="cb-listrow-move" style="display:flex; gap:8px;">
            <button class="cb-btn" style="flex:1;" data-action="up" ${i === 0 ? 'disabled' : ''}>↑</button>
            <button class="cb-btn" style="flex:1;" data-action="down" ${i === lists.length - 1 ? 'disabled' : ''}>↓</button>
          </div>
          <div class="cb-listrow-move" style="display:flex; gap:8px;">
            <button class="cb-btn" data-action="sync">Sync</button>
            <button class="cb-btn" data-action="export">Export</button>
          </div>
          <button class="cb-btn danger" data-action="remove">Remove</button>
        </div>`;
      }).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(() => {});
      }
    }
  }

})();