☰

BooruKit Grabber

Downloads selected booru posts through the BooruKit userscript suite.

Tento skript by neměl být instalován přímo. Jedná se o knihovnu, kterou by měly jiné skripty využívat pomocí meta příkazu // @require https://update.sleazyfork.org/scripts/589429/1973234/BooruKit%20Grabber.js

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

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

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

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

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

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

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

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

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

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

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

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

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

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

// ==UserScript==
// @name         BooruKit Grabber
// @namespace    codeberg.org/0xdev/boorukit
// @version      0.4.1
// @description  Downloads selected booru posts through the BooruKit userscript suite.
// @author       0xdev
// @license      LGPL-3.0-or-later
// ==/UserScript==

// THIS FILE IS AUTO-GENERATED — DO NOT MANUALLY EDIT.
// Source: boorukit/boorukit-grabber.library.js
(() => {
    'use strict';
    function postsApi() {
        return typeof window === 'undefined'
            ? module.require('../../shared/src/posts.ts')
            : globalThis[Symbol.for('boorukit/posts/v1')];
    }
    function legacyGrabberSettings(raw) {
        if (raw === null)
            return { writeTagsTxt: false };
        try {
            const parsed = JSON.parse(raw);
            if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
                return { writeTagsTxt: false };
            }
            const record = parsed;
            if (Object.keys(record).some((key) => key !== 'writeTagsTxt')
                || typeof record.writeTagsTxt !== 'boolean') {
                return { writeTagsTxt: false };
            }
            return { writeTagsTxt: record.writeTagsTxt };
        }
        catch {
            return { writeTagsTxt: false };
        }
    }
    async function migrateLegacyGrabberSettings(context) {
        const snapshot = context.settings.snapshot();
        if (snapshot.migrations.bbdSettings) {
            return snapshot.grabber ?? { writeTagsTxt: false };
        }
        if (snapshot.grabber !== null) {
            const saved = await context.settings.update((current) => ({
                ...current,
                migrations: { ...current.migrations, bbdSettings: true },
            }));
            return saved.grabber ?? { writeTagsTxt: false };
        }
        const raw = typeof localStorage === 'undefined'
            ? null
            : localStorage.getItem('bbd-settings');
        const imported = legacyGrabberSettings(raw);
        const saved = await context.settings.update((current) => {
            if (current.grabber !== null || current.migrations.bbdSettings) {
                return current;
            }
            return {
                ...current,
                grabber: imported,
                migrations: { ...current.migrations, bbdSettings: true },
            };
        });
        return saved.grabber ?? { writeTagsTxt: false };
    }
    function gmDownloadAsync(details) {
        return new Promise((resolve, reject) => {
            GM_download({
                ...details,
                onload: () => resolve(),
                onerror: (error) => reject(new Error(error?.error ? `download ${error.error}` : 'download error')),
                ontimeout: () => reject(new Error('download timeout')),
            });
        });
    }
    async function downloadSidecar(doc, name, text, mime) {
        const blob = new Blob([text], { type: mime });
        const url = URL.createObjectURL(blob);
        try {
            await gmDownloadAsync({ url, name });
        }
        catch {
            const anchor = doc.createElement('a');
            anchor.href = url;
            anchor.download = name;
            doc.body.appendChild(anchor);
            anchor.click();
            anchor.remove();
        }
        finally {
            setTimeout(() => URL.revokeObjectURL(url), 10_000);
        }
    }
    async function downloadPost(doc, settings, record, referer) {
        const { fileNameFor, sidecarBaseFor, sidecarJsonFor, tagsTxtFor, savedPicked } = postsApi();
        const picked = savedPicked(settings.pickedTags);
        await gmDownloadAsync({
            url: record.fileUrl,
            name: fileNameFor(record, settings.filenameFormat, picked),
            ...(referer === undefined ? {} : { headers: { Referer: referer } }),
        });
        await downloadSidecar(doc, `${sidecarBaseFor(record, settings.filenameFormat, picked)}.json`, sidecarJsonFor(record), 'application/json');
        if (settings.writeTagsTxt) {
            await downloadSidecar(doc, `${sidecarBaseFor(record, settings.filenameFormat, picked)}.txt`, tagsTxtFor(record), 'text/plain');
        }
    }
    async function runGrabber(context, settings, selection) {
        const { resolveGrabberSiteConfig, makeQueueItem, runGrabberQueue, resolvePost } = postsApi();
        if (selection.length === 0)
            return;
        const config = resolveGrabberSiteConfig(context.page.hostname);
        if (config === null)
            return;
        const items = selection.map((selected) => makeQueueItem(selected.id, selected.md5));
        // Gelbooru's image host refuses a request without the site as referrer. Other sites get none, so a
        // userscript manager that rejects the header cannot break their downloads.
        const referer = config.dapiFlavor === 'page' ? `${context.page.origin}/` : undefined;
        await runGrabberQueue(context.queue, context.tasks, items, {
            resolve: (item) => resolvePost(context.page.origin, context.page.normalizedHostname, config, item),
            download: (record) => downloadPost(document, settings, record, referer),
            sleep: (milliseconds) => new Promise((resolve) => {
                setTimeout(resolve, milliseconds);
            }),
        }, (item, state, _completed, _total, error) => {
            if (state === 'done') {
                context.deck.setItemState(item.id, 'done');
            }
            else if (state === 'failed') {
                const reason = error?.message ?? 'unknown error';
                context.deck.setItemState(item.id, 'failed', reason);
                context.diagnostics.report('grabber', error ?? new Error(reason));
            }
            else {
                context.deck.setItemState(item.id, 'running');
            }
        });
    }
    function createGrabberDescriptor() {
        return {
            kind: 'feature',
            id: 'grabber',
            label: 'Grabber',
            apiVersion: 1,
            defaultEnabled: true,
            supports: (page) => postsApi().resolveGrabberSiteConfig(page.hostname) !== null,
            activate: async (context) => {
                const { savedFormat } = postsApi();
                let settings = await migrateLegacyGrabberSettings(context);
                context.deck.registerSettingsPanel?.({
                    id: 'grabber.filename', label: 'Filename format',
                    render: (container) => {
                        const editor = globalThis[Symbol.for('boorukit/format-control/v1')];
                        editor.mount(container, { format: savedFormat(settings.filenameFormat), picked: settings.pickedTags ?? '' }, async ({ format, picked }) => {
                            const saved = await context.settings.update(current => ({ ...current,
                                grabber: { writeTagsTxt: false, ...current.grabber, filenameFormat: format, pickedTags: picked } }));
                            settings = saved.grabber ?? settings;
                        });
                    },
                });
                context.deck.registerAction({
                    id: 'grabber.download',
                    label: 'Download',
                    run: (selection) => runGrabber(context, settings, selection),
                });
                context.deck.registerSetting({
                    id: 'grabber.write-tags',
                    label: 'Also write tags .txt',
                    checked: settings.writeTagsTxt,
                    reloadRequired: true,
                    onChange: async (checked) => {
                        await context.settings.update((current) => ({
                            ...current,
                            grabber: { ...current.grabber, writeTagsTxt: checked },
                        }));
                    },
                });
            },
        };
    }
    function registerGlobalGrabber() {
        const key = Symbol.for('codeberg.org/0xdev/boorukit/runtime/v1');
        const runtimes = globalThis;
        const runtime = runtimes[key];
        if (!runtime || runtime.apiVersion !== 1) {
            throw new Error('BooruKit Core API 1 is not available');
        }
        runtime.register(createGrabberDescriptor());
    }
    if (typeof window !== 'undefined')
        registerGlobalGrabber();
    if (typeof window === 'undefined'
        && typeof module !== 'undefined'
        && module.exports) {
        module.exports = {
            ...postsApi(),
            createGrabberDescriptor,
            downloadPost,
            migrateLegacyGrabberSettings,
            runGrabber,
        };
    }
})();

