☰

Booru Tag Search

Modernize and enhance search bar and tag input for booru sites, with modular site-specific configurations, dynamic cheat sheets with caching, e621-specific order syntax, and improved layout for rule34.xxx and e621.net

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

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

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

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

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

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

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

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

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

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

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

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

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

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

// ==UserScript==
// @name         Booru Tag Search
// @namespace    codeberg.org/0xdev/booru-tag-search
// @version      2.9.0
// @description  Modernize and enhance search bar and tag input for booru sites, with modular site-specific configurations, dynamic cheat sheets with caching, e621-specific order syntax, and improved layout for rule34.xxx and e621.net
// @author       0xdev
// @license      LGPL-3.0-or-later
// @match        *://*.booru.org/*
// @match        *://*.booru.com/*
// @match        *://*.booru.ca/*
// @match        *://*.booru.xxx/*
// @match        *://rule34.xxx/*
// @match        *://gelbooru.com/*
// @match        *://www.gelbooru.com/*
// @match        *://safebooru.org/*
// @match        *://danbooru.donmai.us/*
// @match        *://booru.allthefallen.moe/*
// @match        *://e621.net/*
// @grant        GM.getValue
// @grant        GM.setValue
// ==/UserScript==

// THIS FILE IS AUTO-GENERATED — DO NOT MANUALLY EDIT.
// Source: tag-search/booru-tag-search.user.js
'use strict';
/* exported SCRIPT_VERSION */
const SCRIPT_VERSION = '2.9.0';

