☰

e621 Tag Helper (Uploads)

Панель категорий тегов для e621.net/uploads/new + экспорт/импорт БД

Na nainštalovanie skriptu si budete musieť nainštalovať rozšírenie, ako napríklad Tampermonkey, Greasemonkey alebo Violentmonkey.

Na inštaláciu tohto skriptu budete musieť nainštalovať rozšírenie, ako je napríklad Tampermonkey alebo Violentmonkey.

Na nainštalovanie skriptu si budete musieť nainštalovať rozšírenie, ako napríklad Tampermonkey, % alebo Violentmonkey.

Na nainštalovanie skriptu si budete musieť nainštalovať rozšírenie, ako napríklad Tampermonkey alebo Userscripts.

Na inštaláciu tohto skriptu je potrebné nainštalovať rozšírenie, ako napríklad Tampermonkey.

Na inštaláciu tohto skriptu je potrebné nainštalovať rozšírenie správcu používateľských skriptov.

(Už mám správcu používateľských skriptov, nechajte ma ho nainštalovať!)

Na inštaláciu tohto štýlu je potrebné nainštalovať rozšírenie, ako napríklad Stylus.

Na inštaláciu tohto štýlu je potrebné nainštalovať rozšírenie, ako napríklad Stylus.

Na inštaláciu tohto štýlu je potrebné nainštalovať rozšírenie, ako napríklad Stylus.

Na inštaláciu tohto štýlu je potrebné nainštalovať rozšírenie správcu používateľských štýlov.

Na inštaláciu tohto štýlu je potrebné nainštalovať rozšírenie správcu používateľských štýlov.

Na inštaláciu tohto štýlu je potrebné nainštalovať rozšírenie správcu používateľských štýlov.

(Už mám správcu používateľských štýlov, nechajte ma ho nainštalovať!)

// ==UserScript==
// @name         e621 Tag Helper (Uploads)
// @namespace    https://e621.net/
// @version      1.9
// @license MIT 
// @description  Панель категорий тегов для e621.net/uploads/new + экспорт/импорт БД
// @description:en  The tag category panel for e621.net/uploads/new + DATABASE export/import
// @author       DiorionArh
// @name:en      Tag Cataloger
// @name:ru      Каталогизатор Тегов
// @match        https://e621.net/uploads/new*
// @grant        none
// @run-at       document-idle
// ==/UserScript==

