☰

Booru Tag Search

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

// ==UserScript==
// @name         Booru Tag Search
// @namespace    codeberg.org/0xdev/booru-tag-search
// @version      2.5.2
// @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.2';

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

"use strict";
/* exported 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',
        autocomplete: 'e621',
        sortPrefix: 'order',
        inputTag: 'textarea',
        selectors: { form: 'form.post-search-form', gallery: '#c-posts' },
        cheatSheetUrl: 'https://e621.net/help/cheatsheet',
        cheatSheetSelector: '#c-help #a-show .styled-dtext',
        placeholder: "Enter tags...",
        sortOptions: [
            { value: 'score', label: 'Score' },
            { value: 'favcount', label: 'Favorites' },
            { value: 'comment_count', label: 'Comments' },
            { value: 'id', label: 'ID' },
            { value: 'mpixels', label: 'Megapixels' },
            { value: 'filesize', label: 'File Size' },
            { value: 'landscape', label: 'Landscape' },
            { value: 'portrait', label: 'Portrait' },
            { value: 'created', label: 'Created' },
            { value: 'updated', label: 'Updated' },
            { value: 'tagcount', label: 'Tag Count' },
            { value: 'duration', label: 'Duration' },
            { value: 'random', label: 'Random' }
        ],
        ratings: GELBOORU_RATINGS,
        metatagRegex: /^(order:|rating:|user:|parent:|score:|md5:|width:|height:|source:|id:|favcount:|comment_count:|type:|date:|status:|\( rating:)/,
        cheatSheetContent: `
            <section><h4>Basics</h4>
                <ul>
                    <li><code>cat dog</code> — Search for posts that are tagged with both cat and dog. Tags are separated by spaces.</li>
                    <li><code>red_panda african_wild_dog</code> — Words within each tag are separated by underscores.</li>
                    <li><code>~cat ~dog</code> — Search for posts that are tagged either cat or dog (or both). May not work well when combined with other syntaxes.</li>
                    <li><code>-chicken</code> — Search for posts that don't have the chicken tag.</li>
                    <li><code>fox -chicken</code> — Search for posts that are tagged with fox and are not tagged with chicken.</li>
                    <li><code>african_*</code> — Search for posts with any tag that starts with african_, such as african_wild_dog or african_golden_cat. May not work well when combined with other syntaxes. Limit one wildcard per search.</li>
                    <li><code>( ~cat ~tiger ~leopard ) ( ~dog ~wolf )</code> — Search for posts that are tagged with one (or more) of cat, tiger or leopard, and one (or more) of dog or wolf.</li>
                </ul>
            </section>
            <section><h4>Sorting</h4>
                <ul>
                    <li><code>order:id</code> — Oldest to newest</li>
                    <li><code>order:id_desc</code> — Newest to oldest</li>
                    <li><code>order:score</code> — Highest score first</li>
                    <li><code>order:score_asc</code> — Lowest score first</li>
                    <li><code>order:favcount</code> — Most favorites first</li>
                    <li><code>order:favcount_asc</code> — Least favorites first</li>
                    <li><code>order:comment_count</code> — Most comments first</li>
                    <li><code>order:comment_count_asc</code> — Least comments first</li>
                    <li><code>order:mpixels</code> — Largest resolution first</li>
                    <li><code>order:mpixels_asc</code> — Smallest resolution first</li>
                    <li><code>order:filesize</code> — Largest file size first</li>
                    <li><code>order:filesize_asc</code> — Smallest file size first</li>
                    <li><code>order:landscape</code> — Wide and short to tall and thin</li>
                    <li><code>order:portrait</code> — Tall and thin to wide and short</li>
                    <li><code>order:duration</code> — Video duration longest to shortest</li>
                    <li><code>order:duration_asc</code> — Video duration shortest to longest</li>
                    <li><code>order:random</code> — Orders posts randomly</li>
                </ul>
            </section>
            <section><h4>User Metatags</h4>
                <ul>
                    <li><code>user:Bob</code> — Posts uploaded by Bob</li>
                    <li><code>fav:Bob</code> or <code>favoritedby:Bob</code> — Posts favorited by Bob</li>
                    <li><code>voted:anything</code> — Posts you voted on. Only works while logged in.</li>
                    <li><code>votedup:anything</code> or <code>upvote:anything</code> — Posts you upvoted. Only works while logged in.</li>
                    <li><code>voteddown:anything</code> or <code>downvote:anything</code> — Posts you downvoted. Only works while logged in.</li>
                    <li><code>approver:Bob</code> — Posts approved by Bob</li>
                    <li><code>commenter:Bob</code> or <code>comm:Bob</code> — Posts commented on by Bob</li>
                    <li><code>noter:Bob</code> — Posts with notes written by Bob</li>
                </ul>
            </section>
            <section><h4>Post Metatags - Counts</h4>
                <ul>
                    <li><code>id:100</code> — Post with an ID of 100</li>
                    <li><code>score:100</code> — Posts with a score of 100</li>
                    <li><code>favcount:100</code> — Posts with exactly 100 favorites</li>
                    <li><code>comment_count:100</code> — Posts with exactly 100 comments</li>
                    <li><code>tagcount:2</code> — Posts with exactly 2 tags</li>
                    <li><code>gentags:2</code> — Posts with exactly 2 general tags</li>
                    <li><code>arttags:2</code> — Posts with exactly 2 artist tags</li>
                    <li><code>chartags:2</code> — Posts with exactly 2 character tags</li>
                    <li><code>copytags:2</code> — Posts with exactly 2 copyright tags</li>
                    <li><code>spectags:2</code> — Posts with exactly 2 species tags</li>
                    <li><code>invtags:2</code> — Posts with exactly 2 invalid tags</li>
                    <li><code>lortags:2</code> — Posts with exactly 2 lore tags</li>
                    <li><code>metatags:2</code> — Posts with exactly 2 meta tags</li>
                </ul>
            </section>
            <section><h4>Rating</h4>
                <ul>
                    <li><code>rating:safe</code> or <code>rating:s</code> — Posts rated safe</li>
                    <li><code>rating:questionable</code> or <code>rating:q</code> — Posts rated questionable</li>
                    <li><code>rating:explicit</code> or <code>rating:e</code> — Posts rated explicit</li>
                </ul>
            </section>
            <section><h4>File Types</h4>
                <ul>
                    <li><code>type:jpg</code> — Posts that are JPG, a type of image</li>
                    <li><code>type:png</code> — Posts that are PNG, a type of image</li>
                    <li><code>type:gif</code> — Posts that are GIF, a type of image (may be animated)</li>
                    <li><code>type:swf</code> — Posts that are Flash, a format used for animation</li>
                    <li><code>type:webm</code> — Posts that are WebM, a type of video</li>
                </ul>
            </section>
            <section><h4>Image Size</h4>
                <ul>
                    <li><code>width:100</code> — Posts with a width of 100 pixels</li>
                    <li><code>height:100</code> — Posts with a height of 100 pixels</li>
                    <li><code>mpixels:1</code> — Posts that are 1 megapixel (a 1000x1000 image equals 1 megapixel)</li>
                    <li><code>ratio:1.33</code> — Search for posts with a ratio of 4:3. All ratios are rounded to two digits, therefore 1.33 will return posts with a ratio of 4:3.</li>
                    <li><code>filesize:200KB</code> — Posts with a file size of 200 kilobytes. File sizes within ±5% of the value are included.</li>
                    <li><code>filesize:2MB</code> — Posts with a file size of 2 megabytes. File sizes within ±5% of the value are included.</li>
                </ul>
            </section>
            <section><h4>Post Status</h4>
                <ul>
                    <li><code>status:pending</code> — Posts that are waiting to be approved or deleted</li>
                    <li><code>status:active</code> — Posts that have been approved</li>
                    <li><code>status:deleted</code> — Posts that have been deleted</li>
                    <li><code>status:flagged</code> — Posts that are flagged for deletion</li>
                    <li><code>status:modqueue</code> — Posts that are pending or flagged</li>
                    <li><code>status:any</code> — All active or deleted posts</li>
                </ul>
            </section>
            <section><h4>Dates</h4>
                <ul>
                    <li><code>date:2012-04-27</code> or <code>date:april/27/2012</code> — Search for posts uploaded on a specific date</li>
                    <li><code>date:today</code> — Posts from today</li>
                    <li><code>date:yesterday</code> — Posts from yesterday</li>
                    <li><code>date:week</code> — Posts from the last 7 days</li>
                    <li><code>date:month</code> — Posts from the last 30 days</li>
                    <li><code>date:year</code> — Posts from the last 365 days</li>
                    <li><code>date:5_days_ago</code> — Posts from within the last 5 days</li>
                    <li><code>date:5_weeks_ago</code> — Posts from within the last 5 weeks</li>
                    <li><code>date:5_months_ago</code> — Posts from within the last 5 months</li>
                    <li><code>date:5_years_ago</code> — Posts from within the last 5 years</li>
                    <li><code>date:yesterweek</code> — Posts from last week</li>
                    <li><code>date:yestermonth</code> — Posts from last month</li>
                    <li><code>date:yesteryear</code> — Posts from last year</li>
                </ul>
            </section>
            <section><h4>Text Searching</h4>
                <ul>
                    <li><code>source:*example.com</code> — Posts with a source that contains "example.com", prefix matched, use wildcards as needed</li>
                    <li><code>source:none</code> — Posts without a source</li>
                    <li><code>description:whatever</code> — Posts with a description that contains the text "whatever"</li>
                    <li><code>description:"hello there"</code> — Posts with a description that contains the text "hello there"</li>
                    <li><code>note:whatever</code> — Posts with a note that contains the text "whatever"</li>
                    <li><code>delreason:*whatever</code> — Deleted posts that contain a reason with the text "whatever", prefix matched, use wildcards as needed</li>
                </ul>
            </section>
            <section><h4>Parents and Children</h4>
                <ul>
                    <li><code>ischild:true</code> — Posts that are a child</li>
                    <li><code>ischild:false</code> — Posts that aren't a child</li>
                    <li><code>isparent:true</code> — Posts that are a parent</li>
                    <li><code>isparent:false</code> — Posts that aren't a parent</li>
                    <li><code>parent:1234</code> — Posts with a parent of 1234</li>
                    <li><code>parent:none</code> — Posts with no parent (same as ischild:false)</li>
                </ul>
            </section>
            <section><h4>Other</h4>
                <ul>
                    <li><code>hassource:true</code> — Posts with a source</li>
                    <li><code>hassource:false</code> — Posts without a source</li>
                    <li><code>hasdescription:true</code> — Posts with a description</li>
                    <li><code>hasdescription:false</code> — Posts without a description</li>
                    <li><code>inpool:true</code> — Posts that are in a pool</li>
                    <li><code>inpool:false</code> — Posts that aren't in a pool</li>
                    <li><code>pool:4</code> or <code>pool:fox_and_the_grapes</code> — Posts in the pool "Fox and the Grapes"</li>
                    <li><code>set:17</code> or <code>set:cute_rabbits</code> — Posts in the set with the short name "cute_rabbits"</li>
                    <li><code>md5:02dd0...</code> — Post with the given MD5 hash. MD5 hashes will never be shared by more than one image.</li>
                    <li><code>duration:>120</code> — Videos with a duration of at least 120 seconds</li>
                </ul>
            </section>
            <section><h4>Range Syntax</h4>
                <ul>
                    <li><code>id:100</code> — Post with an ID of exactly 100</li>
                    <li><code>date:year..month</code> — Posts uploaded between 30 days ago and 1 year ago</li>
                    <li><code>filesize:200KB..300KB</code> — Posts with a file size between 200 kilobytes and 300 kilobytes</li>
                    <li><code>score:25..50</code> — Posts with a score between 25 and 50</li>
                    <li><code>score:>=100</code> — Posts with a score of 100 or greater (100+)</li>
                    <li><code>score:>100</code> — Posts with a score greater than 100 (101+)</li>
                    <li><code>favcount:<=100</code> — Posts with 100 or less favorites (0-100)</li>
                    <li><code>favcount:<100</code> — Posts with less than 100 favorites (0-99)</li>
                </ul>
            </section>
        `
    },
    "rule34.xxx": {
        site: 'rule34',
        autocomplete: 'rule34',
        sortPrefix: 'sort',
        inputTag: 'input',
        selectors: { form: '.sidebar .tag-search form', gallery: '#post-list' },
        cheatSheetUrl: 'https://rule34.xxx/index.php?page=help&topic=cheatsheet',
        cheatSheetSelector: '.content',
        placeholder: "Enter tags...",
        sortOptions: GELBOORU_SORT_OPTIONS,
        ratings: GELBOORU_RATINGS,
        metatagRegex: GELBOORU_METATAG_REGEX,
        cheatSheetContent: GELBOORU_CHEATSHEET
    },
    "gelbooru.com": {
        site: 'gelbooru',
        autocomplete: 'gelbooru',
        sortPrefix: 'sort',
        inputTag: 'input',
        selectors: { form: '.searchArea form', gallery: 'main .thumbnail-container', hide: '.searchArea' },
        cheatSheetUrl: 'https://gelbooru.com/index.php?page=wiki&s=view&id=26263',
        cheatSheetSelector: 'table td',
        placeholder: 'Enter tags...',
        sortOptions: GELBOORU_SORT_OPTIONS.filter(option => option.value !== 'parent'),
        ratings: GELBOORU_RATINGS,
        metatagRegex: /^(sort:|-?rating:|user:|parent:|score:|md5:|width:|height:|source:|fav:|pool:)/,
        cheatSheetContent: GELBOORU_CHEATSHEET
            .replace('( tag1 ~ tag2 )', '{tag1 ~ tag2}')
            .replace('<code>parent</code>, ', ''),
    },
    // safebooru uses the same layout as rule34, with a same-origin endpoint.
    "safebooru.org": {
        site: 'rule34',
        autocomplete: 'safebooru',
        sortPrefix: 'sort',
        inputTag: 'input',
        selectors: { form: '.sidebar .tag-search form', gallery: '#post-list' },
        placeholder: "Enter tags...",
        sortOptions: GELBOORU_SORT_OPTIONS,
        ratings: GELBOORU_RATINGS,
        metatagRegex: GELBOORU_METATAG_REGEX,
        cheatSheetContent: GELBOORU_CHEATSHEET
    },
    "danbooru.donmai.us": {
        site: 'danbooru',
        autocomplete: '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);
    }
    .bse-tag-suggestions {
        position: absolute;
        z-index: 99999;
        box-sizing: border-box;
        max-height: 400px;
        overflow-y: auto;
        list-style: none;
        margin: 0;
        padding: 0;
        border: 1px solid color-mix(in srgb, currentColor 25%, transparent);
        box-shadow: 0 3px 10px #0002;
        font: inherit;
    }
    .bse-tag-suggestions[hidden] { display: none; }
    .bse-tag-suggestions > li {
        display: flex;
        align-items: center;
        justify-content: space-between;
        gap: 12px;
        padding: 8px 12px;
    }
    .bse-tag-suggestions [role="option"] { cursor: pointer; }
    .bse-tag-suggestions [aria-selected="true"] {
        background: color-mix(in srgb, currentColor 10%, transparent);
    }
    .bse-native-tag { transition: background .2s, border-color .2s; }
    .bse-suggestion-alias { opacity: var(--bse-secondary-opacity, .7); font-size: .85em; }
    .bse-suggestion-count { opacity: var(--bse-secondary-opacity, .7); flex-shrink: 0; }
    .bse-suggestion-content {
        display: flex;
        justify-content: space-between;
        align-items: center;
        width: 100%;
        gap: 12px;
        text-decoration: none;
        cursor: pointer;
    }
    .bse-suggestion-status button {
        background: none;
        color: inherit;
        border: 0;
        padding: 0;
        font: inherit;
        text-decoration: underline;
        cursor: pointer;
    }
    /* Wiki search */
    .bse-wiki-search-wrap {
        display: flex;
        justify-content: center;
        margin: 20px auto;
        max-width: 600px;
        padding: 0 20px;
    }
    .bse-wiki-search-wrap form {
        display: flex;
        gap: 10px;
        width: 100%;
        align-items: center;
    }
