Pornolab — Form autocomplete

Вставляет раздел «Автозаполнение формы» и заполняет поля через VNDB/Steam API

K instalaci tototo skriptu si budete muset nainstalovat rozšíření jako Tampermonkey, Greasemonkey nebo Violentmonkey.

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

K instalaci tohoto skriptu si budete muset nainstalovat rozšíření jako Tampermonkey nebo Violentmonkey.

K instalaci tohoto skriptu si budete muset nainstalovat rozšíření jako Tampermonkey nebo Userscripts.

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

K instalaci tohoto skriptu si budete muset nainstalovat manažer uživatelských skriptů.

(Už mám manažer uživatelských skriptů, nechte mě ho nainstalovat!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(Už mám manažer uživatelských stylů, nechte mě ho nainstalovat!)

// ==UserScript==
// @name         Pornolab — Form autocomplete
// @namespace    https://github.com/yourname
// @version      1.1.1
// @description  Вставляет раздел «Автозаполнение формы» и заполняет поля через VNDB/Steam API
// @author       claude.ai
// @match        https://pornolab.net/forum/posting.php?mode=new_rel&f=1756
// @match        https://pornolab.net/forum/posting.php?mode=new_rel&f=1869
// @match        https://pornolab.net/forum/posting.php?mode=new_rel&f=1750
// @match        https://pornolab.net/forum/posting.php?mode=new_rel&f=1785
// @match        https://pornolab.net/forum/posting.php?mode=new_rel&f=1790
// @match        https://pornolab.net/forum/posting.php?mode=new_rel&f=1827
// @grant        GM_xmlhttpRequest
// @grant        GM_addStyle
// @grant        GM_getValue
// @grant        GM_setValue
// @connect      api.vndb.org
// @connect      store.steampowered.com
// @connect      steamspy.com
// @connect      translate.googleapis.com
// @connect      new.fastpic.org
// @connect      static.pornolab.net
// @connect      api.igdb.com
// @connect      id.twitch.tv
// @connect      images.igdb.com
// @run-at       document-idle
// @license MIT
// ==/UserScript==

(function () {
    'use strict';

    // ─── Конфигурация ──────────────────────────────────────────────────────────

    const FORUM_ID = new URL(location.href).searchParams.get('f') || '';
    const DEFAULT_STEAM_FORUMS = ['1750', '1785', '1790', '1827'];
    const API_BASE = 'https://api.vndb.org/kana';

    const FIELDS = {
        poster:        'poster',
        hieroglyphs:   '7cd5187f9e6f07f61f4ba4b07c559644',
        titleEng:      'title_eng',
        titleRu:       '92bc57d69021863813152bd3afe11942',
        year:          'g_year',
        date:          'g_data',
        tags:          'genre_g',
        cens:          'cens_game',
        developer:     '6952199379228e5408178e7c18b27360',
        platform:      'platform',
        releaseType:   'release_type',
        medicine:      'tablet',
        version:       '88311f8a842b3a42b3f4d079846ef9ab',
        langStory:     'lang_g',
        langInterface: 'menulang_g',
        langVoice:     'language_g',
        sysReq:        '8baf6af65599d806043e079219e49003',
        description:   'description',
        moreInfo:      'moreinfo',
        stepInstall:   'stepinstall',
        screenshots:   'screenshots_g',
    };

    const FIELD_LABELS = {
        hieroglyphs: 'иероглифы',
        titleEng:    'Оригинальное название',
        titleRu:     'Название на русском',
        developer:   'Разработчик',
        version:     'Версия',
        sysReq:      'Системные требования',
    };

    const EXTRA_TAGS_GROUPS = [
        { title: 'Ориентация', tags: [
            { key: 'xt_Straight',      label: 'Straight'        },
            { key: 'xt_Gay_Yaoi',      label: 'Gay/Yaoi'        },
            { key: 'xt_Trans_Trap',    label: 'Trans/Trap'      },
            { key: 'xt_Lesbian_Yuri',  label: 'Lesbian/Yuri'    },
            { key: 'xt_Bisexual',      label: 'Bisexual'        },
        ]},
        { title: 'OS', tags: [
            { key: 'xt_Win',           label: 'Win'             },
            { key: 'xt_Lin',           label: 'Lin'             },
            { key: 'xt_Mac',           label: 'Mac'             },
            { key: 'xt_Apk',           label: 'Apk'             },
        ]},
        { title: 'Разработчик', tags: [
            { key: 'xt_Indie',         label: 'Indie'           },
            { key: 'xt_Pro',           label: 'Pro'             },
        ]},
        { title: 'Движок', tags: [
            { key: 'xt_HTML',          label: 'HTML'            },
            { key: 'xt_Flash',         label: 'Flash'           },
            { key: 'xt_GameMaker',     label: 'GameMaker'       },
            { key: 'xt_Godot',         label: 'Godot'           },
            { key: 'xt_NW_js',         label: 'NW.js'           },
            { key: 'xt_Unity',         label: 'Unity'           },
            { key: 'xt_Unreal',        label: 'Unreal'          },
            { key: 'xt_RenPy',         label: "Ren'Py"          },
            { key: 'xt_Kirikiri',      label: 'Kirikiri'        },
            { key: 'xt_Tyranobuilder', label: 'Tyranobuilder'   },
            { key: 'xt_QSP',           label: 'QSP'             },
            { key: 'xt_RPGMaker',      label: 'RPG Maker'       },
            { key: 'xt_WolfRPG',       label: 'Wolf RPG Editor' },
        ]},
    ];

    const LANG_G_ABR = [
        '',                            // » Выбрать
        'rus',                         // Русский
        'rus(auto)',                   // Русский(авто)
        'eng',                         // Английский
        'eng(auto)',                   // Английский(авто)
        'rus+eng',                     // Русский+Английский
        'rus+eng+multi',               // Русский+Английский и др.
        'rus+eng(auto)+multi',         // Русский+Английский(авто) и др.
        'jap',                         // Японский
        'chi',                         // Китайский
        'jap+eng',                     // Японский+Английский
        'jap+eng(auto)',               // Японский+Английский(авто)
        'jap+rus',                     // Японский+Русский
        'jap+rus(auto)',               // Японский+Русский(авто)
        'chi+eng',                     // Китайский+Английский
        'chi+eng(auto)',               // Китайский+Английский(авто)
        'chi+rus',                     // Китайский+Русский
        'chi+rus(auto)',               // Китайский+Русский(авто)
        'jap+eng+rus',                 // Яп.+Англ.+Рус.
        'jap+eng+rus(auto)',           // Яп.+Англ.+Рус.(авто)
        'chi+eng+rus',                 // Кит.+Англ.+Рус.
        'chi+eng+rus(auto)',           // Кит.+Англ.+Рус.(авто)
        'jap+chi+eng+rus',             // Яп.+Кит.+Англ.+Рус.
        'jap+chi+eng+rus(auto)',       // Яп.+Кит.+Англ.+Рус.(авто)
        'jap+chi+eng+rus+multi',       // Яп.+Кит.+Англ.+Рус.и др.
        'jap+chi+eng+rus(auto)+multi', // Яп.+Кит.+Англ.+Рус.(авто)и др.
        '',                            // Неизвестен
    ];

    const VOICE_ABR_TO_LABEL = {
        'jap':                         'Японский',
        'eng':                         'Английский',
        'rus':                         'Русский',
        'chi':                         'Китайский',
        'jap+eng':                     'Японский+Английский',
        'jap+eng(auto)':               'Японский+Английский(авто)',
        'jap+rus':                     'Японский+Русский',
        'jap+rus(auto)':               'Японский+Русский(авто)',
        'chi+eng':                     'Китайский+Английский',
        'chi+eng(auto)':               'Китайский+Английский(авто)',
        'chi+rus':                     'Китайский+Русский',
        'chi+rus(auto)':               'Китайский+Русский(авто)',
        'jap+eng+rus':                 'Яп.+Англ.+Рус.',
        'jap+eng+rus(auto)':           'Яп.+Англ.+Рус.(авто)',
        'chi+eng+rus':                 'Кит.+Англ.+Рус.',
        'chi+eng+rus(auto)':           'Кит.+Англ.+Рус.(авто)',
        'jap+chi+eng+rus':             'Яп.+Кит.+Англ.+Рус.',
        'jap+chi+eng+rus(auto)':       'Яп.+Кит.+Англ.+Рус.(авто)',
        'jap+chi+eng+rus+multi':       'Яп.+Кит.+Англ.+Рус.и др.',
        'jap+chi+eng+rus(auto)+multi': 'Яп.+Кит.+Англ.+Рус.(авто)и др.',
    };

    const STORE_ICON_MAP = new Map([
        ['ci-en.dlsite.com',       'ci-en.png'],
        ['dl.getchu.com',          'getchu.png'],
        ['dmm.co.jp',              'fanza.png'],
        ['store.steampowered.com', 'steam.png'],
        ['gog.com',                'gogcom.png'],
        ['itch.io',                'itch_io.png'],
        ['nutaku.net',             'nutaku.png'],
        ['jastusa.com',            'jast-usa.png'],
        ['jaststore.com',          'jast-usa.png'],
        ['saikeystudios.com',      'saikey.png'],
        ['dlsite.com',             'dlsite.png'],
        ['getchu.com',             'getchu.png'],
        ['dmm.com',                'dmm.png'],
        ['fantia.jp',              'fantia.png'],
        ['boosty.to',              'boosty.png'],
        ['patreon.com',            'patreon.png'],
        ['subscribestar.com',      'subscribestar.png'],
        ['x.com',                  'x.png'],
    ]);
    const ICON_BASE = 'https://static.pornolab.net/icons_new/r/';

    // ─── Настройки ─────────────────────────────────────────────────────────────

    const SETTINGS_KEY = 'af_settings_v1';
    const SOURCE_KEY   = 'af_source_v1';

    // Поля, общие для обоих источников (дублируются с префиксом stm_)
    const _STM_MIRROR = {
        fieldPoster: true,
        fieldHieroglyphs: true, fieldTitleEng: true, fieldTitleRu: true,
        fieldYear: true, fieldDate: true, fieldTags: true,
        fieldDeveloper: true, fieldPlatform: true,
        fieldLanguage: true, fieldVoice: true,
        fieldSysReq: true, formatSysReqTemplate: true,
        fieldLinks: true, fieldScreenshots: true,
    };
    const DEFAULT_SETTINGS = {
        ..._STM_MIRROR,
        adaptTagEditor: true, addIconLinks: true,
        ...Object.fromEntries(EXTRA_TAGS_GROUPS.flatMap(g => g.tags.map(t => [t.key, false]))),
        fieldCensorship: true, fieldReleaseType: true, fieldDescription: true,
        enableSteamPoster: false, enableSteamTags: false, enableSteamScreenshots: false,
        enableSteamSpy: false, enableGoogleTranslate: false, enableFastpicUpload: true,
        ...Object.fromEntries(Object.entries(_STM_MIRROR).map(([k, v]) => [`stm_${k}`, v])),
        stm_fieldDescShort: true, stm_fieldDescLong: true, stm_enableGoogleTranslate: false,
    };

    function loadSettings() {
        try {
            const raw = GM_getValue(SETTINGS_KEY, null);
            if (!raw) return { ...DEFAULT_SETTINGS };
            return { ...DEFAULT_SETTINGS, ...JSON.parse(raw) };
        } catch { return { ...DEFAULT_SETTINGS }; }
    }

    function saveSettings(s) {
        GM_setValue(SETTINGS_KEY, JSON.stringify(s));
    }

    let settings = loadSettings();

    // ─── Toast-уведомления ─────────────────────────────────────────────────────

    let toastContainer = null;

    function ensureToastContainer() {
        if (toastContainer) return;
        toastContainer = document.createElement('div');
        toastContainer.id = 'af-toast-container';
        document.body.appendChild(toastContainer);
    }

    function toast(msg, type = 'info', duration = 3500) {
        ensureToastContainer();
        const el = document.createElement('div');
        el.className = `af-toast af-toast-${type}`;
        el.textContent = msg;
        el.addEventListener('click', () => el.remove());
        toastContainer.appendChild(el);
        if (duration > 0) setTimeout(() => el.remove(), duration);
        return el;
    }

    async function withToast(loadMsg, fn, { successMsg = '', failMsg = '', failDuration = 5000 } = {}) {
        const t = toast(loadMsg, 'info', 0);
        try {
            const result = await fn();
            t.remove();
            if (successMsg) toast(successMsg, 'success');
            return result;
        } catch (err) {
            t.remove();
            if (failMsg) toast(`${failMsg}: ${err.message}`, 'warning', failDuration);
            return null;
        }
    }

    // ─── Вспомогательные функции ───────────────────────────────────────────────

    function getHostname(url) {
        try { return new URL(url).hostname; } catch { return null; }
    }

    function _matchStore(hostname) {
        if (!hostname) return null;
        for (const [d, icon] of STORE_ICON_MAP)
            if (hostname === d || hostname.endsWith('.' + d)) return { domain: d, icon };
        return null;
    }

    function getCanonicalDomain(url) {
        return _matchStore(getHostname(url))?.domain ?? null;
    }

    function getStoreIcon(url) {
        return _matchStore(getHostname(url))?.icon ?? null;
    }

    function urlToBBIcon(url, iconFile) {
        if (iconFile) return `[url=${url}][img]${ICON_BASE}${iconFile}[/img][/url]`;
        const domain = getCanonicalDomain(url) || getHostname(url) || url;
        return `[url=${url}]${domain}[/url]`;
    }

    function setVal(el, value) {
        if (!el) return;
        el.value = value;
        el.dispatchEvent(new Event('input',  { bubbles: true }));
        el.dispatchEvent(new Event('change', { bubbles: true }));
    }

    function setLangSelect(id, abrValue) {
        const sel = getField(id);
        if (!sel) return;
        const idx = LANG_G_ABR.indexOf(abrValue);
        if (idx >= 0) {
            sel.selectedIndex = idx;
            sel.dispatchEvent(new Event('change', { bubbles: true }));
        }
    }

    function getField(id, label = null) {
        let el = document.getElementById(id);
        if (!el && label) {
            for (const td of document.querySelectorAll('td.rel-title')) {
                if (td.textContent.includes(label)) {
                    el = td.nextElementSibling?.querySelector('input.rel-input, textarea.rel-input, select.rel-input');
                    if (el) break;
                }
            }
        }
        return el || null;
    }

    function getIconsField() {
        const tagsField = getField(FIELDS.tags);
        if (!tagsField) return null;
        const container = tagsField.closest('td') || tagsField.parentElement;
        if (!container) return null;
        const hasEditor = Array.from(container.querySelectorAll('input[type="button"]'))
            .some(b => b.value?.includes('Редактор'));
        if (!hasEditor) return null;
        const inputs = Array.from(container.querySelectorAll('input[type="text"], textarea'));
        const tagIdx = inputs.indexOf(tagsField);
        if (tagIdx >= 0 && tagIdx + 1 < inputs.length) return inputs[tagIdx + 1];
        return null;
    }

    function setAnnotation(targetEl, id, html, before = false) {
        if (!targetEl) return;
        let ann = document.getElementById(id);
        if (!ann) {
            ann = document.createElement(before ? 'div' : 'span');
            ann.id = id;
            ann.style.cssText = before
                ? 'color:#cc0000;margin-bottom:4px;font-size:12px;'
                : 'color:#cc0000;margin-left:8px;font-size:12px;';
            targetEl.parentNode.insertBefore(ann, before ? targetEl : targetEl.nextSibling);
        }
        ann.innerHTML = html;
    }

    function fillIf(settingKey, fieldId, value, label = null) {
        if (!settings[settingKey] || !value) return;
        const f = getField(fieldId, label);
        if (f) setVal(f, value);
    }

    // ─── FastPic — аннотации ──────────────────────────────────────────────────

    const _FP_HREF = '<a href="https://new.fastpic.org/viaurl/" target="_blank" style="color:#345da4;font-weight:bold;">fastpic</a>';
    const ANN_POSTER_MANUAL = `Перезалейте ссылку на ${_FP_HREF} вручную`;
    const ANN_POSTER_COVER  = `Перезалейте ссылку на ${_FP_HREF}, выбрав режим Постер (Cover)`;
    const ANN_SCREENS       = `Перезалейте все ссылки на ${_FP_HREF} и получите ссылки для вставки миниатюр 350px`;
    const TAG_EDITOR_ANN    = 'Скопируйте эти теги в <a href="https://static.pornolab.net/tag_editor/?conf=game" ' +
        'target="_blank" style="color:#345da4;font-weight:bold;">редактор тегов</a> для проверки';

    // ─── Парсинг системных требований ─────────────────────────────────────────

    function parseSteamMinReqs(html) {
    if (!html) return null;
    return html
        .replace(/<br\s*\/?>/gi, '\n')
        .replace(/<strong>Minimum:<\/strong>/gi, '')
        .replace(/<[^>]+>/g, '')
        .replace(/OS \*:/g, 'OS:')
        .replace(/\s*\b(or higher|or better|or equivalent)\b/gi, '')
        .split('\n').map(l => l.trim()).filter(Boolean).join('\n');
    }

    function formatSysReqToTemplate(text) {
        if (!text) return text;
        const lines = text.split('\n').map(l => l.trim()).filter(Boolean);
        const getVal = key => {
            for (const line of lines)
                if (line.toLowerCase().startsWith(key.toLowerCase() + ':'))
                    return line.slice(line.indexOf(':') + 1).trim();
            return null;
        };
        const squishUnits = s => s.replace(/\s+(GHz|MHz|GB|MB)/gi, '$1');

        const os        = getVal('OS');
        const processor = getVal('Processor');
        const memory    = getVal('Memory');
        const graphics  = getVal('Graphics');
        const storage   = getVal('Storage');
        const parts     = [];

        if (os) parts.push(`OS: ${os}`);
        if (processor && /intel|amd|ghz|mhz/i.test(processor)) {
            const cpu = processor
                .replace(/\b(single-core|quad[\s-]?core|dual-core)\b/gi, '')
                .replace(/\s{2,}/g, ' ').trim();
            parts.push(`CPU: ${squishUnits(cpu)}`);
        }
        if (graphics && /intel|nvidia|geforce|amd|radeon/i.test(graphics))
            parts.push(`GPU: ${graphics}`);
        if (memory && /gb|mb/i.test(memory)) {
            const ram = memory.replace(/\bRAM\b/gi, '').replace(/\s{2,}/g, ' ').trim();
            parts.push(`RAM: ${squishUnits(ram)}`);
        }
        if (graphics && /gb|mb/i.test(graphics)) {
            const vram = graphics.replace(/\bVRAM\b/gi, '').replace(/\s{2,}/g, ' ').trim();
            parts.push(`VRAM: ${squishUnits(vram)}`);
        }
        if (storage && /gb|mb/i.test(storage)) {
            const hdd = storage.replace(/\bavailable\s+space\b/gi, '').replace(/\s{2,}/g, ' ').trim();
            parts.push(`HDD: ${squishUnits(hdd)}`);
        }
        return parts.length > 0 ? parts.join(' | ') : text;
    }

    /** Параметрическая замена getSteamSysReq + дублирующего кода из fillFormSteam */
    function parseSysReq(steamData, enabled, format) {
        if (!enabled) return null;
        const raw = parseSteamMinReqs(steamData?.pc_requirements?.minimum);
        if (!raw) return null;
        return format ? formatSysReqToTemplate(raw) : raw;
    }

    // ─── API-функции ───────────────────────────────────────────────────────────

    function apiPost(endpoint, payload) {
        return new Promise((resolve, reject) => {
            GM_xmlhttpRequest({
                method: 'POST',
                url: `${API_BASE}/${endpoint}`,
                headers: { 'Content-Type': 'application/json' },
                data: JSON.stringify(payload),
                timeout: 15000,
                onload: r => {
                    if (r.status === 200) resolve(JSON.parse(r.responseText));
                    else reject(new Error(`VNDB HTTP ${r.status}: ${r.responseText.slice(0, 200)}`));
                },
                onerror:   () => reject(new Error('VNDB: ошибка сети')),
                ontimeout: () => reject(new Error('VNDB: таймаут запроса')),
            });
        });
    }

    function fetchVNData(id) {
        return apiPost('vn', {
            filters: ['id', '=', `v${id}`],
            fields: 'title, released, description, olang, image { url }, titles { lang, main, title }, tags { name, lie }, languages, developers { name }, platforms, screenshots { url }',
            results: 1,
        }).then(r => r.results?.[0]);
    }

    function fetchReleases(id) {
        return apiPost('release', {
            filters: ['vn', '=', ['id', '=', `v${id}`]],
            fields: 'uncensored, official, patch, voiced, platforms, languages { lang }, vns { id, rtype }, producers { developer, publisher, name }, extlinks { url }, released',
            results: 100,
        }).then(r => r.results || []).catch(() => []);
    }

    function fetchReleaseById(releaseId) {
        return apiPost('release', {
            filters: ['id', '=', `r${releaseId}`],
            fields: 'title, uncensored, official, patch, voiced, platforms, vns { id, rtype }, producers { developer, publisher, name }, extlinks { url }, released, languages { lang, title }',
            results: 1,
        }).then(r => r.results?.[0] || null).catch(() => null);
    }

    function gmFetch(url, { throwOnError = false, timeout = 12000 } = {}) {
        return new Promise((resolve, reject) => {
            GM_xmlhttpRequest({
                method: 'GET', url, timeout,
                onload: r => {
                    if (throwOnError && r.status >= 400) { reject(new Error(`HTTP ${r.status}`)); return; }
                    try { resolve(JSON.parse(r.responseText)); }
                    catch { throwOnError ? reject(new Error('Ошибка парсинга JSON')) : resolve(null); }
                },
                onerror:   () => throwOnError ? reject(new Error('Ошибка сети'))     : resolve(null),
                ontimeout: () => throwOnError ? reject(new Error('Таймаут запроса')) : resolve(null),
            });
        });
    }

    function fetchSteamDetails(appId, locale = 'english') {
        return gmFetch(`https://store.steampowered.com/api/appdetails?appids=${appId}&l=${locale}`)
            .then(json => { const e = json?.[String(appId)]; return e?.success ? e.data : null; });
    }

    function fetchSteamSpy(appId) {
        return gmFetch(`https://steamspy.com/api.php?request=appdetails&appid=${appId}`)
            .then(json => json?.tags ?? null);
    }

    async function translateToRussian(text) {
        if (!text || /[а-яА-ЯёЁ]/.test(text)) return text;
        const url = `https://translate.googleapis.com/translate_a/single?client=gtx&sl=auto&tl=ru&dt=t&q=${encodeURIComponent(text)}`;
        const data = await gmFetch(url, { timeout: 15000 });
        if (!data?.[0]) throw new Error('Неверный формат ответа');
        return data[0].map(i => i[0]).join('');
    }

    // ─── Steam-специфичные вспомогательные функции ────────────────────────────

    function parseSteamSupportedLanguages(html) {
        if (!html) return { langs: [], audioLangs: [] };
        const noFootnote = html.replace(/<br>[\s\S]*/i, '');
        const langs = [], audioLangs = [];
        noFootnote.split(',').forEach(entry => {
            const hasAudio = /<strong>\*<\/strong>/i.test(entry);
            const clean = entry.replace(/<[^>]+>/g, '').replace(/\*/g, '').trim().toLowerCase();
            if (clean) {
                langs.push(clean);
                if (hasAudio) audioLangs.push(clean);
            }
        });
        return { langs, audioLangs };
    }

    function cleanSteamHtml(html) {
        if (!html) return '';
        return html
            .replace(/<span[^>]*class=["'][^"']*bb_img_ctn[^"']*["'][^>]*>[\s\S]*?<\/span>/gi, '')
            .replace(/<\/h2>/gi, '\n').replace(/<h2[^>]*>/gi, '\n')
            .replace(/<\/p>/gi, '\n').replace(/<p[^>]*>/gi, '')
            .replace(/<br\s*\/?>/gi, '\n')
            .replace(/<[^>]+>/g, '')
            .replace(/&quot;/g, '"').replace(/&amp;/g, '&').replace(/&lt;/g, '<')
            .replace(/&gt;/g, '>').replace(/&#39;/g, "'").replace(/&apos;/g, "'")
            .replace(/&nbsp;/g, ' ')
            .replace(/\n{3,}/g, '\n\n').trim();
    }

    // ─── Маппинг языков ────────────────────────────────────────────────────────

    /** Общая логика маппинга булевых флагов → строку lang_g_abr */
    function _langsAbrFromFlags(rus, eng, jap, chi, multi) {
        if (jap && chi && eng && rus) return multi ? 'jap+chi+eng+rus+multi' : 'jap+chi+eng+rus';
        if (jap && eng && rus) return 'jap+eng+rus';
        if (chi && eng && rus) return 'chi+eng+rus';
        if (jap && rus)        return 'jap+rus';
        if (jap && eng)        return 'jap+eng';
        if (chi && rus)        return 'chi+rus';
        if (chi && eng)        return 'chi+eng';
        if (rus && eng)        return multi ? 'rus+eng+multi' : 'rus+eng';
        if (jap)  return 'jap';
        if (chi)  return 'chi';
        if (rus)  return 'rus';
        if (eng)  return 'eng';
        return '';
    }

    /** VNDB-коды языков (['ru', 'en', 'ja', 'zh-Hans', ...]) → lang_g_abr */
    function langsToAbr(langArray) {
        if (!langArray?.length) return '';
        const lc = langArray.map(l => l.toLowerCase());
        return _langsAbrFromFlags(
            lc.some(l => l === 'ru'),
            lc.some(l => l === 'en'),
            lc.some(l => l === 'ja'),
            lc.some(l => l === 'zh' || l.startsWith('zh-')),
            lc.some(l => l !== 'ru' && l !== 'en' && l !== 'ja' && !l.startsWith('zh'))
        );
    }

    /** Steam-имена языков (['russian', 'english', ...]) → lang_g_abr */
    function steamLangsToAbr(langs) {
        const hasChi = langs.includes('simplified chinese') || langs.includes('traditional chinese');
        return _langsAbrFromFlags(
            langs.includes('russian'),
            langs.includes('english'),
            langs.includes('japanese'),
            hasChi,
            langs.some(l => l !== 'russian' && l !== 'english' && l !== 'japanese' &&
                            l !== 'simplified chinese' && l !== 'traditional chinese')
        );
    }

    /**
     * Универсальный маппинг языка озвучки → lang_g_abr.
     * Принимает строку VNDB-кода ('ja') ИЛИ массив Steam-имён (['japanese']).
     */
    function audioLangToAbr(input) {
        let jap = false, eng = false, rus = false, chi = false;
        if (typeof input === 'string') {
            const l = input.toLowerCase();
            jap = l === 'ja'; eng = l === 'en'; rus = l === 'ru';
            chi = l === 'zh' || l.startsWith('zh-');
        } else if (Array.isArray(input)) {
            jap = input.includes('japanese');
            eng = input.includes('english');
            rus = input.includes('russian');
            chi = input.includes('simplified chinese') || input.includes('traditional chinese');
        }
        return _langsAbrFromFlags(rus, eng, jap, chi, false);
    }

    // ─── Общие хелперы заполнения полей ───────────────────────────────────────

    /** Заполняет поля языка игры и озвучки */
    function applyLanguages(langEnabled, langAbr, voiceEnabled, voiceAbr) {
        if (langEnabled && langAbr) {
            setLangSelect(FIELDS.langStory,     langAbr);
            setLangSelect(FIELDS.langInterface, langAbr);
        }
        if (voiceEnabled && voiceAbr) {
            const sel = getField(FIELDS.langVoice);
            if (sel) {
                const label = VOICE_ABR_TO_LABEL[voiceAbr] || '';
                if (label) { sel.value = label; sel.dispatchEvent(new Event('change', { bubbles: true })); }
            }
        }
    }

    /** Заполняет поле системных требований (или ставит аннотацию «Заполнить самим») */
    function applySysReq(sysReq, enabled) {
        if (!enabled) return;
        const f = getField(FIELDS.sysReq, FIELD_LABELS.sysReq);
        if (!f) return;
        if (sysReq) {
            document.getElementById('af-ann-sysreq')?.remove();
            setVal(f, sysReq);
        } else {
            setAnnotation(f, 'af-ann-sysreq', '<b>Заполнить самим</b>', true);
        }
    }

    /** Заполняет поле описания (+ ссылки под ним) */
    function applyDescription(hasDesc, description, linksEnabled, linksText) {
        const f = getField(FIELDS.description);
        if (!f) return;
        const suffix = linksEnabled && linksText ? '\n\n' + linksText : '';
        if (hasDesc) setVal(f, '\n' + description + suffix);
        else if (linksEnabled && linksText) setVal(f, '\n\n' + linksText);
    }

    // ─── FastPic: загрузка картинок по URL ────────────────────────────────────

    async function uploadToFastpic(urls, onProgress = null) {
        const BATCH_SIZE = 30;
        const batches = [];
        for (let i = 0; i < urls.length; i += BATCH_SIZE)
            batches.push(urls.slice(i, i + BATCH_SIZE));

        function uploadBatch(batchUrls) {
            return new Promise((resolve, reject) => {
                GM_xmlhttpRequest({
                    method:  'POST',
                    url:     'https://new.fastpic.org/upload_from_url',
                    headers: {
                        'Content-Type': 'application/x-www-form-urlencoded',
                        'Referer':      'https://new.fastpic.org/viaurl/',
                    },
                    data:    'files=' + encodeURIComponent(batchUrls.join('\n')) + '&uploading=1',
                    timeout: 60000,
                    onload(r) {
                        if (r.status >= 400) { reject(new Error(`FastPic HTTP ${r.status}`)); return; }
                        const doc = new DOMParser().parseFromString(r.responseText, 'text/html');
                        const results = [];
                        doc.querySelectorAll('img[data-links]').forEach(img => {
                            try {
                                const links = JSON.parse(img.getAttribute('data-links').replace(/&quot;/g, '"'));
                                if (links.big) results.push(links);
                            } catch (e) {
                                console.warn('[AutoFill] FastPic: ошибка парсинга data-links:', e);
                            }
                        });
                        resolve(results);
                    },
                    onerror()  { reject(new Error('Сетевая ошибка при обращении к FastPic')); },
                    ontimeout(){ reject(new Error('Таймаут FastPic (60 с)')); },
                });
            });
        }

        const results = [];
        for (const batch of batches) {
            const batchResults = await uploadBatch(batch);
            results.push(...batchResults);
            onProgress?.(results.length, urls.length);
        }
        return results;
    }

    // ─── Маппинг платформы ─────────────────────────────────────────────────────

    function mapPlatform(platformsArr) {
        const s = new Set(platformsArr);
        const w = s.has('win'), l = s.has('lin'), m = s.has('mac');
        if (w && l && m) return 'Windows+Linux+MacOS';
        if (w && l)      return 'Windows+Linux';
        if (w && m)      return 'Windows+MacOS';
        if (l && m)      return 'Linux+MacOS';
        if (w)           return 'Windows';
        if (l)           return 'Linux';
        if (m)           return 'MacOS';
        return '';
    }

    // ─── Подготовка данных ─────────────────────────────────────────────────────

    function buildUniqueUrls(releases, priorityRelease) {
        const domainBestLink = new Map();
        if (priorityRelease) {
            (priorityRelease.extlinks || []).forEach(({ url }) => {
                if (!url) return;
                const domain = getCanonicalDomain(url);
                if (domain) domainBestLink.set(domain, { url, date: '9999-99-99' });
            });
        }
        releases.filter(r => r.official && !r.patch).forEach(r => {
            const date = (r.released && r.released !== 'TBA') ? r.released : '';
            (r.extlinks || []).forEach(({ url }) => {
                if (!url) return;
                const domain = getCanonicalDomain(url);
                if (!domain) return;
                const cur = domainBestLink.get(domain);
                if (!cur || date > cur.date) domainBestLink.set(domain, { url, date });
            });
        });
        return [...domainBestLink.values()].map(v => v.url);
    }

    function resolveFormData(vnData, releases, priorityRelease, vnId) {
        const vid      = `v${vnId}`;
        const vnTitles = vnData.titles || [];
        const jaMain   = vnTitles.find(t => t.lang === 'ja' && t.main);
        const enTitle  = vnTitles.find(t => t.lang === 'en');
        const ruTitle  = vnTitles.find(t => t.lang === 'ru');
        const relLangs = priorityRelease?.languages || [];

        const titleJa = priorityRelease
            ? (relLangs.find(l => l.lang === 'ja')?.title ?? jaMain?.title ?? null)
            : (jaMain?.title ?? null);
        const titleEn = priorityRelease
            ? (relLangs.find(l => l.lang === 'en')?.title ?? priorityRelease.title ?? enTitle?.title ?? vnData.title)
            : (enTitle?.title ?? vnData.title);
        const titleRu = priorityRelease
            ? (relLangs.find(l => l.lang === 'ru')?.title ?? ruTitle?.title ?? null)
            : (ruTitle?.title ?? null);

        const hasUncensored = priorityRelease
            ? priorityRelease.uncensored !== false
            : releases.some(r => r.uncensored !== false);

        const hasNonTrial = priorityRelease
            ? (priorityRelease.vns || []).some(v => v.id === vid && v.rtype !== 'trial')
            : releases.some(r => (r.vns || []).some(v => v.id === vid && v.rtype !== 'trial'));

        const platformsArr = priorityRelease
            ? [...new Set(priorityRelease.platforms || [])]
            : [...new Set(releases.filter(r => r.official).flatMap(r => r.platforms || []))];

        let creators;
        if (priorityRelease) {
            const prPublishers  = (priorityRelease.producers || []).filter(p => p.publisher).map(p => p.name);
            const allDevelopers = [...new Set(
                releases.filter(r => r.official).flatMap(r => (r.producers || []).filter(p => p.developer).map(p => p.name))
            )];
            creators = (allDevelopers.length > 0 || prPublishers.length > 0)
                ? [...new Set([...allDevelopers, ...prPublishers])]
                : [...new Set([
                    ...(vnData.developers || []).map(d => d.name),
                    ...releases.filter(r => r.official).flatMap(r => (r.producers || []).map(p => p.name)),
                ])];
        } else {
            creators = [...new Set([
                ...(vnData.developers || []).map(d => d.name),
                ...releases.filter(r => r.official).flatMap(r => (r.producers || []).map(p => p.name)),
            ])];
        }

        const langs = priorityRelease
            ? (priorityRelease.languages || []).map(l => l.lang)
            : (vnData.languages?.length
                ? vnData.languages
                : [...new Set(
                    releases
                        .filter(r => r.official)
                        .flatMap(r => (r.languages || []).map(l => l.lang))
                  )]);
        const hasVoiced = priorityRelease
            ? priorityRelease.voiced >= 3
            : releases.some(r => r.official && (r.vns || []).some(v => v.id === vid) && r.voiced >= 3);
        const voiceLang = hasVoiced ? (vnData.olang || '') : '';

        const released = (priorityRelease?.released && priorityRelease.released !== 'TBA')
            ? priorityRelease.released
            : (vnData.released || '');

        return { titleJa, titleEn, titleRu, hasUncensored, hasNonTrial, platformsArr, creators, langs, voiceLang, released };
    }

    /**
     * Строит массив тегов из VNDB тегов, платформ и опциональных данных Steam/SteamSpy.
     */
    function buildAllTags({ vnTags = [], platforms = [], steamData = null, steamSpyTags = null } = {}) {
        const seen = new Set(), allTags = [];
        const addTag = t => {
            t = t.trim(); if (!t) return;
            const k = t.toLowerCase();
            if (!seen.has(k)) { seen.add(k); allTags.push(t); }
        };
        vnTags.filter(t => !t.lie).forEach(t => addTag(t.name));
        const PLAT_WL = new Set(['win', 'lin', 'mac']);
        platforms.filter(p => PLAT_WL.has(p)).forEach(addTag);
        if (steamData) {
            const STEAM_PLAT = { windows: 'win', mac: 'mac', linux: 'lin' };
            Object.entries(steamData.platforms || {}).filter(([, v]) => v)
                .forEach(([k]) => { const m = STEAM_PLAT[k]; if (m) addTag(m); });
            (steamData.genres || []).forEach(g => g.description && addTag(g.description));
        }
        if (steamSpyTags) Object.keys(steamSpyTags).forEach(addTag);
        return allTags;
    }

    function buildLinksText(releases, priorityRelease, uniqueUrls, vid) {
        if (!settings.fieldLinks) return '';
        let sourceUrls = uniqueUrls;
        if (priorityRelease) {
            const releaseUrls = (priorityRelease.extlinks || [])
                .map(e => e.url)
                .filter(url => url && getCanonicalDomain(url));
            if (releaseUrls.length > 0) sourceUrls = releaseUrls;
        }
        return [...sourceUrls, `https://vndb.org/${vid}`].map(url => {
            const h    = getHostname(url);
            const icon = h === 'vndb.org' ? 'vndb.png' : getStoreIcon(url);
            return urlToBBIcon(url, icon);
        }).join(' ');
    }

    // ─── Заполнение отдельных полей ────────────────────────────────────────────

    async function fillPosterField(posterUrl) {
        const fPoster = getField(FIELDS.poster);
        if (!fPoster || !posterUrl) return;
        setVal(fPoster, posterUrl);
        if (!settings.enableFastpicUpload) {
            setAnnotation(fPoster, 'af-ann-poster', ANN_POSTER_COVER, true);
            return;
        }
        const tPoster = toast('⏳ Загрузка постера на FastPic...', 'info', 0);
        try {
            const result = await uploadToFastpic([posterUrl]);
            tPoster.remove();
            if (result.length > 0) {
                setVal(fPoster, result[0].big);
                toast('✅ Постер загружен на FastPic', 'success');
            } else {
                toast('⚠️ FastPic не вернул ссылку для постера. Проверьте авторизацию.', 'warning', 6000);
                setAnnotation(fPoster, 'af-ann-poster', ANN_POSTER_MANUAL, true);
            }
        } catch (err) {
            tPoster.remove();
            toast(`⚠️ Ошибка FastPic (постер): ${err.message}`, 'warning', 6000);
            setAnnotation(fPoster, 'af-ann-poster', ANN_POSTER_COVER, true);
        }
    }

    async function fillTagsField(allTags) {
        const f = getField(FIELDS.tags);
        if (!f || allTags.length === 0) return;

        if (!settings.adaptTagEditor) {
            setVal(f, allTags.join(', '));
            setAnnotation(f, 'af-ann-tags', TAG_EDITOR_ANN, true);
            return;
        }

        const tAdapt = toast('⏳ Адаптация тегов под редактор pornolab...', 'info', 0);
        try {
            const mergedTags  = [...allTags];
            const mergedLower = new Set(mergedTags.map(t => t.toLowerCase()));
            EXTRA_TAGS_GROUPS.forEach(group => {
                group.tags.forEach(t => {
                    if (settings[t.key] && !mergedLower.has(t.label.toLowerCase())) {
                        mergedTags.push(t.label);
                        mergedLower.add(t.label.toLowerCase());
                    }
                });
            });

            const gameJson   = await gmFetch('https://static.pornolab.net/tag_editor/game.json', { throwOnError: true, timeout: 10000 });
            const tagIndex   = new Map();
            const allCatTags = (gameJson.categories || []).flatMap(c => c.tags || []);
            allCatTags.forEach(entry => {
                tagIndex.set(entry.name.toLowerCase(), entry);
                (entry.knownAs || []).forEach(alias => tagIndex.set(alias.toLowerCase(), entry));
            });

            const validTags = [], validIcons = [], seenValid = new Set();
            mergedTags.forEach(tag => {
                const entry = tagIndex.get(tag.toLowerCase());
                if (entry && !seenValid.has(entry.name.toLowerCase())) {
                    seenValid.add(entry.name.toLowerCase());
                    validTags.push(entry.name);
                    if (settings.addIconLinks && entry.alternative)
                        validIcons.push(entry.alternative);
                }
            });

            setVal(f, validTags.join(', '));
            setAnnotation(f, 'af-ann-tags', TAG_EDITOR_ANN, true);
            if (settings.addIconLinks && validIcons.length > 0) {
                const fIcons = getIconsField();
                if (fIcons) setVal(fIcons, validIcons.join(''));
            }

            tAdapt.remove();
            toast(
                `✅ Теги адаптированы: ${validTags.length} из ${mergedTags.length}` +
                (mergedTags.length - validTags.length > 0
                    ? ` (${mergedTags.length - validTags.length} не найдено в game.json)` : ''),
                'success'
            );
        } catch (err) {
            tAdapt.remove();
            toast(`❌ Ошибка адаптации тегов: ${err.message}`, 'error', 6000);
            console.error('[AutoFill] Ошибка адаптации тегов:', err);
            setVal(f, allTags.join(', '));
            setAnnotation(f, 'af-ann-tags', TAG_EDITOR_ANN, true);
        }
    }

    async function fillScreenshotsField(allScreens) {
        const f = getField(FIELDS.screenshots);
        if (!f || allScreens.length === 0) return;
        setVal(f, allScreens.join('\n'));
        if (!settings.enableFastpicUpload) {
            setAnnotation(f, 'af-ann-screenshots', ANN_SCREENS, true);
            return;
        }
        const tScreens = toast(`⏳ Загрузка ${allScreens.length} скриншотов на FastPic...`, 'info', 0);
        try {
            const result = await uploadToFastpic(allScreens, (done, total) => {
                tScreens.textContent = `⏳ FastPic: обработано ${done} из ${total} скриншотов...`;
            });
            tScreens.remove();
            if (result.length > 0) {
                setVal(f, result.map(links => `[URL=${links.view}][IMG]${links.thumb}[/IMG][/URL]`).join(' '));
                toast(`✅ Скриншоты загружены на FastPic: ${result.length} из ${allScreens.length}`, 'success');
            } else {
                toast('⚠️ FastPic не вернул ссылки для скриншотов. Проверьте авторизацию.', 'warning', 6000);
                setAnnotation(f, 'af-ann-screenshots', ANN_SCREENS, true);
            }
        } catch (err) {
            tScreens.remove();
            toast(`⚠️ Ошибка FastPic (скриншоты): ${err.message}`, 'warning', 6000);
            setAnnotation(f, 'af-ann-screenshots', ANN_SCREENS, true);
        }
    }

    // ─── IGDB / Обложки ────────────────────────────────────────────────────────

    // Учётные данные Twitch для IGDB API
    // При необходимости замените на собственные: https://dev.twitch.tv/console
    const TWITCH_CLIENT_ID     = 'f4nguxprv5p5p94uouph5m8u3qi3a9';
    const TWITCH_CLIENT_SECRET = 'c5yg7qvwmmn5y5qadmmrrpihievuu7';
    const TWITCH_TOKEN_URL     = 'https://id.twitch.tv/oauth2/token';
    const IGDB_BASE            = 'https://api.igdb.com/v4';

    function igdbGmRequest({ method = 'GET', url, headers = {}, data = null, responseType = 'json' }) {
        return new Promise((resolve, reject) => {
            GM_xmlhttpRequest({
                method, url, headers, data, responseType,
                timeout: 30000,
                onload(res) {
                    const body = responseType === 'json' ? res.response : res.responseText;
                    if (res.status >= 200 && res.status < 300) resolve(body);
                    else reject(new Error(`HTTP ${res.status} для ${url}`));
                },
                onerror:   () => reject(new Error(`Ошибка запроса: ${url}`)),
                ontimeout: () => reject(new Error(`Таймаут: ${url}`)),
            });
        });
    }

    async function getTwitchToken() {
        const params = new URLSearchParams({
            client_id:     TWITCH_CLIENT_ID,
            client_secret: TWITCH_CLIENT_SECRET,
            grant_type:    'client_credentials',
        });
        const data = await igdbGmRequest({
            method:       'POST',
            url:          TWITCH_TOKEN_URL,
            headers:      { 'Content-Type': 'application/x-www-form-urlencoded' },
            data:         params.toString(),
            responseType: 'json',
        });
        if (!data?.access_token) throw new Error('Twitch: нет access_token');
        return data.access_token;
    }

    function igdbPost(endpoint, token, body) {
        return igdbGmRequest({
            method: 'POST',
            url: `${IGDB_BASE}/${endpoint}`,
            headers: {
                'Client-ID':     TWITCH_CLIENT_ID,
                'Authorization': `Bearer ${token}`,
                'Content-Type':  'text/plain',
            },
            data:         body,
            responseType: 'json',
        });
    }

    function buildIgdbImageUrl(imageId, size = 't_1080p') {
        return `https://images.igdb.com/igdb/image/upload/${size}/${imageId}.jpg`;
    }

    async function fetchIgdbGameByAppId(token, appId) {
        const rows = await igdbPost(
            'external_games', token,
            `fields id,game,uid,external_game_source; where external_game_source = 1 & uid = "${appId}"; limit 1;`
        );
        if (!rows?.length) return null;
        const gameId = rows[0].game;
        if (!gameId) return null;
        const games = await igdbPost(
            'games', token,
            `fields id,name,cover,artworks; where id = ${gameId}; limit 1;`
        );
        return games?.[0] || null;
    }

    async function fetchIgdbCovers(token, gameId) {
        return igdbPost(
            'covers', token,
            `fields id,game,image_id,width,height; where game = ${gameId}; limit 500;`
        ).catch(() => []);
    }

    async function fetchIgdbArtworks(token, gameId) {
        return igdbPost(
            'artworks', token,
            `fields id,game,image_id,width,height; where game = ${gameId}; limit 500;`
        ).catch(() => []);
    }

    /**
     * Собирает список обложек для выбора.
     * @param {string} appId - Steam App ID
     * @param {object|null} steamData - уже полученные данные Steam API (или null)
     * @returns {Promise<Array<{label:string, url:string, kind:string}>>}
     */
    async function buildCoverUrls(appId, steamData) {
        const urls = [], seen = new Set();

        // Steam header image из уже полученных данных
        if (steamData?.header_image) {
            const url = steamData.header_image;
            seen.add(url);
            urls.push({ label: 'Steam: header', url, kind: 'steam' });
        }

        // IGDB обложки и артворки
        try {
            const token = await getTwitchToken();
            const game  = await fetchIgdbGameByAppId(token, appId);
            if (game) {
                const [covers, artworks] = await Promise.all([
                    fetchIgdbCovers(token, game.id),
                    fetchIgdbArtworks(token, game.id),
                ]);
                for (const row of [...(covers || []), ...(artworks || [])]) {
                    if (!row?.image_id) continue;
                    const url = buildIgdbImageUrl(row.image_id, 't_1080p');
                    if (seen.has(url)) continue;
                    seen.add(url);
                    const isArtwork = 'artwork_type' in row;
                    urls.push({
                        label: isArtwork ? `IGDB artwork #${row.id}` : `IGDB cover #${row.id}`,
                        url,
                        kind: isArtwork ? 'artwork' : 'cover',
                    });
                }
            }
        } catch (err) {
            console.warn('[AutoFill] IGDB недоступен:', err.message);
        }

        return urls;
    }

    // ─── Выбор постера ─────────────────────────────────────────────────────────

    /**
     * Показывает панель выбора постера.
     * @param {Array<{label:string, url:string}>} coverUrls
     * @returns {Promise<string|null>} выбранная ссылка или null
     */
    function showCoverSelector(coverUrls) {
        return new Promise(resolve => {
            document.getElementById('af-cover-panel')?.remove();

            let selectedUrl = coverUrls[0]?.url || null;

            const panel = document.createElement('div');
            panel.id = 'af-cover-panel';

            // Заголовок
            const header = document.createElement('div');
            header.id = 'af-cover-header';
            header.innerHTML =
                '<span>Выберите постер (показаны миниатюры, но будет взят исходник)</span>' +
                '<button id="af-cover-close" title="Закрыть">×</button>';

            // Тело со скроллом
            const body = document.createElement('div');
            body.id = 'af-cover-body';

            if (coverUrls.length === 0) {
                body.innerHTML = '<div style="color:#888;padding:8px;">Обложки не найдены.</div>';
            } else {
                coverUrls.forEach((item, i) => {
                    const div = document.createElement('div');
                    div.className = 'af-cover-item';
                    const safeLabel = item.label.replace(/</g, '&lt;').replace(/>/g, '&gt;');
                    const safeUrl   = item.url.replace(/"/g, '&quot;');
                    div.innerHTML = `
                        <label class="af-cover-label">
                            <input type="radio" name="af-cover-pick" value="${safeUrl}" ${i === 0 ? 'checked' : ''}>
                            <span>${safeLabel}</span>
                        </label>
                        <img class="af-cover-thumb" src="${safeUrl}" alt="${safeLabel}" loading="lazy">`;
                    body.appendChild(div);
                });

                body.querySelectorAll('input[type="radio"]').forEach(r => {
                    r.addEventListener('change', () => { selectedUrl = r.value; });
                });
            }

            // Кнопки (прилеплены к низу)
            const footer = document.createElement('div');
            footer.id = 'af-cover-footer';
            footer.innerHTML =
                '<button id="af-cover-select" class="af-cover-btn-primary">Выбрал</button>' +
                '<button id="af-cover-cancel" class="af-cover-btn-secondary">Закрыть</button>';

            panel.appendChild(header);
            panel.appendChild(body);
            panel.appendChild(footer);
            document.body.appendChild(panel);

            const done = url => { panel.remove(); resolve(url); };

            panel.querySelector('#af-cover-close').addEventListener('click', () => done(null));
            panel.querySelector('#af-cover-cancel').addEventListener('click', () => done(null));
            panel.querySelector('#af-cover-select').addEventListener('click', () => done(selectedUrl));

            // Перетаскивание
            let drag = null;
            header.addEventListener('mousedown', e => {
                if (e.target.tagName === 'BUTTON') return;
                e.preventDefault();
                drag = { x: e.clientX, y: e.clientY };
                const onMove = e => {
                    panel.style.left = (panel.offsetLeft - (drag.x - e.clientX)) + 'px';
                    panel.style.top  = (panel.offsetTop  - (drag.y - e.clientY)) + 'px';
                    drag = { x: e.clientX, y: e.clientY };
                };
                const onUp = () => {
                    document.removeEventListener('mousemove', onMove);
                    document.removeEventListener('mouseup', onUp);
                };
                document.addEventListener('mousemove', onMove);
                document.addEventListener('mouseup', onUp);
            });
        });
    }

    // ─── Заполнение формы по VNDB-ссылке ──────────────────────────────────────

    async function fillForm(vndbUrl) {
        const matchVN  = vndbUrl.match(/\/v(\d+)/);
        const matchRel = vndbUrl.match(/\/r(\d+)/);

        if (!matchVN && !matchRel) {
            toast('Неверный формат ссылки VNDB. Пример: https://vndb.org/v97 или https://vndb.org/r64727', 'error');
            return;
        }

        const tVndb = toast('⏳ Запрос к VNDB API...', 'info', 0);
        let vnData, releases, priorityRelease = null, vnId;
        try {
            if (matchRel) {
                priorityRelease = await fetchReleaseById(matchRel[1]);
                if (!priorityRelease) { tVndb.remove(); toast('❌ Релиз не найден в VNDB', 'error'); return; }
                const vnIdFull = priorityRelease.vns?.[0]?.id;
                if (!vnIdFull) { tVndb.remove(); toast('❌ Не удалось определить VN по данному релизу', 'error'); return; }
                vnId = vnIdFull.replace(/^v/, '');
            } else {
                vnId = matchVN[1];
            }
            [vnData, releases] = await Promise.all([fetchVNData(vnId), fetchReleases(vnId)]);
            tVndb.remove();
            toast('✅ Данные VNDB получены', 'success');
        } catch (err) {
            tVndb.remove();
            toast(`❌ Ошибка VNDB: ${err.message}`, 'error', 6000);
            console.error('[AutoFill] Ошибка VNDB:', err);
            return;
        }

        if (!vnData) { toast('❌ VNDB не вернул данные для этого ID', 'error'); return; }

        const vid = `v${vnId}`;

        const uniqueUrls = buildUniqueUrls(releases, priorityRelease);
        const steamUrl   = uniqueUrls.find(u => getHostname(u) === 'store.steampowered.com');
        const steamAppId = steamUrl ? (steamUrl.match(/\/app\/(\d+)/)?.[1] ?? null) : null;

        let steamData = null, steamSpyTags = null;
        const needSteam = steamAppId && (
            settings.enableSteamPoster || settings.enableSteamTags ||
            settings.enableSteamScreenshots || settings.fieldSysReq
        );
        if (needSteam)
            steamData = await withToast('⏳ Запрос к Steam API...', () => fetchSteamDetails(steamAppId),
                { successMsg: '✅ Данные Steam получены', failMsg: '⚠️ Steam API недоступен', failDuration: 5000 });
        if (steamAppId && settings.enableSteamTags)
            steamSpyTags = await withToast('⏳ Запрос к SteamSpy API...', () => fetchSteamSpy(steamAppId),
                { successMsg: '✅ Данные SteamSpy получены', failMsg: '⚠️ SteamSpy недоступен', failDuration: 5000 });

        const { titleJa, titleEn, titleRu, hasUncensored, hasNonTrial,
                platformsArr, creators, langs, voiceLang, released } =
            resolveFormData(vnData, releases, priorityRelease, vnId);

        // Если VNDB не дал разработчиков/издателей — пробуем Steam как запасной источник
        if (creators.length === 0 && steamAppId && !steamData) {
            steamData = await withToast(
                '⏳ Запрос к Steam API (разработчик)...',
                () => fetchSteamDetails(steamAppId),
                { successMsg: '✅ Данные Steam получены', failMsg: '⚠️ Steam API недоступен', failDuration: 5000 }
            );
        }
        const developerStr = creators.length > 0
            ? creators.join(' / ')
            : [...new Set([...(steamData?.developers || []), ...(steamData?.publishers || [])])].join(' / ');

        const platformVal = mapPlatform(platformsArr);
        const allTags     = buildAllTags({
            vnTags:      vnData.tags || [],
            platforms:   platformsArr,
            steamData:   settings.enableSteamTags ? steamData : null,
            steamSpyTags: settings.enableSteamTags ? steamSpyTags : null,
        });
        const sysReq    = parseSysReq(steamData, settings.fieldSysReq, settings.formatSysReqTemplate);
        const linksText = buildLinksText(releases, priorityRelease, uniqueUrls, vid);

        const allScreens = [
            ...(vnData.screenshots || []).map(s => s.url),
            ...(settings.enableSteamScreenshots && steamData
                ? (steamData.screenshots || []).map(s => s.path_full).filter(Boolean) : []),
        ];

        const vnPosterUrl = vnData.image?.url || '';

        let description = '';
        if (settings.fieldDescription) {
            const rawDesc = (vnData.description || '')
                .replace(/\[(?:[^\[\]]|\[[^\[\]]*\])*\]\s*$/, '').trimEnd();
            if (settings.enableGoogleTranslate && rawDesc) {
                description = await withToast(
                    '⏳ Перевод описания через Google Translate...',
                    () => translateToRussian(rawDesc),
                    { successMsg: '✅ Перевод получен', failMsg: '⚠️ Ошибка перевода', failDuration: 4000 }
                ) ?? rawDesc;
            } else {
                description = rawDesc;
            }
        }

        console.log('[AutoFill] Поля для заполнения:', {
            приоритетРелиз: priorityRelease?.id || null,
            постер: settings.enableSteamPoster && steamAppId ? '(выбор из IGDB/Steam)' : vnPosterUrl,
            иероглифы: titleJa || '',
            titleEng: titleEn || '', titleRu: titleRu || '',
            год: released.split('-')[0] || '', дата: released.replace(/-/g, '/') || '',
            теги: allTags, цензура: hasUncensored ? 'Нет' : 'Есть',
            разработчик: creators.join(' / '), платформа: platformVal,
            типИздания: hasNonTrial ? 'Релиз' : (releases.length ? 'Демо-версия' : ''),
            языки: langs, озвучка: voiceLang, sysReq,
            описание: description, ссылки: linksText, скриншоты: allScreens,
        });

        // Постер
        if (settings.fieldPoster) {
            if (settings.enableSteamPoster && steamAppId) {
                const tCovers = toast('⏳ Загрузка обложек из Steam/IGDB...', 'info', 0);
                const coverUrls = await buildCoverUrls(steamAppId, steamData).catch(() => []);
                tCovers.remove();
                if (coverUrls.length > 0) {
                    const chosen = await showCoverSelector(coverUrls);
                    await fillPosterField(chosen || vnPosterUrl);
                } else {
                    toast('⚠️ Обложки не найдены, используется постер VNDB', 'warning', 4000);
                    await fillPosterField(vnPosterUrl);
                }
            } else {
                await fillPosterField(vnPosterUrl);
            }
        }

        fillIf('fieldHieroglyphs', FIELDS.hieroglyphs, titleJa,           FIELD_LABELS.hieroglyphs);
        fillIf('fieldTitleEng',    FIELDS.titleEng,    titleEn,            FIELD_LABELS.titleEng);
        fillIf('fieldTitleRu',     FIELDS.titleRu,     titleRu,            FIELD_LABELS.titleRu);
        fillIf('fieldYear',        FIELDS.year,        released.split('-')[0]);
        fillIf('fieldDate',        FIELDS.date,        released.replace(/-/g, '/'));
        fillIf('fieldCensorship',  FIELDS.cens,        hasUncensored ? 'Нет' : 'Есть');
        fillIf('fieldDeveloper',   FIELDS.developer,   developerStr,         FIELD_LABELS.developer);
        fillIf('fieldPlatform',    FIELDS.platform,    platformVal);

        if (settings.fieldTags) await fillTagsField(allTags);

        if (settings.fieldReleaseType) {
            const f = getField(FIELDS.releaseType);
            if (f) setVal(f, hasNonTrial ? '' : (releases.length ? 'Демо-версия' : ''));
        }

        const fMed = getField(FIELDS.medicine);
        if (fMed) setAnnotation(fMed, 'af-ann-medicine', 'Заполнить самим');
        const fVer = getField(FIELDS.version, FIELD_LABELS.version);
        if (fVer) setAnnotation(fVer, 'af-ann-version', 'Заполнить самим');

        applyLanguages(settings.fieldLanguage, langsToAbr(langs), settings.fieldVoice, audioLangToAbr(voiceLang));
        applySysReq(sysReq, settings.fieldSysReq);
        applyDescription(settings.fieldDescription, description, settings.fieldLinks, linksText);

        if (settings.fieldScreenshots) await fillScreenshotsField(allScreens);

        toast('✅ Форма заполнена!', 'success', 4000);
    }

    // ─── Заполнение формы по Steam-ссылке ─────────────────────────────────────

    async function fillFormSteam(steamUrl) {
        const matchApp = steamUrl.match(/\/app\/(\d+)/);
        if (!matchApp) {
            toast('Неверный формат ссылки Steam. Пример: https://store.steampowered.com/app/3014080', 'error');
            return;
        }
        const appId = matchApp[1];

        const tSteam = toast('⏳ Запрос к Steam API...', 'info', 0);
        let steamData;
        try {
            steamData = await fetchSteamDetails(appId, 'english');
            tSteam.remove();
            if (!steamData) { toast('❌ Steam не вернул данные для этого App ID', 'error'); return; }
            toast('✅ Данные Steam получены', 'success');
        } catch (err) {
            tSteam.remove();
            toast(`❌ Ошибка Steam API: ${err.message}`, 'error', 6000);
            return;
        }

        let titleEng = null, titleRu = null, titleHieroglyphs = null;

        if (settings.stm_fieldTitleEng) {
            const name = steamData.name || '';
            if (!/[\u3040-\u30ff\u4e00-\u9fff\uac00-\ud7af\u0400-\u04ff]/.test(name))
                titleEng = name;
        }

        if (settings.stm_fieldTitleRu) {
            const tRu = toast('⏳ Steam API (Russian)...', 'info', 0);
            const ruData = await fetchSteamDetails(appId, 'russian');
            tRu.remove();
            const name = ruData?.name || '';
            if (/[а-яА-ЯёЁ]/.test(name)) titleRu = name;
        }

        if (settings.stm_fieldHieroglyphs) {
            const locales = [
                { locale: 'japanese', test: /[\u3040-\u30ff\u4e00-\u9fff]/ },
                { locale: 'schinese', test: /[\u4e00-\u9fff]/ },
                { locale: 'koreana',  test: /[\uac00-\ud7af]/ },
            ];
            for (const { locale, test } of locales) {
                const tLoc = toast(`⏳ Steam API (${locale})...`, 'info', 0);
                const locData = await fetchSteamDetails(appId, locale);
                tLoc.remove();
                const name = locData?.name || '';
                if (test.test(name)) { titleHieroglyphs = name; break; }
            }
        }

        let steamSpyTags = null;
        if (settings.stm_fieldTags)
            steamSpyTags = await withToast(
                '⏳ Запрос к SteamSpy API...',
                () => fetchSteamSpy(appId),
                { successMsg: '✅ Данные SteamSpy получены', failMsg: '⚠️ SteamSpy недоступен', failDuration: 5000 }
            );

        let releaseYear = '', releaseDate = '';
        const relDateStr = steamData.release_date?.date || '';
        if (relDateStr && !steamData.release_date?.coming_soon) {
            try {
                const d = new Date(relDateStr);
                if (!isNaN(d.getTime())) {
                    releaseYear = String(d.getFullYear());
                    const mm = String(d.getMonth() + 1).padStart(2, '0');
                    const dd = String(d.getDate()).padStart(2, '0');
                    releaseDate = `${releaseYear}/${mm}/${dd}`;
                }
            } catch {}
        }

        const STEAM_PLAT_MAP = { windows: 'win', mac: 'mac', linux: 'lin' };
        const platformsArr = Object.entries(steamData.platforms || {})
            .filter(([, v]) => v).map(([k]) => STEAM_PLAT_MAP[k] || k);
        const platformVal  = mapPlatform(platformsArr);

        const developers   = steamData.developers || [];
        const publishers   = steamData.publishers || [];
        const developerStr = [...new Set([...developers, ...publishers])].join(' / ');

        const { langs: steamLangs, audioLangs: steamAudioLangs } =
            parseSteamSupportedLanguages(steamData.supported_languages || '');

        const sysReq = parseSysReq(steamData, settings.stm_fieldSysReq, settings.stm_formatSysReqTemplate);

        let description = '';
        if (settings.stm_fieldDescShort || settings.stm_fieldDescLong) {
            const parts = [];
            if (settings.stm_fieldDescShort && steamData.short_description)
                parts.push(steamData.short_description);
            if (settings.stm_fieldDescLong && steamData.about_the_game)
                parts.push(cleanSteamHtml(steamData.about_the_game));
            description = parts.join('\n\n');

            if (settings.stm_enableGoogleTranslate && description)
                description = await withToast(
                    '⏳ Перевод описания через Google Translate...',
                    () => translateToRussian(description),
                    { successMsg: '✅ Перевод получен', failMsg: '⚠️ Ошибка перевода', failDuration: 4000 }
                ) ?? description;
        }

        const allTags = settings.stm_fieldTags
            ? buildAllTags({ platforms: platformsArr, steamData, steamSpyTags })
            : [];

        const linksText  = settings.stm_fieldLinks
            ? urlToBBIcon(`https://store.steampowered.com/app/${appId}`, 'steam.png')
            : '';
        const allScreens = settings.stm_fieldScreenshots
            ? (steamData.screenshots || []).map(s => s.path_full).filter(Boolean)
            : [];

        console.log('[AutoFill Steam] Поля для заполнения:', {
            appId,
            постер: settings.stm_fieldPoster ? '(выбор из IGDB/Steam)' : '',
            titleHieroglyphs, titleEng, titleRu,
            год: releaseYear, дата: releaseDate, платформа: platformVal,
            разработчик: developerStr, теги: allTags,
            langAbr: steamLangsToAbr(steamLangs), voiceAbr: audioLangToAbr(steamAudioLangs),
            sysReq, описание: description, ссылки: linksText, скриншоты: allScreens,
        });

        // Постер — показываем выбор обложки
        if (settings.stm_fieldPoster) {
            const tCovers = toast('⏳ Загрузка обложек из Steam/IGDB...', 'info', 0);
            const coverUrls = await buildCoverUrls(appId, steamData).catch(() => []);
            tCovers.remove();
            if (coverUrls.length > 0) {
                const chosen = await showCoverSelector(coverUrls);
                if (chosen) await fillPosterField(chosen);
            } else {
                toast('⚠️ Обложки не найдены', 'warning', 4000);
            }
        }

        fillIf('stm_fieldHieroglyphs', FIELDS.hieroglyphs, titleHieroglyphs, FIELD_LABELS.hieroglyphs);
        fillIf('stm_fieldTitleEng',    FIELDS.titleEng,    titleEng,         FIELD_LABELS.titleEng);
        fillIf('stm_fieldTitleRu',     FIELDS.titleRu,     titleRu,          FIELD_LABELS.titleRu);
        fillIf('stm_fieldYear',        FIELDS.year,        releaseYear);
        fillIf('stm_fieldDate',        FIELDS.date,        releaseDate);
        fillIf('stm_fieldDeveloper',   FIELDS.developer,   developerStr,     FIELD_LABELS.developer);
        fillIf('stm_fieldPlatform',    FIELDS.platform,    platformVal);

        if (settings.stm_fieldTags && allTags.length > 0) {
            const f = getField(FIELDS.tags);
            if (f) { setVal(f, allTags.join(', ')); setAnnotation(f, 'af-ann-tags', TAG_EDITOR_ANN, true); }
        }

        applyLanguages(settings.stm_fieldLanguage, steamLangsToAbr(steamLangs),
                       settings.stm_fieldVoice,    audioLangToAbr(steamAudioLangs));
        applySysReq(sysReq, settings.stm_fieldSysReq);
        applyDescription(settings.stm_fieldDescShort || settings.stm_fieldDescLong,
                         description, settings.stm_fieldLinks, linksText);

        if (settings.stm_fieldScreenshots) await fillScreenshotsField(allScreens);

        // Аннотации «Заполнить самим»
        const _fCens = getField(FIELDS.cens);
        setAnnotation(_fCens?.nextElementSibling ?? _fCens,           'af-ann-cens',     'Заполнить самим');
        setAnnotation(getField(FIELDS.releaseType),                   'af-ann-reltype',  'Заполнить самим');
        setAnnotation(getField(FIELDS.medicine),                      'af-ann-medicine', 'Заполнить самим');
        setAnnotation(getField(FIELDS.version, FIELD_LABELS.version), 'af-ann-version',  'Заполнить самим');
        const _fLang  = getField(FIELDS.langStory);
        const _tdLang = _fLang?.closest('td') ?? _fLang?.parentElement;
        setAnnotation(_tdLang?.firstChild ?? _fLang,                  'af-ann-lang-warn','Проверьте эту информацию', true);

        toast('✅ Форма заполнена!', 'success', 4000);
    }

    // ─── Панель настроек ───────────────────────────────────────────────────────

    function buildExtraTagsHTML() {
        return EXTRA_TAGS_GROUPS.map(group => {
            const half = Math.ceil(group.tags.length / 2);
            const renderCol = col => col.map(t => {
                const checked = settings[t.key] ? 'checked' : '';
                return `<label class="af-sp-label" style="display:block;">
                    <input type="checkbox" data-key="${t.key}" ${checked}> ${t.label}
                </label>`;
            }).join('');
            return `
                <div style="margin-top:5px;font-size:11px;color:#777;font-style:italic;">${group.title}</div>
                <div style="display:grid;grid-template-columns:1fr 1fr;gap:0 8px;margin-left:4px;">
                    <div>${renderCol(group.tags.slice(0, half))}</div>
                    <div>${renderCol(group.tags.slice(half))}</div>
                </div>`;
        }).join('');
    }

    function buildSettingsHTML(source) {
        const isSteam = source === 'steam';
        const P = isSteam ? 'stm_' : '';

        const cb = (suffix, label, extraStyle = '') => {
            const key     = P + suffix;
            const checked = settings[key] ? 'checked' : '';
            return `<label class="af-sp-label"${extraStyle ? ` style="${extraStyle}"` : ''}>
                <input type="checkbox" data-key="${key}" ${checked}> ${label}
            </label>`;
        };

        const sysReqFmtId = `af-sp-${isSteam ? 'stm-' : ''}sysreq-fmt`;
        const showSysReq  = settings[P + 'fieldSysReq'];
        const showDesc    = isSteam
            ? (settings.stm_fieldDescShort || settings.stm_fieldDescLong)
            : settings.fieldDescription;

        return `
            <div class="af-sp-section">
                <div class="af-sp-section-title">Поля ${isSteam ? 'Steam' : 'VNDB'}</div>
                ${cb('fieldPoster', 'Постер')}
                ${cb('fieldHieroglyphs', 'Оригинальное название (иероглифы)')}
                ${cb('fieldTitleEng',    'Оригинальное название (английское)')}
                ${cb('fieldTitleRu',     'Название на русском')}
                ${cb('fieldYear',        'Год выпуска')}
                ${cb('fieldDate',        'Дата выпуска')}
                ${cb('fieldTags',        'Теги')}
                ${!isSteam ? `
                <div id="af-sp-tags-options" style="display:${settings.fieldTags ? 'block' : 'none'};margin-left:18px;">
                    ${cb('adaptTagEditor', 'Адаптировать под редактор тегов pornolab')}
                    <div id="af-sp-adapt-options" style="display:${settings.fieldTags && settings.adaptTagEditor ? 'block' : 'none'};margin-left:18px;">
                        ${cb('addIconLinks', 'Добавлять ссылки на иконки')}
                        <div style="margin-top:6px;font-size:12px;font-weight:bold;">Дописать теги:</div>
                        ${buildExtraTagsHTML()}
                    </div>
                </div>
                ${cb('fieldCensorship',  'Цензура')}` : ''}
                ${cb('fieldDeveloper',   'Разработчик/Издатель')}
                ${cb('fieldPlatform',    'Платформа')}
                ${!isSteam ? cb('fieldReleaseType', 'Тип издания') : ''}
                ${cb('fieldLanguage',    'Язык игры (сюжет/интерфейс)')}
                ${cb('fieldVoice',       'Озвучка')}
                ${cb('fieldSysReq',      'Системные требования' + (!isSteam ? ' (из Steam)' : ''))}
                <div id="${sysReqFmtId}" style="display:${showSysReq ? 'block' : 'none'}">
                    ${cb('formatSysReqTemplate', 'Форматировать под шаблон pornolab', 'margin-left:20px;')}
                </div>
                ${isSteam ? `
                ${cb('fieldDescShort',  'Описание (короткое)')}
                ${cb('fieldDescLong',   'Описание (длинное)')}
                <div id="af-sp-stm-desc-options" style="display:${showDesc ? 'block' : 'none'};margin-left:18px;">
                    ${cb('enableGoogleTranslate', 'Перевести через Google Translate')}
                </div>` : `
                ${cb('fieldDescription', 'Описание')}
                <div id="af-sp-desc-options" style="display:${showDesc ? 'block' : 'none'};margin-left:18px;">
                    ${cb('enableGoogleTranslate', 'Перевести через Google Translate')}
                </div>`}
                ${cb('fieldLinks',       'Ссылки на источник (под описанием игры)')}
                ${cb('fieldScreenshots', 'Скриншоты/Примеры')}
            </div>
            <div class="af-sp-section">
                <div class="af-sp-section-title">FastPic</div>
                <label class="af-sp-label">
                    <input type="checkbox" data-key="enableFastpicUpload" ${settings.enableFastpicUpload ? 'checked' : ''}>
                    Перезалить картинки на FastPic
                </label>
            </div>
            ${!isSteam ? `
            <div class="af-sp-section">
                <div class="af-sp-section-title">Steam</div>
                <label class="af-sp-label"><input type="checkbox" data-key="enableSteamPoster" ${settings.enableSteamPoster ? 'checked' : ''}> Использовать постер из Steam (с выбором обложки)</label>
                <label class="af-sp-label"><input type="checkbox" data-key="enableSteamTags" ${settings.enableSteamTags ? 'checked' : ''}> Добавлять Steam теги к текущим</label>
                <label class="af-sp-label"><input type="checkbox" data-key="enableSteamScreenshots" ${settings.enableSteamScreenshots ? 'checked' : ''}> Добавлять скриншоты из Steam к текущим</label>
            </div>` : ''}`;
    }

    function buildSettingsPanel(source) {
        const panel = document.createElement('div');
        panel.id = 'af-settings-panel';
        panel.innerHTML = `
            <div id="af-sp-header">
                <span>Настройки ${source}</span>
                <button id="af-sp-close" title="Закрыть">×</button>
            </div>
            <div id="af-sp-body">${buildSettingsHTML(source)}</div>`;

        document.body.appendChild(panel);
        makeSettingsPanelDraggable(panel);
        panel.style.top  = '120px';
        panel.style.left = '50px';

        try {
            const pos = JSON.parse(GM_getValue('af_settings_pos', '{}'));
            if (pos.top)  { panel.style.top  = pos.top;  panel.style.transform = 'none'; }
            if (pos.left) { panel.style.left = pos.left; }
        } catch {}

        panel.querySelector('#af-sp-close').onclick = () => panel.remove();
        bindSettingsEvents(panel, source);
        return panel;
    }

    function bindSettingsEvents(panel, source) {
        const isSteam = source === 'steam';
        const P = isSteam ? 'stm_' : '';

        const TOGGLE_MAP = isSteam ? {
            [`${P}fieldSysReq`]: '#af-sp-stm-sysreq-fmt',
        } : {
            fieldSysReq:       '#af-sp-sysreq-fmt',
            fieldTags:         '#af-sp-tags-options',
            adaptTagEditor:    '#af-sp-adapt-options',
            fieldDescription:  '#af-sp-desc-options',
        };

        panel.querySelectorAll('input[type="checkbox"][data-key]').forEach(cb => {
            cb.addEventListener('change', () => {
                settings[cb.dataset.key] = cb.checked;
                saveSettings(settings);
                const sel = TOGGLE_MAP[cb.dataset.key];
                if (sel) { const el = panel.querySelector(sel); if (el) el.style.display = cb.checked ? 'block' : 'none'; }
                if (isSteam && (cb.dataset.key === 'stm_fieldDescShort' || cb.dataset.key === 'stm_fieldDescLong')) {
                    const el = panel.querySelector('#af-sp-stm-desc-options');
                    if (el) el.style.display = (settings.stm_fieldDescShort || settings.stm_fieldDescLong) ? 'block' : 'none';
                }
            });
        });
    }

    function makeSettingsPanelDraggable(panel) {
        const header = panel.querySelector('#af-sp-header');
        let drag = null;
        const savePos = () => GM_setValue('af_settings_pos', JSON.stringify({
            top: panel.style.top, left: panel.style.left
        }));
        header.onmousedown = e => {
            if (e.target.tagName === 'BUTTON') return;
            e.preventDefault();
            drag = { x: e.clientX, y: e.clientY };
            document.onmousemove = e => {
                panel.style.top  = (panel.offsetTop  - (drag.y - e.clientY)) + 'px';
                panel.style.left = (panel.offsetLeft - (drag.x - e.clientX)) + 'px';
                panel.style.transform = 'none';
                drag = { x: e.clientX, y: e.clientY };
            };
            document.onmouseup = () => {
                document.onmousemove = document.onmouseup = null;
                savePos();
            };
        };
    }

    // ─── Вставка секции «Автозаполнение» в форму ──────────────────────────────

    function injectAutofillSection() {
        const relForm = document.getElementById('rel-form');
        if (!relForm) {
            console.warn('[AutoFill] #rel-form не найден, повтор через 500мс...');
            setTimeout(injectAutofillSection, 500);
            return;
        }

        const forumDefault  = DEFAULT_STEAM_FORUMS.includes(FORUM_ID) ? 'steam' : 'vndb';
        const defaultSource = GM_getValue(SOURCE_KEY, forumDefault);
        const defaultPlaceholder = defaultSource === 'steam'
            ? 'https://store.steampowered.com/app/702050/'
            : 'https://vndb.org/v97 или https://vndb.org/r64727';

        const afSection = document.createElement('div');
        afSection.id = 'af-section';
        afSection.innerHTML = `
            <table class="forumline">
                <colgroup>
                    <col class="row1" width="20%">
                    <col class="row2" width="80%">
                </colgroup>
                <tbody>
                    <tr><th colspan="2">Автозаполнение формы</th></tr>
                    <tr>
                        <td class="rel-title">Источник:</td>
                        <td class="rel-inputs" id="af-controls-td">
                            <div style="display:flex;align-items:center;gap:6px;flex-wrap:wrap;">
                                <select id="af-source-select" class="rel-el rel-input">
                                    <option value="vndb"  ${defaultSource === 'vndb'  ? 'selected' : ''}>vndb</option>
                                    <option value="steam" ${defaultSource === 'steam' ? 'selected' : ''}>steam</option>
                                </select>
                                <input type="button" id="af-settings-btn" value="Настройки ${defaultSource}">
                                <span id="af-vndb-hint"
                                      style="display:${defaultSource === 'vndb' ? 'inline' : 'none'};cursor:default;font-size:13px;line-height:1;vertical-align:middle;user-select:none;">❓</span>
                            </div>
                            <div style="display:flex;align-items:center;gap:6px;margin-top:4px;flex-wrap:wrap;">
                                <input id="af-url-input" type="text" class="rel-el rel-input"
                                       style="width:410px;"
                                       placeholder="${defaultPlaceholder}"
                                       value="">
                                <input type="button" id="af-fill-btn" value="Заполнить форму">
                            </div>
                        </td>
                    </tr>
                </tbody>
            </table>`;

        const spacer = document.createElement('div');
        spacer.className = 'spacer_12';
        relForm.parentNode.insertBefore(afSection, relForm);
        relForm.parentNode.insertBefore(spacer, relForm);

        const sourceSelect = document.getElementById('af-source-select');
        const urlInput     = document.getElementById('af-url-input');
        const settingsBtn  = document.getElementById('af-settings-btn');
        const fillBtn      = document.getElementById('af-fill-btn');

        function applySource(src, clearUrl = true) {
            sourceSelect.value   = src;
            settingsBtn.value    = `Настройки ${src}`;
            urlInput.placeholder = src === 'steam'
                ? 'https://store.steampowered.com/app/702050/'
                : 'https://vndb.org/v97 или https://vndb.org/r64727';
            if (clearUrl) urlInput.value = '';
            document.getElementById('af-settings-panel')?.remove();
            const hintEl = document.getElementById('af-vndb-hint');
            if (hintEl) hintEl.style.display = src === 'vndb' ? 'inline' : 'none';
            GM_setValue(SOURCE_KEY, src);
        }

        sourceSelect.addEventListener('change', () => applySource(sourceSelect.value));

        urlInput.addEventListener('paste', e => {
            const pasted = (e.clipboardData || window.clipboardData).getData('text').trim();
            try {
                const hostname = new URL(pasted).hostname;
                if (hostname === 'vndb.org' || hostname.endsWith('.vndb.org'))
                    applySource('vndb', false);
                else if (hostname === 'store.steampowered.com')
                    applySource('steam', false);
            } catch {}
        });

        settingsBtn.addEventListener('click', () => {
            const existing = document.getElementById('af-settings-panel');
            if (existing) { existing.remove(); return; }
            buildSettingsPanel(sourceSelect.value);
        });

        fillBtn.addEventListener('click', async () => {
            const src = sourceSelect.value;
            const url = urlInput.value.trim();
            if (!url) { toast('Введите ссылку', 'warning'); return; }
            fillBtn.disabled = true;
            fillBtn.value = '⏳ Заполняю...';
            try {
                if (src === 'steam') await fillFormSteam(url);
                else                 await fillForm(url);
            } finally {
                fillBtn.disabled = false;
                fillBtn.value = 'Заполнить форму';
            }
        });

        const hintEl  = document.getElementById('af-vndb-hint');
        let hintTimer = null;

        hintEl.addEventListener('mouseenter', () => {
            hintTimer = setTimeout(() => {
                let tip = document.getElementById('af-vndb-tooltip');
                if (!tip) {
                    tip = document.createElement('div');
                    tip.id = 'af-vndb-tooltip';
                    tip.innerHTML =
                        'Если вставите ссылку на vn страницу, то дата релиза будет взята из первого официального релиза. ' +
                        'Это нормально для новых игр.<br><br>' +
                        'Если vn с большой историей переизданий, то вставляйте ссылку на конкретный релиз, ' +
                        'его данные будут в приоритете на заполнение формы.<br><br>' +
                        'В настройках можно подключить добавление данных из Steam. ' +
                        'Это будет работать, если на vn странице есть хоть один релиз с ссылкой на Steam.<br><br>' +
                        'Если на один магазин будет несколько разных ссылок, то берётся последняя по дате релиза.';
                    document.body.appendChild(tip);
                }
                const r = hintEl.getBoundingClientRect();
                tip.style.left    = r.left + 'px';
                tip.style.top     = (r.bottom + 6) + 'px';
                tip.style.display = 'block';
            }, 50);
        });

        hintEl.addEventListener('mouseleave', () => {
            clearTimeout(hintTimer);
            const tip = document.getElementById('af-vndb-tooltip');
            if (tip) tip.style.display = 'none';
        });
    }

    // ─── Стили ─────────────────────────────────────────────────────────────────

    GM_addStyle(`
        #af-toast-container { position:fixed; top:16px; left:50%; transform:translateX(-50%); z-index:999999; display:flex; flex-direction:column; gap:8px; max-width:340px; pointer-events:none; }
        .af-toast { padding:10px 14px; border-radius:6px; font:13px/1.4 Verdana,Arial,sans-serif; box-shadow:0 3px 12px rgba(0,0,0,.35); pointer-events:auto; cursor:pointer; animation:af-fadein .25s ease; word-break:break-word; }
        @keyframes af-fadein { from { opacity:0; transform:translateX(20px); } to { opacity:1; } }
        .af-toast-info    { background:#1a6ebf; color:#fff; }
        .af-toast-success { background:#2a8a3e; color:#fff; }
        .af-toast-warning { background:#b07c00; color:#fff; }
        .af-toast-error   { background:#b02020; color:#fff; }

        /* ─── Настройки ─── */
        #af-settings-panel { width:max-content; min-width:340px; max-width:min(560px,90vw); position:fixed; max-height:80vh; overflow:hidden; display:flex; flex-direction:column; background:#fafafa; border:1px solid #bbb; border-radius:8px; box-shadow:0 6px 24px rgba(0,0,0,.25); z-index:99998; font:12px/1.5 Verdana,Arial,sans-serif; }
        #af-sp-header { background:#2a6ea0; color:#fff; padding:8px 12px; border-radius:8px 8px 0 0; display:flex; justify-content:space-between; align-items:center; cursor:move; user-select:none; font-weight:bold; flex-shrink:0; }
        #af-sp-body { padding:10px; overflow-y:auto; flex:1; }
        #af-sp-close { background:none; border:none; color:#fff; font-size:20px; cursor:pointer; line-height:1; padding:0 2px; }
        #af-sp-close:hover { color:#ffc; }
        .af-sp-section { margin-bottom:12px; border:1px solid #ddd; border-radius:5px; padding:8px 10px; }
        .af-sp-section-title { font-weight:bold; color:#2a6ea0; margin-bottom:6px; font-size:12px; text-transform:uppercase; letter-spacing:.5px; }
        .af-sp-label { display:block; padding:2px 0; cursor:pointer; color:#333; }
        .af-sp-label:hover { color:#000; }
        .af-sp-label input[type="checkbox"] { margin-right:6px; cursor:pointer; }
        .af-sp-radio { cursor:pointer; color:#333; }
        .af-sp-radio:hover { color:#000; }
        .af-sp-radio input { margin-right:5px; cursor:pointer; }
        #af-vndb-tooltip { display:none; position:fixed; z-index:999999; max-width:340px; background:#2c2c2c; color:#f0f0f0; font:12px/1.6 Verdana,Arial,sans-serif; padding:10px 13px; border-radius:6px; box-shadow:0 4px 16px rgba(0,0,0,.45); pointer-events:none; word-break:break-word; }

        /* ─── Выбор постера ─── */
        #af-cover-panel { position:fixed; top:80px; left:80px; width:max-content; min-width:340px; max-width:90vw; max-height:82vh; display:flex; flex-direction:column; background:#fafafa; border:1px solid #bbb; border-radius:8px; box-shadow:0 6px 24px rgba(0,0,0,.3); z-index:99999; font:12px/1.5 Verdana,Arial,sans-serif; }
        #af-cover-header { background:#2a6ea0; color:#fff; padding:8px 12px; border-radius:8px 8px 0 0; display:flex; justify-content:space-between; align-items:center; cursor:move; user-select:none; font-weight:bold; flex-shrink:0; }
        #af-cover-header button { background:none; border:none; color:#fff; font-size:20px; cursor:pointer; line-height:1; padding:0 2px; }
        #af-cover-header button:hover { color:#ffc; }
        #af-cover-body { overflow-y:auto; flex:1; padding:10px 12px; }
        #af-cover-footer { flex-shrink:0; border-top:1px solid #ddd; background:#fafafa; padding:10px 12px; display:flex; gap:8px; border-radius:0 0 8px 8px; }
        .af-cover-item { padding:8px 0; border-top:1px solid #eee; }
        .af-cover-item:first-child { border-top:none; }
        .af-cover-label { display:flex; align-items:center; gap:6px; cursor:pointer; font-weight:bold; color:#333; }
        .af-cover-label:hover { color:#000; }
        .af-cover-label input[type="radio"] { cursor:pointer; flex-shrink:0; }
        .af-cover-thumb { display:block; max-height:320px; width:auto; height:auto; margin-top:6px; border-radius:4px; border:1px solid #ddd; }
        .af-cover-btn-primary { background:#2a6ea0; color:#fff; border:none; border-radius:5px; padding:7px 18px; font:12px Verdana,Arial,sans-serif; cursor:pointer; }
        .af-cover-btn-primary:hover { background:#1a5e90; }
        .af-cover-btn-secondary { background:#fff; color:#333; border:1px solid #bbb; border-radius:5px; padding:7px 14px; font:12px Verdana,Arial,sans-serif; cursor:pointer; }
        .af-cover-btn-secondary:hover { background:#f0f0f0; }
    `);

    // ─── Инициализация ─────────────────────────────────────────────────────────

    function init() {
        if (!document.getElementById('rel-form')) return;
        injectAutofillSection();
    }

    if (document.readyState !== 'loading') {
        setTimeout(init, 300);
    } else {
        document.addEventListener('DOMContentLoaded', () => setTimeout(init, 300));
    }

})();