☰

e621 Tag Helper (Uploads)

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

// ==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);
})();