`;
    document.head.appendChild(style);
}

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

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

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

"use strict";
/* exported 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);
    // Named only on submit: Rule34's and Safebooru's awesomplete.min.js
    // attach their own menu to every *[name="tags"] on window.onload.
    searchInput.placeholder = siteConfig.placeholder || 'Enter tags...';
    searchInput.className = 'bse-search-input';
    // Prevent the browser from resurrecting the submitted value (e.g.
    // "solo futanari pussy") on back-navigation — it would otherwise
    // get glommed into a single tag when the user types a space.
    searchInput.autocomplete = 'off';
    if (inputTag === 'textarea') {
        searchInput.rows = 1;
        searchInput.id = 'tags';
    }
    else {
        searchInput.type = 'text';
    }
    // --- Searchbar Buttons ---
    const exportBtn = document.createElement('button');
    exportBtn.type = 'button';
    exportBtn.textContent = 'Export';
    exportBtn.className = 'bse-export-btn';
    exportBtn.title = 'Export tags';
    exportBtn.onclick = showExportModal;
    const 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);
    // --- Event Bindings ---
    function bindInputEvents() {
        searchInput.addEventListener('input', function () {
            let value = searchInput.value;
            // Only split on comma or space if not inside parentheses
            const endsWithSeparator = /[ ,]$/.test(value);
            if (endsWithSeparator) {
                // Parse the input to check if we're inside a parenthetical group
                // This handles both grouping parentheses like "( rating:safe ~ rating:questionable )"
                // and tag name parentheses like "silvia_(artist)"
                let parenLevel = 0;
                let inGroupParens = false;
                let lastChar = '';
                for (let i = 0; i < value.length - 1; i++) { // -1 to exclude the trailing separator
                    const char = value[i];
                    if ((char === '(' || (site === 'gelbooru' && char === '{')) && (i === 0 || lastChar === ' ')) {
                        // Opening paren at start or after space - likely a grouping paren
                        parenLevel++;
                        inGroupParens = true;
                    }
                    else if ((char === ')' || (site === 'gelbooru' && char === '}')) && parenLevel > 0) {
                        parenLevel--;
                        if (parenLevel === 0) {
                            inGroupParens = false;
                        }
                    }
                    if (char !== ' ')
                        lastChar = char;
                }
                // Only split if we're not inside grouping parentheses
                if (parenLevel === 0 && !inGroupParens) {
                    value = value.replace(/[ ,]+$/, '').trim();
                    addNormalizedInput(value);
                    searchInput.value = '';
                }
            }
        });
        // Only submit form on Enter if input is empty and there are tags
        searchInput.addEventListener('keydown', function (event) {
            const e = event;
            if (e.key === 'Enter' && !e.shiftKey) {
                // If input is empty and there are tags, submit
                if (searchInput.value.trim() === '' && getTags().length > 0) {
                    e.preventDefault();
                    searchInput.value = getTags().join(' ');
                    submitSearch();
                }
                else if (searchInput.value.trim() !== '') {
                    // No suggestion selected, add raw input as tag
                    e.preventDefault();
                    const value = searchInput.value.trim();
                    addNormalizedInput(value);
                    searchInput.value = '';
                    tagAutocomplete?.close();
                }
            }
        });
    }
    function buildAndApplyMetatags() {
        const prefix = siteConfig.sortPrefix;
        const prefixes = [prefix + ':', 'rating:', '( rating:'];
        const excludesEveryRating = site === 'gelbooru' && siteConfig.ratings.every(rating => getTags().includes(`-rating:${rating.value}`));
        if (site === 'gelbooru')
            prefixes.push('-rating:');
        setTags(getTags().filter(tag => !prefixes.some(p => tag.startsWith(p))));
        if (sortSelect.value) {
            const order = orderSwitch.dataset.state || 'desc';
            if (prefix === 'order') {
                getTags().push(order === 'asc' ? `order:${sortSelect.value}_asc` : `order:${sortSelect.value}`);
            }
            else {
                getTags().push(`sort:${sortSelect.value}:${order}`);
            }
        }
        const checkedRatings = [...ratingCheckboxes].filter(cb => cb.checked).map(cb => cb.value);
        if (checkedRatings.length === 1) {
            getTags().push(`rating:${checkedRatings[0]}`);
        }
        else if (checkedRatings.length > 1) {
            if (site === 'gelbooru') {
                getTags().push(...siteConfig.ratings.filter(r => !checkedRatings.includes(r.value)).map(r => `-rating:${r.value}`));
            }
            else {
                getTags().push('( ' + checkedRatings.map(r => `rating:${r}`).join(' ~ ') + ' )');
            }
        }
        else if (excludesEveryRating) {
            getTags().push(...siteConfig.ratings.map(rating => `-rating:${rating.value}`));
        }
    }
    function bindFormEvents() {
        searchForm.addEventListener('submit', function (e) {
            e.preventDefault();
            const pending = searchInput.value.trim();
            if (pending)
                addNormalizedInput(pending);
            buildAndApplyMetatags();
            if (getTags().length > 0) {
                searchInput.value = getTags().join(' ');
            }
            submitSearch();
        });
    }
    function submitSearch() {
        searchInput.name = 'tags';
        searchForm.submit();
    }
    function syncMetatagsFromUI() {
        buildAndApplyMetatags();
        renderTags();
    }
    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);
    // 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();