Gelbooru Auto Tags

Save named tag presets and combine them on the fly — auto-append your active preset(s) to every Gelbooru search.

이 스크립트를 설치하려면 Tampermonkey, Greasemonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램을 설치해야 합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Userscripts와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 유저 스크립트 관리자 확장 프로그램이 필요합니다.

(이미 유저 스크립트 관리자가 설치되어 있습니다. 설치를 진행합니다!)

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

(이미 유저 스타일 관리자가 설치되어 있습니다. 설치를 진행합니다!)

// ==UserScript==
// @name         Gelbooru Auto Tags
// @namespace    http://tampermonkey.net/
// @version      4.0
// @description  Save named tag presets and combine them on the fly — auto-append your active preset(s) to every Gelbooru search.
// @match        *://*.gelbooru.com/*
// @run-at       document-start
// @grant        none
// @license MIT
// ==/UserScript==

(function () {
    'use strict';

    // --- Storage keys ----------------------------------------------------

    const STORAGE_ENABLED       = 'gelbooru-auto-tags-enabled';
    const STORAGE_PRESETS       = 'gelbooru-auto-tags-presets';
    const STORAGE_ACTIVE        = 'gelbooru-auto-tags-active';
    const STORAGE_LAST_APPLIED  = 'gelbooru-auto-tags-last-applied';
    const LEGACY_LIST           = 'gelbooru-auto-tags-list';
    const LEGACY_ENABLED        = 'gelbooru-auto-sort-score-enabled';

    function newId() {
        return 'p_' + Date.now().toString(36) + '_' + Math.random().toString(36).slice(2, 8);
    }

    // --- Migration -------------------------------------------------------

    function migrate() {
        if (localStorage.getItem(STORAGE_PRESETS) !== null) return;

        // From v2 (single tag list -> one preset called "Default")
        const v2List = localStorage.getItem(LEGACY_LIST);
        if (v2List) {
            try {
                const tags = JSON.parse(v2List);
                if (Array.isArray(tags) && tags.length > 0) {
                    const id = newId();
                    localStorage.setItem(STORAGE_PRESETS, JSON.stringify([
                        { id, name: 'Default', tags }
                    ]));
                    localStorage.setItem(STORAGE_ACTIVE, JSON.stringify([id]));
                    return;
                }
            } catch (e) { /* ignore */ }
        }

        // Fresh install: seed with one preset
        const id = newId();
        localStorage.setItem(STORAGE_PRESETS, JSON.stringify([
            { id, name: 'Score sorted', tags: ['sort:score'] }
        ]));
        localStorage.setItem(STORAGE_ACTIVE, JSON.stringify([id]));

        // Carry over enable state from very old v1 if present
        if (localStorage.getItem(LEGACY_ENABLED) !== null && localStorage.getItem(STORAGE_ENABLED) === null) {
            localStorage.setItem(STORAGE_ENABLED, localStorage.getItem(LEGACY_ENABLED));
        }
    }
    migrate();

    // --- State accessors -------------------------------------------------

    function isEnabled() {
        const v = localStorage.getItem(STORAGE_ENABLED);
        return v === null ? true : v === 'true';
    }
    function setEnabled(val) {
        localStorage.setItem(STORAGE_ENABLED, val ? 'true' : 'false');
    }

    function getPresets() {
        try {
            const arr = JSON.parse(localStorage.getItem(STORAGE_PRESETS) || '[]');
            if (!Array.isArray(arr)) return [];
            return arr.filter(p =>
                p && typeof p.id === 'string' &&
                typeof p.name === 'string' &&
                Array.isArray(p.tags)
            );
        } catch (e) {
            return [];
        }
    }
    function setPresets(presets) {
        localStorage.setItem(STORAGE_PRESETS, JSON.stringify(presets));
    }

    // Active preset IDs are stored as a JSON array. We also accept a bare
    // string for backwards compatibility with v3.x (single active preset).
    function getActiveIds() {
        const raw = localStorage.getItem(STORAGE_ACTIVE);
        if (!raw) return [];
        try {
            const arr = JSON.parse(raw);
            if (Array.isArray(arr)) return arr.filter(x => typeof x === 'string');
        } catch (e) { /* fall through to legacy single-string format */ }
        return [raw];
    }
    function setActiveIds(ids) {
        if (!ids || ids.length === 0) {
            localStorage.removeItem(STORAGE_ACTIVE);
        } else {
            localStorage.setItem(STORAGE_ACTIVE, JSON.stringify(ids));
        }
    }
    function isActive(id) {
        return getActiveIds().includes(id);
    }

    function getActivePresets() {
        const ids = getActiveIds();
        if (ids.length === 0) return [];
        const presets = getPresets();
        return ids.map(id => presets.find(p => p.id === id)).filter(Boolean);
    }
    // Combined tags from every active preset, deduplicated case-insensitively,
    // preserving the order presets were activated in.
    function getActiveTags() {
        const tags = [];
        const seen = new Set();
        for (const preset of getActivePresets()) {
            for (const tag of preset.tags) {
                const key = (tag || '').trim().toLowerCase();
                if (!key || seen.has(key)) continue;
                seen.add(key);
                tags.push(tag);
            }
        }
        return tags;
    }

    // Tags that the script last auto-added to a search.
    // Used to remove the previous preset's tags when switching presets.
    function getLastApplied() {
        try {
            const arr = JSON.parse(localStorage.getItem(STORAGE_LAST_APPLIED) || '[]');
            return Array.isArray(arr) ? arr.filter(t => typeof t === 'string') : [];
        } catch (e) {
            return [];
        }
    }
    function setLastApplied(tags) {
        localStorage.setItem(STORAGE_LAST_APPLIED, JSON.stringify(tags || []));
    }

    // --- Tag logic -------------------------------------------------------

    function tokenize(tags) {
        return (tags || '').split(/[\s+]+/).filter(Boolean);
    }
    function hasExactTag(tags, tag) {
        const t = tag.toLowerCase();
        return tokenize(tags).some(x => x.toLowerCase() === t);
    }
    function hasSortDirective(tags) {
        return tokenize(tags).some(x => /^sort:/i.test(x));
    }

    // Apply auto-tags to a search string:
    //   1. Remove tags that the previous preset added but aren't in the current preset.
    //   2. Add the current preset's tags (skipping ones already present).
    function applyAutoTags(tags) {
        let result = tags || '';

        const activeTags = getActiveTags();
        const activeSet = new Set(activeTags.map(t => t.toLowerCase()));

        // Step 1: clean up tags from the previous preset that the new preset doesn't want
        const lastApplied = getLastApplied();
        const toRemove = lastApplied.filter(t => !activeSet.has(t.toLowerCase()));
        if (toRemove.length > 0) {
            const removeSet = new Set(toRemove.map(t => t.toLowerCase()));
            const sep = result.includes('+') ? '+' : ' ';
            result = tokenize(result).filter(t => !removeSet.has(t.toLowerCase())).join(sep);
        }

        // Step 2: add tags from the current preset
        for (const tag of activeTags) {
            const trimmed = (tag || '').trim();
            if (!trimmed) continue;
            if (/^sort:/i.test(trimmed) && hasSortDirective(result)) continue;
            if (hasExactTag(result, trimmed)) continue;
            const sep = result === '' ? '' : (result.includes('+') ? '+' : ' ');
            result = result + sep + trimmed;
        }

        return result;
    }

    // --- Core behavior ---------------------------------------------------

    function fixCurrentUrl() {
        if (!isEnabled()) return;

        const params = new URLSearchParams(window.location.search);
        if (params.get('page') !== 'post' || params.get('s') !== 'list') return;

        const tags = params.get('tags') || '';
        const updated = applyAutoTags(tags);

        // Record what we just applied so the next preset switch knows what to clean up
        setLastApplied(getActiveTags());

        if (updated === tags) return;

        params.set('tags', updated);
        const newUrl = window.location.pathname + '?' + params.toString() + window.location.hash;
        window.location.replace(newUrl);
    }

    function hookSearchForms() {
        document.addEventListener('submit', function (e) {
            if (!isEnabled()) return;
            const form = e.target;
            if (!(form instanceof HTMLFormElement)) return;
            const tagInput = form.querySelector('input[name="tags"]');
            if (!tagInput) return;
            tagInput.value = applyAutoTags(tagInput.value);
            setLastApplied(getActiveTags());
        }, true);
    }

    // --- UI --------------------------------------------------------------

    let editingDraft = null; // { id: string|null, name: string, tags: string[] }

    function truncate(str, max) {
        if (!str) return '';
        return str.length > max ? str.slice(0, max - 1) + '…' : str;
    }

    function injectStyles() {
        const style = document.createElement('style');
        style.textContent = `
            #gb-at-root, #gb-at-root * { box-sizing: border-box; }

            #gb-at-pill {
                position: fixed;
                bottom: 16px;
                right: 16px;
                z-index: 99999;
                display: flex;
                align-items: center;
                gap: 8px;
                padding: 8px 12px;
                background: rgba(20, 20, 28, 0.92);
                color: #e8e8ec;
                font-family: system-ui, -apple-system, sans-serif;
                font-size: 12px;
                border: 1px solid rgba(255,255,255,0.12);
                border-radius: 999px;
                box-shadow: 0 4px 14px rgba(0,0,0,0.35);
                user-select: none;
                opacity: 0.85;
                transition: opacity 0.2s ease, transform 0.2s ease;
            }
            #gb-at-pill:hover { opacity: 1; transform: translateY(-1px); }

            #gb-at-pill .gb-at-toggle {
                position: relative;
                width: 30px;
                height: 16px;
                background: #555;
                border-radius: 999px;
                transition: background 0.2s ease;
                flex-shrink: 0;
                cursor: pointer;
            }
            #gb-at-pill .gb-at-toggle::after {
                content: "";
                position: absolute;
                top: 2px; left: 2px;
                width: 12px; height: 12px;
                background: #fff;
                border-radius: 50%;
                transition: left 0.2s ease;
            }
            #gb-at-pill.on .gb-at-toggle { background: #4ea1ff; }
            #gb-at-pill.on .gb-at-toggle::after { left: 16px; }

            #gb-at-pill .gb-at-sep { opacity: 0.35; }
            #gb-at-pill .gb-at-active-name {
                font-weight: 600;
                max-width: 140px;
                overflow: hidden;
                text-overflow: ellipsis;
                white-space: nowrap;
            }
            #gb-at-pill .gb-at-active-name.muted { font-weight: 400; opacity: 0.55; font-style: italic; }
            #gb-at-pill .gb-at-count { opacity: 0.6; font-size: 11px; }
            #gb-at-pill .gb-at-gear {
                cursor: pointer;
                padding: 2px 4px;
                border-radius: 4px;
                opacity: 0.7;
                font-size: 14px;
                line-height: 1;
            }
            #gb-at-pill .gb-at-gear:hover {
                opacity: 1;
                background: rgba(255,255,255,0.08);
            }

            #gb-at-panel {
                position: fixed;
                bottom: 64px;
                right: 16px;
                z-index: 99999;
                width: 320px;
                max-height: calc(100vh - 96px);
                overflow-y: auto;
                padding: 14px;
                background: rgba(24, 24, 32, 0.98);
                color: #e8e8ec;
                font-family: system-ui, -apple-system, sans-serif;
                font-size: 13px;
                border: 1px solid rgba(255,255,255,0.14);
                border-radius: 12px;
                box-shadow: 0 10px 30px rgba(0,0,0,0.5);
                display: none;
            }
            #gb-at-panel.open { display: block; }

            #gb-at-panel .gb-at-header {
                display: flex;
                justify-content: space-between;
                align-items: center;
                margin-bottom: 10px;
                font-weight: 600;
            }
            #gb-at-panel .gb-at-close {
                cursor: pointer;
                opacity: 0.6;
                padding: 0 6px;
                font-size: 18px;
                line-height: 1;
            }
            #gb-at-panel .gb-at-close:hover { opacity: 1; }

            #gb-at-panel .gb-at-label {
                font-size: 11px;
                text-transform: uppercase;
                letter-spacing: 0.05em;
                opacity: 0.55;
                margin: 6px 0 6px;
            }

            /* Preset list rows */
            #gb-at-panel .gb-at-presets {
                display: flex;
                flex-direction: column;
                gap: 4px;
                margin-bottom: 10px;
            }
            #gb-at-panel .gb-at-preset {
                display: flex;
                align-items: center;
                gap: 8px;
                padding: 8px 10px;
                background: rgba(255,255,255,0.03);
                border: 1px solid rgba(255,255,255,0.08);
                border-radius: 8px;
                cursor: pointer;
                transition: background 0.15s ease, border-color 0.15s ease;
            }
            #gb-at-panel .gb-at-preset:hover {
                background: rgba(255,255,255,0.06);
                border-color: rgba(255,255,255,0.15);
            }
            #gb-at-panel .gb-at-preset.active {
                background: rgba(78, 161, 255, 0.12);
                border-color: rgba(78, 161, 255, 0.5);
            }
            #gb-at-panel .gb-at-checkbox {
                width: 16px; height: 16px;
                border: 2px solid rgba(255,255,255,0.3);
                border-radius: 4px;
                flex-shrink: 0;
                position: relative;
                cursor: pointer;
                transition: background 0.15s ease, border-color 0.15s ease;
            }
            #gb-at-panel .gb-at-checkbox:hover {
                border-color: rgba(255,255,255,0.55);
            }
            #gb-at-panel .gb-at-preset.active .gb-at-checkbox {
                border-color: #4ea1ff;
                background: #4ea1ff;
            }
            #gb-at-panel .gb-at-preset.active .gb-at-checkbox::after {
                content: '';
                position: absolute;
                left: 3px; top: 0px;
                width: 4px; height: 8px;
                border: solid white;
                border-width: 0 2px 2px 0;
                transform: rotate(45deg);
            }
            #gb-at-panel .gb-at-preset-info {
                flex: 1;
                min-width: 0;
            }
            #gb-at-panel .gb-at-preset-name {
                font-weight: 600;
                white-space: nowrap;
                overflow: hidden;
                text-overflow: ellipsis;
            }
            #gb-at-panel .gb-at-preset-tags {
                font-size: 11px;
                opacity: 0.55;
                white-space: nowrap;
                overflow: hidden;
                text-overflow: ellipsis;
                margin-top: 2px;
            }
            #gb-at-panel .gb-at-preset-actions {
                display: flex;
                gap: 2px;
                flex-shrink: 0;
            }
            #gb-at-panel .gb-at-icon-btn {
                cursor: pointer;
                width: 24px; height: 24px;
                display: inline-flex;
                align-items: center;
                justify-content: center;
                border-radius: 4px;
                opacity: 0.6;
                font-size: 13px;
            }
            #gb-at-panel .gb-at-icon-btn:hover {
                opacity: 1;
                background: rgba(255,255,255,0.1);
            }

            #gb-at-panel .gb-at-empty {
                opacity: 0.4;
                font-style: italic;
                font-size: 12px;
                padding: 8px 0;
                text-align: center;
            }

            /* Edit form */
            #gb-at-panel .gb-at-name-input {
                width: 100%;
                padding: 8px 10px;
                background: rgba(0,0,0,0.4);
                border: 1px solid rgba(255,255,255,0.15);
                border-radius: 6px;
                color: #e8e8ec;
                font-size: 14px;
                font-weight: 600;
                font-family: inherit;
                outline: none;
                margin-bottom: 12px;
            }
            #gb-at-panel .gb-at-name-input:focus {
                border-color: rgba(78, 161, 255, 0.6);
            }

            #gb-at-panel .gb-at-chips {
                display: flex;
                flex-wrap: wrap;
                gap: 5px;
                min-height: 24px;
                margin-bottom: 10px;
            }
            #gb-at-panel .gb-at-chip {
                display: inline-flex;
                align-items: center;
                gap: 5px;
                padding: 3px 4px 3px 9px;
                background: rgba(78, 161, 255, 0.18);
                border: 1px solid rgba(78, 161, 255, 0.35);
                border-radius: 999px;
                font-size: 12px;
                max-width: 100%;
                word-break: break-all;
            }
            #gb-at-panel .gb-at-chip-x {
                cursor: pointer;
                width: 16px; height: 16px;
                display: inline-flex;
                align-items: center;
                justify-content: center;
                border-radius: 50%;
                opacity: 0.6;
                font-size: 14px;
                line-height: 1;
                flex-shrink: 0;
            }
            #gb-at-panel .gb-at-chip-x:hover {
                opacity: 1;
                background: rgba(255,255,255,0.1);
            }

            #gb-at-panel .gb-at-input-row {
                display: flex;
                gap: 6px;
                margin-bottom: 12px;
            }
            #gb-at-panel input.gb-at-tag-input {
                flex: 1;
                min-width: 0;
                padding: 6px 8px;
                background: rgba(0,0,0,0.4);
                border: 1px solid rgba(255,255,255,0.15);
                border-radius: 6px;
                color: #e8e8ec;
                font-size: 12px;
                font-family: inherit;
                outline: none;
            }
            #gb-at-panel input.gb-at-tag-input:focus {
                border-color: rgba(78, 161, 255, 0.6);
            }

            #gb-at-panel .gb-at-btn-row {
                display: flex;
                justify-content: space-between;
                gap: 6px;
                margin-top: 10px;
            }
            #gb-at-panel button.gb-at-btn {
                padding: 7px 14px;
                background: rgba(255,255,255,0.06);
                border: 1px solid rgba(255,255,255,0.12);
                border-radius: 6px;
                color: #e8e8ec;
                font-size: 12px;
                font-weight: 600;
                cursor: pointer;
                font-family: inherit;
            }
            #gb-at-panel button.gb-at-btn:hover { background: rgba(255,255,255,0.12); }
            #gb-at-panel button.gb-at-btn.primary {
                background: #4ea1ff;
                border-color: #4ea1ff;
                color: #fff;
            }
            #gb-at-panel button.gb-at-btn.primary:hover { background: #6cb4ff; border-color: #6cb4ff; }
            #gb-at-panel button.gb-at-btn.full { width: 100%; }
            #gb-at-panel button.gb-at-btn.add {
                padding: 6px 12px;
                background: #4ea1ff;
                border-color: #4ea1ff;
                color: #fff;
            }
            #gb-at-panel button.gb-at-btn.add:hover { background: #6cb4ff; }

            #gb-at-panel .gb-at-hint {
                margin-top: 8px;
                font-size: 11px;
                opacity: 0.45;
                line-height: 1.4;
            }
            #gb-at-panel .gb-at-hint code {
                background: rgba(255,255,255,0.07);
                padding: 1px 4px;
                border-radius: 3px;
                font-size: 10px;
            }
        `;
        document.head.appendChild(style);
    }

    function buildPanelListView() {
        const presets = getPresets();

        let html = `
            <div class="gb-at-header">
                <span>Auto-tag presets</span>
                <span class="gb-at-close" data-action="close">×</span>
            </div>
            <div class="gb-at-label">Click a preset to use only it · Use the checkbox to combine</div>
            <div class="gb-at-presets">
        `;

        if (presets.length === 0) {
            html += `<div class="gb-at-empty">No presets yet — create one below.</div>`;
        } else {
            for (const p of presets) {
                const active = isActive(p.id);
                html += `
                    <div class="gb-at-preset ${active ? 'active' : ''}" data-action="solo" data-preset-id="${p.id}">
                        <div class="gb-at-checkbox" data-action="toggle-active" data-preset-id="${p.id}" title="Toggle in combination"></div>
                        <div class="gb-at-preset-info">
                            <div class="gb-at-preset-name"></div>
                            <div class="gb-at-preset-tags"></div>
                        </div>
                        <div class="gb-at-preset-actions">
                            <span class="gb-at-icon-btn" data-action="edit" data-preset-id="${p.id}" title="Edit">✏</span>
                            <span class="gb-at-icon-btn" data-action="delete" data-preset-id="${p.id}" title="Delete">🗑</span>
                        </div>
                    </div>
                `;
            }
        }

        html += `
            </div>
            <button class="gb-at-btn full primary" data-action="new">+ New preset</button>
            <div class="gb-at-hint">Click a row to use only that preset. Click the checkbox on the left to add or remove a preset from the active mix without affecting the others.</div>
        `;

        return html;
    }

    function buildPanelEditView() {
        const isNew = editingDraft.id === null;
        return `
            <div class="gb-at-header">
                <span>${isNew ? 'New preset' : 'Edit preset'}</span>
                <span class="gb-at-close" data-action="cancel-edit">×</span>
            </div>
            <div class="gb-at-label">Preset name</div>
            <input class="gb-at-name-input" type="text" placeholder="e.g. Glasses mode" value="">
            <div class="gb-at-label">Tags</div>
            <div class="gb-at-chips"></div>
            <div class="gb-at-input-row">
                <input class="gb-at-tag-input" type="text" placeholder="add a tag…">
                <button class="gb-at-btn add" data-action="add-tag">Add</button>
            </div>
            <div class="gb-at-btn-row">
                <button class="gb-at-btn" data-action="cancel-edit">Cancel</button>
                <button class="gb-at-btn primary" data-action="save">Save</button>
            </div>
            <div class="gb-at-hint">Press Enter in the tag box to add. Multiple tags can be separated by spaces.</div>
        `;
    }

    function injectUI() {
        if (document.getElementById('gb-at-root')) return;
        injectStyles();

        const root = document.createElement('div');
        root.id = 'gb-at-root';
        root.innerHTML = `
            <div id="gb-at-pill" title="Auto-tags">
                <span class="gb-at-toggle" data-action="toggle"></span>
                <span class="gb-at-active-name"></span>
                <span class="gb-at-count"></span>
                <span class="gb-at-sep">|</span>
                <span class="gb-at-gear" data-action="open" title="Manage presets">⚙</span>
            </div>
            <div id="gb-at-panel"></div>
        `;
        document.body.appendChild(root);

        const pill = root.querySelector('#gb-at-pill');
        const panel = root.querySelector('#gb-at-panel');
        const nameEl = pill.querySelector('.gb-at-active-name');
        const countEl = pill.querySelector('.gb-at-count');

        function renderPill() {
            const on = isEnabled();
            const activePresets = getActivePresets();
            pill.classList.toggle('on', on);

            if (activePresets.length === 0) {
                nameEl.textContent = 'no preset';
                nameEl.classList.add('muted');
                countEl.textContent = '';
            } else if (activePresets.length === 1) {
                nameEl.textContent = truncate(activePresets[0].name, 18);
                nameEl.classList.remove('muted');
                const tagCount = getActiveTags().length;
                countEl.textContent = tagCount > 0 ? `(${tagCount})` : '';
            } else {
                const first = activePresets[0].name;
                const more = activePresets.length - 1;
                nameEl.textContent = `${truncate(first, 12)} +${more}`;
                nameEl.classList.remove('muted');
                const tagCount = getActiveTags().length;
                countEl.textContent = tagCount > 0 ? `(${tagCount})` : '';
            }
        }

        function renderPanel() {
            if (editingDraft) {
                panel.innerHTML = buildPanelEditView();
                const nameInput = panel.querySelector('.gb-at-name-input');
                nameInput.value = editingDraft.name;
                nameInput.addEventListener('input', () => {
                    editingDraft.name = nameInput.value;
                });
                renderEditChips();
                const tagInput = panel.querySelector('.gb-at-tag-input');
                tagInput.addEventListener('keydown', (e) => {
                    if (e.key === 'Enter') {
                        e.preventDefault();
                        addTagFromInput(tagInput);
                    }
                });
                setTimeout(() => nameInput.focus(), 0);
            } else {
                panel.innerHTML = buildPanelListView();
                const presets = getPresets();
                const rows = panel.querySelectorAll('.gb-at-preset');
                rows.forEach((row, i) => {
                    const p = presets[i];
                    if (!p) return;
                    row.querySelector('.gb-at-preset-name').textContent = p.name || '(unnamed)';
                    const tagPreview = p.tags.length > 0 ? p.tags.join(', ') : '(no tags)';
                    row.querySelector('.gb-at-preset-tags').textContent = tagPreview;
                });
            }
        }

        function renderEditChips() {
            const chipsEl = panel.querySelector('.gb-at-chips');
            chipsEl.innerHTML = '';
            if (editingDraft.tags.length === 0) {
                const empty = document.createElement('span');
                empty.className = 'gb-at-empty';
                empty.style.padding = '0';
                empty.textContent = 'No tags yet.';
                chipsEl.appendChild(empty);
                return;
            }
            for (const tag of editingDraft.tags) {
                const chip = document.createElement('span');
                chip.className = 'gb-at-chip';
                const text = document.createElement('span');
                text.textContent = tag;
                const x = document.createElement('span');
                x.className = 'gb-at-chip-x';
                x.title = 'Remove';
                x.textContent = '×';
                x.addEventListener('click', () => {
                    editingDraft.tags = editingDraft.tags.filter(t => t !== tag);
                    renderEditChips();
                });
                chip.appendChild(text);
                chip.appendChild(x);
                chipsEl.appendChild(chip);
            }
        }

        function addTagFromInput(input) {
            const raw = input.value.trim();
            if (!raw) return;
            const incoming = raw.split(/\s+/).filter(Boolean);
            const seen = new Set(editingDraft.tags.map(t => t.toLowerCase()));
            for (const t of incoming) {
                if (!seen.has(t.toLowerCase())) {
                    editingDraft.tags.push(t);
                    seen.add(t.toLowerCase());
                }
            }
            input.value = '';
            renderEditChips();
        }

        function openPanel() {
            editingDraft = null;
            renderPanel();
            panel.classList.add('open');
        }
        function closePanel() {
            panel.classList.remove('open');
            editingDraft = null;
        }

        function startEdit(presetId) {
            const p = getPresets().find(x => x.id === presetId);
            if (!p) return;
            editingDraft = { id: p.id, name: p.name, tags: p.tags.slice() };
            renderPanel();
        }

        function startNew() {
            editingDraft = { id: null, name: '', tags: [] };
            renderPanel();
        }

        function saveDraft() {
            const name = (editingDraft.name || '').trim();
            if (!name) {
                alert('Please give the preset a name.');
                return;
            }
            const presets = getPresets();
            if (editingDraft.id === null) {
                const id = newId();
                presets.push({ id, name, tags: editingDraft.tags.slice() });
                setPresets(presets);
                // If nothing was active, make this new preset active
                if (getActiveIds().length === 0) setActiveIds([id]);
            } else {
                const idx = presets.findIndex(p => p.id === editingDraft.id);
                if (idx >= 0) {
                    presets[idx] = { id: editingDraft.id, name, tags: editingDraft.tags.slice() };
                    setPresets(presets);
                }
            }
            editingDraft = null;
            renderPanel();
            renderPill();
            fixCurrentUrl();
        }

        function cancelEdit() {
            editingDraft = null;
            renderPanel();
        }

        function deletePreset(presetId) {
            const p = getPresets().find(x => x.id === presetId);
            if (!p) return;
            if (!confirm(`Delete preset "${p.name}"?`)) return;
            const remaining = getPresets().filter(x => x.id !== presetId);
            setPresets(remaining);
            // Drop the deleted preset from the active list
            const newActive = getActiveIds().filter(id => id !== presetId);
            setActiveIds(newActive);
            renderPanel();
            renderPill();
            fixCurrentUrl();
        }

        function soloPreset(presetId) {
            // "Use only this preset" — replace the active list with just this one
            setActiveIds([presetId]);
            renderPill();
            renderPanel();
            fixCurrentUrl();
        }

        function toggleActive(presetId) {
            // Add or remove this preset from the active list without affecting others
            const ids = getActiveIds();
            if (ids.includes(presetId)) {
                setActiveIds(ids.filter(id => id !== presetId));
            } else {
                setActiveIds(ids.concat(presetId));
            }
            renderPill();
            renderPanel();
            fixCurrentUrl();
        }

        // Event delegation
        root.addEventListener('click', (e) => {
            let el = e.target;
            while (el && el !== root && !(el.dataset && el.dataset.action)) {
                el = el.parentElement;
            }
            if (!el || el === root) return;
            const action = el.dataset.action;
            const presetId = el.dataset.presetId;

            if (action !== 'solo') e.stopPropagation();

            switch (action) {
                case 'toggle': {
                    const on = !isEnabled();
                    setEnabled(on);
                    renderPill();
                    if (on) fixCurrentUrl();
                    break;
                }
                case 'open':
                    if (panel.classList.contains('open')) closePanel();
                    else openPanel();
                    break;
                case 'close':
                    closePanel();
                    break;
                case 'solo':
                    if (presetId) soloPreset(presetId);
                    break;
                case 'toggle-active':
                    if (presetId) toggleActive(presetId);
                    break;
                case 'edit':
                    if (presetId) startEdit(presetId);
                    break;
                case 'delete':
                    if (presetId) deletePreset(presetId);
                    break;
                case 'new':
                    startNew();
                    break;
                case 'save':
                    saveDraft();
                    break;
                case 'cancel-edit':
                    cancelEdit();
                    break;
                case 'add-tag': {
                    const input = panel.querySelector('.gb-at-tag-input');
                    if (input) addTagFromInput(input);
                    break;
                }
            }
        });

        // Click outside panel to close it.
        // We use composedPath() instead of contains(e.target) because some
        // in-panel handlers (e.g. removing a tag chip) re-render the panel,
        // detaching e.target from the DOM before this handler runs. The path
        // is captured at dispatch time, so it's still accurate.
        document.addEventListener('click', (e) => {
            if (!panel.classList.contains('open')) return;
            const path = e.composedPath();
            if (path.includes(panel) || path.includes(pill)) return;
            closePanel();
        });

        renderPill();
    }

    // --- Init ------------------------------------------------------------

    fixCurrentUrl();

    function onReady() {
        hookSearchForms();
        injectUI();
    }

    if (document.readyState === 'loading') {
        document.addEventListener('DOMContentLoaded', onReady);
    } else {
        onReady();
    }
})();