☰

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 เพื่อติดตั้งสคริปต์นี้

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

คุณจะต้องติดตั้งส่วนขยาย เช่น Tampermonkey หรือ Violentmonkey เพื่อติดตั้งสคริปต์นี้

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

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

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

(I already have a user script manager, let me install it!)

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

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

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

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

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

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

(I already have a user style manager, let me install it!)

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