☰

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

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

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

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

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.

ستحتاج إلى تثبيت إضافة مثل Stylus لتثبيت هذا النمط.

ستحتاج إلى تثبيت إضافة لإدارة أنماط المستخدم لتتمكن من تثبيت هذا النمط.

ستحتاج إلى تثبيت إضافة لإدارة أنماط المستخدم لتثبيت هذا النمط.

ستحتاج إلى تثبيت إضافة لإدارة أنماط المستخدم لتثبيت هذا النمط.

(لدي بالفعل مثبت أنماط للمستخدم، دعني أقم بتثبيته!)

// ==UserScript==
// @name         Booru Tag Search
// @namespace    codeberg.org/0xdev/booru-tag-search
// @version      2.5.1
// @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        *://e621.net/*
// @grant        none
// ==/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.5.1';

"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);
}
function renderAutocompleteItemHTML(tagName, alias, count, color) {
    // tagName/alias are user-generated tag data — escape before innerHTML.
    const name = escapeHTML(tagName.replace(/_/g, ' '));
    const aliasPart = alias
        ? ` <span style="color:inherit;opacity:var(--bse-secondary-opacity,.7);font-size:0.85em">→ ${escapeHTML(alias.replace(/_/g, ' '))}</span>`
        : '';
    const countPart = count
        ? `<span style="color:inherit;opacity:var(--bse-secondary-opacity,.7);font-size:0.85em;flex-shrink:0">${escapeHTML(count)}</span>`
        : '';
    return `<span style="color:${color};flex-grow:1">${name}${aliasPart}</span>${countPart}`;
}
// 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,
        renderAutocompleteItemHTML,
        isSafeHref,
        cleanCheatSheetHTML,
        normalizeTagInput,
    };
}