(function () {
    'use strict';

    // ---------- IndexedDB ----------
    const DB_NAME = 'e621_taghelper';
    const DB_VERSION = 1;
    const STORE_CATS = 'categories';
    const STORE_TAGS = 'tags';

    let db = null;

    function openDB() {
        return new Promise((resolve, reject) => {
            const req = indexedDB.open(DB_NAME, DB_VERSION);
            req.onupgradeneeded = (e) => {
                const d = e.target.result;
                if (!d.objectStoreNames.contains(STORE_CATS)) {
                    const s = d.createObjectStore(STORE_CATS, { keyPath: 'id', autoIncrement: true });
                    s.createIndex('order', 'order');
                }
                if (!d.objectStoreNames.contains(STORE_TAGS)) {
                    const s = d.createObjectStore(STORE_TAGS, { keyPath: 'id', autoIncrement: true });
                    s.createIndex('catId', 'catId');
                    s.createIndex('name', 'name', { unique: false });
                    s.createIndex('order', 'order');
                }
            };
            req.onsuccess = () => resolve(req.result);
            req.onerror = () => reject(req.error);
        });
    }

    function tx(store, mode) { return db.transaction(store, mode).objectStore(store); }

    function dbGetAll(store) {
        return new Promise((res, rej) => {
            const r = tx(store, 'readonly').getAll();
            r.onsuccess = () => res(r.result);
            r.onerror = () => rej(r.error);
        });
    }
    function dbAdd(store, obj) {
        return new Promise((res, rej) => {
            const r = tx(store, 'readwrite').add(obj);
            r.onsuccess = () => res(r.result);
            r.onerror = () => rej(r.error);
        });
    }
    function dbPut(store, obj) {
        return new Promise((res, rej) => {
            const r = tx(store, 'readwrite').put(obj);
            r.onsuccess = () => res(r.result);
            r.onerror = () => rej(r.error);
        });
    }
    function dbDelete(store, id) {
        return new Promise((res, rej) => {
            const r = tx(store, 'readwrite').delete(id);
            r.onsuccess = () => res();
            r.onerror = () => rej(r.error);
        });
    }
    function dbClear(store) {
        return new Promise((res, rej) => {
            const r = tx(store, 'readwrite').clear();
            r.onsuccess = () => res();
            r.onerror = () => rej(r.error);
        });
    }

    // ---------- state ----------
    let categories = [];
    let tags = [];
    let searchQuery = '';
    let selectedTagIds = new Set();

    function sortCategories() { categories.sort((a, b) => (a.order ?? 0) - (b.order ?? 0)); }

    async function reloadData() {
        categories = await dbGetAll(STORE_CATS);
        sortCategories();
        tags = await dbGetAll(STORE_TAGS);
        const alive = new Set(tags.map(t => t.id));
        for (const id of [...selectedTagIds]) if (!alive.has(id)) selectedTagIds.delete(id);
        renderPanel();
    }

    // ---------- helpers ----------
    function normalizeTagName(raw) { return raw.trim().replace(/\s+/g, '_').toLowerCase(); }
    function findTagByNameAnywhere(name) { return tags.find(t => t.name.toLowerCase() === name.toLowerCase()); }
    function findTagByNameInCat(name, catId) {
        return tags.find(t => t.catId === catId && t.name.toLowerCase() === name.toLowerCase());
    }
    function catNameById(id) { const c = categories.find(c => c.id === id); return c ? c.name : `#${id}`; }
    function catById(id) { return categories.find(c => c.id === id); }

    function normalizeHex(hex) {
        if (!hex) return '';
        let h = String(hex).replace('#', '');
        if (h.length === 3) h = h.split('').map(c => c + c).join('');
        if (h.length !== 6) return '';
        if (parseInt(h, 16) === 0xffffff) return '';
        return '#' + h.toLowerCase();
    }

    function hexToRgba(hex, alpha) {
        if (!hex) return `rgba(255,255,255,${alpha})`;
        let h = hex.replace('#', '');
        if (h.length === 3) h = h.split('').map(c => c + c).join('');
        const n = parseInt(h, 16);
        if (isNaN(n) || h.length !== 6) return `rgba(255,255,255,${alpha})`;
        const r = (n >> 16) & 255, g = (n >> 8) & 255, b = n & 255;
        return `rgba(${r},${g},${b},${alpha})`;
    }

    function effectiveColor(cat, tag) {
        if (tag && tag.color) return tag.color;
        if (cat && cat.color) return cat.color;
        return '';
    }

    // ---------- export / import ----------
    async function exportDB() {
        const cats = await dbGetAll(STORE_CATS);
        const allTags = await dbGetAll(STORE_TAGS);
        const catMap = new Map(cats.map(c => [c.id, c]));
        const payload = {
            format: 'e621-taghelper-backup',
            version: 2,
            exportedAt: new Date().toISOString(),
            categories: cats.sort((a, b) => (a.order ?? 0) - (b.order ?? 0))
                .map(c => ({ name: c.name, order: c.order ?? 0, color: c.color || '' })),
            tags: allTags.map(t => ({
                catName: catMap.get(t.catId)?.name ?? null,
                name: t.name,
                order: t.order ?? 0,
                color: t.color || '',
            })).filter(t => t.catName),
        };
        const blob = new Blob([JSON.stringify(payload, null, 2)], { type: 'application/json' });
        const url = URL.createObjectURL(blob);
        const a = document.createElement('a');
        a.href = url;
        a.download = `e621_taghelper_${new Date().toISOString().slice(0, 10)}.json`;
        document.body.appendChild(a); a.click(); a.remove();
        setTimeout(() => URL.revokeObjectURL(url), 2000);
        showToast(`Экспорт: ${payload.categories.length} кат., ${payload.tags.length} тегов.`, 'ok');
    }

    async function importDB(file, mode) {
        let data;
        try { data = JSON.parse(await file.text()); }
        catch (e) { showToast('Не удалось прочитать JSON.', 'error'); return; }
        if (!data || data.format !== 'e621-taghelper-backup'
            || !Array.isArray(data.categories) || !Array.isArray(data.tags)) {
            showToast('Неверный формат файла.', 'error');
            return;
        }
        if (mode === 'replace') { await dbClear(STORE_CATS); await dbClear(STORE_TAGS); }

        const existingCats = await dbGetAll(STORE_CATS);
        const catByName = new Map(existingCats.map(c => [c.name.toLowerCase(), c]));
        let nextOrder = existingCats.length ? Math.max(...existingCats.map(c => c.order ?? 0)) + 1 : 1;
        const importedCatMap = new Map();

        for (const c of data.categories) {
            const name = String(c.name || '').trim();
            if (!name) continue;
            const key = name.toLowerCase();
            let cat = catByName.get(key);
            if (!cat) {
                const obj = {
                    name,
                    order: Number.isFinite(c.order) ? c.order : nextOrder,
                    color: normalizeHex(c.color || ''),
                };
                const id = await dbAdd(STORE_CATS, obj);
                cat = { id, ...obj };
                catByName.set(key, cat);
                nextOrder = Math.max(nextOrder, (obj.order ?? 0) + 1);
            } else if (mode === 'replace') {
                cat.color = normalizeHex(c.color || '');
                await dbPut(STORE_CATS, cat);
            }
            importedCatMap.set(key, cat);
        }

        const existingTags = await dbGetAll(STORE_TAGS);
        const tagKey = (catId, name) => `${catId}::${name.toLowerCase()}`;
        const existingSet = new Set(existingTags.map(t => tagKey(t.catId, t.name)));
        const orderByCat = new Map();
        for (const t of existingTags) orderByCat.set(t.catId, Math.max(orderByCat.get(t.catId) ?? 0, t.order ?? 0));

        let added = 0, skipped = 0;
        for (const t of data.tags) {
            const cname = String(t.catName || '').toLowerCase();
            const name = normalizeTagName(String(t.name || ''));
            if (!name || !cname) { skipped++; continue; }
            const cat = importedCatMap.get(cname);
            if (!cat) { skipped++; continue; }
            const key = tagKey(cat.id, name);
            if (existingSet.has(key)) { skipped++; continue; }
            const order = (orderByCat.get(cat.id) ?? 0) + 1;
            orderByCat.set(cat.id, order);
            await dbAdd(STORE_TAGS, { catId: cat.id, name, order, color: normalizeHex(t.color || '') });
            existingSet.add(key);
            added++;
        }
        await reloadData();
        showToast(`Импорт: +${added} тегов, пропущено ${skipped}.`, 'ok');
    }

    // ---------- UI ----------
    let panelEl = null;
    let stickyEl = null;
    let selectionBoxEl = null;
    let bulkBarEl = null;

    function buildPanel() {
        const wrap = document.createElement('div');
        wrap.id = 'taghelper-panel';
        wrap.innerHTML = `
            <style>
                #taghelper-panel {
                    position: fixed;
                    left: 0; top: 0;
                    width: 280px; height: 100vh;
                    overflow-y: auto;
                    background: #1e2124;
                    color: #ddd;
                    font: 12px/1.4 sans-serif;
                    z-index: 99999;
                    box-shadow: 2px 0 8px rgba(0,0,0,.5);
                    padding: 0 8px 8px 8px; box-sizing: border-box;
                    user-select: none;
                }
                body { margin-left: 280px !important; }
                #taghelper-panel .sticky {
                    position: sticky;
                    top: 0;
                    background: #1e2124;
                    padding-top: 8px;
                    margin: 0 -8px;
                    padding-left: 8px;
                    padding-right: 8px;
                    z-index: 5;
                    box-shadow: 0 6px 8px -6px rgba(0,0,0,.6);
                }
                #taghelper-panel .cat { margin-bottom: 10px; margin-top: 8px; }
                #taghelper-panel .cat-name {
                    font-weight: bold; color: #f0ad4e;
                    display: flex; justify-content: space-between; align-items: center;
                    user-select: none;
                }
                #taghelper-panel .cat-name .title { flex: 1; }
                #taghelper-panel .cat-name button { font-size: 10px; padding: 0 4px; cursor: pointer; }
                #taghelper-panel .cat-name .grip,
                #taghelper-panel .tag-row .grip {
                    cursor: grab; color: #666; padding: 0 3px; user-select: none;
                }
                #taghelper-panel .cat-name .swatch {
                    width: 12px; height: 12px; border-radius: 3px;
                    border: 1px solid #555; margin-right: 4px;
                    cursor: pointer; display: inline-block;
                    box-sizing: border-box;
                }
                #taghelper-panel .tag-row {
                    display: flex; align-items: center; gap: 4px;
                    padding: 1px 3px;
                    border-top: 2px solid transparent;
                    border-bottom: 2px solid transparent;
                    border-radius: 3px;
                    transition: background .1s;
                }
                #taghelper-panel .tag-row.drag-over-top { border-top-color: #4a90e2; }
                #taghelper-panel .tag-row.drag-over-bottom { border-bottom-color: #4a90e2; }
                #taghelper-panel .tag-row.selected {
                    outline: 1px solid #4a90e2;
                    background: rgba(74,144,226,.25) !important;
                }
                #taghelper-panel .tag-row input[type=checkbox] { margin: 0; }
                #taghelper-panel .tag-row label { flex: 1; word-break: break-all; cursor: pointer; }
                #taghelper-panel .tag-row .del { color: #c66; cursor: pointer; padding: 0 2px; opacity: .6; }
                #taghelper-panel .tag-row .del:hover { opacity: 1; }
                #taghelper-panel .cat.drag-over { outline: 2px dashed #4a90e2; }
                #taghelper-panel .add-row { display: flex; gap: 4px; margin-bottom: 8px; }
                #taghelper-panel .add-row input {
                    flex: 1; padding: 2px 4px; background: #2a2d31;
                    border: 1px solid #444; color: #ddd; min-width: 0;
                }
                #taghelper-panel .add-row select { max-width: 90px; }
                #taghelper-panel .add-row button { padding: 2px 6px; cursor: pointer; }

                #taghelper-panel .tools {
                    display: flex;
                    gap: 4px;
                    margin: 4px 0 8px;
                    flex-wrap: nowrap;
                    align-items: stretch;
                    width: 100%;
                    box-sizing: border-box;
                }
                #taghelper-panel .tools button {
                    flex: 1 1 0;
                    min-width: 0;
                    padding: 3px 4px;
                    cursor: pointer;
                    font-size: 11px;
                    white-space: nowrap;
                    overflow: hidden;
                    text-overflow: ellipsis;
                }
                #th-collapse {
                    width: 22px;
                    min-width: 22px;
                    max-width: 22px;
                    flex: 0 0 22px !important;
                    padding: 0 !important;
                    font-size: 12px;
                    line-height: 1;
                    opacity: .75;
                }
                #th-collapse:hover { opacity: 1; }

                /* Подсказка — inline */
                #taghelper-panel .hint {
                    display: inline;
                    font-size: 10px;
                    color: #aaa;
                    line-height: 1.35;
                }
                #taghelper-panel .hint-wrap {
                    padding: 4px 6px;
                    margin: 6px 0 8px;
                    background: #23272b;
                    border: 1px solid #2f343a;
                    border-radius: 3px;
                    width: 100%;
                    box-sizing: border-box;
                }

                #taghelper-panel .tag-row.dragging,
                #taghelper-panel .cat.dragging { opacity: .4; }
                #taghelper-panel .search-row { margin-bottom: 8px; }
                #taghelper-panel .search-row input {
                    width: 100%; padding: 3px 6px; background: #2a2d31;
                    border: 1px solid #444; color: #ddd; box-sizing: border-box;
                }
                #taghelper-panel .tag-row.match {
                    background: rgba(255, 235, 59, 0.18);
                    box-shadow: inset 0 0 0 1px rgba(255, 235, 59, 0.5);
                }
                #taghelper-panel .tag-row.match label { font-weight: bold; color: #fff !important; }
                #taghelper-panel .tag-row.dim { opacity: .35; }

                #th-expand {
                    position: fixed; left: 2px; top: 2px;
                    width: 14px; height: 14px;
                    font-size: 11px; line-height: 12px;
                    padding: 0;
                    border: 1px solid #444;
                    background: #2a2d31;
                    color: #888;
                    border-radius: 3px;
                    cursor: pointer;
                    opacity: .55;
                    z-index: 100000;
                }
                #th-expand:hover { opacity: 1; color: #fff; }

                #th-rubber {
                    position: fixed;
                    border: 1px solid #4a90e2;
                    background: rgba(74,144,226,.15);
                    pointer-events: none;
                    z-index: 100002;
                    display: none;
                }

                #th-bulk {
                    position: fixed;
                    left: 8px; bottom: 8px;
                    width: 264px;
                    background: #23272b;
                    border: 1px solid #3a3f44;
                    border-radius: 4px;
                    padding: 6px;
                    box-sizing: border-box;
                    display: none;
                    z-index: 100001;
                    box-shadow: 0 2px 10px rgba(0,0,0,.5);
                }
                #th-bulk.show { display: block; }
                #th-bulk .row { display: flex; gap: 4px; align-items: center; }
                #th-bulk .count { flex: 1; color: #8fbc8f; }
                #th-bulk .swatch-b {
                    width: 18px; height: 18px; border-radius: 3px;
                    border: 1px solid #555; cursor: pointer;
                    box-sizing: border-box;
                }
                #th-bulk button {
                    padding: 2px 6px; cursor: pointer; font-size: 11px;
                }

                #taghelper-toast {
                    position: fixed; bottom: 20px; left: 300px;
                    background: #2b2f33; color: #fff;
                    padding: 8px 12px; border-radius: 4px;
                    box-shadow: 0 2px 10px rgba(0,0,0,.6);
                    font: 12px/1.4 sans-serif;
                    z-index: 100003; max-width: 340px;
                    border-left: 3px solid #4a90e2;
                    opacity: 0; transform: translateY(10px);
                    transition: opacity .2s, transform .2s;
                    pointer-events: none;
                }
                #taghelper-toast.show { opacity: 1; transform: translateY(0); }
                #taghelper-toast.warn { border-left-color: #e2a44a; }
                #taghelper-toast.error { border-left-color: #e24a4a; }
                #taghelper-toast.ok { border-left-color: #4ae28f; }
            </style>
            <div class="sticky" id="th-sticky">
                <div class="tools">
                    <button id="th-add-cat">+ категория</button>
                    <button id="th-export">⇩ экспорт</button>
                    <button id="th-import">⇧ импорт</button>
                    <button id="th-collapse" title="свернуть">‹</button>
                </div>
                <div class="search-row">
                    <input id="th-search" placeholder="поиск / подсветка тегов..." />
                </div>
                <div class="add-row">
                    <input id="th-tag-input" placeholder="новый тег" />
                    <select id="th-tag-cat"></select>
                    <button id="th-add-tag">+</button>
                </div>
            </div>
            <div class="hint-wrap"><span class="hint">Галочка — добавить/удалить. ⠿ — перетащить. 🎨 — цвет. Тяни ЛКМ — выделить. Выделенный тег + новый — вставится под ним.</span></div>
            <div id="th-cats"></div>
            <input type="file" id="th-import-file" accept="application/json,.json" style="display:none" />
        `;
        document.body.appendChild(wrap);
        panelEl = wrap;
        stickyEl = wrap.querySelector('#th-sticky');

        selectionBoxEl = document.createElement('div');
        selectionBoxEl.id = 'th-rubber';
        document.body.appendChild(selectionBoxEl);

        bulkBarEl = document.createElement('div');
        bulkBarEl.id = 'th-bulk';
        bulkBarEl.innerHTML = `
            <div class="row">
                <span class="count" id="th-bulk-count">0</span>
                <span class="swatch-b" id="th-bulk-swatch" title="цвет выделенных"></span>
                <button id="th-bulk-apply">окрасить</button>
                <button id="th-bulk-reset" title="снять цвет">✖</button>
                <button id="th-bulk-clear" title="снять выделение">⌫</button>
            </div>
        `;
        document.body.appendChild(bulkBarEl);

        const toast = document.createElement('div');
        toast.id = 'taghelper-toast';
        document.body.appendChild(toast);

        wrap.querySelector('#th-collapse').onclick = (e) => {
            e.stopPropagation();
            wrap.style.display = 'none';
            document.body.style.marginLeft = '0';
            if (!document.getElementById('th-expand')) {
                const btn = document.createElement('button');
                btn.id = 'th-expand';
                btn.textContent = '›';
                btn.title = 'развернуть';
                btn.onclick = () => {
                    wrap.style.display = '';
                    document.body.style.marginLeft = '280px';
                    btn.remove();
                };
                document.body.appendChild(btn);
            }
        };

        wrap.querySelector('#th-add-cat').onclick = async () => {
            const name = prompt('Название категории:');
            if (!name) return;
            const trimmed = name.trim();
            if (!trimmed) return;
            if (categories.some(c => c.name.toLowerCase() === trimmed.toLowerCase())) {
                showToast(`Категория «${trimmed}» уже существует.`, 'warn');
                return;
            }
            const order = categories.length ? Math.max(...categories.map(c => c.order || 0)) + 1 : 1;
            await dbAdd(STORE_CATS, { name: trimmed, order, color: '' });
            await reloadData();
        };

        wrap.querySelector('#th-export').onclick = async () => {
            try { await exportDB(); }
            catch (e) { console.error(e); showToast('Ошибка экспорта: ' + e.message, 'error'); }
        };
        const fileInput = wrap.querySelector('#th-import-file');
        wrap.querySelector('#th-import').onclick = () => { fileInput.value = ''; fileInput.click(); };
        fileInput.addEventListener('change', async () => {
            const f = fileInput.files && fileInput.files[0];
            if (!f) return;
            const replace = confirm(
                'Импорт файла.\n\n' +
                'OK      = заменить всю текущую базу данными из файла\n' +
                'Отмена = слить (добавить недостающие категории/теги)'
            );
            await importDB(f, replace ? 'replace' : 'merge');
        });

        const input = wrap.querySelector('#th-tag-input');
        const catSel = wrap.querySelector('#th-tag-cat');

        const addTagFromInput = async () => {
            const name = normalizeTagName(input.value);
            if (!name) return;

            // --- Определяем, есть ли выделение и «якорь» (последний по order выделенный тег) ---
            const selectedTags = [...selectedTagIds]
                .map(id => tags.find(t => t.id === id))
                .filter(Boolean)
                .sort((a, b) => (a.order ?? 0) - (b.order ?? 0) || a.name.localeCompare(b.name));

            const hasSelection = selectedTags.length > 0;
            const anchor = hasSelection ? selectedTags[selectedTags.length - 1] : null;

            // Категория: если есть выделение — категория якоря (игнорируем select), иначе из select
            let catId;
            if (anchor) {
                catId = anchor.catId;
            } else {
                catId = Number(catSel.value);
                if (!catId) { showToast('Сначала создайте категорию.', 'warn'); return; }
            }

            // --- Проверки дубликатов ---
            if (findTagByNameInCat(name, catId)) {
                showToast(`Тег «${name}» уже есть в категории «${catNameById(catId)}».`, 'warn');
                input.focus(); input.select();
                return;
            }
            const elsewhere = findTagByNameAnywhere(name);
            if (elsewhere && elsewhere.catId !== catId) {
                const ok = confirm(
                    `Тег «${name}» уже существует в категории «${catNameById(elsewhere.catId)}».\n` +
                    `Добавить его ещё и в «${catNameById(catId)}»?`
                );
                if (!ok) { input.focus(); input.select(); return; }
            }

            // --- Позиция вставки и цвет ---
            let insertAfterOrder = null;
            let inheritedColor = '';

            if (anchor) {
                // Вставка под якорем в его категории
                insertAfterOrder = anchor.order ?? 0;
                // Наследование цвета: tag.color || категория.color
                const anchorCat = catById(anchor.catId);
                inheritedColor = effectiveColor(anchorCat, anchor) || '';
            }

            if (insertAfterOrder != null) {
                const toShift = tags.filter(t => t.catId === catId && (t.order ?? 0) > insertAfterOrder);
                toShift.sort((a, b) => (a.order ?? 0) - (b.order ?? 0));
                for (let i = toShift.length - 1; i >= 0; i--) {
                    const t = toShift[i];
                    t.order = (t.order ?? 0) + 1;
                    await dbPut(STORE_TAGS, t);
                }
            }

            const siblings = tags.filter(t => t.catId === catId);
            const order = (insertAfterOrder != null)
                ? insertAfterOrder + 1
                : (siblings.length ? Math.max(...siblings.map(t => t.order || 0)) + 1 : 1);

            await dbAdd(STORE_TAGS, {
                catId,
                name,
                order,
                color: inheritedColor,
            });
            input.value = '';

            if (anchor) {
                if (inheritedColor) {
                    showToast(`«${name}» добавлен под «${anchor.name}» и окрашен как он.`, 'ok');
                } else {
                    showToast(`«${name}» добавлен под «${anchor.name}».`, 'ok');
                }
            }

            await reloadData();
        };
        wrap.querySelector('#th-add-tag').onclick = addTagFromInput;
        input.addEventListener('keydown', (e) => {
            if (e.key === 'Enter') { e.preventDefault(); addTagFromInput(); }
        });

        const searchInput = wrap.querySelector('#th-search');
        searchInput.addEventListener('input', () => {
            searchQuery = searchInput.value.trim().toLowerCase();
            applySearchHighlight();
        });

        const bulkSwatch = bulkBarEl.querySelector('#th-bulk-swatch');
        const bulkColorInput = document.createElement('input');
        bulkColorInput.type = 'color';
        bulkColorInput.value = '#ffffff';
        bulkColorInput.style.cssText = 'position:absolute;opacity:0;pointer-events:none;width:0;height:0';
        bulkSwatch.appendChild(bulkColorInput);
        bulkSwatch.addEventListener('click', () => bulkColorInput.click());
        bulkColorInput.addEventListener('input', () => {
            bulkSwatch.style.background = bulkColorInput.value;
        });

        bulkBarEl.querySelector('#th-bulk-apply').onclick = async () => {
            const color = normalizeHex(bulkColorInput.value);
            await setColorForSelected(color);
        };
        bulkBarEl.querySelector('#th-bulk-reset').onclick = () => setColorForSelected('');
        bulkBarEl.querySelector('#th-bulk-clear').onclick = () => {
            selectedTagIds.clear();
            updateSelectionUI();
        };

        installRubberBand();
    }

    async function setColorForSelected(color) {
        if (!selectedTagIds.size) return;
        for (const id of selectedTagIds) {
            const t = tags.find(x => x.id === id);
            if (!t) continue;
            t.color = color;
            await dbPut(STORE_TAGS, t);
        }
        await reloadData();
        showToast(color ? `Окрашено: ${selectedTagIds.size} тегов.` : `Снят цвет: ${selectedTagIds.size} тегов.`, 'ok');
    }

    let toastTimer = null;
    function showToast(msg, kind = 'info') {
        const el = document.getElementById('taghelper-toast');
        if (!el) return;
        el.textContent = msg;
        el.className = 'show ' + kind;
        if (toastTimer) clearTimeout(toastTimer);
        toastTimer = setTimeout(() => { el.className = ''; }, 3500);
    }

    function applySearchHighlight() {
        if (!panelEl) return;
        const rows = panelEl.querySelectorAll('.tag-row');
        if (!searchQuery) {
            rows.forEach(r => r.classList.remove('match', 'dim'));
            return;
        }
        let firstMatch = null;
        rows.forEach(r => {
            const lbl = r.querySelector('label');
            const name = lbl ? lbl.textContent.toLowerCase() : '';
            const hit = name.includes(searchQuery);
            r.classList.toggle('match', hit);
            r.classList.toggle('dim', !hit);
            if (hit && !firstMatch) firstMatch = r;
        });
        if (firstMatch) firstMatch.scrollIntoView({ block: 'center', behavior: 'smooth' });
    }

    function updateSelectionUI() {
        if (!panelEl) return;
        panelEl.querySelectorAll('.tag-row').forEach(r => {
            const id = Number(r.dataset.tagId);
            r.classList.toggle('selected', selectedTagIds.has(id));
        });
        const bar = document.getElementById('th-bulk');
        if (!bar) return;
        if (selectedTagIds.size > 0) {
            bar.classList.add('show');
            bar.querySelector('#th-bulk-count').textContent = `выделено: ${selectedTagIds.size}`;
        } else {
            bar.classList.remove('show');
        }
    }

    function installRubberBand() {
        let dragging = false;
        let startX = 0, startY = 0;
        let additive = false;
        let baseSelection = new Set();

        panelEl.addEventListener('mousedown', (e) => {
            if (e.button !== 0) return;
            if (e.target.closest('input, button, select, .grip, .del, label, a')) return;
            if (e.target.closest('.cat-name')) return;
            if (e.target.closest('#th-sticky')) return;

            dragging = true;
            startX = e.clientX;
            startY = e.clientY;
            additive = e.shiftKey || e.ctrlKey || e.metaKey;
            baseSelection = additive ? new Set(selectedTagIds) : new Set();
            if (!additive) selectedTagIds.clear();

            const box = selectionBoxEl;
            box.style.display = 'block';
            box.style.left = startX + 'px';
            box.style.top = startY + 'px';
            box.style.width = '0px';
            box.style.height = '0px';

            const onMove = (ev) => {
                if (!dragging) return;
                const x1 = Math.min(startX, ev.clientX);
                const y1 = Math.min(startY, ev.clientY);
                const x2 = Math.max(startX, ev.clientX);
                const y2 = Math.max(startY, ev.clientY);
                box.style.left = x1 + 'px';
                box.style.top = y1 + 'px';
                box.style.width = (x2 - x1) + 'px';
                box.style.height = (y2 - y1) + 'px';

                const rect = { left: x1, top: y1, right: x2, bottom: y2 };
                const rows = panelEl.querySelectorAll('.tag-row');
                selectedTagIds = new Set(baseSelection);
                rows.forEach(r => {
                    const b = r.getBoundingClientRect();
                    const inter = !(b.right < rect.left || b.left > rect.right
                        || b.bottom < rect.top || b.top > rect.bottom);
                    if (inter) selectedTagIds.add(Number(r.dataset.tagId));
                });
                updateSelectionUI();
            };

            const onUp = () => {
                dragging = false;
                box.style.display = 'none';
                window.removeEventListener('mousemove', onMove);
                window.removeEventListener('mouseup', onUp);
                updateSelectionUI();
            };

            window.addEventListener('mousemove', onMove);
            window.addEventListener('mouseup', onUp);
            e.preventDefault();
        });

        panelEl.addEventListener('keydown', (e) => {
            if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'a') {
                e.preventDefault();
                selectedTagIds = new Set(tags.map(t => t.id));
                updateSelectionUI();
            }
        });
        panelEl.tabIndex = 0;
    }

    let draggedTagIds = [];
    let draggedCatId = null;

    function clearTagIndicators() {
        panelEl.querySelectorAll('.tag-row').forEach(r => {
            r.classList.remove('drag-over-top', 'drag-over-bottom', 'dragging');
        });
    }
    function clearCatIndicators() {
        panelEl.querySelectorAll('.cat').forEach(c => c.classList.remove('drag-over', 'dragging'));
    }

    async function reorderTagGroup(targetTag, insertBefore) {
        if (!draggedTagIds.length || !targetTag) return;
        const newCatId = targetTag.catId;
        const targetId = targetTag.id;

        const draggedSet = new Set(draggedTagIds);
        const siblings = tags
            .filter(t => t.catId === newCatId && !draggedSet.has(t.id))
            .sort((a, b) => (a.order ?? 0) - (b.order ?? 0) || a.name.localeCompare(b.name));

        let insertIdx = siblings.findIndex(t => t.id === targetId);
        if (insertIdx === -1) insertIdx = siblings.length;
        if (!insertBefore) insertIdx += 1;

        const moving = tags
            .filter(t => draggedSet.has(t.id))
            .sort((a, b) => (a.order ?? 0) - (b.order ?? 0) || a.name.localeCompare(b.name));

        const existingNamesInTarget = new Set(siblings.map(t => t.name.toLowerCase()));
        const moved = [];
        const skipped = [];
        for (const t of moving) {
            if (existingNamesInTarget.has(t.name.toLowerCase())) { skipped.push(t.name); continue; }
            existingNamesInTarget.add(t.name.toLowerCase());
            moved.push(t);
        }
        if (!moved.length) {
            if (skipped.length) showToast(`Пропущены дубликаты: ${skipped.join(', ')}`, 'warn');
            return;
        }

        siblings.splice(insertIdx, 0, ...moved);
        for (const t of moved) t.catId = newCatId;
        for (let i = 0; i < siblings.length; i++) {
            const t = siblings[i];
            if ((t.order ?? 0) !== i + 1) { t.order = i + 1; await dbPut(STORE_TAGS, t); }
        }
        if (skipped.length) showToast(`Пропущены дубликаты: ${skipped.join(', ')}`, 'warn');
        await reloadData();
    }

    async function reorderCategory(targetCat, insertBefore) {
        if (draggedCatId == null || targetCat.id === draggedCatId) return;
        const list = categories.filter(c => c.id !== draggedCatId);
        let insertIdx = list.findIndex(c => c.id === targetCat.id);
        if (insertIdx === -1) insertIdx = list.length;
        if (!insertBefore) insertIdx += 1;
        const draggedCat = categories.find(c => c.id === draggedCatId);
        if (!draggedCat) return;
        list.splice(insertIdx, 0, draggedCat);
        for (let i = 0; i < list.length; i++) {
            const c = list[i];
            if ((c.order ?? 0) !== i + 1) { c.order = i + 1; await dbPut(STORE_CATS, c); }
        }
        await reloadData();
    }

    function renderPanel() {
        const catsBox = panelEl.querySelector('#th-cats');
        const catSel = panelEl.querySelector('#th-tag-cat');

        const prevVal = catSel.value;
        catSel.innerHTML = '';
        for (const c of categories) {
            const o = document.createElement('option');
            o.value = c.id; o.textContent = c.name;
            catSel.appendChild(o);
        }
        if (prevVal && categories.some(c => String(c.id) === String(prevVal))) catSel.value = prevVal;

        catsBox.innerHTML = '';
        if (!categories.length) {
            catsBox.innerHTML = '<div class="hint">Нет категорий. Нажми «+ категория».</div>';
            updateSelectionUI();
            return;
        }

        const ta = getTextarea();
        const currentSet = ta ? new Set(getCurrentTags(ta)) : new Set();

        for (const cat of categories) {
            const catEl = document.createElement('div');
            catEl.className = 'cat';
            catEl.dataset.catId = cat.id;

            const catColor = cat.color || '';

            const header = document.createElement('div');
            header.className = 'cat-name';

            const grip = document.createElement('span');
            grip.className = 'grip'; grip.textContent = '⠿';
            grip.title = 'перетащить категорию'; grip.draggable = true;
            grip.addEventListener('dragstart', (e) => {
                draggedCatId = cat.id; draggedTagIds = [];
                catEl.classList.add('dragging');
                e.dataTransfer.effectAllowed = 'move';
                e.dataTransfer.setData('text/plain', `cat:${cat.id}`);
            });
            grip.addEventListener('dragend', () => {
                draggedCatId = null;
                catEl.classList.remove('dragging');
                clearCatIndicators();
            });

            const swatch = document.createElement('span');
            swatch.className = 'swatch'; swatch.title = 'цвет категории';
            swatch.style.background = catColor || 'transparent';
            const colorInput = document.createElement('input');
            colorInput.type = 'color';
            colorInput.value = catColor || '#ffffff';
            colorInput.style.cssText = 'position:absolute;opacity:0;pointer-events:none;width:0;height:0';
            swatch.appendChild(colorInput);
            swatch.addEventListener('click', (e) => { e.stopPropagation(); colorInput.click(); });
            colorInput.addEventListener('input', () => { swatch.style.background = colorInput.value; });
            colorInput.addEventListener('change', async () => {
                const v = normalizeHex(colorInput.value);
                cat.color = v;
                await dbPut(STORE_CATS, cat);
                renderPanel();
            });

            const title = document.createElement('span');
            title.className = 'title'; title.textContent = cat.name;
            title.style.color = catColor || '#f0ad4e';

            const delCat = document.createElement('button');
            delCat.textContent = '✖'; delCat.title = 'удалить категорию';
            delCat.onclick = async (e) => {
                e.stopPropagation();
                if (!confirm(`Удалить категорию "${cat.name}" и все её теги?`)) return;
                for (const t of tags.filter(t => t.catId === cat.id)) await dbDelete(STORE_TAGS, t.id);
                await dbDelete(STORE_CATS, cat.id);
                await reloadData();
            };

            header.appendChild(grip);
            header.appendChild(swatch);
            header.appendChild(title);
            header.appendChild(delCat);
            catEl.appendChild(header);

            catEl.addEventListener('dragover', (e) => {
                if (draggedTagIds.length) {
                    e.preventDefault();
                    e.dataTransfer.dropEffect = 'move';
                    catEl.classList.add('drag-over');
                }
            });
            catEl.addEventListener('dragleave', (e) => {
                if (e.target === catEl) catEl.classList.remove('drag-over');
            });
            catEl.addEventListener('drop', async (e) => {
                if (!draggedTagIds.length) return;
                e.preventDefault();
                catEl.classList.remove('drag-over');
                await reorderTagGroup({ id: -1, catId: cat.id, name: '' }, false);
                draggedTagIds = [];
                clearTagIndicators();
            });

            const catTags = tags
                .filter(t => t.catId === cat.id)
                .sort((a, b) => (a.order ?? 0) - (b.order ?? 0) || a.name.localeCompare(b.name));

            if (!catTags.length) {
                const empty = document.createElement('div');
                empty.className = 'hint'; empty.textContent = '—';
                catEl.appendChild(empty);
            }

            for (const t of catTags) {
                const row = document.createElement('div');
                row.className = 'tag-row';
                row.dataset.tagId = t.id;
                row.draggable = false;

                const eColor = effectiveColor(cat, t);
                if (eColor) row.style.background = hexToRgba(eColor, 0.10);

                const gripT = document.createElement('span');
                gripT.className = 'grip'; gripT.textContent = '⠿';
                gripT.title = 'перетащить тег'; gripT.draggable = true;
                gripT.addEventListener('dragstart', (e) => {
                    if (selectedTagIds.has(t.id)) {
                        draggedTagIds = [...selectedTagIds];
                    } else {
                        draggedTagIds = [t.id];
                        selectedTagIds = new Set([t.id]);
                        updateSelectionUI();
                    }
                    draggedCatId = null;
                    panelEl.querySelectorAll('.tag-row').forEach(r => {
                        if (draggedTagIds.includes(Number(r.dataset.tagId))) r.classList.add('dragging');
                    });
                    e.dataTransfer.effectAllowed = 'move';
                    e.dataTransfer.setData('text/plain', `taggroup:${draggedTagIds.join(',')}`);
                    e.stopPropagation();
                });
                gripT.addEventListener('dragend', () => {
                    draggedTagIds = [];
                    panelEl.querySelectorAll('.tag-row').forEach(r => r.classList.remove('dragging'));
                    clearTagIndicators();
                });

                const cb = document.createElement('input');
                cb.type = 'checkbox';
                cb.dataset.tag = t.name;
                cb.checked = currentSet.has(t.name);
                cb.onchange = () => {
                    if (cb.checked) addTag(t.name); else removeTag(t.name);
                };

                const lbl = document.createElement('label');
                lbl.textContent = t.name;
                if (eColor) lbl.style.color = eColor;
                lbl.onclick = (e) => {
                    e.preventDefault();
                    cb.checked = !cb.checked;
                    cb.onchange();
                };

                const del = document.createElement('span');
                del.className = 'del'; del.textContent = '✖'; del.title = 'удалить тег';
                del.onclick = async (e) => {
                    e.stopPropagation();
                    if (!confirm(`Удалить тег "${t.name}"?`)) return;
                    await dbDelete(STORE_TAGS, t.id);
                    tags = tags.filter(x => x.id !== t.id);
                    selectedTagIds.delete(t.id);
                    row.remove();
                    updateSelectionUI();
                };

                row.addEventListener('dragover', (e) => {
                    if (!draggedTagIds.length) return;
                    if (draggedTagIds.includes(t.id)) return;
                    e.preventDefault(); e.stopPropagation();
                    e.dataTransfer.dropEffect = 'move';
                    const rect = row.getBoundingClientRect();
                    const isTop = (e.clientY - rect.top) < rect.height / 2;
                    row.classList.toggle('drag-over-top', isTop);
                    row.classList.toggle('drag-over-bottom', !isTop);
                });
                row.addEventListener('dragleave', (e) => {
                    if (e.target === row) row.classList.remove('drag-over-top', 'drag-over-bottom');
                });
                row.addEventListener('drop', async (e) => {
                    if (!draggedTagIds.length) return;
                    e.preventDefault(); e.stopPropagation();
                    const rect = row.getBoundingClientRect();
                    const isTop = (e.clientY - rect.top) < rect.height / 2;
                    row.classList.remove('drag-over-top', 'drag-over-bottom');
                    await reorderTagGroup(t, isTop);
                    draggedTagIds = [];
                    clearTagIndicators();
                });

                row.appendChild(gripT);
                row.appendChild(cb);
                row.appendChild(lbl);
                row.appendChild(del);
                catEl.appendChild(row);
            }

            header.addEventListener('dragover', (e) => {
                if (draggedCatId == null || draggedCatId === cat.id) return;
                e.preventDefault(); e.stopPropagation();
                e.dataTransfer.dropEffect = 'move';
                catEl.classList.add('drag-over');
                const rect = header.getBoundingClientRect();
                catEl.dataset.dropBefore = ((e.clientY - rect.top) < rect.height / 2) ? '1' : '0';
            });
            header.addEventListener('dragleave', (e) => {
                if (e.target === header) catEl.classList.remove('drag-over');
            });
            header.addEventListener('drop', async (e) => {
                if (draggedCatId == null) return;
                e.preventDefault(); e.stopPropagation();
                const isTop = catEl.dataset.dropBefore === '1';
                catEl.classList.remove('drag-over');
                await reorderCategory(cat, isTop);
                draggedCatId = null; clearCatIndicators();
            });

            catsBox.appendChild(catEl);
        }

        applySearchHighlight();
        updateSelectionUI();
    }

    let _suppressSync = false;
    function getTextarea() {
        return document.querySelector('textarea#post_tags')
            || document.querySelector('textarea.tag-textarea')
            || document.querySelector('textarea.tag-textarea#post_tags');
    }
    function getCurrentTags(ta) {
        return ta.value.split(/\s+/).map(t => t.trim()).filter(Boolean);
    }
    function setTags(ta, arr) {
        _suppressSync = true;
        ta.value = arr.join(' ');
        ta.dispatchEvent(new Event('input', { bubbles: true }));
        ta.dispatchEvent(new Event('change', { bubbles: true }));
        _suppressSync = false;
    }
    function addTag(tag) {
        const ta = getTextarea(); if (!ta) return;
        const list = getCurrentTags(ta);
        if (!list.includes(tag)) { list.push(tag); setTags(ta, list); }
    }
    function removeTag(tag) {
        const ta = getTextarea(); if (!ta) return;
        setTags(ta, getCurrentTags(ta).filter(t => t !== tag));
    }
    function watchTextarea() {
        const ta = getTextarea();
        if (!ta || ta.__th_watched) return;
        ta.__th_watched = true;
        const syncAll = () => {
            if (_suppressSync || !panelEl) return;
            const cur = new Set(getCurrentTags(ta));
            panelEl.querySelectorAll('.tag-row input[type=checkbox]').forEach(cb => {
                cb.checked = cur.has(cb.dataset.tag);
            });
        };
        ta.addEventListener('input', syncAll);
        ta.addEventListener('change', syncAll);
    }

    async function init() {
        db = await openDB();

        const cats = await dbGetAll(STORE_CATS);
        if (cats.length === 0) {
            await dbAdd(STORE_CATS, { name: 'Особенности тела', order: 1, color: '' });
            await dbAdd(STORE_CATS, { name: 'NSFW', order: 2, color: '' });
            await dbAdd(STORE_CATS, { name: 'Фон', order: 3, color: '' });
        }

        buildPanel();
        await reloadData();
        watchTextarea();

        const allTags = await dbGetAll(STORE_TAGS);
        const byCat = {};
        for (const t of allTags) (byCat[t.catId] ||= []).push(t);
        let needReload = false;
        for (const catId of Object.keys(byCat)) {
            const arr = byCat[catId].sort((a, b) => (a.order ?? 0) - (b.order ?? 0) || a.name.localeCompare(b.name));
            for (let i = 0; i < arr.length; i++) {
                if ((arr[i].order ?? 0) !== i + 1) {
                    arr[i].order = i + 1;
                    await dbPut(STORE_TAGS, arr[i]);
                    needReload = true;
                }
            }
        }
        if (needReload) await reloadData();

        setInterval(() => {
            if (!panelEl) return;
            const ta = getTextarea();
            if (ta && !ta.__th_watched) {
                watchTextarea();
                const cur = new Set(getCurrentTags(ta));
                panelEl.querySelectorAll('.tag-row input[type=checkbox]').forEach(cb => {
                    cb.checked = cur.has(cb.dataset.tag);
                });
            }
        }, 500);
    }

    const wait = setInterval(() => {
        if (document.body) {
            clearInterval(wait);
            init().catch(e => console.error('[taghelper]', e));
        }
    }, 200);
})();