☰

BooruKit Grabber

Downloads selected booru posts through the BooruKit userscript suite.

このスクリプトは単体で利用できません。右のようなメタデータを含むスクリプトから、ライブラリとして読み込まれます: // @require https://update.sleazyfork.org/scripts/589429/1973234/BooruKit%20Grabber.js

スクリプトをインストールするには、Tampermonkey, Greasemonkey や Violentmonkey のような拡張機能のインストールが必要です。

スクリプトをインストールするには、 Tampermonkey や Violentmonkey のような拡張機能のインストールが必要です。

スクリプトをインストールするには、Tampermonkey や Violentmonkey のような拡張機能のインストールが必要です。

スクリプトをインストールするには、Tampermonkey や Userscripts のような拡張機能のインストールが必要です。

このスクリプトをインストールするには、Tampermonkeyなどの拡張機能をインストールする必要があります。

このスクリプトをインストールするには、ユーザースクリプト管理ツールの拡張機能をインストールする必要があります。

(ユーザースクリプト管理ツールは設定済みなのでインストール!)

このスタイルをインストールするには、Stylusなどの拡張機能をインストールする必要があります。

このスタイルをインストールするには、Stylus などの拡張機能をインストールする必要があります。

このスタイルをインストールするには、Stylus tなどの拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

(ユーザースタイル管理ツールは設定済みなのでインストール!)

このスクリプトの質問や評価の投稿はこちら、通報はこちらへお寄せください
// ==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;
})();