// Shared filename and post-page metadata handling for Grabber and Firefox.
(() => {
    const DEFAULT_FORMAT = '{artist} - {character} - {copyright} - {id} - {site}';
    const FIELDS = ['artist', 'character', 'copyright', 'id', 'site', 'meta', 'rating', 'picked'];
    const ROOTS = ['rule34.xxx', 'gelbooru.com', 'safebooru.org', 'hypnohub.net', 'e621.net', 'e926.net'];
    function supportedSite(host) {
        return ROOTS.includes(host.toLowerCase().replace(/^www\./, '')) || /^[a-z0-9-]+\.booru\.org$/i.test(host);
    }
    const MEDIA_EXTENSION = /\.(?:jpe?g|png|gif|webp|avif|webm|mp4|ogv)(?:[?#]|$)/i;
    function mediaUrl(value, site) {
        try {
            const url = new URL(value);
            const root = site.replace(/^www\./, '');
            return ['https:', 'http:'].includes(url.protocol) && !url.username && !url.password
                && (url.hostname === root || url.hostname.endsWith(`.${root}`)
                    || (root.endsWith('.booru.org') && url.hostname === 'img.booru.org'))
                && MEDIA_EXTENSION.test(url.href);
        }
        catch {
            return false;
        }
    }
    function validateFormat(value) {
        if (!value.trim() || value.length > 500)
            throw new Error('Enter a filename format of 1–500 characters.');
        const rest = value.replace(/\{([a-z]+)\}/g, (_whole, field) => {
            if (!FIELDS.includes(field))
                throw new Error(`Unknown field: {${field}}.`);
            return '';
        });
        if (/[{}]/.test(rest))
            throw new Error('Use fields such as {artist} and {id}.');
        return value;
    }
    // Filename controls include C0/C1, bidi controls and the portable reserved set.
    const UNSAFE = /[\p{Cc}\p{Cf}<>:"/\\|?*]/gu;
    function safePart(value) {
        const safe = value.replace(UNSAFE, '_').replace(/^[. ]+|[. ]+$/g, '');
        return /^(?:con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\.|$)/i.test(safe) ? `_${safe}` : safe;
    }
    // Made on first use: loading the library must not need TextEncoder.
    let encoder;
    const bytes = (value) => (encoder ??= new TextEncoder()).encode(value).length;
    // A line holds one or more tags, or "name = tags": the post matches the name or any of the tags,
    // and the filename shows the name.
    function parsePicked(text) {
        if (text.length > 5000)
            throw new Error('Enter at most 5,000 characters of picked tags.');
        const lines = text.split(/\r?\n/);
        if (lines.filter(line => line.trim()).length > 100)
            throw new Error('Enter at most 100 lines of picked tags.');
        const entries = [];
        lines.forEach((line, index) => {
            const tokens = line.split(/\s+/).filter(Boolean), at = tokens.indexOf('=');
            if (at === -1) {
                for (const tag of tokens)
                    entries.push({ name: tag, tags: [tag.toLowerCase()] });
                return;
            }
            const problem = tokens.lastIndexOf('=') !== at ? 'use one = per line' : at === 0 ? 'put a name before ='
                : at > 1 ? 'put one name before =' : at === tokens.length - 1 ? 'put tags after =' : '';
            if (problem)
                throw new Error(`Line ${index + 1}: ${problem}.`);
            entries.push({ name: tokens[0], tags: [...new Set([tokens[0], ...tokens.slice(2)].map(tag => tag.toLowerCase()))] });
        });
        return entries;
    }
    // A stored list that no longer parses picks nothing rather than failing a download.
    function savedPicked(value) {
        if (typeof value !== 'string')
            return [];
        try {
            return parsePicked(value);
        }
        catch {
            return [];
        }
    }
    function pickedNames(record, picked) {
        if (!picked.length)
            return '';
        const has = new Set([...(record.tags ?? []), ...Object.values(record.tagGroups ?? {}).flat()]
            .filter((tag) => typeof tag === 'string').map(tag => tag.toLowerCase()));
        // A name listed twice keeps its first place, and either of its lines can match.
        const names = new Map();
        for (const entry of picked) {
            const slot = names.get(entry.name.toLowerCase()) ?? { name: entry.name, matched: false };
            slot.matched ||= entry.tags.some(tag => has.has(tag));
            names.set(entry.name.toLowerCase(), slot);
        }
        return [...names.values()].filter(slot => slot.matched).map(slot => slot.name).join(', ');
    }
    function filename(record, format = DEFAULT_FORMAT, picked = []) {
        validateFormat(format);
        const id = safePart(record.id.replace(/[^A-Za-z0-9_-]/g, '')) || 'unknown';
        const site = record.site.replace(/^www\./, '').split('.')[0];
        const values = { id, site, rating: record.rating ?? '', picked: pickedNames(record, picked) };
        for (const key of ['artist', 'character', 'copyright', 'meta'])
            values[key] = (record.tagGroups?.[key] ?? []).join(', ');
        const sections = format.split(' - ').map(template => ({ template, text: template.replace(/\{([a-z]+)\}/g, (_, key) => values[key]).trim() }));
        const base = safePart(sections.map(section => section.text).filter(Boolean).join(' - ')) || `${site}_${id}`;
        const ext = /^[a-z0-9]{1,10}$/i.test(record.ext) ? record.ext.toLowerCase() : 'bin';
        if (bytes(`${base}.${ext}`) <= 220)
            return `${base}.${ext}`;
        const kept = shortenBeforeId(sections, ext);
        if (kept)
            return kept;
        const suffix = ` - ${id}.${ext}`;
        let shortened = '';
        for (const char of base) {
            if (bytes(shortened + char + suffix) > 220)
                break;
            shortened += char;
        }
        return `${safePart(shortened)}${suffix}`;
    }
    // The section holding {id}, the sections after it and every {picked} section stay whole; the others are cut
    // from the last one back. Null when the format has no {id} or opens with it, or when the whole sections leave
    // no room for the rest.
    function shortenBeforeId(sections, ext) {
        const idAt = sections.findIndex(section => section.template.includes('{id}'));
        if (idAt < 1)
            return null;
        // A 220-byte name holds at most 220 code points of a section, so the rest is dropped before any cutting.
        const parts = sections.map((section, index) => ({ text: /^[\s\S]{0,220}/u.exec(section.text)[0].replace(UNSAFE, '_'),
            cut: index < idAt && !section.template.includes('{picked}') }));
        const name = () => safePart(parts.map(part => part.text).filter(Boolean).join(' - '));
        for (const part of [...parts].reverse()) {
            const over = bytes(`${name()}.${ext}`) - 220;
            if (over <= 0)
                break;
            if (!part.cut)
                continue;
            const chars = [...part.text];
            for (let removed = 0; chars.length && removed < over;)
                removed += bytes(chars.pop());
            // A cut that lands between tags leaves no comma, dot or space at its end.
            part.text = chars.join('').replace(/[,. ]+$/, '');
        }
        const result = `${name()}.${ext}`;
        return parts.some(part => part.cut && part.text) && bytes(result) <= 220 ? result : null;
    }
    function groupsFromRaw(raw) {
        const groups = {};
        for (const [category, list] of Object.entries(raw)) {
            if (!/^[a-z_]+$/.test(category) || !Array.isArray(list))
                continue;
            Object.defineProperty(groups, category, { value: [...new Set(list.filter((tag) => typeof tag === 'string'))], enumerable: true });
        }
        return groups;
    }
    // e621 stores tag names percent-encoded. decodeURIComponent keeps a literal '+' that a
    // query-string decoder would read as a space, and throws on a '%' that is not an escape.
    function decodedTag(value) {
        try {
            return decodeURIComponent(value);
        }
        catch {
            return value;
        }
    }
    const MAX_SOURCES = 50, MAX_SOURCE_LENGTH = 2048;
    function limitSources(list) {
        return list.filter(source => source.length <= MAX_SOURCE_LENGTH).slice(0, MAX_SOURCES);
    }
    // A source list from outside the extension's own pages is kept only whole and within the limits.
    function validSources(value) {
        // Array.from turns holes into undefined, which every() would otherwise skip.
        const list = Array.isArray(value) ? Array.from(value) : [];
        return list.length <= MAX_SOURCES && list.every(source => typeof source === 'string' && source.length <= MAX_SOURCE_LENGTH) ? list : [];
    }
    // Gelbooru-family boorus keep Source as one text field: several links become several entries, other text stays whole.
    function splitSource(value) {
        const text = value.trim(), parts = text.split(/\s+/);
        if (!text)
            return [];
        return limitSources(parts.every(part => /^https?:\/\/\S+$/i.test(part)) ? parts : [text]);
    }
    function readSources(doc, url) {
        if (['e621.net', 'e926.net'].includes(url.hostname.replace(/^www\./, ''))) {
            return limitSources([...doc.querySelectorAll('.source-links .source-link')].map(item => {
                const link = item.querySelector('a[href]'), struck = item.querySelector('s');
                // e621 keeps a source that no longer works with a leading '-' and shows it struck through.
                return link ? link.getAttribute('href') : struck ? `-${struck.textContent.trim()}` : item.textContent.trim();
            }).filter(source => source && source !== '-'));
        }
        const field = doc.querySelector('input#source');
        if (field)
            return splitSource(field.getAttribute('value') ?? '');
        const line = [...doc.querySelectorAll('#stats li, #tag-list li, #tag-sidebar li')].find(item => /^\s*Source:/.test(item.textContent));
        if (!line)
            return [];
        // A line of links and nothing else keeps each link's URL; any other text is read as Source text.
        const links = [...line.querySelectorAll('a[href]')];
        const other = [...line.childNodes].filter(node => !(node.nodeType === 1 && ['A', 'BR'].includes(node.tagName)))
            .map(node => node.textContent ?? '').join('').replace(/^\s*Source:/, '').trim();
        return links.length && !other ? limitSources(links.map(link => link.getAttribute('href')))
            : splitSource(line.textContent.replace(/^\s*Source:/, ''));
    }
    // e621 marks the original with #image-download-link and Rule34 with #highres; Gelbooru's link, and Rule34's on a
    // video page, have only their text.
    function originalLinks(doc) {
        return [doc.querySelector('#image-download-link'), doc.querySelector('#highres'),
            ...[...doc.querySelectorAll('a[href]')].filter(link => /^\s*original image\s*$/i.test(link.textContent ?? ''))]
            .filter((link) => !!link);
    }
    // The file name must be in the path: a redirect script naming it in its query answers with a page.
    function originalHref(link, base, site) {
        const raw = link.getAttribute('href');
        if (!raw)
            return null;
        const url = new URL(raw, base);
        return mediaUrl(url.href, site) && MEDIA_EXTENSION.test(url.pathname) ? url.href : null;
    }
    function readPost(doc, href) {
        const url = new URL(href);
        if (!supportedSite(url.hostname))
            return null;
        const id = /\/posts\/(\d+)\/?$/.exec(url.pathname)?.[1]
            ?? (url.searchParams.get('s') === 'view' ? url.searchParams.get('id') : null);
        if (!id || !/^\d+$/.test(id))
            return null;
        const numeric = { '0': 'general', '1': 'artist', '3': 'copyright', '4': 'character', '5': 'species', '6': 'invalid', '7': 'meta', '8': 'lore' };
        const tagGroups = {};
        for (const li of doc.querySelectorAll('#tag-list li, #tag-sidebar li, #tag_list li')) {
            const link = [...li.querySelectorAll('a[href]')].find(a => {
                const target = new URL(a.getAttribute('href'), url);
                return target.searchParams.has('tags');
            });
            const stored = li.getAttribute('data-name') || li.getAttribute('data-tag-name') || null;
            const tag = stored !== null ? decodedTag(stored)
                : (link ? new URL(link.getAttribute('href'), url).searchParams.get('tags') : null);
            if (!tag || /\s/.test(tag))
                continue;
            const classes = `${li.className} ${li.parentElement?.className ?? ''}`;
            const category = /tag-type-([a-z]+)/.exec(classes)?.[1]
                ?? numeric[/category-(\d+)/.exec(classes)?.[1] ?? '']
                ?? /\b([a-z]+)-tag-list\b/.exec(classes)?.[1] ?? 'general';
            const key = category === 'metadata' ? 'meta' : category;
            if (!Object.hasOwn(tagGroups, key))
                Object.defineProperty(tagGroups, key, { value: [], enumerable: true });
            if (!tagGroups[key].includes(tag))
                tagGroups[key].push(tag);
        }
        const tags = [...new Set(Object.values(tagGroups).flat())];
        if (!tags.length)
            return null;
        const urls = new Set();
        for (const el of doc.querySelectorAll('#image, #image-container img, #image-container video, #image-container source, #gelcomVideoPlayer, #gelcomVideoPlayer source, #highres, #image-download-link')) {
            const raw = el.getAttribute('src') || el.getAttribute('href');
            if (raw) {
                const value = new URL(raw, url).href;
                if (mediaUrl(value, url.hostname))
                    urls.add(value);
            }
        }
        // A fetched Rule34 video page has no player; its Original image link is the only one naming the file.
        for (const link of originalLinks(doc)) {
            const value = originalHref(link, url.href, url.hostname);
            if (value)
                urls.add(value);
        }
        const fileUrl = [...urls][0];
        if (!fileUrl)
            return null;
        return { site: url.hostname, id, postUrl: url.href, sources: readSources(doc, url), fileUrl, mediaUrls: [...urls],
            ext: /\.([a-z0-9]+)(?:[?#]|$)/i.exec(fileUrl)[1].toLowerCase(), tags, tagGroups,
            rating: doc.querySelector('[data-rating]')?.getAttribute('data-rating') ?? '' };
    }
    // Without an original link the page shows the original itself, unless what it shows is a resized sample.
    function originalPost(doc, post) {
        for (const link of originalLinks(doc)) {
            const original = originalHref(link, post.postUrl, post.site);
            if (!original)
                continue;
            return { ...post, fileUrl: original, mediaUrls: [original, ...post.mediaUrls.filter(value => value !== original)],
                ext: /\.([a-z0-9]+)(?:[?#]|$)/i.exec(original)[1].toLowerCase() };
        }
        return /\/samples?\//i.test(new URL(post.fileUrl).pathname) ? null : { ...post, mediaUrls: [...post.mediaUrls] };
    }
    const api = Object.freeze({ DEFAULT_FORMAT, FIELDS, validateFormat, parsePicked, savedPicked, safePart, filename, groupsFromRaw, supportedSite, mediaUrl,
        splitSource, limitSources, validSources, readPost, originalPost });
    globalThis[Symbol.for('boorukit/media/v1')] = api;
    if (typeof module !== 'undefined' && module.exports)
        module.exports = api;
})();

// Shared, accessible filename-format editor for the two download surfaces.
(() => {
    // Gives each mounted editor its own element ids.
    let mounted = 0;
    function mount(container, settings, save, native = false) {
        const media = globalThis[Symbol.for('boorukit/media/v1')];
        const doc = container.ownerDocument;
        const element = (tag, text = '') => {
            const el = doc.createElement(tag);
            el.textContent = text;
            return el;
        };
        const root = element('div');
        root.className = 'bk-filename';
        const style = element('style');
        style.textContent = `
.bk-filename{display:grid;gap:12px;max-width:620px;min-width:0;font:14px system-ui,sans-serif;color:var(--bk-text,#18202c)}
.bk-filename label{display:grid;gap:6px}.bk-filename input,.bk-filename textarea{box-sizing:border-box;width:100%;min-width:0;padding:10px;border:1px solid var(--bk-border,#435064);border-radius:5px;background:var(--bk-ink,#f1f4f8);color:inherit;font:13px ui-monospace,monospace}
.bk-filename textarea{resize:vertical;line-height:1.45}.bk-filename textarea[aria-invalid="true"]{border-color:var(--bk-danger,#c52f44)}.bk-filename .hint{color:var(--bk-muted,#516174);font-size:12px}
.bk-filename .fields{display:flex;flex-wrap:wrap;gap:7px}.bk-filename button{font:inherit;min-height:36px;padding:6px 11px;border:1px solid var(--bk-border,#435064);border-radius:5px;background:var(--bk-panel-raised,#edf1f6);color:inherit;cursor:pointer}
.bk-filename output{display:block;padding:10px;background:var(--bk-ink,#f1f4f8);border:1px solid var(--bk-border,#435064);border-radius:5px;overflow-wrap:anywhere;font-size:13px}
.bk-filename .error{color:var(--bk-danger,#c52f44)}.bk-filename .footer{display:flex;align-items:center;gap:12px}.bk-filename :focus-visible{outline:3px solid #58b8e8;outline-offset:2px}`;
        const label = element('label', 'Filename format');
        const input = element('input');
        input.type = 'text';
        input.value = settings.format;
        input.spellcheck = false;
        input.maxLength = 500;
        label.append(input);
        const fields = element('div');
        fields.className = 'fields';
        fields.setAttribute('aria-label', 'Filename fields');
        const labels = { artist: 'Artist', character: 'Character', copyright: 'Copyright', id: 'Post ID', site: 'Site', meta: 'Meta tags', rating: 'Rating', picked: 'Picked tags' };
        for (const key of media.FIELDS) {
            const token = element('button', labels[key]);
            token.type = 'button';
            token.addEventListener('click', () => { input.setRangeText(`{${key}}`, input.selectionStart ?? input.value.length, input.selectionEnd ?? input.value.length, 'end'); input.focus(); preview(); });
            fields.append(token);
        }
        const listLabel = element('label', 'Picked tags');
        const list = element('textarea');
        list.rows = 4;
        list.value = settings.picked;
        list.spellcheck = false;
        list.placeholder = 'solo\n3d = 3d_(artwork)\naudio = sound has_audio';
        listLabel.append(list);
        const hint = element('div', 'One tag per line, or name = tags to show one name for several tags.');
        hint.className = 'hint';
        const id = ++mounted;
        hint.id = `bk-picked-hint-${id}`;
        const exampleLabel = element('div', 'Example filename');
        const example = element('output');
        example.setAttribute('aria-live', 'polite');
        const note = element('div', 'Picked tags are not in the filename format.');
        note.className = 'hint';
        note.hidden = true;
        note.setAttribute('aria-live', 'polite');
        const error = element('div');
        error.className = 'error';
        error.setAttribute('role', 'alert');
        error.id = `bk-filename-error-${id}`;
        list.setAttribute('aria-describedby', `${hint.id} ${error.id}`);
        const footer = element('div');
        footer.className = 'footer';
        const button = element('button', 'Save');
        button.type = 'button';
        const status = element('span');
        status.setAttribute('role', 'status');
        let saving = false;
        function preview() {
            status.textContent = '';
            input.removeAttribute('aria-invalid');
            list.removeAttribute('aria-invalid');
            let checking = input;
            try {
                media.validateFormat(input.value);
                checking = list;
                const picked = media.parsePicked(list.value);
                // The example post has every picked tag, so the example shows where they go.
                example.textContent = media.filename({ site: 'e621.net', id: '12345', ext: 'png', rating: 'safe', tags: picked.map(entry => entry.name), tagGroups: { artist: ['artist_name'], character: ['character_name'], copyright: ['series_name'], meta: ['high_resolution'] } }, input.value, picked);
                note.hidden = !picked.length || input.value.includes('{picked}');
                error.textContent = '';
                button.disabled = saving;
            }
            catch (reason) {
                error.textContent = reason instanceof Error ? reason.message : 'Invalid filename format.';
                checking.setAttribute('aria-invalid', 'true');
                button.disabled = true;
                example.textContent = '';
                note.hidden = true;
            }
        }
        input.addEventListener('input', preview);
        list.addEventListener('input', preview);
        const controls = () => [input, list, ...fields.querySelectorAll('button')];
        button.addEventListener('click', async () => {
            saving = true;
            button.disabled = true;
            button.textContent = 'Saving…';
            for (const control of controls())
                control.disabled = true;
            try {
                media.parsePicked(list.value);
                await save({ format: media.validateFormat(input.value), picked: list.value });
                status.textContent = native ? 'Saved. Reload open posts.' : 'Saved';
            }
            catch (reason) {
                status.textContent = 'Could not save filename format. Try again.';
                console.error('[BooruKit] Could not save filename format', reason);
            }
            finally {
                saving = false;
                button.disabled = false;
                button.textContent = 'Save';
                for (const control of controls())
                    control.disabled = false;
            }
        });
        footer.append(button, status);
        root.append(style, label, fields, listLabel, hint, exampleLabel, example, note, error, footer);
        container.append(root);
        preview();
    }
    const api = { mount };
    globalThis[Symbol.for('boorukit/format-control/v1')] = api;
    if (typeof module !== 'undefined' && module.exports)
        module.exports = api;
})();

// Shared post resolution, tag normalization and download serialization.
(() => {
    function mediaApi() {
        return typeof window === 'undefined'
            ? module.require('./media.ts')
            : globalThis[Symbol.for('boorukit/media/v1')];
    }
    function savedFormat(value) {
        try {
            return mediaApi().validateFormat(value ?? mediaApi().DEFAULT_FORMAT);
        }
        catch {
            return mediaApi().DEFAULT_FORMAT;
        }
    }
    function siteShortFromHostname(hostname) {
        const normalized = hostname.toLowerCase().replace(/^www\./, '');
        return normalized.split('.')[0] || normalized;
    }
    function dapiBase(origin, flavor) {
        const json = flavor === 'json' ? '&json=1' : '';
        return `${origin}/index.php?page=dapi&s=post&q=index${json}`;
    }
    function buildDapiUrlById(origin, flavor, id) {
        return `${dapiBase(origin, flavor)}&id=${encodeURIComponent(id)}`;
    }
    function buildDapiUrlByMd5(origin, flavor, md5) {
        return `${dapiBase(origin, flavor)}&tags=md5:${encodeURIComponent(md5)}`;
    }
    function toAbsoluteUrl(url) {
        return url.startsWith('//') ? `https:${url}` : url;
    }
    function extFromFileUrl(fileUrl) {
        const clean = fileUrl.split(/[?#]/)[0];
        const match = /\.([a-z0-9]+)$/i.exec(clean);
        return match ? match[1].toLowerCase() : 'bin';
    }
    function numOrNull(value) {
        if (value == null || value === '')
            return null;
        const number = Number(value);
        return Number.isFinite(number) ? number : null;
    }
    function recordFromRaw(site, raw) {
        const id = raw.id == null ? '' : String(raw.id);
        const rawUrl = typeof raw.file_url === 'string' ? raw.file_url : '';
        if (id === '' || rawUrl === '')
            return null;
        const fileUrl = toAbsoluteUrl(rawUrl);
        const md5 = typeof raw.md5 === 'string' && raw.md5 !== ''
            ? raw.md5
            : typeof raw.hash === 'string' ? raw.hash : '';
        const tags = typeof raw.tags === 'string'
            ? raw.tags.trim().split(/\s+/).filter(Boolean)
            : [];
        const source = typeof raw.source === 'string' ? raw.source : '';
        return {
            site,
            id,
            md5,
            fileUrl,
            ext: extFromFileUrl(fileUrl),
            tags,
            rating: typeof raw.rating === 'string' ? raw.rating : '',
            score: numOrNull(raw.score),
            source,
            sources: mediaApi().splitSource(source),
            width: numOrNull(raw.width),
            height: numOrNull(raw.height),
        };
    }
    function postsFromDapiJson(site, jsonText) {
        let parsed;
        try {
            parsed = JSON.parse(jsonText);
        }
        catch {
            return [];
        }
        const list = Array.isArray(parsed)
            ? parsed
            : parsed && typeof parsed === 'object'
                ? parsed.post
                : null;
        if (!Array.isArray(list))
            return [];
        const records = [];
        for (const item of list) {
            if (!item || typeof item !== 'object')
                continue;
            const record = recordFromRaw(site, item);
            if (record)
                records.push(record);
        }
        return records;
    }
    function postsFromDapiXml(site, xmlText, parse) {
        let doc;
        try {
            doc = parse(xmlText);
        }
        catch {
            return [];
        }
        const records = [];
        for (const element of Array.from(doc.querySelectorAll('post'))) {
            const raw = {};
            for (const name of [
                'id',
                'md5',
                'file_url',
                'tags',
                'rating',
                'score',
                'source',
                'width',
                'height',
            ]) {
                const value = element.getAttribute(name);
                if (value !== null)
                    raw[name] = value;
            }
            const record = recordFromRaw(site, raw);
            if (record)
                records.push(record);
        }
        return records;
    }
    function postsFromE621Json(site, jsonText) {
        let parsed;
        try {
            parsed = JSON.parse(jsonText);
        }
        catch {
            return [];
        }
        const list = parsed && typeof parsed === 'object'
            ? parsed.posts
            : null;
        if (!Array.isArray(list))
            return [];
        const records = [];
        for (const item of list) {
            if (!item || typeof item !== 'object')
                continue;
            const post = item;
            const file = (post.file ?? {});
            const fileUrl = typeof file.url === 'string' ? file.url : '';
            const id = post.id == null ? '' : String(post.id);
            if (id === '' || fileUrl === '')
                continue;
            const tagGroups = (post.tags ?? {});
            const tags = [];
            for (const group of Object.values(tagGroups)) {
                if (!Array.isArray(group))
                    continue;
                for (const tag of group) {
                    if (typeof tag === 'string')
                        tags.push(tag);
                }
            }
            const score = (post.score ?? {});
            const preview = (post.preview ?? {});
            const sources = Array.isArray(post.sources)
                ? post.sources
                : [];
            const rawExtension = typeof file.ext === 'string'
                ? file.ext.toLowerCase()
                : '';
            records.push({
                site,
                id,
                md5: typeof file.md5 === 'string' ? file.md5 : '',
                fileUrl,
                ext: /^[a-z0-9]+$/.test(rawExtension)
                    ? rawExtension
                    : extFromFileUrl(fileUrl),
                tags,
                tagGroups: mediaApi().groupsFromRaw(tagGroups),
                rating: typeof post.rating === 'string' ? post.rating : '',
                score: typeof score.total === 'number' ? score.total : null,
                source: typeof sources[0] === 'string' ? sources[0] : '',
                sources: e621Sources(sources),
                width: typeof file.width === 'number' ? file.width : null,
                height: typeof file.height === 'number' ? file.height : null,
                thumbUrl: typeof preview.url === 'string' ? preview.url : '',
            });
        }
        return records;
    }
    // e621 lists working sources before the ones it keeps with a leading '-'.
    function e621Sources(list) {
        const strings = list.filter((value) => typeof value === 'string');
        return mediaApi().limitSources([
            ...strings.filter((value) => !value.startsWith('-')),
            ...strings.filter((value) => value.startsWith('-')),
        ]);
    }
    function savedPicked(value) {
        return mediaApi().savedPicked(value);
    }
    function sidecarBaseFor(record, format, picked) {
        return fileNameFor(record, format, picked).replace(/\.[^.]+$/, '');
    }
    function fileNameFor(record, format, picked) {
        return mediaApi().filename(record, savedFormat(format), picked);
    }
    function sidecarJsonFor(record) {
        return JSON.stringify(record, null, 2);
    }
    function tagsTxtFor(record) {
        return record.tags.join(', ');
    }
    function makeQueueItem(id, md5) {
        return Object.freeze({ id, md5 });
    }
    async function runGrabberQueue(queue, tasks, items, effects, onProgress) {
        return tasks.run('grabber.download', (signal, report) => queue.run(items, async (item) => {
            const record = await effects.resolve(item);
            await effects.download(record);
            return record;
        }, {
            attempts: 2,
            throttleMs: 1000,
            retryDelayMs: 2000,
            signal,
            sleep: effects.sleep,
            onProgress: (item, state, completed, total, error) => {
                report(completed, total);
                onProgress(item, state, completed, total, error);
            },
        }));
    }
    function resolveGrabberSiteConfig(hostname) {
        const normalized = hostname.toLowerCase().replace(/^www\./, '');
        if (normalized === 'e621.net' || normalized === 'e926.net') {
            return Object.freeze({ dapiFlavor: 'e621' });
        }
        // Their dapi endpoints refuse requests without an API key.
        if (normalized === 'rule34.xxx' || normalized === 'gelbooru.com') {
            return Object.freeze({ dapiFlavor: 'page' });
        }
        if (normalized === 'safebooru.org' || normalized === 'hypnohub.net') {
            return Object.freeze({ dapiFlavor: 'json' });
        }
        if (normalized.endsWith('.booru.org')) {
            return Object.freeze({ dapiFlavor: 'xml' });
        }
        return null;
    }
    function buildResolveUrl(origin, config, id) {
        if (config.dapiFlavor === 'e621') {
            return `${origin}/posts.json?tags=id:${encodeURIComponent(id)}&limit=1`;
        }
        if (config.dapiFlavor === 'page') {
            return `${origin}/index.php?page=post&s=view&id=${encodeURIComponent(id)}`;
        }
        return buildDapiUrlById(origin, config.dapiFlavor, id);
    }
    function resolveHttpError(status) {
        if (status === 401 || status === 403) {
            return new Error('authentication required to resolve posts');
        }
        return new Error(`resolve HTTP ${status}`);
    }
    function normalizedMd5(value) {
        const normalized = value.trim().toLowerCase();
        return /^[0-9a-f]{32}$/.test(normalized) ? normalized : null;
    }
    async function resolvePost(origin, site, config, item) {
        const flavor = config.dapiFlavor;
        if (flavor === 'page')
            return resolveFromPostPage(origin, site, item);
        const parse = (text) => new DOMParser().parseFromString(text, 'text/xml');
        const fromBody = (body) => {
            if (flavor === 'e621') {
                return postsFromE621Json(site, body);
            }
            if (flavor === 'json') {
                return postsFromDapiJson(site, body);
            }
            return postsFromDapiXml(site, body, parse);
        };
        let response = await fetch(buildResolveUrl(origin, config, item.id), {
            credentials: 'same-origin',
        });
        if (!response.ok)
            throw resolveHttpError(response.status);
        const idPosts = fromBody(await response.text());
        const idMatch = idPosts.find((candidate) => candidate.id === item.id);
        if (idMatch)
            return enrichCategories(idMatch, origin, config);
        const expectedMd5 = normalizedMd5(item.md5);
        if (expectedMd5 !== null) {
            const md5Url = flavor === 'e621'
                ? `${origin}/posts.json?tags=md5:${encodeURIComponent(item.md5)}&limit=1`
                : buildDapiUrlByMd5(origin, flavor, item.md5);
            response = await fetch(md5Url, { credentials: 'same-origin' });
            if (!response.ok)
                throw resolveHttpError(response.status);
            const md5Posts = fromBody(await response.text());
            const md5Match = md5Posts.find((candidate) => normalizedMd5(candidate.md5) === expectedMd5);
            if (md5Match)
                return enrichCategories(md5Match, origin, config);
        }
        throw new Error('post not found');
    }
    async function resolveFromPostPage(origin, site, item) {
        const response = await fetch(buildResolveUrl(origin, { dapiFlavor: 'page' }, item.id), { credentials: 'same-origin' });
        if (!response.ok)
            throw resolveHttpError(response.status);
        const doc = new DOMParser().parseFromString(await response.text(), 'text/html');
        // readPost takes the id from the URL, so the final one after redirects is what shows another page.
        const read = mediaApi().readPost(doc, response.url);
        if (!read || read.id !== item.id)
            throw new Error('could not read post');
        const post = mediaApi().originalPost(doc, read);
        if (!post)
            throw new Error('original file not found');
        const fileMd5 = /\/([0-9a-f]{32})\.[a-z0-9]+(?:[?#]|$)/i.exec(post.fileUrl)?.[1].toLowerCase();
        return {
            site,
            id: item.id,
            md5: fileMd5 ?? normalizedMd5(item.md5) ?? '',
            fileUrl: post.fileUrl,
            ext: post.ext,
            tags: post.tags,
            tagGroups: post.tagGroups,
            rating: post.rating ?? '',
            score: null,
            source: post.sources[0] ?? '',
            sources: post.sources,
            width: null,
            height: null,
        };
    }
    async function enrichCategories(record, origin, config) {
        if (config.dapiFlavor === 'e621' || typeof DOMParser === 'undefined')
            return record;
        const href = `${origin}/index.php?page=post&s=view&id=${encodeURIComponent(record.id)}`;
        const response = await fetch(href, { credentials: 'same-origin' });
        if (!response.ok)
            throw resolveHttpError(response.status);
        const parsed = mediaApi().readPost(new DOMParser().parseFromString(await response.text(), 'text/html'), href);
        if (!parsed || parsed.id !== record.id)
            throw new Error('could not read post tag categories');
        return { ...record, tagGroups: parsed.tagGroups, tags: [...new Set([...record.tags, ...parsed.tags])],
            ...(parsed.sources.length ? { sources: parsed.sources } : {}) };
    }
    const api = Object.freeze({ savedFormat, savedPicked, siteShortFromHostname, buildDapiUrlById, buildDapiUrlByMd5, toAbsoluteUrl, extFromFileUrl, postsFromDapiJson, postsFromDapiXml, postsFromE621Json, sidecarBaseFor, fileNameFor, sidecarJsonFor, tagsTxtFor, makeQueueItem, runGrabberQueue, resolveGrabberSiteConfig, buildResolveUrl, resolvePost });
    globalThis[Symbol.for('boorukit/posts/v1')] = api;
    if (typeof module !== 'undefined' && module.exports)
        module.exports = api;
})();