"use strict";
// ───────────────────────────────────────────────────────────────────────────
// Pure, DOM-free helpers. Hoisted to module scope so Node can require() them
// for unit tests (see test/). Everything below the helpers is browser-only and
// guarded by `typeof window`, so importing this file in Node has no side effects.
// ───────────────────────────────────────────────────────────────────────────
function escapeHTML(s) {
    const entities = {
        '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;',
    };
    return String(s).replace(/[&<>"']/g, (c) => entities[c] ?? c);
}
function parseTagString(tagString) {
    // Keep parenthesized groups, Gelbooru brace groups, and tag-name parens
    // together; spaces split outside those groups.
    const tags = [];
    let buffer = '';
    let parenLevel = 0;
    for (let i = 0; i < tagString.length; ++i) {
        const c = tagString[i];
        if (c === '(' || c === '{') {
            // Grouping paren (preceded by space / at start) vs tag-name paren.
            const prevChar = i > 0 ? tagString[i - 1] : ' ';
            const isGroupingParen = prevChar === ' ' || i === 0;
            if (isGroupingParen && parenLevel === 0 && buffer.trim()) {
                tags.push(buffer.trim());
                buffer = '';
            }
            parenLevel++;
            buffer += c;
        }
        else if ((c === ')' || c === '}') && parenLevel > 0) {
            buffer += c;
            parenLevel--;
            const nextChar = i < tagString.length - 1 ? tagString[i + 1] : ' ';
            const isGroupingParen = nextChar === ' ' || i === tagString.length - 1;
            if (parenLevel === 0 && isGroupingParen) {
                tags.push(buffer.trim());
                buffer = '';
            }
        }
        else if (c === ' ' && parenLevel === 0) {
            if (buffer.trim()) {
                tags.push(buffer.trim());
                buffer = '';
            }
        }
        else {
            buffer += c;
        }
    }
    if (buffer.trim())
        tags.push(buffer.trim());
    return tags;
}
// "Fuzz" normalization for tag strings that arrive URL-encoded — pasted out of
// an address bar (`cat%2bdog`) or double-encoded by a redirect/chat app
// (`cat%252bdog`). Loops until stable: `+` means space (form encoding), then
// percent-decode when a valid %XX escape is present. Malformed escapes abort
// that decode pass (all-or-nothing), so strings like "100%" survive untouched.
// Trade-off: tags containing a literal `+` (danbooru's `+_+`) can't survive
// this; selecting them from autocomplete bypasses normalization.
function normalizeTagInput(raw) {
    let value = String(raw);
    for (let i = 0; i < 5; i++) {
        let next = value.replace(/\+/g, ' ');
        if (/%[0-9a-f]{2}/i.test(next)) {
            try {
                next = decodeURIComponent(next);
            }
            catch {
                // Malformed escape somewhere in the string — keep it as-is.
            }
        }
        if (next === value)
            break;
        value = next;
    }
    return value;
}
function convertSortSyntax(parsedTags, sortPrefix) {
    // e621/danbooru use "order:" — convert legacy "sort:field:dir" to it.
    if (sortPrefix === 'order') {
        return parsedTags.map((tag) => {
            if (tag.startsWith('sort:') && tag.includes(':')) {
                const parts = tag.split(':');
                if (parts.length === 3) {
                    const [, field, direction] = parts;
                    return direction === 'asc' ? `order:${field}_asc` : `order:${field}`;
                }
            }
            return tag;
        }).filter(Boolean);
    }
    // Gelbooru-family: drop the "all" placeholder — it's not a real tag.
    return parsedTags.filter((t) => t && t !== 'all');
}
function formatPostCount(n) {
    if (n >= 1000000)
        return (n / 1000000).toFixed(1).replace(/\.0$/, '') + 'M';
    if (n >= 1000)
        return (n / 1000).toFixed(1).replace(/\.0$/, '') + 'k';
    return String(n);
}
// Allowlist of structural tags the cheatsheet sanitizer keeps; everything else
// is unwrapped (its text is preserved, the tag itself dropped).
const CHEATSHEET_ALLOWED_TAGS = new Set([
    'SECTION', 'DIV', 'P', 'SPAN', 'BR', 'HR', 'BLOCKQUOTE',
    'H1', 'H2', 'H3', 'H4', 'H5', 'H6',
    'UL', 'OL', 'LI', 'DL', 'DT', 'DD',
    'B', 'STRONG', 'I', 'EM', 'U', 'S', 'SUB', 'SUP', 'SMALL', 'MARK', 'ABBR',
    'CODE', 'PRE', 'KBD', 'SAMP', 'VAR',
    'A',
    'TABLE', 'THEAD', 'TBODY', 'TFOOT', 'TR', 'TD', 'TH', 'CAPTION', 'COLGROUP', 'COL',
]);
// True for hrefs safe to keep on a sanitized <a>: http(s), mailto, and
// scheme-less (relative / anchor) URLs. Blocks javascript:/data:/vbscript: etc.,
// including tab/newline/leading-control-char obfuscations a browser would strip
// before acting on the URL.
function isSafeHref(href) {
    if (typeof href !== 'string')
        return false;
    const v = [...href].filter((c) => c.charCodeAt(0) > 0x20).join('');
    if (v === '')
        return false;
    const m = v.match(/^([a-z][a-z0-9+.-]*):/i);
    if (!m)
        return true; // no scheme → relative / anchor
    return ['http', 'https', 'mailto'].includes(m[1].toLowerCase());
}
// Sanitize a fetched cheat-sheet DOM element (e.g. danbooru's community-editable
// help wiki) into a safe HTML string for innerHTML: allowlist structural tags,
// unwrap everything else, drop dangerous elements, and strip every attribute
// except an isSafeHref-approved <a href>. No closure deps, so it is unit-testable
// (jsdom) — see test/sanitizer.test.js.
function cleanCheatSheetHTML(element) {
    const el = element.cloneNode(true);
    // Remove TOC sections and navigation
    el.querySelectorAll('[class*="toc"], [id*="toc"], nav').forEach(n => n.remove());
    el.querySelectorAll('h1, h2, h3, h4, h5, h6').forEach(h => {
        if (/table of contents|contents/i.test(h.textContent ?? '')) {
            let next = h.nextElementSibling;
            h.remove();
            while (next && !/^H[1-6]$/.test(next.tagName)) {
                const rm = next;
                next = next.nextElementSibling;
                rm.remove();
            }
        }
    });
    // Drop dangerous elements outright (content and all).
    el.querySelectorAll('script, style, iframe, img, svg, math, object, embed, applet, ' +
        'link, meta, base, form, input, button, textarea, select, ' +
        'video, audio, source, track, template, noscript, frame, frameset').forEach(n => n.remove());
    // Allowlist sweep: unwrap any element not on the safe structural list
    // (keeping its text), and strip every attribute except a safe href on <a>.
    for (const node of [...el.querySelectorAll('*')]) {
        if (!CHEATSHEET_ALLOWED_TAGS.has(node.tagName)) {
            node.replaceWith(...node.childNodes);
            continue;
        }
        for (const attr of [...node.attributes]) {
            const keepHref = node.tagName === 'A'
                && attr.name.toLowerCase() === 'href'
                && isSafeHref(attr.value);
            if (!keepHref)
                node.removeAttribute(attr.name);
        }
    }
    // Tag code elements with chip classes based on content
    el.querySelectorAll('code').forEach(code => {
        const text = (code.textContent ?? '').trim();
        if (text.startsWith('-'))
            code.classList.add('bse-chip-exclude');
        else if (/^[a-z_]+:/.test(text) || text.startsWith('('))
            code.classList.add('bse-chip-meta');
        else
            code.classList.add('bse-chip-include');
    });
    return `<div class="dynamic-cheatsheet">${el.innerHTML}</div>`;
}
if (typeof window === 'undefined' && typeof module !== 'undefined' && module.exports) {
    module.exports = {
        escapeHTML,
        parseTagString,
        convertSortSyntax,
        formatPostCount,
        isSafeHref,
        cleanCheatSheetHTML,
        normalizeTagInput,
    };
}

"use strict";
/* exported SAVED_UNREADABLE_MESSAGE, siteKey, parseSavedData, findSearch, saveQuery, removeQuery, restoreQuery, editSearch, moveSearch, moveGroup, renameGroup, deleteGroup, mergeImport, filterSavedData, createSavedSearchStore */
/**
 * Saved-search data model: pure functions over `SavedData`.
 * Names are unique across all groups and group names are unique, both compared
 * case-insensitively. Every function returns new data and never mutates its
 * input. Storage and UI live elsewhere.
 */
/** Message of the error thrown when stored data fails validation. */
const SAVED_UNREADABLE_MESSAGE = 'Saved searches could not be read';
function siteKey(hostname) {
    return hostname.replace(/^www\./, '');
}
function sameName(a, b) {
    return a.toLowerCase() === b.toLowerCase();
}
/** Trims a name or group name and rejects one that is then empty. */
function cleanName(name) {
    const trimmed = name.trim();
    if (trimmed === '')
        throw new Error('Name required');
    return trimmed;
}
function cloneSavedData(data) {
    return data.map(([group, searches]) => [
        group,
        searches.map(([name, queries]) => [name, { ...queries }]),
    ]);
}
function isPlainObject(value) {
    return typeof value === 'object' && value !== null && !Array.isArray(value);
}
function parseSavedData(raw) {
    if (raw === undefined || raw === null)
        return [];
    if (!Array.isArray(raw))
        return null;
    const groups = [];
    const seenNames = new Set();
    const seenGroups = new Set();
    for (const rawGroup of raw) {
        if (!Array.isArray(rawGroup) || rawGroup.length !== 2)
            return null;
        const [groupName, rawSearches] = rawGroup;
        if (typeof groupName !== 'string' || groupName.trim() === '' || !Array.isArray(rawSearches))
            return null;
        const groupKey = groupName.toLowerCase();
        if (seenGroups.has(groupKey))
            return null;
        seenGroups.add(groupKey);
        const searches = [];
        for (const rawSearch of rawSearches) {
            if (!Array.isArray(rawSearch) || rawSearch.length !== 2)
                return null;
            const [name, rawQueries] = rawSearch;
            if (typeof name !== 'string' || name.trim() === '' || !isPlainObject(rawQueries))
                return null;
            const nameKey = name.toLowerCase();
            if (seenNames.has(nameKey))
                return null;
            seenNames.add(nameKey);
            const queries = {};
            for (const [site, query] of Object.entries(rawQueries)) {
                if (typeof query !== 'string' || site === '__proto__')
                    return null;
                queries[site] = query;
            }
            searches.push([name, queries]);
        }
        groups.push([groupName, searches]);
    }
    return groups;
}
function findSearch(data, name) {
    for (const [group, searches] of data) {
        const search = searches.find(([n]) => sameName(n, name));
        if (search)
            return { group, search };
    }
    return undefined;
}
/** Applies `update` to the search called `name`, if there is one. */
function updateSearch(data, name, update) {
    const out = cloneSavedData(data);
    for (const [, searches] of out) {
        const search = searches.find(([n]) => sameName(n, name));
        if (search) {
            update(search);
            break;
        }
    }
    return out;
}
function saveQuery(data, input) {
    const name = cleanName(input.name);
    if (findSearch(data, name)) {
        return updateSearch(data, name, (search) => {
            search[1][input.site] = input.query;
        });
    }
    const groupName = cleanName(input.group);
    const out = cloneSavedData(data);
    const search = [name, { [input.site]: input.query }];
    const group = out.find(([g]) => sameName(g, groupName));
    if (group)
        group[1].push(search);
    else
        out.push([groupName, [search]]);
    return out;
}
function removeQuery(data, name, site) {
    const groupIndex = data.findIndex(([, searches]) => searches.some(([n]) => sameName(n, name)));
    if (groupIndex < 0)
        return { data, removed: null };
    const [group, searches] = data[groupIndex];
    const index = searches.findIndex(([n]) => sameName(n, name));
    const [foundName, queries] = searches[index];
    if (!Object.prototype.hasOwnProperty.call(queries, site))
        return { data, removed: null };
    const out = cloneSavedData(data);
    const outSearches = out[groupIndex][1];
    const remaining = outSearches[index][1];
    delete remaining[site];
    const removedSearch = Object.keys(remaining).length === 0;
    if (removedSearch)
        outSearches.splice(index, 1);
    return {
        data: out,
        removed: { group, groupIndex, index, name: foundName, site, query: queries[site], removedSearch },
    };
}
function restoreQuery(data, removed) {
    const existing = findSearch(data, removed.name);
    if (existing) {
        if (Object.prototype.hasOwnProperty.call(existing.search[1], removed.site))
            return data;
        return updateSearch(data, removed.name, (search) => {
            search[1][removed.site] = removed.query;
        });
    }
    const out = cloneSavedData(data);
    let group = out.find(([g]) => sameName(g, removed.group));
    if (!group) {
        group = [removed.group, []];
        out.splice(Math.min(removed.groupIndex, out.length), 0, group);
    }
    group[1].splice(Math.min(removed.index, group[1].length), 0, [removed.name, { [removed.site]: removed.query }]);
    return out;
}
function editSearch(data, name, change) {
    const cleanedName = change.name === undefined ? undefined : cleanName(change.name);
    const cleanedGroup = change.group === undefined ? undefined : cleanName(change.group);
    const found = findSearch(data, name);
    if (!found)
        return data;
    const newName = cleanedName ?? found.search[0];
    if (!sameName(newName, found.search[0]) && findSearch(data, newName))
        throw new Error('Name already used');
    const out = cloneSavedData(data);
    const groupIndex = out.findIndex(([g]) => sameName(g, found.group));
    const searches = out[groupIndex][1];
    const index = searches.findIndex(([n]) => sameName(n, name));
    const search = searches[index];
    search[0] = newName;
    if (change.query !== undefined)
        search[1][change.site] = change.query;
    if (cleanedGroup !== undefined && !sameName(cleanedGroup, found.group)) {
        searches.splice(index, 1);
        const target = out.find(([g]) => sameName(g, cleanedGroup));
        if (target)
            target[1].push(search);
        else
            out.push([cleanedGroup, [search]]);
    }
    return out;
}
function moveSearch(data, name, delta) {
    const out = cloneSavedData(data);
    for (const [, searches] of out) {
        const index = searches.findIndex(([n]) => sameName(n, name));
        if (index < 0)
            continue;
        const target = index + delta;
        if (target >= 0 && target < searches.length) {
            [searches[index], searches[target]] = [searches[target], searches[index]];
        }
        break;
    }
    return out;
}
function moveGroup(data, group, delta) {
    const out = cloneSavedData(data);
    const index = out.findIndex(([g]) => sameName(g, group));
    const target = index + delta;
    if (index >= 0 && target >= 0 && target < out.length) {
        [out[index], out[target]] = [out[target], out[index]];
    }
    return out;
}
function renameGroup(data, from, to) {
    const newName = cleanName(to);
    const out = cloneSavedData(data);
    const group = out.find(([g]) => sameName(g, from));
    if (!group)
        return out;
    if (!sameName(from, newName) && out.some(([g]) => sameName(g, newName)))
        throw new Error('Name already used');
    group[0] = newName;
    return out;
}
function deleteGroup(data, group) {
    return cloneSavedData(data).filter(([g]) => !sameName(g, group));
}
/**
 * Reads the export format of the original script into saved data. A query
 * that is blank, or sits in a group with a blank label, is counted in `skipped`.
 */
function parseLegacyExport(raw) {
    if (!isPlainObject(raw) || !isPlainObject(raw.profiles))
        return null;
    let out = [];
    let skipped = 0;
    for (const [host, profile] of Object.entries(raw.profiles)) {
        if (siteKey(host) === '__proto__' || !isPlainObject(profile) || !Array.isArray(profile.groups))
            continue;
        for (const group of profile.groups) {
            if (!isPlainObject(group) || typeof group.label !== 'string' || !Array.isArray(group.queries))
                continue;
            for (const query of group.queries) {
                if (typeof query !== 'string')
                    continue;
                if (query.trim() === '' || group.label.trim() === '') {
                    skipped++;
                    continue;
                }
                out = saveQuery(out, { name: query, group: group.label, site: siteKey(host), query });
            }
        }
    }
    return { data: out, skipped };
}
/**
 * Adds searches and site queries from `raw` that are not saved yet. A site
 * query that already exists is kept and counted as skipped.
 */
function mergeImport(data, raw) {
    const legacy = Array.isArray(raw) ? null : parseLegacyExport(raw);
    const incoming = Array.isArray(raw) ? parseSavedData(raw) : legacy?.data;
    if (!incoming)
        return null;
    let out = data;
    let imported = 0;
    let skipped = legacy?.skipped ?? 0;
    for (const [group, searches] of incoming) {
        for (const [name, queries] of searches) {
            for (const [site, query] of Object.entries(queries)) {
                const existing = findSearch(out, name);
                if (existing && Object.prototype.hasOwnProperty.call(existing.search[1], site)) {
                    skipped++;
                }
                else {
                    out = saveQuery(out, { name, group, site, query });
                    imported++;
                }
            }
        }
    }
    return { data: out, imported, skipped };
}
function filterSavedData(data, text) {
    const out = cloneSavedData(data);
    const needle = text.toLowerCase();
    if (needle === '')
        return out;
    return out
        .map(([group, searches]) => [
        group,
        searches.filter(([name, queries]) => name.toLowerCase().includes(needle)
            || Object.values(queries).some((q) => q.toLowerCase().includes(needle))),
    ])
        .filter(([, searches]) => searches.length > 0);
}
/** Reads and writes saved searches under one storage key. */
function createSavedSearchStore(gm, key = 'savedSearches') {
    async function read() {
        const data = parseSavedData(await gm.getValue(key));
        if (data === null)
            throw Object.assign(new Error(SAVED_UNREADABLE_MESSAGE), { code: 'unreadable' });
        return data;
    }
    let queue = Promise.resolve();
    return {
        read,
        readRaw: () => gm.getValue(key),
        // Re-reads before writing so a save made in another tab is kept; updates
        // from this store run one after another so they cannot overwrite each other.
        update(change) {
            const run = queue.then(async () => {
                const next = change(await read());
                await gm.setValue(key, next);
                return next;
            });
            queue = run.catch(() => undefined);
            return run;
        },
    };
}
if (typeof window === 'undefined' && typeof module !== 'undefined' && module.exports) {
    module.exports = {
        siteKey,
        parseSavedData,
        findSearch,
        saveQuery,
        removeQuery,
        restoreQuery,
        editSearch,
        moveSearch,
        moveGroup,
        renameGroup,
        deleteGroup,
        mergeImport,
        filterSavedData,
        createSavedSearchStore,
    };
}

"use strict";
/* exported queryKey, isQueryApplied, queryTags, convertQuery */
/**
 * Saved-search query helpers: matching a query against the tags in the search
 * bar, and rewriting a query into another site's sort and OR-group syntax.
 * Pure string functions; no storage and no DOM.
 */
function queryTags(query) {
    return parseTagString(query);
}
function queryKey(query) {
    return queryTags(query).map((t) => t.toLowerCase()).sort().join(' ');
}
function isQueryApplied(query, tags) {
    const wanted = queryTags(query).map((t) => t.toLowerCase());
    if (wanted.length === 0)
        return false;
    const have = new Set(tags.map((t) => t.toLowerCase()));
    return wanted.every((t) => have.has(t));
}
// Sort tag as field and direction, from either "sort:f:dir" or "order:f[_dir]".
function parseSortTag(tag) {
    const sort = /^sort:([^:]+):(asc|desc)$/.exec(tag);
    if (sort)
        return { field: sort[1], dir: sort[2] };
    const order = /^order:([^:]+?)(?:_(asc|desc))?$/.exec(tag);
    if (order)
        return { field: order[1], dir: order[2] === 'asc' ? 'asc' : 'desc' };
    return null;
}
function formatSortTag(sort, prefix) {
    if (prefix === 'sort')
        return `sort:${sort.field}:${sort.dir}`;
    return sort.dir === 'asc' ? `order:${sort.field}_asc` : `order:${sort.field}`;
}
// Members of an OR group written as "( a ~ b )", "{a ~ b}", "( ~a ~b )" or
// Danbooru's "(a or b)".
function groupMembers(tag) {
    const m = /^(?:\(([\s\S]*)\)|\{([\s\S]*)\})$/.exec(tag);
    if (!m)
        return null;
    const inner = m[1] ?? m[2];
    if (inner.includes('~'))
        return plainMembers(inner.split('~'));
    // Danbooru's form needs parentheses and at least two members.
    const members = m[1] !== undefined ? inner.trim().split(/\s+or\s+/) : [];
    return members.length >= 2 ? plainMembers(members) : null;
}
function plainMembers(parts) {
    const members = parts.map((s) => s.trim()).filter(Boolean);
    // Nested groups and multi-word members cannot be rewritten safely.
    if (members.some((x) => /[\s(){}~]/.test(x)))
        return null;
    return members;
}
// A bare "~x" OR member; a tag with a further "~" (such as "~_~") is plain.
function bareMember(tag) {
    return tag.length > 1 && tag.startsWith('~') && !tag.includes('~', 1) ? tag.slice(1) : null;
}
// Danbooru's bare "~x" tags all join one group, so a second group needs parentheses.
function formatGroup(members, site, groupCount) {
    switch (site) {
        case 'gelbooru': return [`{${members.join(' ~ ')}}`];
        case 'e621': return [`( ${members.map((x) => '~' + x).join(' ')} )`];
        case 'danbooru': return groupCount > 1 ? [`(${members.join(' or ')})`] : members.map((x) => '~' + x);
        default: return [`( ${members.join(' ~ ')} )`];
    }
}
function convertQuery(query, to) {
    const tags = queryTags(query);
    // Every bare "~x" tag joins one OR group wherever it sits (Danbooru syntax);
    // a single one is not a group.
    const bare = tags.map(bareMember).filter((m) => m !== null);
    const bareGroup = bare.length >= 2;
    const groupCount = tags.filter((tag) => groupMembers(tag) !== null).length + (bareGroup ? 1 : 0);
    let bareEmitted = false;
    const out = [];
    for (const tag of tags) {
        if (bareGroup && bareMember(tag) !== null) {
            if (!bareEmitted)
                out.push(...formatGroup(bare, to.site, groupCount));
            bareEmitted = true;
            continue;
        }
        const members = groupMembers(tag);
        if (members) {
            out.push(...formatGroup(members, to.site, groupCount));
            continue;
        }
        const sort = parseSortTag(tag);
        out.push(sort ? formatSortTag(sort, to.sortPrefix) : tag);
    }
    return out.join(' ');
}
if (typeof window === 'undefined' && typeof module !== 'undefined' && module.exports) {
    module.exports = {
        queryKey,
        isQueryApplied,
        queryTags,
        convertQuery,
    };
}

"use strict";
/* exported buildSavedSearchView */
/**
 * Saved-search view model: turns saved data (and, on an account site, the
 * account's own list) into the groups and entries the dialog shows for the
 * current site. Pure; no storage and no DOM.
 */
function buildSavedSearchView(data, site, account, accountGroup) {
    const byKey = new Map();
    for (const item of account ?? []) {
        const key = queryKey(item.query);
        if (!byKey.has(key))
            byKey.set(key, item);
    }
    const matched = new Set();
    const groups = [];
    for (const [groupName, searches] of data) {
        const entries = [];
        for (const [name, queries] of searches) {
            if (Object.prototype.hasOwnProperty.call(queries, site)) {
                const query = queries[site];
                const entry = { kind: 'here', name, group: groupName, query };
                if (account) {
                    const item = byKey.get(queryKey(query));
                    if (item) {
                        entry.account = item;
                        matched.add(item);
                    }
                    else {
                        entry.kind = 'not-on-account';
                    }
                }
                entries.push(entry);
            }
            else {
                const [otherSite] = Object.keys(queries);
                if (otherSite !== undefined) {
                    entries.push({ kind: 'unset', name, group: groupName, query: queries[otherSite], otherSite });
                }
            }
        }
        if (entries.length > 0)
            groups.push({ name: groupName, entries });
    }
    for (const item of account ?? []) {
        if (matched.has(item))
            continue;
        const name = item.group || accountGroup;
        let group = groups.find((g) => g.name.toLowerCase() === name.toLowerCase());
        if (!group) {
            group = { name, entries: [] };
            groups.push(group);
        }
        group.entries.push({ kind: 'account-only', name: item.query, group: group.name, query: item.query, account: item });
    }
    return groups;
}
if (typeof window === 'undefined' && typeof module !== 'undefined' && module.exports) {
    module.exports = { buildSavedSearchView };
}

"use strict";
/* exported accountBackendFor, createDanbooruBackend, createE621Backend, createGelbooruBackend, parseGelbooruSavedSearches */
const DANBOORU_PAGE_SIZE = 200;
/** Danbooru serves at most 1000 saved searches per account; one extra page confirms the end. */
const DANBOORU_MAX_PAGES = Math.ceil(1000 / DANBOORU_PAGE_SIZE) + 1;
/** Gelbooru lists 5 saved searches per page, paged by `&pid=<offset>`. */
const GELBOORU_PAGE_SIZE = 5;
/** Up to 1000 searches, read one page at a time. */
const GELBOORU_MAX_PAGES = 1000 / GELBOORU_PAGE_SIZE;
const GELBOORU_LIST_URL = '/index.php?page=tags&s=saved_search';
const GELBOORU_TOKEN_PATTERN = /saved_search&csrf-token=([0-9a-f]+)/;
const GELBOORU_REMOVE_LINK = 'a[href*="remove_save_tag_search="]';
const GELBOORU_POST_LINK = 'a[href*="page=post"]';
const GELBOORU_NOT_SAVED = 'Gelbooru did not save the search';
const GELBOORU_NOT_REMOVED = 'Gelbooru did not remove the search';
const GELBOORU_UNREADABLE = 'Saved searches could not be read';
const NO_TOKEN_MESSAGE = 'Could not save: reload the page and try again';
function accountRequest(fetchImpl, csrfToken, method, url, form) {
    const headers = { Accept: 'application/json' };
    const init = { method, credentials: 'same-origin', headers };
    if (method !== 'GET') {
        const token = csrfToken();
        if (!token)
            throw new Error(NO_TOKEN_MESSAGE);
        headers['X-CSRF-Token'] = token;
    }
    if (form) {
        headers['Content-Type'] = 'application/x-www-form-urlencoded';
        init.body = form;
    }
    return fetchImpl(url, init);
}
async function failureOf(response) {
    if (response.status === 422) {
        try {
            const body = (await response.json());
            const message = body.message ?? body.reason;
            if (message)
                return new Error(message);
        }
        catch {
            // Unreadable body: fall back to the status.
        }
    }
    return new Error(`HTTP ${response.status}`);
}
/** `text` read as JSON, or null when it is not JSON. */
function jsonOrNull(text) {
    try {
        return { value: JSON.parse(text) };
    }
    catch {
        return null;
    }
}
/** `text` read as JSON; anything else is an unexpected response. */
function parseJsonBody(text) {
    const json = jsonOrNull(text);
    if (!json)
        throw new Error('Unexpected response');
    return json.value;
}
function savedSearchForm(query, labels) {
    const form = new URLSearchParams();
    form.set('saved_search[query]', query);
    if (labels !== undefined)
        form.set('saved_search[label_string]', labels.join(' '));
    return form;
}
function isRecord(value) {
    return typeof value === 'object' && value !== null && !Array.isArray(value);
}
function hasIdAndQuery(raw) {
    return (typeof raw.id === 'number' || typeof raw.id === 'string') && typeof raw.query === 'string';
}
function createAccountBackend(options, fetchImpl, csrfToken) {
    const send = async (method, url, form) => {
        const response = await accountRequest(fetchImpl, csrfToken, method, url, form);
        // A search already deleted elsewhere is gone, which is what DELETE asked for.
        if (method === 'DELETE' && response.status === 404)
            return response;
        if (response.status === 401 || response.status === 403) {
            // As in list(): the site answers a logged-out request in JSON; a challenge page is an error.
            if (jsonOrNull(await response.text()))
                throw new Error(`Log in to ${options.label} to change saved searches`);
            throw new Error(`HTTP ${response.status}`);
        }
        if (!response.ok)
            throw await failureOf(response);
        return response;
    };
    const parseOne = async (response, sent) => {
        const text = response.status === 204 ? '' : await response.text();
        if (text.trim() === '') {
            if (sent)
                return sent;
            throw new Error('Unexpected response');
        }
        const body = parseJsonBody(text);
        const item = isRecord(body) ? options.parse(body) : null;
        if (!item)
            throw new Error('Unexpected response');
        return item;
    };
    const sentItem = (id, query, labels) => {
        const item = { id, query };
        if (labels && labels.length > 0) {
            item.group = labels[0];
            item.labels = labels;
        }
        return item;
    };
    const itemUrl = (item) => `/saved_searches/${encodeURIComponent(item.id)}.json`;
    return {
        label: options.label,
        accountGroup: options.accountGroup,
        labelled: options.labelled,
        async list() {
            const items = [];
            const seen = new Set();
            for (let page = 1; page <= (options.pageSize ? DANBOORU_MAX_PAGES : 1); page++) {
                const url = options.pageSize
                    ? `/saved_searches.json?limit=${options.pageSize}&page=${page}`
                    : '/saved_searches.json';
                const response = await accountRequest(fetchImpl, csrfToken, 'GET', url);
                if (response.status === 401 || response.status === 403) {
                    // The site answers a logged-out request in JSON; a challenge page is an error.
                    if (jsonOrNull(await response.text()))
                        return 'logged-out';
                    throw new Error(`HTTP ${response.status}`);
                }
                if (!response.ok)
                    throw await failureOf(response);
                const rows = parseJsonBody(await response.text());
                if (!Array.isArray(rows))
                    throw new Error('Unexpected response');
                let added = 0;
                for (const row of rows) {
                    const item = isRecord(row) ? options.parse(row) : null;
                    if (item && !seen.has(item.id)) {
                        seen.add(item.id);
                        items.push(item);
                        added++;
                    }
                }
                if (!options.pageSize || rows.length < options.pageSize || added === 0)
                    break;
            }
            return items;
        },
        async create(query, group, restore) {
            const sent = options.labelled ? restore?.labels ?? options.labelsFor(group, []) : undefined;
            const form = savedSearchForm(query, sent);
            if (restore)
                options.restoreFields?.(form, restore);
            const response = await send('POST', '/saved_searches.json', form);
            return parseOne(response, null);
        },
        async update(item, query, group) {
            const sent = group === undefined ? undefined : options.labelsFor(group, item.labels ?? []);
            const response = await send(options.updateMethod, itemUrl(item), savedSearchForm(query, sent));
            return parseOne(response, sentItem(item.id, query, sent ?? item.labels));
        },
        async remove(item) {
            await send('DELETE', itemUrl(item));
        },
    };
}
function danbooruLabel(group) {
    return group.trim().toLowerCase().replace(/\s+/g, '_');
}
function createDanbooruBackend(fetchImpl, csrfToken, name = 'Danbooru') {
    return createAccountBackend({
        label: name,
        accountGroup: `${name} account`,
        updateMethod: 'PUT',
        pageSize: DANBOORU_PAGE_SIZE,
        labelled: true,
        parse(raw) {
            if (!hasIdAndQuery(raw))
                return null;
            const item = { id: String(raw.id), query: raw.query };
            // Only string labels count; the first is the group, as before a bad first label hid it.
            const labels = Array.isArray(raw.labels) ? raw.labels : [];
            if (typeof labels[0] === 'string' && labels[0] !== '') {
                item.group = labels[0];
                item.labels = labels.filter((l) => typeof l === 'string' && l !== '');
            }
            return item;
        },
        labelsFor(group, current) {
            const first = danbooruLabel(group);
            // The new first label replaces the old one and is sent once.
            return [...(first ? [first] : []), ...current.slice(1).filter((label) => label !== first)];
        },
    }, fetchImpl, csrfToken);
}
function createE621Backend(fetchImpl, csrfToken) {
    return createAccountBackend({
        label: 'e621',
        accountGroup: 'e621 account',
        updateMethod: 'PATCH',
        labelled: false,
        parse(raw) {
            if (raw.is_subscription === true)
                return null;
            if (!hasIdAndQuery(raw))
                return null;
            const item = { id: String(raw.id), query: raw.query };
            if (typeof raw.name === 'string' && raw.name !== '')
                item.name = raw.name;
            if (typeof raw.is_tracked === 'boolean')
                item.tracked = raw.is_tracked;
            return item;
        },
        labelsFor: () => undefined,
        restoreFields(form, restore) {
            // A name e621 gave the search is one it accepts; the user's own names are never sent.
            if (restore.name)
                form.set('saved_search[name]', restore.name);
            if (restore.tracked !== undefined)
                form.set('saved_search[is_tracked]', String(restore.tracked));
        },
    }, fetchImpl, csrfToken);
}
/** `text` trimmed, with each run of whitespace inside it read as one space. */
function collapseSpaces(text) {
    return text.trim().split(/\s+/).join(' ');
}
/** The CSRF token in Gelbooru's `saveTagSearch` script under `root`, if any. */
function gelbooruTokenIn(root) {
    for (const script of Array.from(root.querySelectorAll('script'))) {
        const match = GELBOORU_TOKEN_PATTERN.exec(script.textContent ?? '');
        if (match)
            return match[1];
    }
    return null;
}
/**
 * One Gelbooru saved-search page: the rows it could read, how many remove
 * links it has, the `pid` offsets its pager links to, and its CSRF token.
 */
function readGelbooruPage(html) {
    const page = new DOMParser().parseFromString(html, 'text/html');
    const removeLinks = Array.from(page.querySelectorAll(GELBOORU_REMOVE_LINK));
    const items = [];
    for (const remove of removeLinks) {
        const id = /[?&]remove_save_tag_search=([^&]*)/.exec(remove.getAttribute('href') ?? '')?.[1] ?? '';
        // The query link belongs to this row only if no other remove link comes first.
        let link = null;
        for (let el = remove.nextElementSibling; el && !el.matches(GELBOORU_REMOVE_LINK); el = el.nextElementSibling) {
            if (el.matches(GELBOORU_POST_LINK)) {
                link = el;
                break;
            }
        }
        const query = collapseSpaces(link?.textContent ?? '');
        const href = link?.getAttribute('href') ?? '';
        const tags = collapseSpaces(new URLSearchParams(href.slice(href.indexOf('?') + 1)).get('tags') ?? '');
        if (/^\d+$/.test(id) && query !== '' && tags === query)
            items.push({ id, query });
    }
    const pids = Array.from(page.querySelectorAll('#paginator a[href*="pid="]'), (link) => Number(/[?&]pid=(\d+)(?:&|$)/.exec(link.getAttribute('href') ?? '')?.[1] ?? NaN));
    return { items, removeLinks: removeLinks.length, pids, token: gelbooruTokenIn(page) };
}
/** The account searches on one Gelbooru saved-search page, in page order (newest first). */
function parseGelbooruSavedSearches(html) {
    return readGelbooruPage(html).items;
}
/** An empty reply, or a JSON `0`, `false` or `null`, means Gelbooru did not save the search. */
function isFailedSaveReply(text, contentType) {
    const trimmed = text.trim();
    if (trimmed === '')
        return true;
    if (!/json/i.test(contentType))
        return false;
    try {
        return !JSON.parse(trimmed);
    }
    catch {
        return false;
    }
}
function createGelbooruBackend(fetchImpl, doc) {
    /** The token from the last list page read; newer than the one this page was served with. */
    let listedToken = null;
    const csrfToken = () => listedToken ?? gelbooruTokenIn(doc);
    const send = async (url, form, goneIsDone = false) => {
        const init = { method: form ? 'POST' : 'GET', credentials: 'same-origin' };
        if (form) {
            init.headers = { 'Content-Type': 'application/x-www-form-urlencoded' };
            init.body = form;
        }
        const response = await fetchImpl(url, init);
        // A search already deleted elsewhere is gone, which is what the remove asked for.
        if (goneIsDone && response.status === 404)
            return response;
        if (!response.ok)
            throw new Error(`HTTP ${response.status}`);
        return response;
    };
    /** Reads a list page; a remove link without a readable row fails the whole page. */
    const readReply = (html) => {
        const page = readGelbooruPage(html);
        listedToken = page.token ?? listedToken;
        if (page.removeLinks > page.items.length)
            throw new Error(GELBOORU_UNREADABLE);
        return page;
    };
    const readPage = async (pid) => readReply(await (await send(pid === 0 ? GELBOORU_LIST_URL : `${GELBOORU_LIST_URL}&pid=${pid}`)).text());
    const requireToken = () => {
        const token = csrfToken();
        if (!token)
            throw new Error(NO_TOKEN_MESSAGE);
        return token;
    };
    // The page HTML is the same logged in and out, so the session cookie decides.
    const loggedIn = () => /(?:^|;\s*)user_id=[^;]/.test(doc.cookie);
    const backend = {
        label: 'Gelbooru',
        accountGroup: 'Gelbooru account',
        labelled: false,
        async list() {
            if (!loggedIn())
                return 'logged-out';
            const items = [];
            const seen = new Set();
            for (let page = 0; page < GELBOORU_MAX_PAGES; page++) {
                const pid = page * GELBOORU_PAGE_SIZE;
                const { items: rows, pids } = await readPage(pid);
                for (const item of rows) {
                    if (!seen.has(item.id)) {
                        seen.add(item.id);
                        items.push(item);
                    }
                }
                if (rows.length === 0 || !pids.includes(pid + GELBOORU_PAGE_SIZE))
                    break;
            }
            return items;
        },
        async create(query) {
            const token = requireToken();
            const form = new URLSearchParams();
            form.set('save_tag_search', query);
            const response = await send(`${GELBOORU_LIST_URL}&csrf-token=${encodeURIComponent(token)}`, form);
            if (isFailedSaveReply(await response.text(), response.headers.get('Content-Type') ?? '')) {
                throw new Error(GELBOORU_NOT_SAVED);
            }
            // The reply carries no id. Rows are newest first, so the first page holds the one just saved;
            // Gelbooru may store its tags in another case or order.
            const key = queryKey(query);
            const saved = (await readPage(0)).items.find((item) => queryKey(item.query) === key);
            if (!saved)
                throw new Error(GELBOORU_NOT_SAVED);
            return saved;
        },
        async update(item, query) {
            const created = await backend.create(query, '');
            // Gelbooru kept the old search for this query; removing it would delete the result.
            if (created.id === item.id)
                return created;
            await backend.remove(item);
            return created;
        },
        async remove(item) {
            const token = requireToken();
            const response = await send(`${GELBOORU_LIST_URL}&remove_save_tag_search=${encodeURIComponent(item.id)}`
                + `&csrf-token=${encodeURIComponent(token)}`, undefined, true);
            if (response.status === 404)
                return;
            if (readReply(await response.text()).items.some((listed) => listed.id === item.id)) {
                throw new Error(GELBOORU_NOT_REMOVED);
            }
        },
    };
    return backend;
}
/** `name` names the account in the dialog for a site that runs another site's software. */
function accountBackendFor(site, win, name) {
    // Looked up per request, so a page without `fetch` only fails when an account call is made.
    // Absolute, because a userscript manager may run this in a content script,
    // where a relative URL does not resolve against the page.
    const fetchImpl = (path, init) => win.fetch(win.location.origin + path, init);
    const csrfToken = () => win.document.querySelector('meta[name="csrf-token"]')?.content ?? null;
    if (site === 'danbooru')
        return createDanbooruBackend(fetchImpl, csrfToken, name);
    if (site === 'e621')
        return createE621Backend(fetchImpl, csrfToken);
    if (site === 'gelbooru')
        return createGelbooruBackend(fetchImpl, win.document);
    return null;
}
if (typeof window === 'undefined' && typeof module !== 'undefined' && module.exports) {
    module.exports = {
        accountBackendFor, createDanbooruBackend, createE621Backend, createGelbooruBackend, parseGelbooruSavedSearches,
    };
}

"use strict";
/* exported injectSavedSearchStyles */
/**
 * Styles for the saved searches dialog. Injected once, under its own id, the
 * first time the dialog opens. The dialog is white like tag-search's other
 * dialogs; there is no dark variant.
 */
function injectSavedSearchStyles() {
    if (document.getElementById('bse-saved-styles'))
        return;
    const style = document.createElement('style');
    style.id = 'bse-saved-styles';
    style.textContent = `
    #modal-saved-searches .modal-content {
        max-width: 620px;
        align-items: stretch;
    }
    .bse-saved {
        display: flex;
        flex-direction: column;
        width: 100%;
        min-width: 0;
    }
    .bse-saved-source {
        font-size: 0.9em;
        color: #555;
        margin: -12px 0 12px;
    }
    .bse-saved input, .bse-saved select {
        font: inherit;
        color: #222;
        background: #fff;
        border: 2px solid #ccc;
        box-sizing: border-box;
    }
    .bse-saved input:focus, .bse-saved select:focus {
        border-color: #4a90e2;
        outline: none;
    }
    .bse-saved-find {
        width: 100%;
        border-radius: 18px;
        padding: 8px 16px;
    }
    .bse-saved-group {
        margin-top: 16px;
    }
    .bse-saved-group-head {
        display: flex;
        justify-content: space-between;
        align-items: center;
        gap: 8px;
        font-size: 0.82em;
        font-weight: bold;
        text-transform: uppercase;
        color: #777;
        margin-bottom: 6px;
    }
    .bse-saved-group-head input {
        flex: 1;
        min-width: 0;
        border-radius: 12px;
        padding: 2px 10px;
        text-transform: none;
    }
    .bse-saved-entry, .bse-saved-edit-row {
        display: flex;
        align-items: center;
        gap: 10px;
        padding: 7px 10px;
        border-radius: 10px;
        border: 1.5px solid #e6e6e6;
        margin-bottom: 6px;
        color: #222;
    }
    .bse-saved-entry {
        cursor: pointer;
    }
    .bse-saved-entry:hover, .bse-saved-entry:focus-visible {
        border-color: #4a90e2;
        outline: none;
    }
    .bse-saved-entry.applied {
        background: #e3f2fd;
        border-color: #2196f3;
    }
    .bse-saved-entry.unset, .bse-saved-entry.missing {
        border-style: dashed;
        color: #666;
    }
    .bse-saved-check {
        flex: 0 0 16px;
        color: #1565c0;
        font-weight: bold;
    }
    .bse-saved-name {
        font-weight: bold;
        white-space: nowrap;
        overflow: hidden;
        text-overflow: ellipsis;
        max-width: 40%;
    }
    .bse-saved-query {
        flex: 1;
        min-width: 0;
        font-family: Consolas, monospace;
        font-size: 0.9em;
        color: #444;
        overflow: hidden;
        text-overflow: ellipsis;
        white-space: nowrap;
    }
    .bse-saved-entry.unset .bse-saved-query, .bse-saved-entry.missing .bse-saved-query {
        font-family: inherit;
        font-style: italic;
    }
    .bse-saved-icons {
        display: flex;
        flex: 0 0 auto;
    }
    .bse-saved-icon {
        background: none;
        border: none;
        color: #888;
        padding: 0 4px;
        font: inherit;
        font-size: 1em;
        cursor: pointer;
    }
    .bse-saved-icon:hover:not(:disabled) {
        color: #222;
    }
    .bse-saved-icon:disabled {
        opacity: 0.35;
        cursor: default;
    }
    .bse-saved-edit-row input {
        min-width: 0;
        border-radius: 12px;
        padding: 4px 10px;
    }
    .bse-saved-edit-name {
        flex: 1 1 30%;
    }
    .bse-saved-edit-query {
        flex: 2 1 40%;
        font-family: Consolas, monospace !important;
    }
    .bse-saved-picker {
        display: flex;
        flex: 0 1 auto;
        min-width: 0;
    }
    .bse-saved-picker select, .bse-saved-picker input {
        border-radius: 16px;
        padding: 6px 10px;
        min-width: 0;
        max-width: 100%;
    }
    .bse-saved-picker [hidden] {
        display: none;
    }
    .bse-saved-row-error, .bse-saved-save-error {
        color: #991b1b;
        font-size: 0.9em;
        margin: -2px 0 8px 10px;
    }
    .bse-saved-save-error:empty {
        display: none;
    }
    .bse-saved-more {
        display: block;
        margin: 4px auto 0;
        border-radius: 16px;
        padding: 4px 16px;
        border: 2px solid #ddd;
        background: #f7f7f7;
        color: #222;
        font: inherit;
        font-size: 0.9em;
        cursor: pointer;
    }
    .bse-saved-status {
        display: flex;
        justify-content: flex-end;
        min-height: 0;
        margin-top: 8px;
    }
    .bse-saved-status:empty {
        display: none;
    }
    #modal-saved-searches .modal-actions button:disabled {
        opacity: 0.5;
        cursor: default;
    }
    #modal-saved-searches .modal-actions button:disabled:hover {
        border-color: color-mix(in srgb, currentColor 25%, transparent);
        background: color-mix(in srgb, currentColor 8%, transparent);
    }
    .bse-saved-status-text {
        margin-right: auto;
    }
    .modal-actions .bse-saved-export {
        margin-right: auto;
    }
    .bse-saved-undo {
        background: none;
        border: none;
        color: #0066cc;
        text-decoration: underline;
        font: inherit;
        cursor: pointer;
        padding: 0;
    }
    .bse-saved-save-row {
        display: flex;
        gap: 8px;
        margin-top: 18px;
        border-top: 1px solid #eee;
        padding-top: 14px;
    }
    .bse-saved-save-row[hidden] {
        display: none;
    }
    .bse-saved-name-input {
        flex: 1;
        min-width: 0;
        border-radius: 16px;
        padding: 6px 14px;
    }
    .bse-saved-save-row button {
        border-radius: 16px;
        padding: 7px 20px;
        font: inherit;
        border: 2px solid #ccc;
        background: #f4f4f4;
        color: #222;
        cursor: pointer;
        white-space: nowrap;
    }
    .bse-saved-save-row button:disabled {
        opacity: 0.5;
        cursor: default;
    }
    .bse-saved-empty, .bse-saved-error, .bse-saved-loading {
        text-align: center;
        color: #666;
        padding: 26px 0;
    }
    .bse-saved-error {
        color: #991b1b;
    }
    .bse-saved-error .bse-saved-more {
        margin-top: 10px;
    }
    @media (max-width: 700px) {
        .bse-saved-save-row {
            flex-wrap: wrap;
        }
        .bse-saved-name-input {
            flex-basis: 100%;
        }
        .bse-saved-edit-row {
            flex-wrap: wrap;
        }
    }
    `;
    document.head.appendChild(style);
}

"use strict";
/* exported savedErrorMessage, savedElement, savedButton, savedIconButton, savedPickerValue, createSavedGroupPicker */
/**
 * Controls shared by the saved searches dialog: element and button helpers
 * and the group picker. Text reaches the DOM only through `textContent` and
 * `value`. No storage and no dialog state.
 */
function savedErrorMessage(error) {
    // Duck-typed: errors from GM or another realm fail `instanceof Error`.
    const message = error?.message;
    return typeof message === 'string' ? message : String(error);
}
function savedElement(tag, className, text) {
    const el = document.createElement(tag);
    el.className = className;
    if (text !== undefined)
        el.textContent = text;
    return el;
}
function savedButton(text, className, focusKey) {
    const button = savedElement('button', className, text);
    button.type = 'button';
    if (focusKey !== undefined)
        button.dataset.focusKey = focusKey;
    return button;
}
function savedIconButton(icon, label, focusKey, disabled, onClick) {
    const button = savedButton(icon, 'bse-saved-icon', `${label}:${focusKey}`);
    button.setAttribute('aria-label', label);
    button.title = label;
    button.disabled = disabled;
    button.addEventListener('click', (event) => {
        event.stopPropagation();
        onClick();
    });
    return button;
}
function savedPickerValue(state) {
    return 'group' in state ? state.group : state.newGroup.trim();
}
/** A group `<select>` whose "New group…" option swaps it for a name box. */
function createSavedGroupPicker(groups, initial, focusKey, onChange = () => undefined) {
    const el = savedElement('span', 'bse-saved-picker');
    const select = document.createElement('select');
    select.dataset.focusKey = `${focusKey}:select`;
    for (const group of groups) {
        const option = document.createElement('option');
        option.value = group;
        option.textContent = group;
        select.appendChild(option);
    }
    const newOption = document.createElement('option');
    newOption.value = '';
    newOption.textContent = 'New group…';
    select.appendChild(newOption);
    const input = savedElement('input', 'bse-saved-group-input');
    input.type = 'text';
    input.placeholder = 'Group name';
    input.dataset.focusKey = `${focusKey}:input`;
    el.append(select, input);
    const existing = (group) => groups.find((g) => g.toLowerCase() === group.toLowerCase());
    function showInput(on) {
        select.hidden = on;
        input.hidden = !on;
    }
    function state() {
        return select.hidden ? { newGroup: input.value } : { group: select.value };
    }
    function choose(group) {
        const match = existing(group);
        if (match === undefined)
            return;
        select.value = match;
        showInput(false);
    }
    select.addEventListener('change', () => {
        if (select.value === '') {
            showInput(true);
            input.focus();
        }
        onChange(state());
    });
    input.addEventListener('input', () => onChange(state()));
    const wanted = initial === undefined ? undefined : 'group' in initial ? initial.group : initial.newGroup;
    if (groups.length > 0)
        select.value = groups[0];
    if (wanted !== undefined && existing(wanted) !== undefined) {
        choose(wanted);
    }
    else if (groups.length === 0 || (initial !== undefined && 'newGroup' in initial)) {
        // With no groups, "New group…" is the only option and can never fire change.
        select.value = '';
        input.value = initial !== undefined && 'newGroup' in initial ? initial.newGroup : '';
        showInput(true);
    }
    else {
        showInput(false);
    }
    return {
        el,
        state,
        choose,
        setDisabled(disabled) {
            select.disabled = disabled;
            input.disabled = disabled;
        },
    };
}

"use strict";
/* exported createSavedFileActions */
/**
 * Import and Export for the saved searches dialog. Import merges a file into
 * the store through the dialog's own serialised write; Export downloads the
 * stored value exactly as stored, even when it fails validation. Neither
 * touches account searches.
 */
const SAVED_EXPORT_FILE = 'booru-saved-searches.json';
const SAVED_NOT_A_FILE = 'Not a saved-searches file';
const SAVED_IMPORT_MAX_BYTES = 5 * 1024 * 1024;
function readSavedFileText(file) {
    return new Promise((resolve, reject) => {
        const reader = new FileReader();
        reader.onload = () => resolve(String(reader.result));
        reader.onerror = () => reject(reader.error);
        reader.readAsText(file);
    });
}
function createSavedFileActions(ctx) {
    async function importFile(file) {
        const counts = { imported: 0, skipped: 0 };
        if (ctx.pending.has('import'))
            return;
        if (file.size > SAVED_IMPORT_MAX_BYTES) {
            ctx.setError(SAVED_NOT_A_FILE);
            ctx.render();
            return;
        }
        ctx.pending.add('import');
        ctx.render();
        try {
            const parsed = JSON.parse(await readSavedFileText(file));
            const ok = await ctx.write('import-merge', null, (d) => {
                const merged = mergeImport(d, parsed);
                if (!merged)
                    throw new Error(SAVED_NOT_A_FILE);
                counts.imported = merged.imported;
                counts.skipped = merged.skipped;
                return merged.data;
            });
            if (ok)
                ctx.setStatus(`Imported ${counts.imported}, skipped ${counts.skipped} already saved`);
        }
        catch (error) {
            ctx.setError(error instanceof SyntaxError
                ? SAVED_NOT_A_FILE
                : savedErrorMessage(error));
        }
        finally {
            ctx.pending.delete('import');
        }
        ctx.render();
    }
    async function exportFile() {
        if (!ctx.store)
            return;
        try {
            const raw = await ctx.store.readRaw();
            const text = JSON.stringify(raw ?? [], null, 2);
            const url = URL.createObjectURL(new Blob([text], { type: 'application/json' }));
            const a = document.createElement('a');
            a.href = url;
            a.download = SAVED_EXPORT_FILE;
            document.body.appendChild(a);
            a.click();
            setTimeout(() => { document.body.removeChild(a); URL.revokeObjectURL(url); }, 100);
            ctx.setError('');
        }
        catch (error) {
            ctx.setError(savedErrorMessage(error));
        }
        ctx.render();
    }
    return { importFile, exportFile };
}

"use strict";
/* exported openSavedSearchDialog */
/**
 * Saved searches dialog: lists this site's saved searches, adds or removes
 * their tags in the search bar, and saves, edits, moves and deletes them
 * through the saved-search store. Stored names and queries reach the DOM only
 * through `textContent` and `value`.
 *
 * On a site with an account backend (Danbooru, e621, Gelbooru) and a logged-in user,
 * the account's own searches are listed too. Every write goes to the account
 * first and to script storage only once the account call succeeded, and
 * script storage keeps the query the account returned. Names, groups and
 * order live in script storage only.
 *
 * The list is rebuilt on every change. Dialog state (pending writes, edit
 * drafts, the typed new group) lives outside the DOM so a rebuild keeps it,
 * and focus moves to the rebuilt control with the same focus key.
 */
const SAVED_PAGE_SIZE = 25;
const SAVED_UNDO_MS = 5000;
/** Group used by Save current when no group is chosen or typed. */
const SAVED_DEFAULT_GROUP = 'Saved';
const savedSearchKey = (name) => `s:${name.toLowerCase()}`;
const savedGroupKey = (name) => `g:${name.toLowerCase()}`;
/** An account-only row is keyed by its account id, so a script search of the same name is a different row. */
const savedEntryKey = (entry) => (entry.kind === 'account-only' && entry.account
    ? `a:${entry.account.id}`
    : savedSearchKey(entry.name));
const SAVED_NAME_USED = 'Name already used';
function openSavedSearchDialog(deps) {
    injectSavedSearchStyles();
    const { site, siteConfig, store, account, accountCache } = deps;
    let data = null;
    /** The account's searches; null without a backend, when logged out, or before the list loads. */
    let accountItems = null;
    /** `queryKey` of each account search, computed once per item. */
    const accountKeys = new WeakMap();
    let loading = false;
    let loadError = null;
    /** Stored data failed validation; writes are blocked, Export still works. */
    let unreadable = false;
    let editing = null;
    /** An error shown under one row or group heading, by its key. */
    let rowError = null;
    /** An error from Save current or Undo, shown under the save row. */
    let generalError = '';
    /** The result of the last import, shown beside Undo. */
    let statusText = '';
    /**
     * The last delete; kept until its restore succeeds or Undo expires.
     * `recreate` is the account search to create again, cleared once created
     * so a retry after a failed store write does not create it twice;
     * `created` is what the account returned, whose query the store keeps.
     */
    let undo = null;
    /** Keys of writes in flight; controls for those keys are disabled. */
    const pending = new Set();
    const shown = new Map();
    /** Focus key to return to once its control is enabled again. */
    let focusWanted;
    // --- Fixed parts ---
    const root = savedElement('div', 'bse-saved');
    const source = savedElement('div', 'bse-saved-source');
    const findInput = savedElement('input', 'bse-saved-find');
    findInput.type = 'text';
    findInput.placeholder = 'Find a saved search';
    const list = savedElement('div', 'bse-saved-list');
    const status = savedElement('div', 'bse-saved-status');
    status.setAttribute('role', 'status');
    const saveRow = savedElement('div', 'bse-saved-save-row');
    const nameInput = savedElement('input', 'bse-saved-name-input');
    nameInput.type = 'text';
    nameInput.placeholder = 'Name for the current search';
    const pickerSlot = savedElement('span', 'bse-saved-picker-slot');
    const saveButton = savedButton('Save current', 'bse-saved-save');
    saveRow.append(nameInput, pickerSlot, saveButton);
    const saveError = savedElement('div', 'bse-saved-save-error');
    const fileInput = savedElement('input', 'bse-saved-file');
    fileInput.type = 'file';
    fileInput.accept = '.json,application/json';
    fileInput.hidden = true;
    root.append(source, findInput, list, status, saveRow, saveError, fileInput);
    let savePicker = null;
    let savePickerKey = '';
    const importButton = savedButton('Import', 'bse-saved-import', 'import');
    const exportButton = savedButton('Export', 'bse-saved-export', 'export');
    const closeButton = savedButton('Close', 'bse-saved-close');
    const { modal, closeModal } = deps.createModal('modal-saved-searches', 'Saved searches', root, [importButton, exportButton, closeButton]);
    closeButton.onclick = closeModal;
    // --- Writes ---
    /**
     * Runs one write under `key`: the account call `remote` first, then the
     * store update `change`, which gets what `remote` returned (either step
     * may be absent). Before `remote`, `change` runs once on the current data
     * as a dry run, so a store change that would be refused (a used or empty
     * name) leaves the account alone. When `remote` fails the store is not
     * written. A failure shows under the row `errorAt`, or under the save row
     * when `errorAt` is null; a success clears every shown error.
     * `accountDone` says whether the account call succeeded.
     */
    async function writeSteps(key, errorAt, change, remote) {
        if (!store || pending.has(key))
            return { ok: false, accountDone: false };
        pending.add(key);
        render();
        let accountDone = false;
        let accountCalled = false;
        try {
            let result;
            if (remote) {
                if (change && data)
                    change(data, undefined);
                accountCalled = true;
                result = await remote();
                accountDone = true;
            }
            if (change)
                data = await store.update((d) => change(d, result));
            rowError = null;
            generalError = '';
            statusText = '';
            return { ok: true, accountDone };
        }
        catch (error) {
            // The account may have changed anyway; the next open lists it again.
            if (accountCalled && !accountDone)
                accountCache.items = null;
            const message = savedErrorMessage(error);
            if (errorAt === null)
                generalError = message;
            else
                rowError = { key: errorAt, message };
            return { ok: false, accountDone };
        }
        finally {
            pending.delete(key);
            render();
        }
    }
    async function write(key, errorAt, change, remote) {
        return (await writeSteps(key, errorAt, change, remote)).ok;
    }
    // --- Account list ---
    function accountKeyOf(item) {
        let key = accountKeys.get(item);
        if (key === undefined) {
            key = queryKey(item.query);
            accountKeys.set(item, key);
        }
        return key;
    }
    /** The account search whose tags match `query`, if the account is in use. */
    function accountItemFor(query) {
        const key = queryKey(query);
        return accountItems?.find((item) => accountKeyOf(item) === key);
    }
    /** True when a script search other than `name` has a query for this site matching `item`. */
    function sharedByAnother(item, name) {
        const key = accountKeyOf(item);
        return (data ?? []).some(([, searches]) => searches.some(([n, queries]) => !sameName(n, name)
            && Object.prototype.hasOwnProperty.call(queries, site)
            && queryKey(queries[site]) === key));
    }
    /**
     * Applies a successful account call to the account list. The page's
     * cache is changed and read back, so a write that finishes after another
     * dialog changed the cache keeps that change.
     */
    function changeAccountList(change) {
        if (!accountItems)
            return;
        const cached = accountCache.items;
        if (Array.isArray(cached)) {
            accountCache.items = change(cached);
            accountItems = accountCache.items;
        }
        else {
            accountItems = change(accountItems);
        }
    }
    function rememberAccount(item, replaces) {
        changeAccountList((items) => (replaces
            ? items.map((x) => (x.id === replaces.id ? item : x))
            : [...items, item]));
    }
    function forgetAccount(item) {
        changeAccountList((items) => items.filter((x) => x.id !== item.id));
    }
    /** The group sent to the account: none for the backend's own fallback group. */
    function accountGroupFor(group) {
        return account && sameName(group, account.accountGroup) ? '' : group;
    }
    /** Refuses to give an account search a name that a script search already has. */
    function requireFreeName(d, name) {
        if (findSearch(d, name.trim()))
            throw new Error(SAVED_NAME_USED);
    }
    function setUndo(removed, recreate) {
        if (undo?.timer)
            clearTimeout(undo.timer);
        const timer = setTimeout(() => {
            if (undo?.timer === timer) {
                undo = null;
                render();
            }
        }, SAVED_UNDO_MS);
        undo = { removed, recreate, created: null, timer };
        render();
    }
    async function deleteEntry(entry) {
        const key = savedEntryKey(entry);
        const linked = account ? entry.account : undefined;
        // Another script search still uses this account search: leave it on the account.
        const item = linked && !sharedByAnother(linked, entry.name) ? linked : undefined;
        let removed = null;
        const change = entry.kind === 'account-only' ? null : (d) => {
            const result = removeQuery(d, entry.name, site);
            removed = result.removed;
            return result.data;
        };
        const remote = item && account
            ? async () => {
                await account.remove(item);
                forgetAccount(item);
            }
            : undefined;
        const { ok, accountDone } = await writeSteps(key, key, change, remote);
        const recreate = item
            ? { query: item.query, group: item.group ?? '', labels: item.labels, name: item.name, tracked: item.tracked }
            : null;
        if (ok && (removed || recreate))
            setUndo(removed, recreate);
        // The account search is gone though the store kept its query: Undo can still bring it back.
        else if (!ok && accountDone && recreate)
            setUndo(null, recreate);
        return ok;
    }
    async function undoDelete() {
        if (!undo)
            return;
        const current = undo;
        // A failed restore keeps the record without expiry so Undo can be retried.
        if (current.timer)
            clearTimeout(current.timer);
        current.timer = null;
        const { recreate, removed } = current;
        const remote = recreate && account
            ? async () => {
                const item = await account.create(recreate.query, recreate.group, recreate);
                rememberAccount(item);
                current.recreate = null;
                current.created = item;
            }
            : undefined;
        const change = removed
            ? (d) => restoreQuery(d, current.created ? { ...removed, query: current.created.query } : removed)
            : null;
        const ok = await write('undo', null, change, remote);
        if (ok && undo === current) {
            undo = null;
            render();
        }
    }
    async function saveCurrent() {
        if (!store || !data || !savePicker)
            return;
        const name = nameInput.value;
        const found = findSearch(data, name.trim());
        const group = savedPickerValue(savePicker.state()) || SAVED_DEFAULT_GROUP;
        const query = deps.currentQuery();
        let remote;
        if (account && accountItems && !accountItemFor(query)) {
            // Replace updates the account search the old query matched, unless
            // another script search uses it too; otherwise create one.
            const oldQuery = found && Object.prototype.hasOwnProperty.call(found.search[1], site) ? found.search[1][site] : undefined;
            const matched = oldQuery === undefined ? undefined : accountItemFor(oldQuery);
            const old = matched && !sharedByAnother(matched, name.trim()) ? matched : undefined;
            const accountGroup = accountGroupFor(found?.group ?? group);
            remote = async () => {
                const item = old
                    ? await account.update(old, query)
                    : await account.create(query, accountGroup);
                rememberAccount(item, old);
                return item;
            };
        }
        const change = (d, item) => saveQuery(d, { name, group, site, query: item?.query ?? query });
        if (await write('save', null, change, remote)) {
            nameInput.value = '';
            render();
        }
    }
    // --- Import and export ---
    const { importFile, exportFile } = createSavedFileActions({
        store,
        pending,
        write,
        render,
        setError: (message) => { generalError = message; },
        setStatus: (text) => { statusText = text; },
    });
    // --- Search bar ---
    function convertedQuery(entry) {
        if (entry.kind !== 'unset')
            return entry.query;
        return convertQuery(entry.query, { site: siteConfig.site, sortPrefix: siteConfig.sortPrefix });
    }
    function applyEntry(entry) {
        deps.applyQuery(convertedQuery(entry));
        if (entry.kind === 'unset') {
            nameInput.value = entry.name;
            onNameInput();
        }
        render();
    }
    function toggleEntry(entry) {
        const query = convertedQuery(entry);
        if (isQueryApplied(query, deps.getTags())) {
            deps.removeQuery(query);
            render();
        }
        else {
            applyEntry(entry);
        }
    }
    // --- Save row ---
    function updateSaveLabel() {
        const found = data ? findSearch(data, nameInput.value.trim()) : undefined;
        const replaces = found !== undefined && Object.prototype.hasOwnProperty.call(found.search[1], site);
        saveButton.textContent = replaces ? 'Replace' : 'Save current';
    }
    function onNameInput() {
        const found = data ? findSearch(data, nameInput.value.trim()) : undefined;
        if (found && savePicker)
            savePicker.choose(found.group);
        updateSaveLabel();
    }
    function refreshSaveRow(view) {
        const groups = view.map((g) => g.name);
        const key = JSON.stringify(groups);
        if (!savePicker || key !== savePickerKey) {
            // A typed new group survives the rebuild; a blank one does not.
            const previous = savePicker?.state();
            const carried = previous && 'newGroup' in previous && previous.newGroup.trim() === '' ? undefined : previous;
            savePicker = createSavedGroupPicker(groups, carried, 'save-group');
            savePickerKey = key;
            pickerSlot.replaceChildren(savePicker.el);
        }
        const usable = store !== null && data !== null && !loading && loadError === null && !pending.has('save');
        saveButton.disabled = !usable || deps.getTags().length === 0;
        nameInput.disabled = !usable;
        savePicker.setDisabled(!usable);
        saveError.textContent = generalError;
        importButton.disabled = !usable || pending.has('import');
        exportButton.disabled = store === null;
        updateSaveLabel();
    }
    // --- List ---
    function rowErrorFor(key) {
        return rowError?.key === key ? savedElement('div', 'bse-saved-row-error', rowError.message) : null;
    }
    function handleEditKeys(event, save) {
        if (event.key === 'Enter') {
            event.preventDefault();
            save();
        }
        else if (event.key === 'Escape') {
            // Cancels the edit; the dialog stays open.
            event.preventDefault();
            event.stopPropagation();
            editing = null;
            rowError = null;
            render();
        }
    }
    function startEdit(next) {
        editing = next;
        rowError = null;
        render();
        const field = list.querySelector(next.kind === 'search' ? '.bse-saved-edit-name' : '.bse-saved-group-rename');
        field?.focus();
    }
    function finishEdit(ok) {
        if (!ok)
            return;
        editing = null;
        render();
    }
    function saveSearchEdit(entry, draft) {
        const key = savedEntryKey(entry);
        const query = queryTags(draft.query).join(' ');
        if (query === '') {
            void deleteEntry(entry).then(finishEdit);
            return;
        }
        const group = savedPickerValue(draft.group);
        const regrouped = !sameName(group.trim(), entry.group);
        const item = account ? entry.account : undefined;
        const queryChanged = queryKey(query) !== queryKey(entry.query);
        let remote;
        // Another script search still uses this account search. A new query gets
        // its own account search; a group change alone stays in script storage,
        // and the shared search keeps its label.
        const shared = item !== undefined && sharedByAnother(item, entry.name);
        if (item && account && (queryChanged || (regrouped && account.labelled && !shared))) {
            remote = async () => {
                if (shared) {
                    const created = await account.create(query, accountGroupFor(group));
                    rememberAccount(created);
                    return created;
                }
                // A group change replaces the first Danbooru label; otherwise labels stay.
                const updated = await account.update(item, query, regrouped ? accountGroupFor(group) : undefined);
                rememberAccount(updated, item);
                return updated;
            };
        }
        const stored = (result) => result?.query ?? query;
        let change;
        if (entry.kind !== 'account-only') {
            change = (d, result) => editSearch(d, entry.name, { name: draft.name, group, site, query: stored(result) });
        }
        else if (regrouped || draft.name.trim() !== entry.name) {
            // Naming or regrouping an account search keeps it in script storage.
            change = (d, result) => {
                requireFreeName(d, draft.name);
                return saveQuery(d, { name: draft.name, group, site, query: stored(result) });
            };
        }
        else {
            change = null;
        }
        void write(key, key, change, remote).then(finishEdit);
    }
    function renderEditRow(entry, draft, groups) {
        const key = savedEntryKey(entry);
        const busy = pending.has(key);
        const row = savedElement('div', 'bse-saved-edit-row');
        const nameField = savedElement('input', 'bse-saved-edit-name');
        nameField.type = 'text';
        nameField.value = draft.name;
        nameField.dataset.focusKey = `edit-name:${key}`;
        nameField.addEventListener('input', () => { draft.name = nameField.value; });
        const queryField = savedElement('input', 'bse-saved-edit-query');
        queryField.type = 'text';
        queryField.value = draft.query;
        queryField.dataset.focusKey = `edit-query:${key}`;
        queryField.addEventListener('input', () => { draft.query = queryField.value; });
        const picker = createSavedGroupPicker(groups, draft.group, `edit-group:${key}`, (state) => { draft.group = state; });
        nameField.disabled = busy;
        queryField.disabled = busy;
        picker.setDisabled(busy);
        row.append(nameField, queryField, picker.el);
        row.addEventListener('keydown', (event) => handleEditKeys(event, () => {
            draft.name = nameField.value;
            draft.query = queryField.value;
            draft.group = picker.state();
            saveSearchEdit(entry, draft);
        }));
        return row;
    }
    /** Move up and Move down for one entry; `viewIndex` is its place in its whole group. */
    function entryMoves(entry, searches, viewIndex) {
        const key = savedEntryKey(entry);
        if (entry.kind === 'account-only') {
            // Moving an account search keeps it in script storage, which lists it
            // after the group's script searches; account order cannot change, so
            // it only moves up.
            const promote = (d) => {
                requireFreeName(d, entry.name);
                const saved = saveQuery(d, { name: entry.name, group: entry.group, site, query: entry.query });
                return viewIndex === searches.length ? moveSearch(saved, entry.name, -1) : saved;
            };
            return { up: viewIndex > 0 ? () => void write(key, key, promote) : null, down: null };
        }
        const index = searches.findIndex(([n]) => sameName(n, entry.name));
        const move = (delta) => () => void write(key, key, (d) => moveSearch(d, entry.name, delta));
        return {
            up: index > 0 ? move(-1) : null,
            down: index >= 0 && index < searches.length - 1 ? move(1) : null,
        };
    }
    function renderEntry(entry, searches, viewIndex) {
        const key = savedEntryKey(entry);
        const busy = pending.has(key);
        const row = savedElement('div', 'bse-saved-entry');
        const applied = isQueryApplied(convertedQuery(entry), deps.getTags());
        if (applied)
            row.classList.add('applied');
        if (entry.kind === 'unset')
            row.classList.add('unset');
        if (entry.kind === 'not-on-account')
            row.classList.add('missing');
        row.tabIndex = 0;
        row.setAttribute('role', 'button');
        row.dataset.focusKey = `row:${key}`;
        row.append(savedElement('span', 'bse-saved-check', applied ? '✓' : ''), savedElement('span', 'bse-saved-name', entry.name), savedElement('span', 'bse-saved-query', entry.kind === 'unset'
            ? `Not set for this site. Saved on ${entry.otherSite ?? ''}`
            : entry.kind === 'not-on-account' ? 'Not on your account' : entry.query));
        row.addEventListener('click', () => toggleEntry(entry));
        row.addEventListener('keydown', (event) => {
            if (event.target !== row || (event.key !== 'Enter' && event.key !== ' '))
                return;
            event.preventDefault();
            toggleEntry(entry);
        });
        if (entry.kind !== 'unset') {
            const { up, down } = entryMoves(entry, searches, viewIndex);
            const icons = savedElement('span', 'bse-saved-icons');
            icons.append(savedIconButton('✎', 'Edit', key, busy, () => startEdit({
                kind: 'search',
                key,
                draft: { name: entry.name, query: entry.query, group: { group: entry.group } },
            })), savedIconButton('▲', 'Move up', key, busy || !up, () => up?.()), savedIconButton('▼', 'Move down', key, busy || !down, () => down?.()), savedIconButton('🗑', 'Delete', key, busy, () => void deleteEntry(entry)));
            row.appendChild(icons);
        }
        return row;
    }
    function renderGroupHead(groupName, groupIndex, groupCount, empty) {
        const head = savedElement('div', 'bse-saved-group-head');
        const key = savedGroupKey(groupName);
        const busy = pending.has(key);
        if (editing?.kind === 'group' && editing.name === groupName) {
            const current = editing;
            const field = savedElement('input', 'bse-saved-group-rename');
            field.type = 'text';
            field.value = current.draft;
            field.disabled = busy;
            field.dataset.focusKey = `rename:${key}`;
            field.addEventListener('input', () => { current.draft = field.value; });
            field.addEventListener('keydown', (event) => handleEditKeys(event, () => {
                current.draft = field.value;
                void write(key, key, (d) => renameGroup(d, groupName, current.draft)).then(finishEdit);
            }));
            head.appendChild(field);
            return head;
        }
        head.appendChild(savedElement('span', 'bse-saved-group-name', groupName));
        // A group made only of account searches has nothing in script storage to rename or move.
        if (groupIndex < 0)
            return head;
        const move = (delta) => () => void write(key, key, (d) => moveGroup(d, groupName, delta));
        const icons = savedElement('span', 'bse-saved-icons');
        icons.append(savedIconButton('✎', 'Rename group', key, busy, () => startEdit({ kind: 'group', name: groupName, draft: groupName })), savedIconButton('▲', 'Move up', key, busy || groupIndex <= 0, move(-1)), savedIconButton('▼', 'Move down', key, busy || groupIndex >= groupCount - 1, move(1)));
        if (empty) {
            icons.appendChild(savedIconButton('🗑', 'Delete', key, busy, () => void write(key, key, (d) => deleteGroup(d, groupName))));
        }
        head.appendChild(icons);
        return head;
    }
    /** Every group in order: script groups (empty ones too), then groups made only of account searches. */
    function viewGroups(all) {
        const view = buildSavedSearchView(all, site, accountItems, account?.accountGroup ?? '');
        const groups = all.map(([name]) => ({ name, entries: view.find((g) => sameName(g.name, name))?.entries ?? [] }));
        for (const g of view) {
            if (!all.some(([name]) => sameName(name, g.name)))
                groups.push(g);
        }
        return groups;
    }
    /** The groups and entries the find text matches; groups without a match are left out. */
    function visibleGroups(all, groups) {
        const text = findInput.value.trim().toLowerCase();
        if (text === '')
            return groups;
        const names = new Set(filterSavedData(all, text).flatMap(([, searches]) => searches.map(([n]) => n.toLowerCase())));
        return groups
            .map((g) => ({
            name: g.name,
            entries: g.entries.filter((e) => (e.kind === 'account-only'
                ? e.query.toLowerCase().includes(text)
                : names.has(e.name.toLowerCase()))),
        }))
            .filter((g) => g.entries.length > 0);
    }
    function renderList(all, groups) {
        const text = findInput.value.trim();
        const visible = visibleGroups(all, groups);
        if (visible.length === 0) {
            list.appendChild(savedElement('div', 'bse-saved-empty', text === '' ? 'No saved searches' : `No saved search matches "${text}"`));
            return;
        }
        const groupNames = groups.map((g) => g.name);
        for (const { name: groupName, entries } of visible) {
            const groupIndex = all.findIndex(([g]) => sameName(g, groupName));
            const searches = groupIndex < 0 ? [] : all[groupIndex][1];
            const whole = groups.find((g) => sameName(g.name, groupName))?.entries ?? [];
            const group = savedElement('div', 'bse-saved-group');
            // Empty means no script searches; account searches shown in it stay on the account.
            group.appendChild(renderGroupHead(groupName, groupIndex, all.length, groupIndex >= 0 && searches.length === 0));
            const groupError = rowErrorFor(savedGroupKey(groupName));
            if (groupError)
                group.appendChild(groupError);
            const limit = shown.get(groupName.toLowerCase()) ?? SAVED_PAGE_SIZE;
            for (const entry of entries.slice(0, limit)) {
                const draft = editing?.kind === 'search' && editing.key === savedEntryKey(entry) ? editing.draft : null;
                group.appendChild(draft ? renderEditRow(entry, draft, groupNames) : renderEntry(entry, searches, whole.indexOf(entry)));
                const error = rowErrorFor(savedEntryKey(entry));
                if (error)
                    group.appendChild(error);
            }
            if (entries.length > limit) {
                const more = savedButton(`Show ${SAVED_PAGE_SIZE} more (of ${entries.length})`, 'bse-saved-more', `more:${savedGroupKey(groupName)}`);
                more.onclick = () => {
                    shown.set(groupName.toLowerCase(), limit + SAVED_PAGE_SIZE);
                    render();
                };
                group.appendChild(more);
            }
            list.appendChild(group);
        }
    }
    /** Focuses the enabled control with `key`, else the dialog; returns whether `key` got focus. */
    function restoreFocus(key) {
        const target = key === undefined ? undefined
            : [...modal.querySelectorAll('[data-focus-key]')].find((el) => el.dataset.focusKey === key);
        if (target && !target.disabled) {
            target.focus();
            if (document.activeElement === target)
                return true;
        }
        modal.focus();
        return false;
    }
    function render() {
        const active = document.activeElement;
        const hadFocus = active !== null && modal.contains(active);
        const activeKey = active && modal.contains(active) ? active.dataset.focusKey : undefined;
        const focusKey = activeKey ?? (active === modal ? focusWanted : undefined);
        list.replaceChildren();
        status.replaceChildren();
        const view = data && !loading ? viewGroups(data) : [];
        if (loading) {
            list.appendChild(savedElement('div', 'bse-saved-loading', 'Loading'));
        }
        else if (unreadable) {
            list.appendChild(savedElement('div', 'bse-saved-error-text', SAVED_UNREADABLE_MESSAGE));
        }
        else if (loadError !== null) {
            const error = savedElement('div', 'bse-saved-error');
            error.appendChild(savedElement('div', 'bse-saved-error-text', `Could not load saved searches: ${loadError}`));
            if (store) {
                const retry = savedButton('Try again', 'bse-saved-more', 'retry');
                retry.onclick = () => void load(true);
                error.appendChild(retry);
            }
            list.appendChild(error);
        }
        else if (data) {
            renderList(data, view);
        }
        if (statusText)
            status.appendChild(savedElement('span', 'bse-saved-status-text', statusText));
        if (undo) {
            const undoButton = savedButton('Undo', 'bse-saved-undo', 'undo');
            undoButton.disabled = pending.has('undo');
            undoButton.onclick = () => void undoDelete();
            status.appendChild(undoButton);
        }
        refreshSaveRow(view);
        source.textContent = account && accountItems
            ? `${site}, from your ${account.label} account`
            : `${site}, stored in this browser`;
        // Loading and a load error show no save row; unreadable data keeps it, disabled.
        saveRow.hidden = loading || (loadError !== null && !unreadable);
        // A rebuild removes the focused control; without this, focus falls to
        // the page body and Esc no longer reaches the dialog.
        const lost = active === modal || !active?.isConnected || active.disabled === true;
        if (hadFocus && lost && focusKey !== undefined) {
            focusWanted = restoreFocus(focusKey) ? undefined : focusKey;
        }
        else if (hadFocus && lost) {
            modal.focus();
        }
    }
    /** Reads the store, and the account list unless the page has it already or `fresh` is set. */
    async function load(fresh = false) {
        if (!store) {
            loadError = 'GM is not defined';
            render();
            return;
        }
        loading = true;
        loadError = null;
        unreadable = false;
        render();
        try {
            data = await store.read();
            accountItems = null;
            if (account) {
                if (fresh || accountCache.items === null) {
                    // Cleared first, so a failed list is read again next time.
                    accountCache.items = null;
                    accountCache.items = await account.list();
                }
                const listed = accountCache.items;
                accountItems = listed === 'logged-out' ? null : listed;
            }
        }
        catch (error) {
            loadError = savedErrorMessage(error);
            unreadable = error?.code === 'unreadable';
        }
        finally {
            loading = false;
            render();
        }
    }
    // --- Events ---
    findInput.addEventListener('input', render);
    findInput.addEventListener('keydown', (event) => {
        if (event.key !== 'Enter' || !data)
            return;
        event.preventDefault();
        const entries = visibleGroups(data, viewGroups(data)).flatMap((g) => g.entries);
        if (entries.length === 1)
            applyEntry(entries[0]);
    });
    nameInput.addEventListener('input', onNameInput);
    nameInput.addEventListener('keydown', (event) => {
        if (event.key !== 'Enter')
            return;
        event.preventDefault();
        if (!saveButton.disabled)
            void saveCurrent();
    });
    saveButton.onclick = () => void saveCurrent();
    importButton.onclick = () => fileInput.click();
    exportButton.onclick = () => void exportFile();
    fileInput.addEventListener('change', () => {
        const file = fileInput.files?.[0];
        fileInput.value = '';
        if (!file)
            return;
        statusText = '';
        generalError = '';
        void importFile(file);
    });
    modal.style.display = 'flex';
    findInput.focus();
    void load();
}

"use strict";
/* exported SITE_CONFIG */
// --- Configuration ---
const GELBOORU_CHEATSHEET = `
    <section><h4>Basic Search</h4>
        <ul>
            <li><code>tag1 tag2</code> — Posts with <b>tag1</b> and <b>tag2</b></li>
            <li><code>( tag1 ~ tag2 )</code> — Posts with <b>tag1</b> or <b>tag2</b></li>
            <li><code>-tag1</code> — Posts without <b>tag1</b></li>
            <li><code>ta*1</code> — Tags starting with <b>ta</b> and ending with <b>1</b></li>
        </ul>
    </section>
    <section><h4>Metatags</h4>
        <ul>
            <li><code>user:bob</code> — Posts by user <b>bob</b></li>
            <li><code>rating:questionable</code> — Rated <b>questionable</b></li>
            <li><code>score:>=10</code> — Score 10 or higher</li>
            <li><code>width:>=1000</code> — Width 1000px or more</li>
            <li><code>height:>1000</code> — Height greater than 1000px</li>
            <li><code>parent:1234</code> — Has parent <b>1234</b></li>
            <li><code>md5:foo</code> — Posts with MD5 hash <b>foo</b></li>
        </ul>
    </section>
    <section><h4>Sorting</h4>
        <ul>
            <li><code>sort:updated:desc</code> — Sort by updated (descending)</li>
            <li><code>sort:score:asc</code> — Sort by score (ascending)</li>
            <li>Other types: <code>id</code>, <code>rating</code>, <code>user</code>, <code>height</code>, <code>width</code>, <code>parent</code>, <code>source</code></li>
        </ul>
    </section>`;
const GELBOORU_SORT_OPTIONS = [
    { value: 'score', label: 'Score' },
    { value: 'updated', label: 'Updated' },
    { value: 'id', label: 'ID' },
    { value: 'rating', label: 'Rating' },
    { value: 'user', label: 'User' },
    { value: 'height', label: 'Height' },
    { value: 'width', label: 'Width' },
    { value: 'parent', label: 'Parent' },
    { value: 'source', label: 'Source' }
];
const GELBOORU_RATINGS = [
    { value: 'safe', label: 'Safe' },
    { value: 'questionable', label: 'Questionable' },
    { value: 'explicit', label: 'Explicit' }
];
const GELBOORU_METATAG_REGEX = /^(sort:|rating:|user:|parent:|score:|md5:|width:|height:|source:|\( rating:)/;
// Danbooru and sites running its software, such as ATFBooru, share one setup.
function danbooruSite(origin, accountName) {
    return {
        site: 'danbooru',
        autocomplete: 'danbooru',
        accountName,
        sortPrefix: 'order',
        inputTag: 'input',
        selectors: {
            form: 'form[action*="posts"], form.search-form, #search-form, form:has(input[name="tags"])',
            gallery: '#posts, .posts, #post-list, .post-list',
            skipPattern: /^\/posts\/\d+/
        },
        cheatSheetUrl: origin + '/wiki_pages/help%3Acheatsheet',
        cheatSheetSelector: '#c-wiki-pages #a-show .prose, .wiki-page-body, .dtext-container',
        placeholder: "Enter tags...",
        sortOptions: [
            { value: 'score', label: 'Score' },
            { value: 'favcount', label: 'Favorites' },
            { value: 'comment_count', label: 'Comments' },
            { value: 'id', label: 'ID' },
            { value: 'mpixels', label: 'Megapixels' },
            { value: 'filesize', label: 'File Size' },
            { value: 'landscape', label: 'Landscape' },
            { value: 'portrait', label: 'Portrait' },
            { value: 'created_at', label: 'Created' },
            { value: 'updated_at', label: 'Updated' },
            { value: 'tagcount', label: 'Tag Count' },
            { value: 'duration', label: 'Duration' },
            { value: 'random', label: 'Random' }
        ],
        ratings: [
            { value: 'general', label: 'General' },
            { value: 'sensitive', label: 'Sensitive' },
            { value: 'questionable', label: 'Questionable' },
            { value: 'explicit', label: 'Explicit' }
        ],
        metatagRegex: /^(order:|rating:|user:|parent:|score:|md5:|width:|height:|source:|id:|favcount:|comment_count:|type:|date:|status:|pool:|set:|\( rating:)/,
    };
}
const SITE_CONFIG = {
    "e621.net": {
        site: 'e621',
        autocomplete: 'e621',
        sortPrefix: 'order',
        inputTag: 'textarea',
        selectors: { form: 'form.post-search-form', gallery: '#c-posts' },
        cheatSheetUrl: 'https://e621.net/help/cheatsheet',
        cheatSheetSelector: '#c-help #a-show .styled-dtext',
        placeholder: "Enter tags...",
        sortOptions: [
            { value: 'score', label: 'Score' },
            { value: 'favcount', label: 'Favorites' },
            { value: 'comment_count', label: 'Comments' },
            { value: 'id', label: 'ID' },
            { value: 'mpixels', label: 'Megapixels' },
            { value: 'filesize', label: 'File Size' },
            { value: 'landscape', label: 'Landscape' },
            { value: 'portrait', label: 'Portrait' },
            { value: 'created', label: 'Created' },
            { value: 'updated', label: 'Updated' },
            { value: 'tagcount', label: 'Tag Count' },
            { value: 'duration', label: 'Duration' },
            { value: 'random', label: 'Random' }
        ],
        ratings: GELBOORU_RATINGS,
        metatagRegex: /^(order:|rating:|user:|parent:|score:|md5:|width:|height:|source:|id:|favcount:|comment_count:|type:|date:|status:|\( rating:)/,
        cheatSheetContent: `
            <section><h4>Basics</h4>
                <ul>
                    <li><code>cat dog</code> — Search for posts that are tagged with both cat and dog. Tags are separated by spaces.</li>
                    <li><code>red_panda african_wild_dog</code> — Words within each tag are separated by underscores.</li>
                    <li><code>~cat ~dog</code> — Search for posts that are tagged either cat or dog (or both). May not work well when combined with other syntaxes.</li>
                    <li><code>-chicken</code> — Search for posts that don't have the chicken tag.</li>
                    <li><code>fox -chicken</code> — Search for posts that are tagged with fox and are not tagged with chicken.</li>
                    <li><code>african_*</code> — Search for posts with any tag that starts with african_, such as african_wild_dog or african_golden_cat. May not work well when combined with other syntaxes. Limit one wildcard per search.</li>
                    <li><code>( ~cat ~tiger ~leopard ) ( ~dog ~wolf )</code> — Search for posts that are tagged with one (or more) of cat, tiger or leopard, and one (or more) of dog or wolf.</li>
                </ul>
            </section>
            <section><h4>Sorting</h4>
                <ul>
                    <li><code>order:id</code> — Oldest to newest</li>
                    <li><code>order:id_desc</code> — Newest to oldest</li>
                    <li><code>order:score</code> — Highest score first</li>
                    <li><code>order:score_asc</code> — Lowest score first</li>
                    <li><code>order:favcount</code> — Most favorites first</li>
                    <li><code>order:favcount_asc</code> — Least favorites first</li>
                    <li><code>order:comment_count</code> — Most comments first</li>
                    <li><code>order:comment_count_asc</code> — Least comments first</li>
                    <li><code>order:mpixels</code> — Largest resolution first</li>
                    <li><code>order:mpixels_asc</code> — Smallest resolution first</li>
                    <li><code>order:filesize</code> — Largest file size first</li>
                    <li><code>order:filesize_asc</code> — Smallest file size first</li>
                    <li><code>order:landscape</code> — Wide and short to tall and thin</li>
                    <li><code>order:portrait</code> — Tall and thin to wide and short</li>
                    <li><code>order:duration</code> — Video duration longest to shortest</li>
                    <li><code>order:duration_asc</code> — Video duration shortest to longest</li>
                    <li><code>order:random</code> — Orders posts randomly</li>
                </ul>
            </section>
            <section><h4>User Metatags</h4>
                <ul>
                    <li><code>user:Bob</code> — Posts uploaded by Bob</li>
                    <li><code>fav:Bob</code> or <code>favoritedby:Bob</code> — Posts favorited by Bob</li>
                    <li><code>voted:anything</code> — Posts you voted on. Only works while logged in.</li>
                    <li><code>votedup:anything</code> or <code>upvote:anything</code> — Posts you upvoted. Only works while logged in.</li>
                    <li><code>voteddown:anything</code> or <code>downvote:anything</code> — Posts you downvoted. Only works while logged in.</li>
                    <li><code>approver:Bob</code> — Posts approved by Bob</li>
                    <li><code>commenter:Bob</code> or <code>comm:Bob</code> — Posts commented on by Bob</li>
                    <li><code>noter:Bob</code> — Posts with notes written by Bob</li>
                </ul>
            </section>
            <section><h4>Post Metatags - Counts</h4>
                <ul>
                    <li><code>id:100</code> — Post with an ID of 100</li>
                    <li><code>score:100</code> — Posts with a score of 100</li>
                    <li><code>favcount:100</code> — Posts with exactly 100 favorites</li>
                    <li><code>comment_count:100</code> — Posts with exactly 100 comments</li>
                    <li><code>tagcount:2</code> — Posts with exactly 2 tags</li>
                    <li><code>gentags:2</code> — Posts with exactly 2 general tags</li>
                    <li><code>arttags:2</code> — Posts with exactly 2 artist tags</li>
                    <li><code>chartags:2</code> — Posts with exactly 2 character tags</li>
                    <li><code>copytags:2</code> — Posts with exactly 2 copyright tags</li>
                    <li><code>spectags:2</code> — Posts with exactly 2 species tags</li>
                    <li><code>invtags:2</code> — Posts with exactly 2 invalid tags</li>
                    <li><code>lortags:2</code> — Posts with exactly 2 lore tags</li>
                    <li><code>metatags:2</code> — Posts with exactly 2 meta tags</li>
                </ul>
            </section>
            <section><h4>Rating</h4>
                <ul>
                    <li><code>rating:safe</code> or <code>rating:s</code> — Posts rated safe</li>
                    <li><code>rating:questionable</code> or <code>rating:q</code> — Posts rated questionable</li>
                    <li><code>rating:explicit</code> or <code>rating:e</code> — Posts rated explicit</li>
                </ul>
            </section>
            <section><h4>File Types</h4>
                <ul>
                    <li><code>type:jpg</code> — Posts that are JPG, a type of image</li>
                    <li><code>type:png</code> — Posts that are PNG, a type of image</li>
                    <li><code>type:gif</code> — Posts that are GIF, a type of image (may be animated)</li>
                    <li><code>type:swf</code> — Posts that are Flash, a format used for animation</li>
                    <li><code>type:webm</code> — Posts that are WebM, a type of video</li>
                </ul>
            </section>
            <section><h4>Image Size</h4>
                <ul>
                    <li><code>width:100</code> — Posts with a width of 100 pixels</li>
                    <li><code>height:100</code> — Posts with a height of 100 pixels</li>
                    <li><code>mpixels:1</code> — Posts that are 1 megapixel (a 1000x1000 image equals 1 megapixel)</li>
                    <li><code>ratio:1.33</code> — Search for posts with a ratio of 4:3. All ratios are rounded to two digits, therefore 1.33 will return posts with a ratio of 4:3.</li>
                    <li><code>filesize:200KB</code> — Posts with a file size of 200 kilobytes. File sizes within ±5% of the value are included.</li>
                    <li><code>filesize:2MB</code> — Posts with a file size of 2 megabytes. File sizes within ±5% of the value are included.</li>
                </ul>
            </section>
            <section><h4>Post Status</h4>
                <ul>
                    <li><code>status:pending</code> — Posts that are waiting to be approved or deleted</li>
                    <li><code>status:active</code> — Posts that have been approved</li>
                    <li><code>status:deleted</code> — Posts that have been deleted</li>
                    <li><code>status:flagged</code> — Posts that are flagged for deletion</li>
                    <li><code>status:modqueue</code> — Posts that are pending or flagged</li>
                    <li><code>status:any</code> — All active or deleted posts</li>
                </ul>
            </section>
            <section><h4>Dates</h4>
                <ul>
                    <li><code>date:2012-04-27</code> or <code>date:april/27/2012</code> — Search for posts uploaded on a specific date</li>
                    <li><code>date:today</code> — Posts from today</li>
                    <li><code>date:yesterday</code> — Posts from yesterday</li>
                    <li><code>date:week</code> — Posts from the last 7 days</li>
                    <li><code>date:month</code> — Posts from the last 30 days</li>
                    <li><code>date:year</code> — Posts from the last 365 days</li>
                    <li><code>date:5_days_ago</code> — Posts from within the last 5 days</li>
                    <li><code>date:5_weeks_ago</code> — Posts from within the last 5 weeks</li>
                    <li><code>date:5_months_ago</code> — Posts from within the last 5 months</li>
                    <li><code>date:5_years_ago</code> — Posts from within the last 5 years</li>
                    <li><code>date:yesterweek</code> — Posts from last week</li>
                    <li><code>date:yestermonth</code> — Posts from last month</li>
                    <li><code>date:yesteryear</code> — Posts from last year</li>
                </ul>
            </section>
            <section><h4>Text Searching</h4>
                <ul>
                    <li><code>source:*example.com</code> — Posts with a source that contains "example.com", prefix matched, use wildcards as needed</li>
                    <li><code>source:none</code> — Posts without a source</li>
                    <li><code>description:whatever</code> — Posts with a description that contains the text "whatever"</li>
                    <li><code>description:"hello there"</code> — Posts with a description that contains the text "hello there"</li>
                    <li><code>note:whatever</code> — Posts with a note that contains the text "whatever"</li>
                    <li><code>delreason:*whatever</code> — Deleted posts that contain a reason with the text "whatever", prefix matched, use wildcards as needed</li>
                </ul>
            </section>
            <section><h4>Parents and Children</h4>
                <ul>
                    <li><code>ischild:true</code> — Posts that are a child</li>
                    <li><code>ischild:false</code> — Posts that aren't a child</li>
                    <li><code>isparent:true</code> — Posts that are a parent</li>
                    <li><code>isparent:false</code> — Posts that aren't a parent</li>
                    <li><code>parent:1234</code> — Posts with a parent of 1234</li>
                    <li><code>parent:none</code> — Posts with no parent (same as ischild:false)</li>
                </ul>
            </section>
            <section><h4>Other</h4>
                <ul>
                    <li><code>hassource:true</code> — Posts with a source</li>
                    <li><code>hassource:false</code> — Posts without a source</li>
                    <li><code>hasdescription:true</code> — Posts with a description</li>
                    <li><code>hasdescription:false</code> — Posts without a description</li>
                    <li><code>inpool:true</code> — Posts that are in a pool</li>
                    <li><code>inpool:false</code> — Posts that aren't in a pool</li>
                    <li><code>pool:4</code> or <code>pool:fox_and_the_grapes</code> — Posts in the pool "Fox and the Grapes"</li>
                    <li><code>set:17</code> or <code>set:cute_rabbits</code> — Posts in the set with the short name "cute_rabbits"</li>
                    <li><code>md5:02dd0...</code> — Post with the given MD5 hash. MD5 hashes will never be shared by more than one image.</li>
                    <li><code>duration:>120</code> — Videos with a duration of at least 120 seconds</li>
                </ul>
            </section>
            <section><h4>Range Syntax</h4>
                <ul>
                    <li><code>id:100</code> — Post with an ID of exactly 100</li>
                    <li><code>date:year..month</code> — Posts uploaded between 30 days ago and 1 year ago</li>
                    <li><code>filesize:200KB..300KB</code> — Posts with a file size between 200 kilobytes and 300 kilobytes</li>
                    <li><code>score:25..50</code> — Posts with a score between 25 and 50</li>
                    <li><code>score:>=100</code> — Posts with a score of 100 or greater (100+)</li>
                    <li><code>score:>100</code> — Posts with a score greater than 100 (101+)</li>
                    <li><code>favcount:<=100</code> — Posts with 100 or less favorites (0-100)</li>
                    <li><code>favcount:<100</code> — Posts with less than 100 favorites (0-99)</li>
                </ul>
            </section>
        `
    },
    "rule34.xxx": {
        site: 'rule34',
        autocomplete: 'rule34',
        sortPrefix: 'sort',
        inputTag: 'input',
        selectors: { form: '.sidebar .tag-search form', gallery: '#post-list' },
        cheatSheetUrl: 'https://rule34.xxx/index.php?page=help&topic=cheatsheet',
        cheatSheetSelector: '.content',
        placeholder: "Enter tags...",
        sortOptions: GELBOORU_SORT_OPTIONS,
        ratings: GELBOORU_RATINGS,
        metatagRegex: GELBOORU_METATAG_REGEX,
        cheatSheetContent: GELBOORU_CHEATSHEET
    },
    "gelbooru.com": {
        site: 'gelbooru',
        autocomplete: 'gelbooru',
        sortPrefix: 'sort',
        inputTag: 'input',
        selectors: { form: '.searchArea form', gallery: 'main .thumbnail-container', hide: '.searchArea' },
        cheatSheetUrl: 'https://gelbooru.com/index.php?page=wiki&s=view&id=26263',
        cheatSheetSelector: 'table td',
        placeholder: 'Enter tags...',
        sortOptions: GELBOORU_SORT_OPTIONS.filter(option => option.value !== 'parent'),
        ratings: GELBOORU_RATINGS,
        metatagRegex: /^(sort:|-?rating:|user:|parent:|score:|md5:|width:|height:|source:|fav:|pool:)/,
        cheatSheetContent: GELBOORU_CHEATSHEET
            .replace('( tag1 ~ tag2 )', '{tag1 ~ tag2}')
            .replace('<code>parent</code>, ', ''),
    },
    // safebooru uses the same layout as rule34, with a same-origin endpoint.
    "safebooru.org": {
        site: 'rule34',
        autocomplete: 'safebooru',
        sortPrefix: 'sort',
        inputTag: 'input',
        selectors: { form: '.sidebar .tag-search form', gallery: '#post-list' },
        placeholder: "Enter tags...",
        sortOptions: GELBOORU_SORT_OPTIONS,
        ratings: GELBOORU_RATINGS,
        metatagRegex: GELBOORU_METATAG_REGEX,
        cheatSheetContent: GELBOORU_CHEATSHEET
    },
    "danbooru.donmai.us": danbooruSite('https://danbooru.donmai.us', 'Danbooru'),
    "booru.allthefallen.moe": danbooruSite('https://booru.allthefallen.moe', 'ATFBooru'),
    "default": {
        site: '',
        sortPrefix: 'sort',
        inputTag: 'input',
        placeholder: "Enter tags...",
        sortOptions: GELBOORU_SORT_OPTIONS,
        ratings: GELBOORU_RATINGS,
        metatagRegex: GELBOORU_METATAG_REGEX,
        cheatSheetContent: GELBOORU_CHEATSHEET
    }
};

"use strict";
/* exported injectStyles */
function injectStyles() {
    if (document.getElementById('bse-styles'))
        return;
    const style = document.createElement('style');
    style.id = 'bse-styles';
    style.textContent = `
    .bse-center-wrap {
        display: flex;
        flex-direction: column;
        align-items: center;
        width: 100%;
        margin-bottom: 20px;
    }
    .bse-search-form {
        width: 90vw;
        max-width: 900px;
        min-width: 220px;
        margin: 0 auto;
    }
    .bse-modern-searchbar {
        display: flex;
        flex-direction: row;
        align-items: center;
        gap: 16px;
        width: 100%;
        margin-bottom: 10px;
        justify-content: center;
    }
            .bse-export-btn, .bse-saved-btn, .bse-file-btn, .bse-cheat-btn, .bse-settings-btn {
                border-radius: 18px;
                padding: 8px 18px;
                font-size: 1.1em;
                border: 2px solid color-mix(in srgb, currentColor 25%, transparent) !important;
                background: color-mix(in srgb, currentColor 8%, transparent) !important;
                color: inherit !important;
                cursor: pointer;
                transition: border-color 0.2s, background 0.2s;
                white-space: nowrap;
                flex-shrink: 0;
            }
            .bse-export-btn:hover, .bse-saved-btn:hover, .bse-file-btn:hover, .bse-cheat-btn:hover, .bse-settings-btn:hover {
                border-color: color-mix(in srgb, currentColor 45%, transparent) !important;
                background: color-mix(in srgb, currentColor 18%, transparent) !important;
    }
    .bse-search-input, .bse-file-status {
        flex: 1 1 0%;
        min-width: 0;
        border-radius: 18px;
        padding: 8px 16px;
        font-size: 1.1em;
        border: 2px solid color-mix(in srgb, currentColor 25%, transparent);
        background: color-mix(in srgb, currentColor 5%, transparent);
        color: inherit;
        box-shadow: 0 2px 8px 0 rgba(0,0,0,0.06);
        transition: border-color 0.2s;
        box-sizing: border-box;
        width: 90vw;
        max-width: 500px;
    }
    .bse-search-input:focus {
        border-color: #4a90e2;
        outline: none;
    }
    .bse-file-status {
        display: flex;
        align-items: center;
        gap: 8px;
        white-space: nowrap;
    }
    .bse-file-status-text {
        overflow: hidden;
        text-overflow: ellipsis;
    }
    .bse-file-spinner {
        flex-shrink: 0;
        width: 0.8em;
        height: 0.8em;
        border: 2px solid currentColor;
        border-right-color: transparent;
        border-radius: 50%;
        animation: bse-spin 0.8s linear infinite;
    }
    @keyframes bse-spin {
        to { transform: rotate(360deg); }
    }
    .bse-file-error {
        margin: 0 0 10px;
        text-align: center;
        color: color-mix(in srgb, #e5484d 75%, currentColor);
    }
    .bse-drop-overlay {
        position: fixed;
        inset: 0;
        z-index: 2147483000;
        display: flex;
        align-items: center;
        justify-content: center;
        pointer-events: none;
        background: color-mix(in srgb, #4a90e2 22%, transparent);
    }
    .bse-drop-overlay[hidden] {
        display: none;
    }
    .bse-drop-overlay::before {
        content: "";
        position: absolute;
        inset: 8px;
        border: 3px dashed #4a90e2;
        border-radius: 10px;
    }
    .bse-drop-label {
        padding: 12px 26px;
        border-radius: 24px;
        background: #4a90e2;
        color: #fff;
        font-size: 18px;
    }
    .bse-search-button {
        border-radius: 18px;
        padding: 8px 28px;
        font-size: 1.1em;
        border: 2px solid color-mix(in srgb, currentColor 25%, transparent) !important;
        background: color-mix(in srgb, currentColor 8%, transparent) !important;
        color: inherit !important;
        cursor: pointer;
        transition: border-color 0.2s, background 0.2s;
        white-space: nowrap;
        flex-shrink: 0;
    }
    .bse-search-button:hover {
        border-color: color-mix(in srgb, currentColor 45%, transparent) !important;
        background: color-mix(in srgb, currentColor 18%, transparent) !important;
    }
    .bse-tag-list {
        margin: 18px 0 0 0;
        padding: 0;
        display: flex;
        flex-direction: row;
        flex-wrap: wrap;
        gap: 10px;
        max-width: 1000px;
        justify-content: flex-start;
    }
    .bse-tag-item {
        border-radius: 18px;
        padding: 4px 18px;
        display: flex;
        align-items: center;
        font-size: 1.08em;
        font-weight: 500;
        transition: background 0.2s, color 0.2s, border-color 0.2s;
        /* Default neutral — overridden by type-specific classes */
        background: color-mix(in srgb, currentColor 10%, transparent);
        border: 1.5px solid color-mix(in srgb, currentColor 25%, transparent);
    }
    .bse-tag-item:not(.bse-category-tag) { color: inherit; }
    .bse-tag-item.bse-category-tag {
        border-color: color-mix(in srgb, currentColor 45%, transparent);
        text-decoration: none;
        cursor: default;
    }
    .bse-tag-item.bse-include-item:not(.bse-category-tag) {
        background: #d1fae5 !important;
        border-color: #6ee7b7 !important;
        color: #065f46 !important;
    }
    .bse-tag-item.bse-exclude-item:not(.bse-category-tag) {
        background: #fee2e2 !important;
        border-color: #fca5a5 !important;
        color: #991b1b !important;
    }
    .bse-tag-item.bse-metatag-item {
        background: #fef9c3 !important;
        border-color: #fde68a !important;
        color: #854d0e !important;
    }
    .bse-tag-item .bse-remove-tag {
        margin-left: 12px;
        cursor: pointer;
        color: #c00;
        font-weight: bold;
        font-size: 1.1em;
        }
            .modal {
                display: none;
                position: fixed;
                z-index: 1000;
                left: 0; top: 0; right: 0; bottom: 0;
                background: rgba(24, 28, 36, 0.55);
                backdrop-filter: blur(2.5px);
                align-items: center;
                justify-content: center;
                transition: background 0.2s;
            }
            .modal[style*="display: flex"] {
                display: flex !important;
            }
            .modal-content {
                background: #fff;
                color: #222;
                border-radius: 18px;
                padding: 32px 28px 24px 28px;
                min-width: 320px;
                max-width: 800px;
                width: 50vw;
                margin: auto;
                box-shadow: 0 8px 40px 0 rgba(0,0,0,0.18);
                display: flex;
                flex-direction: column;
                gap: 18px;
                position: relative;
                font-size: 1.08em;
                align-items: center;
                max-height: 80vh;
                overflow: auto;
            }
            .modal-close {
                position: absolute;
                top: 12px;
                right: 16px;
                background: none;
                border: none;
                font-size: 1.7em;
                color: #888;
                cursor: pointer;
                z-index: 2;
                padding: 0 8px;
                line-height: 1;
                transition: color 0.2s;
            }
            .modal-close:hover {
                color: #c00;
            }
            .modal-content h3 {
                margin: 0 0 8px 0;
                font-size: 1.35em;
                font-weight: bold;
                text-align: left;
                align-self: flex-start;
                color: #0066cc;
                text-shadow: 1px 1px 2px rgba(0,0,0,0.1);
            }
            .modal-actions {
                display: flex;
                gap: 12px;
                justify-content: flex-end;
                margin-top: 8px;
                flex-wrap: wrap;
                width: 100%;
            }
            .modal-actions button {
                border-radius: 16px;
                padding: 8px 22px;
                font-size: 1em;
                border: 2px solid color-mix(in srgb, currentColor 25%, transparent);
                background: color-mix(in srgb, currentColor 8%, transparent);
                cursor: pointer;
                transition: border-color 0.2s, background 0.2s;
                font-weight: 500;
            }
            .modal-actions button:hover {
                border-color: #4a90e2;
                background: color-mix(in srgb, currentColor 18%, transparent);
            }
            .modal-pastebin {
                background: #f6f8fa;
                border: 1.5px solid #e0e0e0;
                border-radius: 10px;
                padding: 12px 14px;
                font-size: 1em;
                width: 100%;
                min-width: 220px;
                max-width: 100%;
                box-sizing: border-box;
                color: inherit;
                margin-bottom: 0;
                font-family: 'Fira Mono', 'Consolas', 'Menlo', 'Monaco', monospace;
                overflow: auto;
                box-shadow: 0 2px 8px 0 rgba(0,0,0,0.04);
                resize: vertical;
                height: 260px;
                min-height: 120px;
                max-height: 50vh;
            }
            .modal-content pre, .modal-doc {
                background: #f6f8fa;
                border: 1.5px solid #e0e0e0;
                border-radius: 10px;
                padding: 12px 14px;
                font-size: 1em;
                width: 100%;
                min-width: 220px;
                max-width: 100%;
                box-sizing: border-box;
                color: inherit;
                margin-bottom: 0;
                font-family: inherit;
                overflow: auto;
            }
            .modal-content pre {
                white-space: pre-wrap;
                word-break: break-word;
                max-height: 320px;
                overflow: auto;
            }
            .modal-doc section {
                margin-bottom: 18px;
            }
            .modal-doc h4 {
                margin: 0 0 6px 0;
                font-size: 1.08em;
                color: #4a90e2;
                font-weight: bold;
            }
            .dynamic-cheatsheet, .dynamic-cheatsheet * { color: inherit !important; line-height: 1.6; }
            .dynamic-cheatsheet h1, .dynamic-cheatsheet h2,
            .dynamic-cheatsheet h3, .dynamic-cheatsheet h4,
            .dynamic-cheatsheet h5, .dynamic-cheatsheet h6 {
                color: #4a90e2 !important;
                margin: 1.2em 0 0.5em;
                padding-bottom: 4px;
                border-bottom: 1px solid color-mix(in srgb, currentColor 15%, transparent);
            }
            .dynamic-cheatsheet ul, .dynamic-cheatsheet ol {
                list-style: none;
                padding: 0;
                margin: 0.5em 0;
            }
            .dynamic-cheatsheet li {
                margin-bottom: 12px;
                padding: 0;
            }
            .dynamic-cheatsheet p {
                margin: 0.4em 0;
                color: inherit;
            }
            /* Code chips — block-level so description wraps below */
            .dynamic-cheatsheet code {
                display: inline-block;
                margin-bottom: 4px;
                padding: 3px 10px;
                border-radius: 12px;
                font-family: 'Fira Mono', Consolas, Monaco, monospace;
                font-size: 0.88em;
                font-weight: 600;
            }
            .dynamic-cheatsheet code.bse-chip-include {
                background: #d1fae5 !important;
                border: 1px solid #6ee7b7;
                color: #065f46 !important;
            }
            .dynamic-cheatsheet code.bse-chip-exclude {
                background: #fee2e2 !important;
                border: 1px solid #fca5a5;
                color: #991b1b !important;
            }
            .dynamic-cheatsheet code.bse-chip-meta {
                background: #fef9c3 !important;
                border: 1px solid #fde68a;
                color: #854d0e !important;
            }
            /* Fallback for unclassified code */
            .dynamic-cheatsheet code:not([class]) {
                background: #edf2f7 !important;
                border: 1px solid #e2e8f0;
                color: #2d3748 !important;
            }
            .dynamic-cheatsheet table {
                width: 100%;
                border-collapse: collapse;
                margin: 0.5em 0;
            }
            .dynamic-cheatsheet td, .dynamic-cheatsheet th {
                padding: 6px 10px;
                border: 1px solid color-mix(in srgb, currentColor 15%, transparent);
                text-align: left;
            }
            .dynamic-cheatsheet a {
                color: #4a90e2 !important;
            }
            .modal-doc ul {
                margin: 0;
                padding: 0;
                list-style: none !important;
            }
            .modal-doc b {
                color: inherit;
            }
            .modal-tabs {
                display: flex;
                background: #f0f4fa;
                border-radius: 999px;
                padding: 4px;
                gap: 8px;
                margin-bottom: 16px;
                width: 100%;
                justify-content: flex-start;
            }
            .modal-tab {
                border: none;
                background: none;
                border-radius: 999px;
                padding: 8px 28px;
                font-size: 1.08em;
                font-weight: 500;
                cursor: pointer;
                transition: background 0.2s, color 0.2s, box-shadow 0.2s;
                box-shadow: none;
                outline: none;
                margin-bottom: 0;
                color: inherit;
            }
            .modal-tab.active {
                background: #4a90e2;
                color: #fff;
                box-shadow: 0 2px 8px 0 rgba(74,144,226,0.12);
            }
            .modal-tab:not(.active):hover {
                background: color-mix(in srgb, currentColor 10%, transparent);
                color: inherit;
            }
            .modal-tab:focus {
                outline: 2px solid #4a90e2;
            }
            /* Responsive for mobile */
            @media (max-width: 700px) {
                .modal-content {
                    min-width: 0;
                    max-width: 98vw;
                    width: 98vw;
                    padding: 18px 2vw 12px 2vw;
                    font-size: 1em;
                    max-height: 98vh;
                }
                .modal-content h3 {
                    font-size: 1.08em;
                }
                .modal-actions {
                    flex-direction: column;
                    align-items: stretch;
                    gap: 8px;
                }
                .modal-pastebin, .modal-content pre, .modal-doc {
                    font-size: 0.98em;
                    padding: 8px 6px;
                    width: 98vw;
                    min-width: 0;
                    max-width: 100vw;
                    height: 120px;
                    min-height: 60px;
                    max-height: 30vh;
                }
                .modal-tabs {
                    width: 98vw;
                    min-width: 0;
                    max-width: 100vw;
                }
            }
            /* --- Add width/centering for modal-tabs and modal-pastebin --- */
            .modal-tabs,
            .modal-pastebin {
                width: 90%;
                max-width: 760px;
                min-width: 220px;
                margin-left: auto;
                margin-right: auto;
            }
            .modal-form {
                width: 90%;
                max-width: 760px;
                min-width: 220px;
                margin-left: auto;
                margin-right: auto;
                display: flex;
                flex-direction: column;
                align-items: stretch;
                height: auto;
                max-height: 100%;
            }
            @media (max-width: 700px) {
                .modal-tabs,
                .modal-pastebin {
                    width: 98vw;
                    max-width: 100vw;
                    min-width: 0;
                }
                .modal-form {
                    width: 98vw;
                    max-width: 100vw;
                    min-width: 0;
                    height: auto;
                    max-height: 100%;
                }
    }
    @media (max-width: 600px) {
        body#body .bse-search-input, body#body .bse-file-status {
            width: 100vw;
            max-width: 100vw;
        }
        /* Phones: the field and Search share the first row, the other
           buttons wrap below it. CSS order leaves the Tab order alone. */
        .bse-modern-searchbar {
            flex-wrap: wrap;
            gap: 0 8px;
        }
        .bse-modern-searchbar .bse-search-input, .bse-modern-searchbar .bse-file-status {
            order: -3;
            flex: 1 1 180px;
            width: auto;
            max-width: none;
        }
        .bse-modern-searchbar .bse-search-button {
            order: -2;
        }
        /* An empty full-width item that ends the first row after Search. */
        .bse-modern-searchbar::before {
            content: "";
            order: -1;
            flex-basis: 100%;
            height: 0;
        }
        .bse-export-btn, .bse-saved-btn, .bse-file-btn, .bse-cheat-btn, .bse-settings-btn {
            margin-top: 8px;
        }
        .bse-export-btn, .bse-saved-btn, .bse-file-btn, .bse-cheat-btn, .bse-settings-btn, .bse-search-button {
            padding: 6px 12px;
        }
    }
    .bse-sort-row {
        display: flex;
        flex-direction: row;
        align-items: center;
        gap: 18px;
        width: 100%;
        margin: 8px 0 0 0;
        justify-content: flex-start;
    }
    .bse-sort-select {
        appearance: none;
        border-radius: 8px;
        padding: 8px 32px 8px 14px;
        font-size: 0.95em;
        border: 1px solid color-mix(in srgb, currentColor 25%, transparent) !important;
        background-color: color-mix(in srgb, currentColor 8%, transparent) !important;
        background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='8' viewBox='0 0 12 8'%3E%3Cpath fill='%23999' d='M1 1l5 5 5-5'/%3E%3C/svg%3E") !important;
        background-repeat: no-repeat !important;
        background-position: right 10px center !important;
        color: inherit !important;
        cursor: pointer;
        transition: border-color 0.2s, background-color 0.2s;
        min-width: 8rem;
    }
    .bse-sort-select:hover {
        background-color: color-mix(in srgb, currentColor 18%, transparent) !important;
        border-color: color-mix(in srgb, currentColor 45%, transparent) !important;
    }
    .bse-sort-select:focus {
        border-color: #4a90e2 !important;
        outline: none !important;
    }
    .bse-sort-select option {
        background: var(--body-background-color, #1a1a2e) !important;
        color: var(--body-font-color, inherit) !important;
    }
    .bse-order-switch {
        appearance: none;
        border-radius: 8px;
        padding: 8px 14px;
        font-size: inherit;
        border: 1px solid color-mix(in srgb, currentColor 25%, transparent) !important;
        background-color: color-mix(in srgb, currentColor 8%, transparent) !important;
        color: inherit !important;
        cursor: pointer;
        transition: border-color 0.2s, background-color 0.2s;
        font-weight: 500;
        white-space: nowrap;
        min-width: 6rem;
        text-align: center;
    }
    .bse-order-switch:hover {
        background-color: color-mix(in srgb, currentColor 18%, transparent) !important;
        border-color: color-mix(in srgb, currentColor 45%, transparent) !important;
    }
    .bse-order-switch:focus {
        border-color: #4a90e2 !important;
        outline: none !important;
    }
    .bse-rating-label {
        display: flex;
        align-items: center;
        gap: 4px;
        font-size: 1.08em;
        font-weight: 500;
        color: inherit;
        background: color-mix(in srgb, currentColor 10%, transparent);
        border-radius: 10px;
        padding: 4px 12px;
        border: 1.5px solid color-mix(in srgb, currentColor 25%, transparent);
        margin-right: 4px;
        cursor: pointer;
        transition: background 0.2s, border-color 0.2s;
    }
    .bse-rating-checkbox {
        accent-color: #4a90e2;
        width: 1.1em;
        height: 1.1em;
    }
    .bse-rating-label:hover {
        background: #d0eaff;
        border-color: #4a90e2;
    }
    /* Metatag Row */
    .bse-metatag-row-wrap {
        width: 100%;
        margin: 10px 0 0 0;
        display: flex;
        flex-direction: column;
        align-items: center;
        gap: 4px;
    }
    .bse-metatag-row-header {
        display: flex;
        flex-direction: row;
        align-items: center;
        gap: 12px;
        font-size: 1.08em;
        font-weight: 600;
        color: inherit;
        margin-bottom: 2px;
    }
    .bse-metatag-toggle {
        border-radius: 10px;
        padding: 4px 16px;
        font-size: 1em;
        border: 2px solid color-mix(in srgb, currentColor 25%, transparent);
        background: color-mix(in srgb, currentColor 8%, transparent);
        cursor: pointer;
        transition: border-color 0.2s, background 0.2s;
        font-weight: 500;
        color: inherit;
    }
    .bse-metatag-toggle:focus {
        border-color: #4a90e2;
        outline: none;
    }
    .bse-metatag-list {
        display: flex;
        flex-direction: row;
        flex-wrap: wrap;
        gap: 10px;
        width: 100%;
        margin-top: 2px;
        justify-content: center;
        margin-left: auto;
        margin-right: auto;
    }
    .bse-metatag-item {
        background: color-mix(in srgb, currentColor 10%, transparent);
        border: 1.5px solid color-mix(in srgb, currentColor 25%, transparent);
        color: inherit;
    }
    @media (max-width: 700px) {
        .bse-sort-row {
            flex-direction: column;
            align-items: stretch;
            gap: 10px;
        }
        .bse-metatag-row-header {
            flex-direction: column;
            align-items: flex-start;
            gap: 4px;
        }
        .bse-metatag-list {
            gap: 6px;
        }
    }
    /* Include/Exclude Tag Rows */
    .bse-include-row-wrap, .bse-exclude-row-wrap {
        width: 100%;
        margin: 10px 0 0 0;
        display: flex;
        flex-direction: column;
        align-items: center;
        gap: 4px;
    }
    .bse-include-row-header, .bse-exclude-row-header {
        display: flex;
        flex-direction: row;
        align-items: center;
        gap: 12px;
        font-size: 1.08em;
        font-weight: 600;
        color: inherit;
        margin-bottom: 2px;
    }
    .bse-include-toggle, .bse-exclude-toggle {
        border-radius: 10px;
        padding: 4px 16px;
        font-size: 1em;
        border: 2px solid color-mix(in srgb, currentColor 25%, transparent);
        background: color-mix(in srgb, currentColor 8%, transparent);
        cursor: pointer;
        transition: border-color 0.2s, background 0.2s;
        font-weight: 500;
        color: inherit;
    }
    .bse-include-toggle:focus, .bse-exclude-toggle:focus {
        border-color: #4a90e2;
        outline: none;
    }
    .bse-include-list, .bse-exclude-list {
        display: flex;
        flex-direction: row;
        flex-wrap: wrap;
        gap: 10px;
        width: 100%;
        margin-top: 2px;
        justify-content: center;
        margin-left: auto;
        margin-right: auto;
    }
    .bse-include-item:not(.bse-category-tag), .bse-exclude-item:not(.bse-category-tag) {
        background: color-mix(in srgb, currentColor 10%, transparent);
        border: 1.5px solid color-mix(in srgb, currentColor 25%, transparent);
        color: inherit;
    }
    @media (max-width: 700px) {
        .bse-include-row-header, .bse-exclude-row-header {
            flex-direction: column;
            align-items: flex-start;
            gap: 4px;
        }
        .bse-include-list, .bse-exclude-list {
            gap: 6px;
        }
        .bse-all-tags-row-header {
            flex-direction: column;
            align-items: flex-start;
            gap: 4px;
        }
        .bse-all-tags-list {
            gap: 6px;
        }
    }
    /* All Tags Row */
    .bse-all-tags-row-wrap {
        width: 100%;
        margin: 10px 0 0 0;
        display: flex;
        flex-direction: column;
        align-items: center;
        gap: 4px;
    }
    .bse-all-tags-row-header {
        display: flex;
        flex-direction: row;
        align-items: center;
        gap: 12px;
        font-size: 1.08em;
        font-weight: 600;
        color: inherit;
        margin-bottom: 2px;
    }
    .bse-all-tags-toggle {
        border-radius: 10px;
        padding: 4px 16px;
        font-size: 1em;
        border: 2px solid color-mix(in srgb, currentColor 25%, transparent);
        background: color-mix(in srgb, currentColor 8%, transparent);
        cursor: pointer;
        transition: border-color 0.2s, background 0.2s;
        font-weight: 500;
        color: inherit;
    }
    .bse-all-tags-toggle:focus {
        border-color: #4a90e2;
        outline: none;
    }
    .bse-all-tags-list {
        display: flex;
        flex-direction: row;
        flex-wrap: wrap;
        gap: 10px;
        width: 100%;
        margin-top: 2px;
        justify-content: center;
        margin-left: auto;
        margin-right: auto;
    }
    /* Settings Form */
    .settings-form {
        display: flex;
        flex-direction: column;
        gap: 16px;
        padding: 8px 0;
    }
    .settings-row {
        display: flex;
        align-items: center;
        gap: 8px;
    }
    .settings-label {
        display: flex;
        align-items: center;
        gap: 8px;
        font-size: 1.08em;
        font-weight: 500;
        color: inherit;
        cursor: pointer;
    }
    .settings-label input[type="checkbox"] {
        accent-color: #4a90e2;
        width: 1.2em;
        height: 1.2em;
    }
    /* Cheat sheet info bar */
    .cheatsheet-info-bar {
        margin-top: 16px;
        padding: 12px;
        background: #f0f4fa;
        border-radius: 8px;
        display: flex;
        justify-content: space-between;
        align-items: center;
        flex-wrap: wrap;
        gap: 12px;
    }
    .cheatsheet-info-bar a {
        color: #4a90e2;
        text-decoration: none;
        font-weight: 500;
    }
    .cheatsheet-info-bar a:hover {
        text-decoration: underline;
    }
    .cheatsheet-cache-info {
        font-size: 0.9em;
        color: #666;
    }
    .cheatsheet-flush-btn {
        border-radius: 12px;
        padding: 6px 16px;
        font-size: 0.9em;
        border: 1.5px solid color-mix(in srgb, currentColor 25%, transparent);
        background: color-mix(in srgb, currentColor 8%, transparent);
        cursor: pointer;
        transition: all 0.2s;
    }
    .cheatsheet-flush-btn:hover {
        border-color: color-mix(in srgb, currentColor 45%, transparent);
        background: color-mix(in srgb, currentColor 18%, transparent);
    }
    .bse-tag-suggestions {
        position: absolute;
        z-index: 99999;
        box-sizing: border-box;
        max-height: 400px;
        overflow-y: auto;
        list-style: none;
        margin: 0;
        padding: 0;
        border: 1px solid color-mix(in srgb, currentColor 25%, transparent);
        box-shadow: 0 3px 10px #0002;
        font: inherit;
    }
    .bse-tag-suggestions[hidden] { display: none; }
    .bse-tag-suggestions > li {
        display: flex;
        align-items: center;
        justify-content: space-between;
        gap: 12px;
        padding: 8px 12px;
    }
    .bse-tag-suggestions [role="option"] { cursor: pointer; }
    .bse-tag-suggestions [aria-selected="true"] {
        background: color-mix(in srgb, currentColor 10%, transparent);
    }
    .bse-native-tag { transition: background .2s, border-color .2s; }
    .bse-suggestion-alias { opacity: var(--bse-secondary-opacity, .7); font-size: .85em; }
    .bse-suggestion-count { opacity: var(--bse-secondary-opacity, .7); flex-shrink: 0; }
    .bse-suggestion-content {
        display: flex;
        justify-content: space-between;
        align-items: center;
        width: 100%;
        gap: 12px;
        text-decoration: none;
        cursor: pointer;
    }
    .bse-suggestion-status button {
        background: none;
        color: inherit;
        border: 0;
        padding: 0;
        font: inherit;
        text-decoration: underline;
        cursor: pointer;
    }
    /* Wiki search */
    .bse-wiki-search-wrap {
        display: flex;
        justify-content: center;
        margin: 20px auto;
        max-width: 600px;
        padding: 0 20px;
    }
    .bse-wiki-search-wrap form {
        display: flex;
        gap: 10px;
        width: 100%;
        align-items: center;
    }
`;
    document.head.appendChild(style);
}

"use strict";
function tagLuminance(color) {
    return color.reduce((sum, value, i) => {
        const channel = value / 255;
        return sum + [0.2126, 0.7152, 0.0722][i] * (channel <= 0.04045 ? channel / 12.92 : ((channel + 0.055) / 1.055) ** 2.4);
    }, 0);
}
function mixTagColor(color, background, alpha) {
    const channel = (i) => background[i] * (1 - alpha) + color[i] * alpha;
    return [channel(0), channel(1), channel(2)];
}
function readableTagColor(native, background) {
    if (tagLuminance(background) <= 0.5)
        return native;
    const readable = (color) => {
        // Reserve the chip's 10% tint, also keeping plain menu labels readable.
        const backdrop = mixTagColor(color, background, 0.1);
        return (tagLuminance(backdrop) + 0.05) / (tagLuminance(color) + 0.05) >= 4.55;
    };
    // A small margin above 4.5 absorbs browser rounding of mixed backgrounds.
    if (readable(native))
        return native;
    const scaled = (factor) => [Math.round(native[0] * factor), Math.round(native[1] * factor), Math.round(native[2] * factor)];
    let low = 0, high = 1;
    for (let i = 0; i < 20; i++) {
        const factor = (low + high) / 2;
        if (readable(scaled(factor)))
            low = factor;
        else
            high = factor;
    }
    return scaled(low);
}
let tagColorContext;
function readTagColor(css) {
    const rgb = css.match(/^rgba?\(([^)]+)\)$/);
    const srgb = css.match(/^color\(srgb ([^)]+)\)$/);
    if (rgb || srgb) {
        const values = (rgb || srgb)[1].trim().split(/[,\s/]+/).map(Number);
        if (values.length < 3 || values.some(value => !Number.isFinite(value)))
            return;
        const scale = srgb ? 255 : 1;
        return [values[0] * scale, values[1] * scale, values[2] * scale, values[3] ?? 1];
    }
    // Computed styles may also use modern spaces such as oklch/display-p3.
    if (!CSS.supports('color', css))
        return;
    tagColorContext ??= document.createElement('canvas').getContext('2d', { willReadFrequently: true });
    if (!tagColorContext)
        return;
    tagColorContext.clearRect(0, 0, 1, 1);
    tagColorContext.fillStyle = css;
    tagColorContext.fillRect(0, 0, 1, 1);
    const values = tagColorContext.getImageData(0, 0, 1, 1).data;
    return [values[0], values[1], values[2], values[3] / 255];
}
function tagBackground(element, canvas) {
    const layers = [];
    for (let node = element; node; node = node.parentElement) {
        const color = readTagColor(getComputedStyle(node).backgroundColor);
        if (color)
            layers.push(color);
        if (color?.[3] === 1)
            break;
    }
    let background = layers.some(color => color[3] === 1) ? [255, 255, 255] : canvas();
    for (const color of layers.reverse())
        background = mixTagColor([color[0], color[1], color[2]], background, color[3]);
    return background;
}
function refreshTagContrast() {
    let canvasColor;
    const canvas = () => {
        if (canvasColor)
            return canvasColor;
        // Canvas 2D's system colors do not inherit the page's color-scheme.
        const probe = document.createElement('span');
        probe.style.cssText = 'display:none;color:Canvas';
        document.body.append(probe);
        const color = readTagColor(getComputedStyle(probe).color);
        probe.remove();
        return canvasColor = color ? [color[0], color[1], color[2]] : [255, 255, 255];
    };
    const labels = [...document.querySelectorAll('.bse-native-tag')];
    for (const label of labels) {
        // Sample the current native state, never the previous correction.
        label.style.removeProperty('color');
        label.style.removeProperty('--bse-secondary-opacity');
    }
    // Separate style reads from writes so hovering a tag does not repeatedly
    // recalculate styles for the rest of the tag list.
    const corrections = labels.map(label => {
        const native = readTagColor(getComputedStyle(label).color);
        if (!native)
            return;
        const background = tagBackground(label.classList.contains('bse-category-tag') ? label.parentElement : label, canvas);
        if (tagLuminance(background) <= 0.5)
            return;
        const color = readableTagColor([native[0], native[1], native[2]], background);
        return { label, color: native[3] !== 1 || color.some((value, i) => value !== native[i]) ? `rgb(${color.join(', ')})` : undefined };
    });
    for (const correction of corrections) {
        if (!correction)
            continue;
        const { label, color } = correction;
        label.style.setProperty('--bse-secondary-opacity', '1');
        if (color) {
            // Native tag rules on some sites use !important.
            label.style.setProperty('color', color, 'important');
        }
    }
}
let tagContrastFrame;
function scheduleTagContrast() {
    if (tagContrastFrame !== undefined)
        return;
    tagContrastFrame = window.requestAnimationFrame(() => {
        tagContrastFrame = undefined;
        refreshTagContrast();
    });
}
function watchTagContrast() {
    const observer = new MutationObserver(scheduleTagContrast);
    observer.observe(document.documentElement, { attributes: true });
    observer.observe(document.body, { attributes: true });
    window.matchMedia?.('(prefers-color-scheme: dark)').addEventListener('change', scheduleTagContrast);
    for (const event of ['pointerover', 'pointerout', 'focusin', 'focusout']) {
        document.addEventListener(event, event => {
            if (event.target instanceof Element && event.target.closest('.bse-native-tag'))
                scheduleTagContrast();
        });
    }
}

"use strict";
/* exported normalizeTagCategory, createTagCategoryLabel, createTagCategoryRegistry, getTagMenuColors, watchTagMenuColors */
// Category identity is site-specific; native CSS owns the actual palette.
const STANDARD_TAG_CATEGORIES = {
    0: 'general', 1: 'artist', 3: 'copyright', 4: 'character', 5: 'metadata',
};
const E621_TAG_CATEGORIES = {
    0: 'general', 1: 'artist', 3: 'copyright', 4: 'character',
    5: 'species', 6: 'invalid', 7: 'metadata', 8: 'lore',
};
function normalizeTagCategory(value, site) {
    const categories = site === 'e621' ? E621_TAG_CATEGORIES : STANDARD_TAG_CATEGORIES;
    if (typeof value === 'number')
        return Object.hasOwn(categories, value) ? categories[value] : undefined;
    if (typeof value !== 'string')
        return;
    if (/^\d+$/.test(value))
        return normalizeTagCategory(Number(value), site);
    const name = value === 'tag' ? 'general' : value === 'meta' ? 'metadata' : value;
    const names = Object.values(categories);
    if (names.includes(name))
        return name;
    if (name === 'deprecated' && site !== 'e621' && site !== 'danbooru')
        return 'deprecated';
}
function tagCategoryClasses(category, site) {
    if (site === 'e621' || site === 'danbooru') {
        const categories = site === 'e621' ? E621_TAG_CATEGORIES : STANDARD_TAG_CATEGORIES;
        const id = Object.keys(categories).find(key => categories[Number(key)] === category);
        return id === undefined ? '' : `tag-type-${id} category-${id}`;
    }
    return 'tag-type-' + (category === 'general' && site === 'gelbooru' ? 'tag' : category);
}
function createTagCategoryLabel(category, site) {
    const wrapper = document.createElement('span');
    const label = document.createElement('a');
    if (category) {
        wrapper.className = tagCategoryClasses(category, site);
        label.className = wrapper.className;
    }
    label.classList.add('bse-native-tag');
    scheduleTagContrast();
    // An anchor without href is inert, while matching the site's tag selectors.
    wrapper.append(label);
    return { wrapper, label };
}
function createTagCategoryRegistry(site, changed) {
    const categories = new Map();
    const MAX_CATEGORIES = 512;
    const canonical = (name) => name.replace(/^-/, '');
    function remember(name, category, notify = true) {
        if (typeof name !== 'string')
            return;
        name = canonical(name);
        const valid = normalizeTagCategory(category, site);
        if (!name || !valid)
            return;
        const previous = categories.get(name);
        categories.delete(name);
        categories.set(name, valid);
        if (categories.size > MAX_CATEGORIES)
            categories.delete(categories.keys().next().value);
        if (notify && valid !== previous)
            changed(name);
    }
    document.querySelectorAll('[class*="tag-type-"], [class*="category-"], [data-tag-category]').forEach(node => {
        const raw = node.dataset.tagCategory ?? [...node.classList].map(name => name.match(/^(?:tag-type-|category-)([a-z]+|\d+)$/)?.[1]).find(Boolean);
        const category = normalizeTagCategory(raw, site);
        if (!category)
            return;
        if (node.dataset.tagName)
            remember(node.dataset.tagName, category, false);
        const links = node.matches('a[href]') ? [node] : node.querySelectorAll('a[href]');
        for (const link of links) {
            let url;
            try {
                url = new URL(link.href, location.href);
            }
            catch {
                continue;
            }
            const name = url.searchParams.get('tags');
            if (url.origin === location.origin && name && /^-?\S+$/.test(name))
                remember(name, category, false);
        }
    });
    return {
        get(name) {
            if (typeof name !== 'string')
                return;
            name = canonical(name);
            const category = categories.get(name);
            if (category) {
                categories.delete(name);
                categories.set(name, category);
            }
            return category;
        },
        remember,
    };
}
function getTagMenuColors(input) {
    const body = getComputedStyle(document.body);
    const root = getComputedStyle(document.documentElement);
    const background = [body.backgroundColor, root.backgroundColor]
        .find(color => color !== 'transparent' && color !== 'rgba(0, 0, 0, 0)');
    return { background: background || 'Canvas', color: getComputedStyle(input).color };
}
function watchTagMenuColors(input, apply) {
    const refresh = () => apply(getTagMenuColors(input));
    // Theme selectors may use classes, inline styles or data attributes.
    const observer = new MutationObserver(refresh);
    observer.observe(document.documentElement, { attributes: true });
    observer.observe(document.body, { attributes: true });
    window.matchMedia?.('(prefers-color-scheme: dark)').addEventListener('change', refresh);
    refresh();
}

"use strict";
/* exported bindTagAutocomplete */
// Suggestion requests belong to the replacement field, independently of site libraries.
function bindTagAutocomplete(input, provider, addTag, tagCategories) {
    if (!provider)
        return;
    // Category numbering and class names follow the site the endpoint belongs to.
    const site = provider === 'safebooru' ? 'rule34' : provider;
    const menu = document.createElement('ul');
    menu.id = 'bse-tag-suggestions';
    menu.className = 'bse-tag-suggestions';
    menu.setAttribute('role', 'listbox');
    menu.setAttribute('aria-label', 'Tag suggestions');
    menu.hidden = true;
    document.body.append(menu);
    watchTagMenuColors(input, colors => {
        menu.style.backgroundColor = colors.background;
        menu.style.color = colors.color;
    });
    input.setAttribute('role', 'combobox');
    input.setAttribute('aria-autocomplete', 'list');
    input.setAttribute('aria-controls', menu.id);
    input.setAttribute('aria-expanded', 'false');
    const MAX_RESULTS = 10;
    const DEBOUNCE_MS = 225;
    // e621's tag autocomplete needs at least three characters.
    const MIN_TERM_LENGTH = provider === 'e621' ? 3 : 1;
    let timer;
    let controller;
    let revision = 0;
    let active = -1;
    let suggestions = [];
    let query = '';
    function invalidate() {
        revision++;
        if (timer !== undefined)
            window.clearTimeout(timer);
        timer = undefined;
        controller?.abort();
        controller = undefined;
    }
    function close() {
        invalidate();
        menu.hidden = true;
        suggestions = [];
        active = -1;
        input.setAttribute('aria-expanded', 'false');
        input.removeAttribute('aria-activedescendant');
    }
    function position() {
        if (menu.hidden)
            return;
        const rect = input.getBoundingClientRect();
        menu.style.left = rect.left + window.scrollX + 'px';
        menu.style.top = rect.bottom + window.scrollY + 'px';
        menu.style.width = rect.width + 'px';
    }
    function show() {
        const colors = getTagMenuColors(input);
        menu.style.backgroundColor = colors.background;
        menu.style.color = colors.color;
        menu.hidden = false;
        input.setAttribute('aria-expanded', 'true');
        position();
    }
    function status(message, retry = false) {
        active = -1;
        suggestions = [];
        input.removeAttribute('aria-activedescendant');
        menu.replaceChildren();
        const row = document.createElement('li');
        row.className = 'bse-suggestion-status';
        row.setAttribute('role', 'presentation');
        const text = document.createElement('span');
        text.setAttribute('role', 'status');
        text.textContent = message;
        row.append(text);
        if (retry) {
            const button = document.createElement('button');
            button.type = 'button';
            button.textContent = 'Retry';
            button.addEventListener('mousedown', e => e.preventDefault());
            button.addEventListener('click', () => {
                input.focus();
                schedule(true);
            });
            row.append(button);
        }
        menu.append(row);
        show();
    }
    function highlight(index) {
        active = index;
        [...menu.querySelectorAll('[role="option"]')].forEach((item, i) => {
            item.setAttribute('aria-selected', String(i === active));
        });
        const selected = menu.querySelector('[aria-selected="true"]');
        if (selected) {
            input.setAttribute('aria-activedescendant', selected.id);
            selected.scrollIntoView?.({ block: 'nearest' });
        }
        else
            input.removeAttribute('aria-activedescendant');
        scheduleTagContrast();
    }
    function select(index) {
        const suggestion = suggestions[index];
        if (!suggestion)
            return;
        const exclusion = query.startsWith('-') ? '-' : '';
        close();
        addTag(exclusion + suggestion.value.replace(/^-/, ''));
        input.value = '';
        input.focus();
    }
    function render(items) {
        if (!items.length) {
            status('No suggestions');
            return;
        }
        active = -1;
        suggestions = items;
        menu.replaceChildren();
        input.removeAttribute('aria-activedescendant');
        items.forEach((item, index) => {
            const row = document.createElement('li');
            row.id = menu.id + '-' + index;
            row.setAttribute('role', 'option');
            row.setAttribute('aria-selected', 'false');
            const { wrapper, label } = createTagCategoryLabel(item.category, site);
            row.className = wrapper.className;
            label.classList.add('bse-suggestion-content');
            const name = document.createElement('span');
            // An alias row shows the typed name first, then the tag it resolves to.
            name.textContent = (item.antecedent ?? item.label).replace(/^-/, '').replace(/_/g, ' ');
            if (item.antecedent) {
                const alias = document.createElement('span');
                alias.className = 'bse-suggestion-alias';
                alias.textContent = '→ ' + item.label.replace(/^-/, '').replace(/_/g, ' ');
                name.append(' ', alias);
            }
            label.append(name);
            row.append(label);
            if (item.count !== undefined) {
                const count = document.createElement('span');
                count.className = 'bse-suggestion-count';
                count.textContent = formatPostCount(item.count);
                label.append(count);
            }
            row.addEventListener('pointerdown', e => { e.preventDefault(); select(index); });
            row.addEventListener('click', () => select(index));
            row.addEventListener('mouseenter', () => highlight(index));
            menu.append(row);
        });
        show();
    }
    // Maps one entry of the provider's response onto the shared row fields.
    function parseEntry(item) {
        switch (provider) {
            case 'gelbooru':
                return { value: item.value, label: item.label ?? item.value, category: item.category, count: item.post_count };
            case 'e621':
                return { value: item.name, label: item.name, category: item.category, count: item.post_count, antecedent: item.antecedent_name };
            case 'danbooru': {
                // Danbooru puts category and post_count either on the entry or on its tag.
                const tag = item.tag && typeof item.tag === 'object' ? item.tag : {};
                return { value: item.value, label: item.label ?? item.value, category: item.category ?? tag.category,
                    count: item.post_count ?? tag.post_count, antecedent: item.antecedent };
            }
            default: {
                // Rule34 and Safebooru append the post count to the label: "landscape (10718)".
                const label = typeof item.label === 'string' ? item.label : item.value;
                const suffix = typeof label === 'string' ? label.match(/ \((\d+)\)$/) : null;
                return { value: item.value, label: suffix ? label.slice(0, -suffix[0].length) : label,
                    category: item.type, count: suffix?.[1] };
            }
        }
    }
    function normalize(data) {
        if (!Array.isArray(data))
            throw new Error('Invalid autocomplete response');
        const items = [];
        const seen = new Set();
        for (const entry of data) {
            if (!entry || typeof entry !== 'object')
                continue;
            const { value, label, category: rawCategory, count: rawCount, antecedent } = parseEntry(entry);
            if (typeof value !== 'string' || !value.trim() || seen.has(value))
                continue;
            const count = typeof rawCount === 'number' || (typeof rawCount === 'string' && /^\d+$/.test(rawCount)) ? Number(rawCount) : undefined;
            const category = normalizeTagCategory(rawCategory, site) ?? tagCategories.get(value);
            tagCategories.remember(value, category);
            seen.add(value);
            items.push({
                value, label: typeof label === 'string' ? label : value, category,
                count: count !== undefined && Number.isFinite(count) && count >= 0 ? count : undefined,
                antecedent: typeof antecedent === 'string' && antecedent ? antecedent : undefined,
            });
            if (items.length === MAX_RESULTS)
                break;
        }
        return items;
    }
    function requestUrl(term) {
        const name = term.replace(/^-/, '');
        switch (provider) {
            case 'gelbooru':
                return location.origin + '/index.php?page=autocomplete2&' + new URLSearchParams({ term, type: 'tag_query', limit: String(MAX_RESULTS) });
            case 'e621':
                return location.origin + '/tags/autocomplete.json?' + new URLSearchParams({ 'search[name_matches]': name, expiry: '7' });
            case 'danbooru':
                return location.origin + '/autocomplete.json?' + new URLSearchParams({
                    'search[query]': name, 'search[type]': 'tag_query', limit: String(MAX_RESULTS), version: '3',
                });
            default: {
                // Rule34's API mirror omits types; its native endpoint includes them.
                const base = provider === 'rule34' ? 'https://ac.rule34.xxx' : location.origin;
                return base + '/autocomplete.php?q=' + encodeURIComponent(name);
            }
        }
    }
    async function request(currentRevision, term) {
        controller = new AbortController();
        const currentController = controller;
        status('Loading suggestions…');
        try {
            const response = await fetch(requestUrl(term), { signal: currentController.signal });
            if (!response.ok)
                throw new Error('Autocomplete HTTP status ' + response.status);
            const data = await response.json();
            if (currentRevision !== revision || currentController.signal.aborted || input.value.trim() !== term)
                return;
            render(normalize(data));
        }
        catch (error) {
            if (currentRevision !== revision || currentController.signal.aborted)
                return;
            console.warn('[Booru Tag Search] Autocomplete request failed', error);
            status('Suggestions unavailable.', true);
        }
    }
    function schedule(immediate = false) {
        close();
        query = input.value.trim();
        if (query.replace(/^-/, '').length < MIN_TERM_LENGTH)
            return;
        const currentRevision = revision;
        const term = query;
        if (immediate)
            void request(currentRevision, term);
        else
            timer = window.setTimeout(() => { timer = undefined; void request(currentRevision, term); }, DEBOUNCE_MS);
    }
    input.addEventListener('input', () => schedule());
    input.addEventListener('keydown', event => {
        const keyEvent = event;
        if (keyEvent.isComposing)
            return;
        if (keyEvent.key === 'Escape') {
            close();
            return;
        }
        if (menu.hidden || !suggestions.length)
            return;
        if (keyEvent.key === 'ArrowDown' || keyEvent.key === 'ArrowUp') {
            event.preventDefault();
            event.stopImmediatePropagation();
            const next = keyEvent.key === 'ArrowDown' ? Math.min(active + 1, suggestions.length - 1) : Math.max(active - 1, 0);
            highlight(next);
        }
        else if (keyEvent.key === 'Enter' && active >= 0 && !keyEvent.shiftKey) {
            event.preventDefault();
            event.stopImmediatePropagation();
            select(active);
        }
    }, true);
    input.addEventListener('blur', event => {
        const target = event.relatedTarget;
        if (!(target instanceof Node) || !menu.contains(target))
            close();
    });
    menu.addEventListener('focusout', event => {
        if (event.relatedTarget !== input && (!(event.relatedTarget instanceof Node) || !menu.contains(event.relatedTarget)))
            close();
    });
    input.form?.addEventListener('submit', close, true);
    window.addEventListener('pageshow', close);
    window.addEventListener('scroll', position, true);
    window.addEventListener('resize', position);
    return { close };
}

"use strict";
/* exported createMd5, hashFile */
// MD5 (RFC 1321), fed in chunks so a large file never has to sit in memory
// whole. Boorus index posts by the MD5 of the original file, and Web Crypto
// has no MD5.
const MD5_SHIFTS = [
    7, 12, 17, 22, 7, 12, 17, 22, 7, 12, 17, 22, 7, 12, 17, 22,
    5, 9, 14, 20, 5, 9, 14, 20, 5, 9, 14, 20, 5, 9, 14, 20,
    4, 11, 16, 23, 4, 11, 16, 23, 4, 11, 16, 23, 4, 11, 16, 23,
    6, 10, 15, 21, 6, 10, 15, 21, 6, 10, 15, 21, 6, 10, 15, 21,
];
// floor(|sin(i + 1)| * 2^32), the table RFC 1321 defines.
const MD5_CONSTANTS = Array.from({ length: 64 }, (_, i) => Math.floor(Math.abs(Math.sin(i + 1)) * 2 ** 32) | 0);
function createMd5() {
    const state = new Int32Array([0x67452301, 0xefcdab89 | 0, 0x98badcfe | 0, 0x10325476]);
    const words = new Int32Array(16);
    const pending = new Uint8Array(64);
    let pendingLength = 0;
    let totalLength = 0;
    function processBlock(source, offset) {
        for (let i = 0; i < 16; i++) {
            const p = offset + i * 4;
            words[i] = source[p] | (source[p + 1] << 8) | (source[p + 2] << 16) | (source[p + 3] << 24);
        }
        let a = state[0], b = state[1], c = state[2], d = state[3];
        for (let i = 0; i < 64; i++) {
            let f, g;
            if (i < 16) {
                f = (b & c) | (~b & d);
                g = i;
            }
            else if (i < 32) {
                f = (d & b) | (~d & c);
                g = (5 * i + 1) & 15;
            }
            else if (i < 48) {
                f = b ^ c ^ d;
                g = (3 * i + 5) & 15;
            }
            else {
                f = c ^ (b | ~d);
                g = (7 * i) & 15;
            }
            const sum = (a + f + MD5_CONSTANTS[i] + words[g]) | 0;
            const shift = MD5_SHIFTS[i];
            a = d;
            d = c;
            c = b;
            b = (b + ((sum << shift) | (sum >>> (32 - shift)))) | 0;
        }
        state[0] += a;
        state[1] += b;
        state[2] += c;
        state[3] += d;
    }
    function update(bytes) {
        totalLength += bytes.length;
        let i = 0;
        if (pendingLength > 0) {
            i = Math.min(64 - pendingLength, bytes.length);
            pending.set(bytes.subarray(0, i), pendingLength);
            pendingLength += i;
            if (pendingLength < 64)
                return;
            processBlock(pending, 0);
            pendingLength = 0;
        }
        for (; i + 64 <= bytes.length; i += 64)
            processBlock(bytes, i);
        if (i < bytes.length) {
            pending.set(bytes.subarray(i), 0);
            pendingLength = bytes.length - i;
        }
    }
    function digest() {
        const bitLength = totalLength * 8;
        const padding = new Uint8Array((pendingLength < 56 ? 56 : 120) - pendingLength + 8);
        padding[0] = 0x80;
        const view = new DataView(padding.buffer);
        view.setUint32(padding.length - 8, bitLength % 2 ** 32, true);
        view.setUint32(padding.length - 4, Math.floor(bitLength / 2 ** 32), true);
        update(padding);
        const out = new DataView(new ArrayBuffer(16));
        state.forEach((word, i) => out.setInt32(i * 4, word, true));
        return Array.from(new Uint8Array(out.buffer), byte => byte.toString(16).padStart(2, '0')).join('');
    }
    return { update, digest };
}
const HASH_CHUNK_BYTES = 4 * 1024 * 1024;
/** MD5 of a file, read in chunks; onProgress gets the fraction done after each chunk. */
async function hashFile(file, onProgress) {
    const md5 = createMd5();
    for (let offset = 0; offset < file.size; offset += HASH_CHUNK_BYTES) {
        const end = Math.min(offset + HASH_CHUNK_BYTES, file.size);
        md5.update(new Uint8Array(await file.slice(offset, end).arrayBuffer()));
        onProgress(end / file.size);
    }
    return md5.digest();
}
if (typeof window === 'undefined' && typeof module !== 'undefined' && module.exports) {
    module.exports = { createMd5, hashFile };
}

"use strict";
/* exported md5SearchTags, isUploadPage, bindFileDrop */
// Search by file: a file dropped anywhere on the page, or picked with the
// File button, is hashed and searched as md5:<hash>.
/** The tags that find the post whose original file has this MD5. */
function md5SearchTags(hash, site) {
    // Danbooru hides deleted posts unless the search asks for them.
    return site === 'danbooru' ? [`md5:${hash}`, 'status:any'] : [`md5:${hash}`];
}
/** Upload pages have their own drop zone, so the page-wide drop stays off there. */
function isUploadPage(loc) {
    return /^\/uploads(\/|$)/.test(loc.pathname) || new URLSearchParams(loc.search).get('s') === 'add';
}
/**
 * Shows the overlay while a file from outside the page is dragged over the
 * window, and hands the dropped file to onDrop. Drags that start on the page
 * (thumbnails, links) are ignored, and so is any drag or drop the site's own
 * handlers already accepted with preventDefault.
 */
function bindFileDrop(win, overlay, handlers) {
    let depth = 0;
    let dragFromPage = false;
    win.document.addEventListener('dragstart', () => { dragFromPage = true; });
    win.document.addEventListener('dragend', () => { dragFromPage = false; });
    const carriesFile = (e) => !dragFromPage && !!e.dataTransfer && Array.from(e.dataTransfer.types).includes('Files');
    const hide = () => {
        depth = 0;
        overlay.hidden = true;
    };
    win.addEventListener('dragenter', e => {
        if (!carriesFile(e))
            return;
        if (depth++ === 0)
            handlers.onEnter();
    });
    win.addEventListener('dragleave', e => {
        if (!carriesFile(e))
            return;
        if (--depth <= 0)
            hide();
    });
    win.addEventListener('dragover', e => {
        if (!carriesFile(e))
            return;
        if (e.defaultPrevented) {
            overlay.hidden = true;
            return;
        }
        e.preventDefault();
        e.dataTransfer.dropEffect = 'copy';
        overlay.hidden = false;
    });
    win.addEventListener('drop', e => {
        if (!carriesFile(e))
            return;
        hide();
        if (e.defaultPrevented)
            return;
        e.preventDefault();
        handlers.onDrop(e.dataTransfer.files[0] ?? null);
    });
}
if (typeof window === 'undefined' && typeof module !== 'undefined' && module.exports) {
    module.exports = { md5SearchTags, isUploadPage, bindFileDrop };
}

"use strict";
/* exported createSearchSection */
function createCollapsibleRow(prefix, title, startExpanded) {
    const rowWrap = document.createElement('div');
    rowWrap.className = `bse-${prefix}-row-wrap`;
    const rowHeader = document.createElement('div');
    rowHeader.className = `bse-${prefix}-row-header`;
    const rowTitle = document.createElement('span');
    rowTitle.textContent = title;
    const toggle = document.createElement('button');
    toggle.type = 'button';
    toggle.className = `bse-${prefix}-toggle`;
    toggle.textContent = startExpanded ? 'See less' : 'See more';
    rowHeader.appendChild(rowTitle);
    rowHeader.appendChild(toggle);
    const list = document.createElement('div');
    list.className = `bse-${prefix}-list`;
    rowWrap.appendChild(rowHeader);
    rowWrap.appendChild(list);
    let expanded = startExpanded;
    list.style.display = expanded ? '' : 'none';
    toggle.onclick = () => {
        expanded = !expanded;
        list.style.display = expanded ? '' : 'none';
        toggle.textContent = expanded ? 'See less' : 'See more';
    };
    return { rowWrap, list };
}
// --- Search Bar Creation ---
function createSearchSection({ siteConfig, getTags, setTags, ui, tagCategories, addTag, addNormalizedInput, showExportModal, showSavedSearchesModal, showCheatSheetModal, showSettingsModal, renderTags }) {
    const site = siteConfig.site;
    const centerWrap = document.createElement('div');
    centerWrap.className = 'bse-center-wrap';
    const searchForm = document.createElement('form');
    searchForm.className = 'bse-search-form';
    searchForm.method = 'GET';
    searchForm.action = '';
    const searchBarContainer = document.createElement('div');
    searchBarContainer.className = 'bse-modern-searchbar';
    const inputTag = siteConfig.inputTag || 'input';
    const searchInput = document.createElement(inputTag);
    // Named only on submit: Rule34's and Safebooru's awesomplete.min.js
    // attach their own menu to every *[name="tags"] on window.onload.
    searchInput.placeholder = siteConfig.placeholder || 'Enter tags...';
    searchInput.className = 'bse-search-input';
    // Prevent the browser from resurrecting the submitted value (e.g.
    // "solo futanari pussy") on back-navigation — it would otherwise
    // get glommed into a single tag when the user types a space.
    searchInput.autocomplete = 'off';
    if (inputTag === 'textarea') {
        searchInput.rows = 1;
        searchInput.id = 'tags';
    }
    else {
        searchInput.type = 'text';
    }
    // --- Searchbar Buttons ---
    const exportBtn = document.createElement('button');
    exportBtn.type = 'button';
    exportBtn.textContent = 'Export';
    exportBtn.className = 'bse-export-btn';
    exportBtn.title = 'Export tags';
    exportBtn.onclick = showExportModal;
    const savedBtn = document.createElement('button');
    savedBtn.type = 'button';
    savedBtn.textContent = 'Saved';
    savedBtn.className = 'bse-saved-btn';
    savedBtn.onclick = showSavedSearchesModal;
    const cheatBtn = document.createElement('button');
    cheatBtn.type = 'button';
    cheatBtn.textContent = '?';
    cheatBtn.className = 'bse-cheat-btn';
    cheatBtn.title = 'Show cheat sheet';
    cheatBtn.onclick = showCheatSheetModal;
    const settingsBtn = document.createElement('button');
    settingsBtn.type = 'button';
    settingsBtn.textContent = '⚙';
    settingsBtn.className = 'bse-settings-btn';
    settingsBtn.title = 'Settings';
    settingsBtn.onclick = showSettingsModal;
    const searchButton = document.createElement('button');
    searchButton.type = 'submit';
    searchButton.textContent = 'Search';
    searchButton.className = 'bse-search-button';
    // --- Search by file ---
    const fileBtn = document.createElement('button');
    fileBtn.type = 'button';
    fileBtn.textContent = 'File';
    fileBtn.className = 'bse-file-btn';
    fileBtn.title = 'Search by file MD5';
    const filePicker = document.createElement('input');
    filePicker.type = 'file';
    filePicker.hidden = true;
    fileBtn.onclick = () => filePicker.click();
    filePicker.addEventListener('change', () => {
        const file = filePicker.files?.[0];
        filePicker.value = '';
        if (file)
            void searchByFile(file);
    });
    // Takes the field's place while a file is hashed.
    const fileStatus = document.createElement('span');
    fileStatus.className = 'bse-file-status';
    fileStatus.style.display = 'none';
    const fileSpinner = document.createElement('span');
    fileSpinner.className = 'bse-file-spinner';
    const fileStatusText = document.createElement('span');
    fileStatusText.className = 'bse-file-status-text';
    fileStatus.append(fileSpinner, fileStatusText);
    const fileError = document.createElement('p');
    fileError.className = 'bse-file-error';
    fileError.hidden = true;
    // --- New Sort/Order/Ratings Row ---
    const sortRow = document.createElement('div');
    sortRow.className = 'bse-sort-row';
    // Sort dropdown
    const sortSelect = document.createElement('select');
    sortSelect.className = 'bse-sort-select';
    const emptyOption = document.createElement('option');
    emptyOption.value = '';
    emptyOption.textContent = 'Sort';
    sortSelect.appendChild(emptyOption);
    const sortOptions = siteConfig.sortOptions;
    sortOptions.forEach((opt) => {
        const option = document.createElement('option');
        option.value = opt.value;
        option.textContent = opt.label;
        sortSelect.appendChild(option);
    });
    sortRow.appendChild(sortSelect);
    // Order switch
    const orderSwitch = document.createElement('button');
    orderSwitch.type = 'button';
    orderSwitch.className = 'bse-order-switch';
    orderSwitch.dataset.state = 'desc';
    orderSwitch.textContent = 'Descent \u2193';
    orderSwitch.style.display = 'none';
    orderSwitch.addEventListener('click', function () {
        // Flip direction, then push the change into the tag list.
        const next = orderSwitch.dataset.state === 'desc' ? 'asc' : 'desc';
        orderSwitch.dataset.state = next;
        orderSwitch.textContent = next === 'asc' ? 'Ascent \u2191' : 'Descent \u2193';
        syncMetatagsFromUI();
    });
    sortRow.appendChild(orderSwitch);
    // Ratings checkboxes
    const ratings = siteConfig.ratings;
    ratings.forEach((r) => {
        const label = document.createElement('label');
        label.className = 'bse-rating-label';
        const checkbox = document.createElement('input');
        checkbox.type = 'checkbox';
        checkbox.value = r.value;
        checkbox.className = 'bse-rating-checkbox';
        label.appendChild(checkbox);
        label.appendChild(document.createTextNode(' ' + r.label));
        sortRow.appendChild(label);
    });
    // Cache rating checkboxes — queried once, reused everywhere
    const ratingCheckboxes = sortRow.querySelectorAll('.bse-rating-checkbox');
    // --- Collapsible Tag Rows ---
    const { rowWrap: includeRowWrap, list: includeList } = createCollapsibleRow('include', 'Include Tags', true);
    const { rowWrap: excludeRowWrap, list: excludeList } = createCollapsibleRow('exclude', 'Exclude Tags', true);
    const { rowWrap: metatagRowWrap, list: metatagList } = createCollapsibleRow('metatag', 'Metatags', false);
    const { rowWrap: allTagsRowWrap, list: allTagsList } = createCollapsibleRow('all-tags', 'All Tags', true);
    // --- Event Bindings ---
    function bindInputEvents() {
        searchInput.addEventListener('input', function () {
            let value = searchInput.value;
            // Only split on comma or space if not inside parentheses
            const endsWithSeparator = /[ ,]$/.test(value);
            if (endsWithSeparator) {
                // Parse the input to check if we're inside a parenthetical group
                // This handles both grouping parentheses like "( rating:safe ~ rating:questionable )"
                // and tag name parentheses like "silvia_(artist)"
                let parenLevel = 0;
                let inGroupParens = false;
                let lastChar = '';
                for (let i = 0; i < value.length - 1; i++) { // -1 to exclude the trailing separator
                    const char = value[i];
                    if ((char === '(' || (site === 'gelbooru' && char === '{')) && (i === 0 || lastChar === ' ')) {
                        // Opening paren at start or after space - likely a grouping paren
                        parenLevel++;
                        inGroupParens = true;
                    }
                    else if ((char === ')' || (site === 'gelbooru' && char === '}')) && parenLevel > 0) {
                        parenLevel--;
                        if (parenLevel === 0) {
                            inGroupParens = false;
                        }
                    }
                    if (char !== ' ')
                        lastChar = char;
                }
                // Only split if we're not inside grouping parentheses
                if (parenLevel === 0 && !inGroupParens) {
                    value = value.replace(/[ ,]+$/, '').trim();
                    addNormalizedInput(value);
                    searchInput.value = '';
                }
            }
        });
        // Only submit form on Enter if input is empty and there are tags
        searchInput.addEventListener('keydown', function (event) {
            const e = event;
            if (e.key === 'Enter' && !e.shiftKey) {
                // If input is empty and there are tags, submit
                if (searchInput.value.trim() === '' && getTags().length > 0) {
                    e.preventDefault();
                    searchInput.value = getTags().join(' ');
                    submitSearch();
                }
                else if (searchInput.value.trim() !== '') {
                    // No suggestion selected, add raw input as tag
                    e.preventDefault();
                    const value = searchInput.value.trim();
                    addNormalizedInput(value);
                    searchInput.value = '';
                    tagAutocomplete?.close();
                }
            }
        });
    }
    function buildAndApplyMetatags() {
        const prefix = siteConfig.sortPrefix;
        const prefixes = [prefix + ':', 'rating:', '( rating:'];
        const excludesEveryRating = site === 'gelbooru' && siteConfig.ratings.every(rating => getTags().includes(`-rating:${rating.value}`));
        if (site === 'gelbooru')
            prefixes.push('-rating:');
        setTags(getTags().filter(tag => !prefixes.some(p => tag.startsWith(p))));
        if (sortSelect.value) {
            const order = orderSwitch.dataset.state || 'desc';
            if (prefix === 'order') {
                getTags().push(order === 'asc' ? `order:${sortSelect.value}_asc` : `order:${sortSelect.value}`);
            }
            else {
                getTags().push(`sort:${sortSelect.value}:${order}`);
            }
        }
        const checkedRatings = [...ratingCheckboxes].filter(cb => cb.checked).map(cb => cb.value);
        if (checkedRatings.length === 1) {
            getTags().push(`rating:${checkedRatings[0]}`);
        }
        else if (checkedRatings.length > 1) {
            if (site === 'gelbooru') {
                getTags().push(...siteConfig.ratings.filter(r => !checkedRatings.includes(r.value)).map(r => `-rating:${r.value}`));
            }
            else {
                getTags().push('( ' + checkedRatings.map(r => `rating:${r}`).join(' ~ ') + ' )');
            }
        }
        else if (excludesEveryRating) {
            getTags().push(...siteConfig.ratings.map(rating => `-rating:${rating.value}`));
        }
    }
    function bindFormEvents() {
        searchForm.addEventListener('submit', function (e) {
            e.preventDefault();
            const pending = searchInput.value.trim();
            if (pending)
                addNormalizedInput(pending);
            buildAndApplyMetatags();
            if (getTags().length > 0) {
                searchInput.value = getTags().join(' ');
            }
            submitSearch();
        });
    }
    function submitSearch() {
        searchInput.name = 'tags';
        searchForm.submit();
    }
    function syncMetatagsFromUI() {
        buildAndApplyMetatags();
        renderTags();
    }
    function showFileError(message) {
        fileError.textContent = message;
        fileError.hidden = message === '';
    }
    function showFileStatus(text) {
        searchInput.style.display = text === null ? '' : 'none';
        fileStatus.style.display = text === null ? 'none' : '';
        fileStatusText.textContent = text ?? '';
    }
    // A newer drop or pick supersedes a hash still running.
    let fileSearchRun = 0;
    async function searchByFile(file) {
        const run = ++fileSearchRun;
        showFileError('');
        if (!file) {
            showFileStatus(null);
            showFileError("Couldn't read the dropped item. Drop a file saved on your computer.");
            return;
        }
        showFileStatus(`Hashing ${file.name}`);
        let hash;
        try {
            hash = await hashFile(file, done => {
                if (run === fileSearchRun)
                    showFileStatus(`Hashing ${file.name} ${Math.round(done * 100)}%`);
            });
        }
        catch {
            if (run !== fileSearchRun)
                return;
            showFileStatus(null);
            showFileError(`Couldn't read ${file.name}. Drop a file saved on your computer.`);
            return;
        }
        if (run !== fileSearchRun)
            return;
        // A hash matches at most one post, so other tags could only hide it.
        setTags(md5SearchTags(hash, site));
        renderTags();
        showFileStatus(null);
        searchInput.value = getTags().join(' ');
        submitSearch();
    }
    function bindFileSearch() {
        searchInput.addEventListener('input', () => showFileError(''));
        if (isUploadPage(location))
            return;
        const dropOverlay = document.createElement('div');
        dropOverlay.className = 'bse-drop-overlay';
        dropOverlay.hidden = true;
        const dropLabel = document.createElement('span');
        dropLabel.className = 'bse-drop-label';
        dropLabel.textContent = 'Drop to search by MD5';
        dropOverlay.appendChild(dropLabel);
        document.body.appendChild(dropOverlay);
        bindFileDrop(window, dropOverlay, {
            onEnter: () => showFileError(''),
            onDrop: file => void searchByFile(file),
        });
    }
    sortSelect.addEventListener('change', syncMetatagsFromUI);
    ratingCheckboxes.forEach(cb => {
        cb.addEventListener('change', syncMetatagsFromUI);
    });
    // --- Assemble ---
    searchBarContainer.appendChild(exportBtn);
    searchBarContainer.appendChild(savedBtn);
    searchBarContainer.appendChild(searchInput);
    searchBarContainer.appendChild(fileStatus);
    searchBarContainer.appendChild(fileBtn);
    searchBarContainer.appendChild(filePicker);
    searchBarContainer.appendChild(cheatBtn);
    searchBarContainer.appendChild(settingsBtn);
    searchBarContainer.appendChild(searchButton);
    searchForm.appendChild(searchBarContainer);
    searchForm.appendChild(fileError);
    // Insert the new row below the search bar, above the tag list
    searchForm.appendChild(sortRow);
    centerWrap.appendChild(searchForm);
    centerWrap.appendChild(includeRowWrap);
    centerWrap.appendChild(excludeRowWrap);
    centerWrap.appendChild(metatagRowWrap);
    centerWrap.appendChild(allTagsRowWrap);
    // --- Bind Events ---
    bindInputEvents();
    bindFormEvents();
    bindFileSearch();
    const tagAutocomplete = bindTagAutocomplete(searchInput, siteConfig.autocomplete, addTag, tagCategories);
    // Store UI references for renderTags and cross-scope access
    Object.assign(ui, {
        includeList, excludeList, includeRowWrap, excludeRowWrap,
        metatagList, metatagRowWrap, allTagsList, allTagsRowWrap,
        sortSelect, orderSwitch, ratingCheckboxes
    });
    // The pills as one query, with the sort and rating controls folded in.
    function currentQuery() {
        syncMetatagsFromUI();
        return getTags().join(' ');
    }
    return { centerWrap, searchForm, searchInput, currentQuery };
}

"use strict";
function main() {
    'use strict';
    // --- State ---
    let tags = [];
    const ui = {};
    const siteConfig = SITE_CONFIG[location.hostname.replace(/^www\./, '')] || SITE_CONFIG['default'];
    const site = siteConfig.site;
    if (site === 'gelbooru') {
        const params = new URLSearchParams(location.search);
        if (params.get('page') !== 'post' || params.get('s') !== 'list')
            return;
    }
    const isWikiPage = site !== 'gelbooru'
        && (location.pathname.includes('/wiki') || location.pathname.includes('/help') || document.title.includes('Wiki'));
    // --- Settings State ---
    let settings = {
        showIncludeExclude: true,
        showMetatags: true,
        showAllTags: true,
        colorChipsByCategory: false
    };
    // Load settings from localStorage
    function loadSettings() {
        const saved = localStorage.getItem('booru-enhancer-settings');
        if (saved) {
            try {
                settings = { ...settings, ...JSON.parse(saved) };
            }
            catch (e) {
                console.warn('Failed to load settings:', e);
            }
        }
    }
    // Save settings to localStorage
    function saveSettings() {
        try {
            localStorage.setItem('booru-enhancer-settings', JSON.stringify(settings));
        }
        catch (e) {
            console.warn('Failed to save settings:', e);
        }
    }
    loadSettings();
    watchTagContrast();
    const tagCategories = createTagCategoryRegistry(site, name => {
        if (settings.colorChipsByCategory === true && tags.some(tag => tag.replace(/^-/, '') === name))
            renderTags();
    });
    // --- Tag Management ---
    function addTag(tag) {
        if (!tag || tags.includes(tag))
            return;
        // Cancel out contradicting include/exclude (ass + -ass = remove both)
        const opposite = tag.startsWith('-') ? tag.slice(1) : `-${tag}`;
        const oppIdx = tags.indexOf(opposite);
        if (oppIdx !== -1) {
            tags.splice(oppIdx, 1);
            renderTags();
            return;
        }
        tags.push(tag);
        renderTags();
    }
    // Entry point for raw user input (typed or pasted, as opposed to
    // autocomplete-selected): URL-decode it, then split — a pasted
    // "cat%2bdog" becomes the two tags "cat" and "dog".
    function addNormalizedInput(raw) {
        for (const tag of parseTagString(normalizeTagInput(raw))) {
            addTag(tag);
        }
    }
    function getTagsFromURL() {
        const params = new URLSearchParams(window.location.search);
        // URLSearchParams already decodes one layer; normalizeTagInput mops up
        // double-encoded tags= values (%252b) that survive it.
        const tagString = normalizeTagInput(params.get('tags') || '');
        return convertSortSyntax(parseTagString(tagString), siteConfig.sortPrefix);
    }
    function syncUiOnRemove(tag) {
        const { sortSelect, orderSwitch, ratingCheckboxes } = ui;
        const isSortTag = siteConfig.sortPrefix === 'order' ? /^order:[a-z_]+(_asc)?$/.test(tag) : /^sort:[a-z_]+:(asc|desc)$/.test(tag);
        if (isSortTag && sortSelect && orderSwitch) {
            sortSelect.value = '';
            orderSwitch.style.display = 'none';
        }
        if (/^rating:(safe|questionable|explicit|general|sensitive)$/.test(tag) && ratingCheckboxes) {
            ratingCheckboxes.forEach(cb => {
                if (cb.value === tag.split(':')[1])
                    cb.checked = false;
            });
        }
    }
    function createTagPill(tag, className, onRemove) {
        const category = settings.colorChipsByCategory === true && className !== 'bse-metatag-item' ? tagCategories.get(tag) : undefined;
        const colored = category ? createTagCategoryLabel(category, site) : undefined;
        const tagEl = colored?.label ?? document.createElement('span');
        tagEl.className = `bse-tag-item ${className}`;
        if (colored) {
            tagEl.className += ' bse-category-tag bse-native-tag ' + colored.wrapper.className;
        }
        tagEl.textContent = tag;
        const removeBtn = document.createElement('span');
        removeBtn.className = 'bse-remove-tag';
        removeBtn.textContent = '×';
        removeBtn.onclick = () => {
            if (onRemove)
                onRemove(tag);
            const tagIdx = tags.indexOf(tag);
            if (tagIdx !== -1) {
                tags.splice(tagIdx, 1);
                renderTags();
            }
        };
        tagEl.appendChild(removeBtn);
        return colored?.wrapper ?? tagEl;
    }
    function renderTags() {
        const { metatagList, metatagRowWrap, includeList, excludeList, includeRowWrap, excludeRowWrap, allTagsRowWrap, allTagsList, sortSelect, orderSwitch, ratingCheckboxes } = ui;
        const metatagRegex = siteConfig.metatagRegex;
        const sortRegex = siteConfig.sortPrefix === 'order'
            ? /^order:([a-z_]+?)(_asc)?$/
            : /^sort:([a-z_]+):(asc|desc)$/;
        // Clear all lists
        if (includeList)
            includeList.innerHTML = '';
        if (excludeList)
            excludeList.innerHTML = '';
        if (metatagList)
            metatagList.innerHTML = '';
        if (allTagsList)
            allTagsList.innerHTML = '';
        // Single pass: classify tags and build UI
        let hasIncludeTags = false;
        let hasExcludeTags = false;
        let hasMetatags = false;
        let sortType = '';
        let sortOrder = 'desc';
        let foundSort = false;
        const ratingsSet = new Set();
        const excludedRatings = new Set();
        for (const tag of tags) {
            const isMeta = metatagRegex.test(tag);
            if (isMeta) {
                // Metatag pill
                hasMetatags = true;
                if (metatagList && settings.showMetatags) {
                    metatagList.appendChild(createTagPill(tag, 'bse-metatag-item', syncUiOnRemove));
                }
                // Extract sort info
                const sortMatch = tag.match(sortRegex);
                if (sortMatch) {
                    sortType = sortMatch[1];
                    sortOrder = siteConfig.sortPrefix === 'order'
                        ? (sortMatch[2] ? 'asc' : 'desc')
                        : sortMatch[2];
                    foundSort = true;
                }
                // Extract rating info
                const ratingMatch = tag.match(/^rating:(safe|questionable|explicit|general|sensitive)$/);
                if (ratingMatch) {
                    ratingsSet.add(ratingMatch[1]);
                }
                else {
                    if (site === 'gelbooru' && /^-rating:/.test(tag)) {
                        excludedRatings.add(tag.slice('-rating:'.length));
                    }
                    const ratingOrMatch = tag.match(/^\(\s*([^)]+)\s*\)$/);
                    if (ratingOrMatch) {
                        for (const part of ratingOrMatch[1].split('~')) {
                            const m = part.trim().match(/^rating:(safe|questionable|explicit|general|sensitive)$/);
                            if (m)
                                ratingsSet.add(m[1]);
                        }
                    }
                }
            }
            else {
                // Regular tag — include or exclude
                const isExclude = tag.startsWith('-');
                if (isExclude) {
                    hasExcludeTags = true;
                    if (excludeList && settings.showIncludeExclude) {
                        excludeList.appendChild(createTagPill(tag, 'bse-exclude-item'));
                    }
                }
                else {
                    hasIncludeTags = true;
                    if (includeList && settings.showIncludeExclude) {
                        includeList.appendChild(createTagPill(tag, 'bse-include-item'));
                    }
                }
                // All-tags list
                if (allTagsList && settings.showAllTags) {
                    const cls = isExclude ? 'bse-exclude-item' : 'bse-include-item';
                    allTagsList.appendChild(createTagPill(tag, cls, syncUiOnRemove));
                }
            }
        }
        // Toggle row visibility
        if (includeRowWrap)
            includeRowWrap.style.display = hasIncludeTags ? '' : 'none';
        if (excludeRowWrap)
            excludeRowWrap.style.display = hasExcludeTags ? '' : 'none';
        if (metatagRowWrap)
            metatagRowWrap.style.display = hasMetatags && settings.showMetatags ? '' : 'none';
        if (allTagsRowWrap)
            allTagsRowWrap.style.display = (hasIncludeTags || hasExcludeTags) && settings.showAllTags ? '' : 'none';
        // Sync sort dropdown
        if (sortSelect && orderSwitch) {
            sortSelect.value = foundSort ? sortType : '';
            if (foundSort) {
                orderSwitch.style.display = '';
                orderSwitch.dataset.state = sortOrder;
                orderSwitch.textContent = sortOrder === 'asc' ? 'Ascent \u2191' : 'Descent \u2193';
            }
            else {
                orderSwitch.style.display = 'none';
            }
        }
        // Sync rating checkboxes
        if (site === 'gelbooru' && excludedRatings.size) {
            if (!ratingsSet.size)
                siteConfig.ratings.forEach(rating => ratingsSet.add(rating.value));
            excludedRatings.forEach(rating => ratingsSet.delete(rating));
        }
        if (ratingCheckboxes) {
            ratingCheckboxes.forEach(cb => {
                cb.checked = ratingsSet.has(cb.value);
            });
        }
    }
    // --- Modal Creation ---
    function createModal(id, title, contentNode, actions) {
        // Remove any existing modal with this id
        const old = document.getElementById(id);
        if (old)
            old.remove();
        const modal = document.createElement('div');
        modal.className = 'modal';
        modal.id = id;
        modal.tabIndex = -1;
        modal.style.display = 'none';
        // Modal overlay
        const modalContent = document.createElement('div');
        modalContent.className = 'modal-content';
        // Close (×) button
        const closeX = document.createElement('button');
        closeX.className = 'modal-close';
        closeX.type = 'button';
        closeX.innerHTML = '&times;';
        closeX.title = 'Close';
        closeX.onclick = () => { modal.style.display = 'none'; };
        modalContent.appendChild(closeX);
        // Title
        const h3 = document.createElement('h3');
        h3.textContent = title;
        modalContent.appendChild(h3);
        modalContent.appendChild(contentNode);
        const actionsDiv = document.createElement('div');
        actionsDiv.className = 'modal-actions';
        actions.forEach((btn) => actionsDiv.appendChild(btn));
        modalContent.appendChild(actionsDiv);
        modal.appendChild(modalContent);
        document.body.appendChild(modal);
        // Modal close on Esc/click outside
        function closeModal() { modal.style.display = 'none'; }
        modal.addEventListener('mousedown', e => { if (e.target === modal)
            closeModal(); });
        modal.addEventListener('keydown', (event) => {
            const e = event;
            if (e.key === 'Escape')
                closeModal();
        });
        return { modal, closeModal };
    }
    // --- Saved Searches ---
    let savedSearchStore;
    let savedSearchAccount;
    const accountCache = { items: null };
    /** Set by setupSite; the Saved button that opens the dialog only exists after it. */
    let currentQuery;
    function applySavedQuery(query) {
        queryTags(query).forEach(addTag);
    }
    function removeSavedQuery(query) {
        const drop = new Set(queryTags(query).map(tag => tag.toLowerCase()));
        tags = tags.filter(tag => !drop.has(tag.toLowerCase()));
        renderTags();
    }
    function showSavedSearchesModal() {
        // One store per page, so its writes stay serialised across dialog opens.
        if (savedSearchStore === undefined) {
            savedSearchStore = typeof GM !== 'undefined' && GM ? createSavedSearchStore(GM) : null;
        }
        if (savedSearchAccount === undefined)
            savedSearchAccount = accountBackendFor(site, window, siteConfig.accountName);
        openSavedSearchDialog({
            site: siteKey(location.hostname), siteConfig, store: savedSearchStore, account: savedSearchAccount, accountCache,
            getTags: () => tags, applyQuery: applySavedQuery, removeQuery: removeSavedQuery,
            currentQuery: () => currentQuery(), createModal,
        });
    }
    // --- Export Modal ---
    function showExportModal() {
        // --- Export Data Preparation ---
        const website = location.hostname;
        const timestamp = new Date().toISOString();
        const rawTags = tags.join(' ');
        const jsonExport = JSON.stringify({ website, tags: [...tags], timestamp }, null, 2);
        // --- Modal Elements ---
        const tabWrap = document.createElement('div');
        tabWrap.className = 'modal-tabs';
        const rawTab = document.createElement('button');
        rawTab.textContent = 'Raw';
        rawTab.type = 'button';
        rawTab.className = 'modal-tab';
        const jsonTab = document.createElement('button');
        jsonTab.textContent = 'JSON';
        jsonTab.type = 'button';
        jsonTab.className = 'modal-tab';
        tabWrap.appendChild(rawTab);
        tabWrap.appendChild(jsonTab);
        const textarea = document.createElement('textarea');
        textarea.readOnly = true;
        textarea.className = 'modal-pastebin';
        textarea.value = rawTags;
        // --- Action Buttons ---
        const copyBtn = document.createElement('button');
        copyBtn.textContent = 'Copy';
        copyBtn.type = 'button';
        copyBtn.onclick = function () {
            const markCopied = () => {
                copyBtn.textContent = 'Copied!';
                setTimeout(() => { copyBtn.textContent = 'Copy'; }, 1200);
            };
            if (navigator.clipboard && navigator.clipboard.writeText) {
                navigator.clipboard.writeText(textarea.value).then(markCopied).catch(() => {
                    textarea.focus();
                    textarea.select();
                });
            }
            else {
                textarea.focus();
                textarea.select();
            }
        };
        const exportBtn = document.createElement('button');
        exportBtn.textContent = 'Export to file';
        exportBtn.type = 'button';
        exportBtn.style.display = 'none';
        exportBtn.onclick = function () {
            const blob = new Blob([jsonExport], { type: 'application/json' });
            const url = URL.createObjectURL(blob);
            const a = document.createElement('a');
            a.href = url;
            a.download = 'booru-tags.json';
            document.body.appendChild(a);
            a.click();
            setTimeout(() => { document.body.removeChild(a); URL.revokeObjectURL(url); }, 100);
        };
        const closeBtn = document.createElement('button');
        closeBtn.textContent = 'Close';
        closeBtn.type = 'button';
        // --- Tab Switch Logic ---
        function showRaw() {
            textarea.value = rawTags;
            copyBtn.style.display = '';
            exportBtn.style.display = 'none';
            rawTab.classList.add('active');
            jsonTab.classList.remove('active');
        }
        function showJSON() {
            textarea.value = jsonExport;
            copyBtn.style.display = 'none';
            exportBtn.style.display = '';
            rawTab.classList.remove('active');
            jsonTab.classList.add('active');
        }
        rawTab.onclick = showRaw;
        jsonTab.onclick = showJSON;
        // --- Modal Content (Refactored) ---
        const formWrap = document.createElement('div');
        formWrap.className = 'modal-form';
        formWrap.appendChild(tabWrap);
        formWrap.appendChild(textarea);
        // The modal-actions div is handled by createModal, so do not add buttons here
        // --- Modal Actions ---
        const actions = [copyBtn, exportBtn, closeBtn];
        const modalObj = createModal('modal-export', 'Export Tags', formWrap, actions);
        closeBtn.onclick = modalObj.closeModal;
        modalObj.modal.style.display = 'flex';
        modalObj.modal.focus();
        // Default to raw view
        showRaw();
    }
    // --- Settings Modal ---
    function showSettingsModal() {
        const formWrap = document.createElement('div');
        formWrap.className = 'modal-form settings-form';
        // Include/Exclude toggle
        const includeExcludeRow = document.createElement('div');
        includeExcludeRow.className = 'settings-row';
        const includeExcludeLabel = document.createElement('label');
        includeExcludeLabel.className = 'settings-label';
        const includeExcludeCheckbox = document.createElement('input');
        includeExcludeCheckbox.type = 'checkbox';
        includeExcludeCheckbox.checked = settings.showIncludeExclude;
        includeExcludeLabel.appendChild(includeExcludeCheckbox);
        includeExcludeLabel.appendChild(document.createTextNode(' Show Include/Exclude Tags'));
        includeExcludeRow.appendChild(includeExcludeLabel);
        // Metatags toggle
        const metatagsRow = document.createElement('div');
        metatagsRow.className = 'settings-row';
        const metatagsLabel = document.createElement('label');
        metatagsLabel.className = 'settings-label';
        const metatagsCheckbox = document.createElement('input');
        metatagsCheckbox.type = 'checkbox';
        metatagsCheckbox.checked = settings.showMetatags;
        metatagsLabel.appendChild(metatagsCheckbox);
        metatagsLabel.appendChild(document.createTextNode(' Show Metatags'));
        metatagsRow.appendChild(metatagsLabel);
        // All Tags toggle
        const allTagsRow = document.createElement('div');
        allTagsRow.className = 'settings-row';
        const allTagsLabel = document.createElement('label');
        allTagsLabel.className = 'settings-label';
        const allTagsCheckbox = document.createElement('input');
        allTagsCheckbox.type = 'checkbox';
        allTagsCheckbox.checked = settings.showAllTags;
        allTagsLabel.appendChild(allTagsCheckbox);
        allTagsLabel.appendChild(document.createTextNode(' Show All Tags'));
        allTagsRow.appendChild(allTagsLabel);
        formWrap.appendChild(includeExcludeRow);
        formWrap.appendChild(metatagsRow);
        formWrap.appendChild(allTagsRow);
        const colorRow = document.createElement('div');
        colorRow.className = 'settings-row';
        const colorLabel = document.createElement('label');
        colorLabel.className = 'settings-label';
        const colorCheckbox = document.createElement('input');
        colorCheckbox.type = 'checkbox';
        colorCheckbox.checked = settings.colorChipsByCategory === true;
        colorLabel.append(colorCheckbox, document.createTextNode(' Color chips by category'));
        colorRow.append(colorLabel);
        formWrap.append(colorRow);
        // Action buttons
        const saveBtn = document.createElement('button');
        saveBtn.textContent = 'Save';
        saveBtn.type = 'button';
        saveBtn.onclick = function () {
            settings.showIncludeExclude = includeExcludeCheckbox.checked;
            settings.showMetatags = metatagsCheckbox.checked;
            settings.showAllTags = allTagsCheckbox.checked;
            settings.colorChipsByCategory = colorCheckbox.checked;
            saveSettings();
            renderTags();
            modalObj.closeModal();
        };
        const cancelBtn = document.createElement('button');
        cancelBtn.textContent = 'Cancel';
        cancelBtn.type = 'button';
        const actions = [saveBtn, cancelBtn];
        const modalObj = createModal('modal-settings', 'Settings', formWrap, actions);
        cancelBtn.onclick = modalObj.closeModal;
        modalObj.modal.style.display = 'flex';
        modalObj.modal.focus();
    }
    // --- Dynamic Cheat Sheet Cache ---
    const CACHE_DURATION = 7 * 24 * 60 * 60 * 1000; // 7 days in milliseconds
    async function fetchCheatSheet(url) {
        try {
            const response = await fetch(url, {
                credentials: 'same-origin',
                headers: {
                    'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8'
                }
            });
            if (!response.ok) {
                throw new Error(`HTTP error! status: ${response.status}`);
            }
            const html = await response.text();
            const parser = new DOMParser();
            const doc = parser.parseFromString(html, 'text/html');
            const selector = siteConfig.cheatSheetSelector || null;
            const cheatsheetSection = selector && doc.querySelector(selector);
            const content = cheatsheetSection ? cleanCheatSheetHTML(cheatsheetSection) : '';
            return content;
        }
        catch (error) {
            console.error('Failed to fetch cheat sheet:', error);
            return null;
        }
    }
    async function getCachedCheatSheet(siteKey) {
        const cacheKey = `booru-cheatsheet-${siteKey}`;
        const cached = localStorage.getItem(cacheKey);
        if (cached) {
            try {
                const data = JSON.parse(cached);
                const now = Date.now();
                if (data.timestamp && (now - data.timestamp) < CACHE_DURATION && data.version === SCRIPT_VERSION) {
                    return data.content ?? null;
                }
            }
            catch (e) {
                console.error('Failed to parse cached cheat sheet:', e);
            }
        }
        const url = siteConfig.cheatSheetUrl || '';
        if (url) {
            const content = await fetchCheatSheet(url);
            if (content) {
                // Cache the result
                const cacheData = {
                    content: content,
                    timestamp: Date.now(),
                    url: url,
                    version: SCRIPT_VERSION
                };
                try {
                    localStorage.setItem(cacheKey, JSON.stringify(cacheData));
                }
                catch (e) {
                    console.error('Failed to cache cheat sheet:', e);
                }
                return content;
            }
        }
        // Fallback to static content
        return null;
    }
    function clearCheatSheetCache() {
        // Derive the distinct site keys from SITE_CONFIG so this can't drift.
        const sites = new Set(Object.values(SITE_CONFIG).map(cfg => cfg.site).filter(Boolean));
        sites.forEach(s => localStorage.removeItem(`booru-cheatsheet-${s}`));
    }
    // --- Cheat Sheet Modal ---
    async function showCheatSheetModal() {
        // Show loading state
        const loadingDiv = document.createElement('div');
        loadingDiv.className = 'modal-doc';
        loadingDiv.innerHTML = '<p style="text-align: center;">Loading cheat sheet...</p>';
        const closeBtn = document.createElement('button');
        closeBtn.textContent = 'Close';
        closeBtn.type = 'button';
        const modalObj = createModal('modal-cheat', 'Cheat Sheet', loadingDiv, [closeBtn]);
        closeBtn.onclick = modalObj.closeModal;
        modalObj.modal.style.display = 'flex';
        modalObj.modal.focus();
        // Try to get dynamic content
        let cheatSheetContent = null;
        if (site) {
            cheatSheetContent = await getCachedCheatSheet(site);
        }
        // If dynamic fetch failed, use static content
        if (!cheatSheetContent) {
            cheatSheetContent = siteConfig.cheatSheetContent || `
          <section><h4>Basic Search</h4>
            <ul>
              <li><code>tag1 tag2</code> — Posts with <b>tag1</b> and <b>tag2</b></li>
              <li><code>( tag1 ~ tag2 )</code> — Posts with <b>tag1</b> or <b>tag2</b></li>
              <li><code>night~</code> — Fuzzy search for <b>night</b> (e.g. <b>night</b>, <b>fight</b>, <b>bright</b>)</li>
              <li><code>-tag1</code> — Exclude posts with <b>tag1</b></li>
              <li><code>ta*1</code> — Tags starting with <b>ta</b> and ending with <b>1</b></li>
            </ul>
          </section>
          <section><h4>Metatags</h4>
            <ul>
              <li><code>user:bob</code> — Uploaded by user <b>bob</b></li>
              <li><code>md5:foo</code> — Posts with MD5 <b>foo</b></li>
              <li><code>md5:foo*</code> — MD5 starts with <b>foo</b></li>
              <li><code>rating:questionable</code> — Rated <b>questionable</b></li>
              <li><code>-rating:questionable</code> — Not rated <b>questionable</b></li>
              <li><code>parent:1234</code> — Has parent <b>1234</b> (includes 1234)</li>
              <li><code>width:>=1000 height:>1000</code> — Width ≥ 1000, Height > 1000</li>
              <li><code>score:>=10</code> — Score ≥ 10</li>
            </ul>
          </section>
          <section><h4>Sorting</h4>
            <ul>
              <li><code>sort:updated:desc</code> — Sort by <b>updated</b> (descending)</li>
              <li>Other sortable types: <code>id</code>, <code>score</code>, <code>rating</code>, <code>user</code>, <code>height</code>, <code>width</code>, <code>parent</code>, <code>source</code>, <code>updated</code></li>
              <li>Can be sorted by both <b>asc</b> or <b>desc</b></li>
            </ul>
          </section>
          <section><h4>Notes</h4>
            <ul>
              <li>Combining the same metatags (with colons) usually does not work.</li>
              <li>You can combine different metatags (e.g. <code>rating:questionable parent:100</code>).</li>
            </ul>
          </section>
        `;
        }
        // Update modal content
        const docWrap = document.createElement('div');
        docWrap.className = 'modal-doc';
        docWrap.innerHTML = cheatSheetContent;
        // Add reference link and cache info
        const infoBar = document.createElement('div');
        infoBar.className = 'cheatsheet-info-bar';
        const refLink = document.createElement('a');
        refLink.href = siteConfig.cheatSheetUrl || '#';
        refLink.target = '_blank';
        refLink.textContent = 'View on site →';
        refLink.className = 'cheatsheet-ref-link';
        const cacheInfo = document.createElement('span');
        cacheInfo.className = 'cheatsheet-cache-info';
        // Check cache status
        const cacheKey = `booru-cheatsheet-${site}`;
        const cached = localStorage.getItem(cacheKey);
        if (cached) {
            try {
                const data = JSON.parse(cached);
                if (typeof data.timestamp === 'number') {
                    const age = Date.now() - data.timestamp;
                    const days = Math.floor(age / (24 * 60 * 60 * 1000));
                    const hours = Math.floor((age % (24 * 60 * 60 * 1000)) / (60 * 60 * 1000));
                    cacheInfo.textContent = `Cached: ${days}d ${hours}h ago`;
                }
                else {
                    cacheInfo.textContent = 'Using static content';
                }
            }
            catch {
                cacheInfo.textContent = 'Using static content';
            }
        }
        else {
            cacheInfo.textContent = 'Using static content';
        }
        const flushBtn = document.createElement('button');
        flushBtn.textContent = 'Refresh Cache';
        flushBtn.type = 'button';
        flushBtn.className = 'cheatsheet-flush-btn';
        flushBtn.onclick = async () => {
            clearCheatSheetCache();
            modalObj.closeModal();
            await showCheatSheetModal(); // Reload the modal
        };
        infoBar.appendChild(refLink);
        infoBar.appendChild(cacheInfo);
        infoBar.appendChild(flushBtn);
        docWrap.appendChild(infoBar);
        // Update modal with new content
        const modalContent = modalObj.modal.querySelector('.modal-content');
        if (!modalContent)
            return;
        const oldDoc = modalContent.querySelector('.modal-doc');
        if (oldDoc) {
            oldDoc.replaceWith(docWrap);
        }
    }
    // Load tags from the URL, render them, and keep the live input cleared —
    // the browser resurrects the submitted "a b c" value on back-navigation
    // (bfcache and regular session-history restore), which would otherwise be
    // treated as one giant tag when the user types the next space.
    function initTagField(field) {
        tags = getTagsFromURL();
        renderTags();
        field.value = '';
        window.addEventListener('pageshow', function () {
            field.value = '';
        });
    }
    function setupSite() {
        const sel = siteConfig.selectors;
        if (!sel)
            return false;
        // Skip pages that shouldn't have the enhanced search
        if (sel.skipPattern && sel.skipPattern.test(location.pathname))
            return false;
        const originalForm = document.querySelector(sel.form);
        const gallery = document.querySelector(sel.gallery);
        if (!originalForm || !gallery)
            return false;
        const section = createSearchSection({
            siteConfig, getTags: () => tags, setTags: next => { tags = next; }, ui,
            tagCategories, addTag, addNormalizedInput, showExportModal, showSavedSearchesModal,
            showCheatSheetModal, showSettingsModal, renderTags
        });
        const { centerWrap, searchForm, searchInput } = section;
        currentQuery = section.currentQuery;
        searchForm.action = originalForm.action || '/posts';
        searchForm.method = originalForm.method || 'GET';
        if (!gallery.parentNode)
            return false;
        gallery.parentNode.insertBefore(centerWrap, gallery);
        const originalArea = sel.hide ? document.querySelector(sel.hide) : originalForm;
        if (originalArea)
            originalArea.style.display = 'none';
        initTagField(searchInput);
        return true;
    }
    function setupWiki() {
        // For wiki pages, create a simple centered search input
        const content = document.querySelector('#content') || document.querySelector('main') || document.body;
        if (!content)
            return false;
        // Create simple search form
        const searchWrap = document.createElement('div');
        searchWrap.className = 'bse-wiki-search-wrap';
        const searchForm = document.createElement('form');
        searchForm.action = '/posts';
        searchForm.method = 'GET';
        const searchInput = document.createElement('input');
        searchInput.type = 'text';
        searchInput.name = 'tags';
        searchInput.placeholder = 'Search posts...';
        searchInput.className = 'bse-search-input';
        searchInput.autocomplete = 'off';
        const searchButton = document.createElement('button');
        searchButton.type = 'submit';
        searchButton.textContent = 'Search';
        searchButton.className = 'bse-search-button';
        searchForm.appendChild(searchInput);
        searchForm.appendChild(searchButton);
        searchWrap.appendChild(searchForm);
        // Insert at the top of content
        content.insertBefore(searchWrap, content.firstChild);
        return true;
    }
    function setupGeneric() {
        const originalForm = findSearchForm();
        if (!originalForm)
            return false;
        const searchField = originalForm.querySelector('input[name="tags"], textarea[name="tags"]');
        if (!searchField)
            return false;
        const tagList = document.createElement('div');
        tagList.className = 'bse-tag-list';
        searchField.insertAdjacentElement('afterend', tagList);
        initTagField(searchField);
        searchField.addEventListener('keydown', function (event) {
            const e = event;
            if (e.key === 'Enter' && !e.shiftKey) {
                const value = searchField.value.trim();
                if (value) {
                    e.preventDefault();
                    if (!tags.includes(value)) {
                        tags.push(value);
                        renderTags();
                    }
                    searchField.value = '';
                }
                else if (tags.length > 0) {
                    e.preventDefault();
                    searchField.value = tags.join(' ');
                    originalForm.submit();
                }
            }
        });
        originalForm.addEventListener('submit', function () {
            if (tags.length > 0) {
                searchField.value = tags.join(' ');
            }
        });
        return true;
    }
    function findSearchForm() {
        let form = document.querySelector('.sidebar form input[name="tags"], .sidebar form textarea[name="tags"]');
        if (form)
            return form.closest('form');
        form = document.querySelector('form input[name="tags"], form textarea[name="tags"]');
        return form ? form.closest('form') : null;
    }
    // --- Style Injection ---
    // --- Main Entrypoint ---
    let initialized = false;
    function init() {
        if (initialized)
            return;
        const setupSuccess = isWikiPage
            ? setupWiki()
            : siteConfig.selectors
                ? setupSite()
                : setupGeneric();
        if (setupSuccess !== false) {
            injectStyles();
            initialized = true;
        }
    }
    // --- Run ---
    if (document.readyState === 'loading') {
        document.addEventListener('DOMContentLoaded', init);
    }
    else {
        init();
    }
    // Also try after a short delay in case of dynamic content
    setTimeout(() => {
        if (!initialized) {
            init();
        }
    }, 1000);
}
// Browser-only bootstrap; skipped when this file is require()d in Node tests.
if (typeof window !== 'undefined')
    main();