"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:)/;
const SITE_CONFIG = {
    "e621.net": {
        site: '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": {
        site: 'danbooru',
        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: 'https://danbooru.donmai.us/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:)/,
    },
    "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: 700px;
        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-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-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 {
        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-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 {
            width: 100vw;
            max-width: 100vw;
        }
    }
    .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);
    }
    /* e621 autocomplete dropdown */
    .bse-ac-dropdown {
        display: none;
        position: absolute;
        z-index: 99999;
        background: Canvas;
        border: 1px solid color-mix(in srgb, currentColor 25%, transparent);
        max-height: 400px;
        overflow-y: auto;
        list-style: none;
        margin: 0;
        padding: 0;
        min-width: 300px;
    }
    .bse-ac-dropdown li {
        padding: 4px 8px;
        cursor: pointer;
        display: flex;
        justify-content: space-between;
        align-items: center;
        gap: 12px;
    }
    .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-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;
    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;
    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, provider === 'gelbooru' ? 'gelbooru' : 'rule34');
            row.className = wrapper.className;
            label.classList.add('bse-suggestion-content');
            const name = document.createElement('span');
            name.textContent = item.label.replace(/^-/, '').replace(/_/g, ' ');
            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();
    }
    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 item = entry;
            if (typeof item.value !== 'string' || !item.value.trim() || seen.has(item.value))
                continue;
            let label = typeof item.label === 'string' ? item.label : item.value;
            let count;
            if (provider === 'gelbooru') {
                if (typeof item.post_count === 'number' || (typeof item.post_count === 'string' && /^\d+$/.test(item.post_count))) {
                    count = Number(item.post_count);
                }
            }
            else {
                const suffix = label.match(/ \((\d+)\)$/);
                if (suffix) {
                    count = Number(suffix[1]);
                    label = label.slice(0, -suffix[0].length);
                }
            }
            const category = normalizeTagCategory(provider === 'gelbooru' ? item.category : item.type, provider === 'gelbooru' ? 'gelbooru' : 'rule34')
                ?? tagCategories.get(item.value);
            tagCategories.remember(item.value, category);
            seen.add(item.value);
            items.push({ value: item.value, label, category, count: typeof count === 'number' && Number.isFinite(count) && count >= 0 ? count : undefined });
            if (items.length === MAX_RESULTS)
                break;
        }
        return items;
    }
    function requestUrl(term) {
        if (provider === 'gelbooru') {
            return '/index.php?page=autocomplete2&' + new URLSearchParams({ term, type: 'tag_query', limit: String(MAX_RESULTS) });
        }
        // 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(term.replace(/^-/, ''));
    }
    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(/^-/, ''))
            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 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, 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);
    searchInput.name = 'tags';
    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 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';
    // --- 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);
    // Track if user is navigating autocomplete with arrow keys
    let autocompleteNavigating = false;
    // Set by the e621 autocomplete binder so other handlers can hide its dropdown.
    let e621HideDropdown = null;
    // --- Event Bindings ---
    function bindInputEvents() {
        searchInput.addEventListener('keydown', function (event) {
            const e = event;
            if (!siteConfig.autocomplete && (e.key === 'ArrowDown' || e.key === 'ArrowUp')) {
                autocompleteNavigating = true;
            }
        });
        searchInput.addEventListener('input', function () {
            autocompleteNavigating = false;
            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 (autocompleteNavigating) {
                    // Let autocomplete handle Enter
                    return;
                }
                // If input is empty and there are tags, submit
                if (searchInput.value.trim() === '' && getTags().length > 0) {
                    e.preventDefault();
                    searchInput.value = getTags().join(' ');
                    searchForm.submit();
                }
                else if (searchInput.value.trim() !== '') {
                    // No suggestion selected, add raw input as tag
                    e.preventDefault();
                    const value = searchInput.value.trim();
                    addNormalizedInput(value);
                    searchInput.value = '';
                    autocompleteNavigating = false;
                    // Close/hide autocomplete dropdown
                    tagAutocomplete?.close();
                    if (site === 'danbooru') {
                        const $ = window.jQuery;
                        if ($ && $(searchInput).autocomplete('instance')) {
                            $(searchInput).autocomplete('close');
                        }
                    }
                    if (site === 'e621' && e621HideDropdown) {
                        e621HideDropdown();
                    }
                }
            }
        });
    }
    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(' ');
            }
            searchForm.submit();
        });
    }
    // --- e621 Autocomplete integration (vanilla JS — e621 does NOT use jQuery UI) ---
    function bindE621Autocomplete() {
        if (site !== 'e621')
            return;
        const dropdown = document.createElement('ul');
        dropdown.className = 'bse-ac-dropdown';
        // Styled via .bse-ac-dropdown in stylesheet
        document.body.appendChild(dropdown);
        watchTagMenuColors(searchInput, colors => {
            dropdown.style.background = colors.background;
            dropdown.style.color = colors.color;
        });
        const DEBOUNCE_MS = 225;
        const MIN_QUERY_LEN = 3;
        const BLUR_HIDE_MS = 150;
        let debounceTimer;
        let selectedIdx = -1;
        const positionDropdown = () => {
            const rect = searchInput.getBoundingClientRect();
            dropdown.style.left = rect.left + window.scrollX + 'px';
            dropdown.style.top = rect.bottom + window.scrollY + 'px';
            dropdown.style.width = Math.max(rect.width, 300) + 'px';
        };
        const hideDropdown = () => { dropdown.style.display = 'none'; selectedIdx = -1; };
        e621HideDropdown = hideDropdown;
        const showResults = (items) => {
            dropdown.replaceChildren();
            if (!items.length) {
                hideDropdown();
                return;
            }
            items.forEach((item, i) => {
                const li = document.createElement('li');
                const category = normalizeTagCategory(item.category, site) ?? tagCategories.get(item.name);
                tagCategories.remember(item.name, category);
                const { wrapper, label } = createTagCategoryLabel(category, site);
                li.className = wrapper.className;
                const tagName = item.antecedent_name || item.name;
                const alias = item.antecedent_name ? item.name : null;
                const count = item.post_count ? formatPostCount(item.post_count) : '';
                label.classList.add('bse-suggestion-content');
                label.innerHTML = renderAutocompleteItemHTML(tagName, alias, count, 'inherit');
                li.append(label);
                li.addEventListener('mousedown', (e) => {
                    e.preventDefault();
                    addTag(item.name);
                    searchInput.value = '';
                    hideDropdown();
                });
                li.addEventListener('mouseenter', () => {
                    selectedIdx = i;
                    highlightItem();
                });
                dropdown.appendChild(li);
            });
            const colors = getTagMenuColors(searchInput);
            dropdown.style.background = colors.background;
            dropdown.style.color = colors.color;
            positionDropdown();
            dropdown.style.display = 'block';
        };
        const highlightItem = () => {
            Array.from(dropdown.children).forEach((li, i) => {
                const item = li;
                item.style.background = i === selectedIdx ? 'color-mix(in srgb, currentColor 10%, transparent)' : 'transparent';
            });
            scheduleTagContrast();
        };
        let abortCtrl = null;
        searchInput.addEventListener('input', () => {
            if (debounceTimer !== undefined)
                window.clearTimeout(debounceTimer);
            const term = searchInput.value.trim();
            if (term.length < MIN_QUERY_LEN) {
                hideDropdown();
                return;
            }
            debounceTimer = window.setTimeout(() => {
                if (abortCtrl)
                    abortCtrl.abort();
                abortCtrl = new AbortController();
                fetch(`/tags/autocomplete.json?search[name_matches]=${encodeURIComponent(term)}&expiry=7`, { signal: abortCtrl.signal })
                    .then(r => r.json())
                    .then(showResults)
                    .catch((e) => {
                    if (!(e instanceof Error) || e.name !== 'AbortError')
                        hideDropdown();
                });
            }, DEBOUNCE_MS);
        });
        searchInput.addEventListener('keydown', (event) => {
            const e = event;
            if (dropdown.style.display === 'none')
                return;
            const items = dropdown.children;
            if (e.key === 'ArrowDown') {
                e.preventDefault();
                selectedIdx = Math.min(selectedIdx + 1, items.length - 1);
                highlightItem();
            }
            else if (e.key === 'ArrowUp') {
                e.preventDefault();
                selectedIdx = Math.max(selectedIdx - 1, 0);
                highlightItem();
            }
            else if (e.key === 'Enter' && selectedIdx >= 0) {
                e.preventDefault();
                items[selectedIdx]?.dispatchEvent(new MouseEvent('mousedown'));
            }
            else if (e.key === 'Escape') {
                hideDropdown();
            }
        });
        searchInput.addEventListener('blur', () => setTimeout(hideDropdown, BLUR_HIDE_MS));
        // Keep the dropdown anchored to the input on scroll/resize while open.
        const reposition = () => { if (dropdown.style.display === 'block')
            positionDropdown(); };
        window.addEventListener('scroll', reposition, true);
        window.addEventListener('resize', reposition);
    }
    // --- Danbooru jQuery UI Autocomplete integration ---
    const DANBOORU_RETRY_MS = 500;
    const DANBOORU_MAX_RETRIES = 20;
    let danbooruRetries = 0;
    function bindDanbooruAutocomplete() {
        if (site !== 'danbooru')
            return;
        const $ = window.jQuery;
        if (!$ || typeof $.fn.autocomplete !== 'function') {
            if (++danbooruRetries < DANBOORU_MAX_RETRIES)
                setTimeout(bindDanbooruAutocomplete, DANBOORU_RETRY_MS);
            return;
        }
        $(searchInput).autocomplete({
            delay: 100,
            minLength: 1,
            source: function (request, response) {
                $.get('/autocomplete.json', {
                    'search[query]': request.term,
                    'search[type]': 'tag_query',
                    limit: 20,
                    version: 3
                }).then(function (data) {
                    response(data.map(function (item) {
                        // post_count/category may be top-level or nested in item.tag
                        const tag = item.tag || {};
                        return {
                            label: item.label || item.value || '',
                            value: item.value || item.label || '',
                            category: item.category ?? tag.category,
                            post_count: item.post_count ?? tag.post_count ?? 0,
                            antecedent: item.antecedent || null
                        };
                    }));
                });
            }
        });
        // Custom rendering with category colors and post counts
        const acInstance = $(searchInput).autocomplete('instance');
        watchTagMenuColors(searchInput, colors => acInstance.menu.element.css(colors));
        acInstance._renderItem = function (ul, item) {
            const category = normalizeTagCategory(item.category, site) ?? tagCategories.get(item.value || item.label || '');
            tagCategories.remember(item.value || item.label || '', category);
            const count = item.post_count ? formatPostCount(item.post_count) : '';
            const tagName = item.antecedent || item.label || item.value || '';
            const alias = item.antecedent ? item.label || null : null;
            const li = $('<li>').appendTo(ul);
            const { wrapper, label } = createTagCategoryLabel(category, site);
            label.classList.add('bse-suggestion-content');
            label.innerHTML = renderAutocompleteItemHTML(tagName, alias, count, 'inherit');
            wrapper.style.padding = '4px 8px';
            wrapper.style.display = 'flex';
            wrapper.style.width = '100%';
            li.append(wrapper);
            return li;
        };
        // Style and position the dropdown menu
        acInstance.menu.element.css({
            ...getTagMenuColors(searchInput),
            border: '1px solid color-mix(in srgb, currentColor 25%, transparent)',
            maxHeight: '400px',
            overflowY: 'auto',
            zIndex: 99999,
            width: 'auto',
            minWidth: $(searchInput).outerWidth() + 'px',
            maxWidth: '500px'
        });
        // Override positioning to anchor to the search input
        acInstance._resizeMenu = function () {
            this.menu.element.css(getTagMenuColors(searchInput));
            this.menu.element.outerWidth(Math.max(this.element.outerWidth(), 300));
        };
        // Intercept selection: add tag to our list instead of inserting into input
        $(searchInput).on('autocompleteselect', function (e, acUi) {
            e.preventDefault();
            if (acUi.item && acUi.item.value) {
                addTag(acUi.item.value);
                searchInput.value = '';
            }
        });
        $(searchInput).on('autocompletefocus', scheduleTagContrast);
    }
    function syncMetatagsFromUI() {
        buildAndApplyMetatags();
        renderTags();
    }
    sortSelect.addEventListener('change', syncMetatagsFromUI);
    ratingCheckboxes.forEach(cb => {
        cb.addEventListener('change', syncMetatagsFromUI);
    });
    // --- Assemble ---
    searchBarContainer.appendChild(exportBtn);
    searchBarContainer.appendChild(searchInput);
    searchBarContainer.appendChild(cheatBtn);
    searchBarContainer.appendChild(settingsBtn);
    searchBarContainer.appendChild(searchButton);
    searchForm.appendChild(searchBarContainer);
    // 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();
    const tagAutocomplete = bindTagAutocomplete(searchInput, siteConfig.autocomplete, addTag, tagCategories);
    bindE621Autocomplete();
    bindDanbooruAutocomplete();
    // Store UI references for renderTags and cross-scope access
    Object.assign(ui, {
        includeList, excludeList, includeRowWrap, excludeRowWrap,
        metatagList, metatagRowWrap, allTagsList, allTagsRowWrap,
        sortSelect, orderSwitch, ratingCheckboxes
    });
    return { centerWrap, searchForm, searchInput };
}

"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 };
    }
    // --- 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 { centerWrap, searchForm, searchInput } = createSearchSection({
            siteConfig, getTags: () => tags, setTags: next => { tags = next; }, ui,
            tagCategories, addTag, addNormalizedInput, showExportModal, showCheatSheetModal,
            showSettingsModal, renderTags
        });
        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();