Modernize and enhance search bar and tag input for booru sites, with modular site-specific configurations, dynamic cheat sheets with caching, e621-specific order syntax, and improved layout for rule34.xxx and e621.net
// ==UserScript==
// @name Booru Tag Search
// @namespace codeberg.org/0xdev/booru-tag-search
// @version 2.9.0
// @description Modernize and enhance search bar and tag input for booru sites, with modular site-specific configurations, dynamic cheat sheets with caching, e621-specific order syntax, and improved layout for rule34.xxx and e621.net
// @author 0xdev
// @license LGPL-3.0-or-later
// @match *://*.booru.org/*
// @match *://*.booru.com/*
// @match *://*.booru.ca/*
// @match *://*.booru.xxx/*
// @match *://rule34.xxx/*
// @match *://gelbooru.com/*
// @match *://www.gelbooru.com/*
// @match *://safebooru.org/*
// @match *://danbooru.donmai.us/*
// @match *://booru.allthefallen.moe/*
// @match *://e621.net/*
// @grant GM.getValue
// @grant GM.setValue
// ==/UserScript==
// THIS FILE IS AUTO-GENERATED — DO NOT MANUALLY EDIT.
// Source: tag-search/booru-tag-search.user.js
'use strict';
/* exported SCRIPT_VERSION */
const SCRIPT_VERSION = '2.9.0';
"use strict";
// ───────────────────────────────────────────────────────────────────────────
// Pure, DOM-free helpers. Hoisted to module scope so Node can require() them
// for unit tests (see test/). Everything below the helpers is browser-only and
// guarded by `typeof window`, so importing this file in Node has no side effects.
// ───────────────────────────────────────────────────────────────────────────
function escapeHTML(s) {
const entities = {
'&': '&', '<': '<', '>': '>', '"': '"', "'": ''',
};
return String(s).replace(/[&<>"']/g, (c) => entities[c] ?? c);
}
function parseTagString(tagString) {
// Keep parenthesized groups, Gelbooru brace groups, and tag-name parens
// together; spaces split outside those groups.
const tags = [];
let buffer = '';
let parenLevel = 0;
for (let i = 0; i < tagString.length; ++i) {
const c = tagString[i];
if (c === '(' || c === '{') {
// Grouping paren (preceded by space / at start) vs tag-name paren.
const prevChar = i > 0 ? tagString[i - 1] : ' ';
const isGroupingParen = prevChar === ' ' || i === 0;
if (isGroupingParen && parenLevel === 0 && buffer.trim()) {
tags.push(buffer.trim());
buffer = '';
}
parenLevel++;
buffer += c;
}
else if ((c === ')' || c === '}') && parenLevel > 0) {
buffer += c;
parenLevel--;
const nextChar = i < tagString.length - 1 ? tagString[i + 1] : ' ';
const isGroupingParen = nextChar === ' ' || i === tagString.length - 1;
if (parenLevel === 0 && isGroupingParen) {
tags.push(buffer.trim());
buffer = '';
}
}
else if (c === ' ' && parenLevel === 0) {
if (buffer.trim()) {
tags.push(buffer.trim());
buffer = '';
}
}
else {
buffer += c;
}
}
if (buffer.trim())
tags.push(buffer.trim());
return tags;
}
// "Fuzz" normalization for tag strings that arrive URL-encoded — pasted out of
// an address bar (`cat%2bdog`) or double-encoded by a redirect/chat app
// (`cat%252bdog`). Loops until stable: `+` means space (form encoding), then
// percent-decode when a valid %XX escape is present. Malformed escapes abort
// that decode pass (all-or-nothing), so strings like "100%" survive untouched.
// Trade-off: tags containing a literal `+` (danbooru's `+_+`) can't survive
// this; selecting them from autocomplete bypasses normalization.
function normalizeTagInput(raw) {
let value = String(raw);
for (let i = 0; i < 5; i++) {
let next = value.replace(/\+/g, ' ');
if (/%[0-9a-f]{2}/i.test(next)) {
try {
next = decodeURIComponent(next);
}
catch {
// Malformed escape somewhere in the string — keep it as-is.
}
}
if (next === value)
break;
value = next;
}
return value;
}
function convertSortSyntax(parsedTags, sortPrefix) {
// e621/danbooru use "order:" — convert legacy "sort:field:dir" to it.
if (sortPrefix === 'order') {
return parsedTags.map((tag) => {
if (tag.startsWith('sort:') && tag.includes(':')) {
const parts = tag.split(':');
if (parts.length === 3) {
const [, field, direction] = parts;
return direction === 'asc' ? `order:${field}_asc` : `order:${field}`;
}
}
return tag;
}).filter(Boolean);
}
// Gelbooru-family: drop the "all" placeholder — it's not a real tag.
return parsedTags.filter((t) => t && t !== 'all');
}
function formatPostCount(n) {
if (n >= 1000000)
return (n / 1000000).toFixed(1).replace(/\.0$/, '') + 'M';
if (n >= 1000)
return (n / 1000).toFixed(1).replace(/\.0$/, '') + 'k';
return String(n);
}
// Allowlist of structural tags the cheatsheet sanitizer keeps; everything else
// is unwrapped (its text is preserved, the tag itself dropped).
const CHEATSHEET_ALLOWED_TAGS = new Set([
'SECTION', 'DIV', 'P', 'SPAN', 'BR', 'HR', 'BLOCKQUOTE',
'H1', 'H2', 'H3', 'H4', 'H5', 'H6',
'UL', 'OL', 'LI', 'DL', 'DT', 'DD',
'B', 'STRONG', 'I', 'EM', 'U', 'S', 'SUB', 'SUP', 'SMALL', 'MARK', 'ABBR',
'CODE', 'PRE', 'KBD', 'SAMP', 'VAR',
'A',
'TABLE', 'THEAD', 'TBODY', 'TFOOT', 'TR', 'TD', 'TH', 'CAPTION', 'COLGROUP', 'COL',
]);
// True for hrefs safe to keep on a sanitized <a>: http(s), mailto, and
// scheme-less (relative / anchor) URLs. Blocks javascript:/data:/vbscript: etc.,
// including tab/newline/leading-control-char obfuscations a browser would strip
// before acting on the URL.
function isSafeHref(href) {
if (typeof href !== 'string')
return false;
const v = [...href].filter((c) => c.charCodeAt(0) > 0x20).join('');
if (v === '')
return false;
const m = v.match(/^([a-z][a-z0-9+.-]*):/i);
if (!m)
return true; // no scheme → relative / anchor
return ['http', 'https', 'mailto'].includes(m[1].toLowerCase());
}
// Sanitize a fetched cheat-sheet DOM element (e.g. danbooru's community-editable
// help wiki) into a safe HTML string for innerHTML: allowlist structural tags,
// unwrap everything else, drop dangerous elements, and strip every attribute
// except an isSafeHref-approved <a href>. No closure deps, so it is unit-testable
// (jsdom) — see test/sanitizer.test.js.
function cleanCheatSheetHTML(element) {
const el = element.cloneNode(true);
// Remove TOC sections and navigation
el.querySelectorAll('[class*="toc"], [id*="toc"], nav').forEach(n => n.remove());
el.querySelectorAll('h1, h2, h3, h4, h5, h6').forEach(h => {
if (/table of contents|contents/i.test(h.textContent ?? '')) {
let next = h.nextElementSibling;
h.remove();
while (next && !/^H[1-6]$/.test(next.tagName)) {
const rm = next;
next = next.nextElementSibling;
rm.remove();
}
}
});
// Drop dangerous elements outright (content and all).
el.querySelectorAll('script, style, iframe, img, svg, math, object, embed, applet, ' +
'link, meta, base, form, input, button, textarea, select, ' +
'video, audio, source, track, template, noscript, frame, frameset').forEach(n => n.remove());
// Allowlist sweep: unwrap any element not on the safe structural list
// (keeping its text), and strip every attribute except a safe href on <a>.
for (const node of [...el.querySelectorAll('*')]) {
if (!CHEATSHEET_ALLOWED_TAGS.has(node.tagName)) {
node.replaceWith(...node.childNodes);
continue;
}
for (const attr of [...node.attributes]) {
const keepHref = node.tagName === 'A'
&& attr.name.toLowerCase() === 'href'
&& isSafeHref(attr.value);
if (!keepHref)
node.removeAttribute(attr.name);
}
}
// Tag code elements with chip classes based on content
el.querySelectorAll('code').forEach(code => {
const text = (code.textContent ?? '').trim();
if (text.startsWith('-'))
code.classList.add('bse-chip-exclude');
else if (/^[a-z_]+:/.test(text) || text.startsWith('('))
code.classList.add('bse-chip-meta');
else
code.classList.add('bse-chip-include');
});
return `<div class="dynamic-cheatsheet">${el.innerHTML}</div>`;
}
if (typeof window === 'undefined' && typeof module !== 'undefined' && module.exports) {
module.exports = {
escapeHTML,
parseTagString,
convertSortSyntax,
formatPostCount,
isSafeHref,
cleanCheatSheetHTML,
normalizeTagInput,
};
}
"use strict";
/* exported SAVED_UNREADABLE_MESSAGE, siteKey, parseSavedData, findSearch, saveQuery, removeQuery, restoreQuery, editSearch, moveSearch, moveGroup, renameGroup, deleteGroup, mergeImport, filterSavedData, createSavedSearchStore */
/**
* Saved-search data model: pure functions over `SavedData`.
* Names are unique across all groups and group names are unique, both compared
* case-insensitively. Every function returns new data and never mutates its
* input. Storage and UI live elsewhere.
*/
/** Message of the error thrown when stored data fails validation. */
const SAVED_UNREADABLE_MESSAGE = 'Saved searches could not be read';
function siteKey(hostname) {
return hostname.replace(/^www\./, '');
}
function sameName(a, b) {
return a.toLowerCase() === b.toLowerCase();
}
/** Trims a name or group name and rejects one that is then empty. */
function cleanName(name) {
const trimmed = name.trim();
if (trimmed === '')
throw new Error('Name required');
return trimmed;
}
function cloneSavedData(data) {
return data.map(([group, searches]) => [
group,
searches.map(([name, queries]) => [name, { ...queries }]),
]);
}
function isPlainObject(value) {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
function parseSavedData(raw) {
if (raw === undefined || raw === null)
return [];
if (!Array.isArray(raw))
return null;
const groups = [];
const seenNames = new Set();
const seenGroups = new Set();
for (const rawGroup of raw) {
if (!Array.isArray(rawGroup) || rawGroup.length !== 2)
return null;
const [groupName, rawSearches] = rawGroup;
if (typeof groupName !== 'string' || groupName.trim() === '' || !Array.isArray(rawSearches))
return null;
const groupKey = groupName.toLowerCase();
if (seenGroups.has(groupKey))
return null;
seenGroups.add(groupKey);
const searches = [];
for (const rawSearch of rawSearches) {
if (!Array.isArray(rawSearch) || rawSearch.length !== 2)
return null;
const [name, rawQueries] = rawSearch;
if (typeof name !== 'string' || name.trim() === '' || !isPlainObject(rawQueries))
return null;
const nameKey = name.toLowerCase();
if (seenNames.has(nameKey))
return null;
seenNames.add(nameKey);
const queries = {};
for (const [site, query] of Object.entries(rawQueries)) {
if (typeof query !== 'string' || site === '__proto__')
return null;
queries[site] = query;
}
searches.push([name, queries]);
}
groups.push([groupName, searches]);
}
return groups;
}
function findSearch(data, name) {
for (const [group, searches] of data) {
const search = searches.find(([n]) => sameName(n, name));
if (search)
return { group, search };
}
return undefined;
}
/** Applies `update` to the search called `name`, if there is one. */
function updateSearch(data, name, update) {
const out = cloneSavedData(data);
for (const [, searches] of out) {
const search = searches.find(([n]) => sameName(n, name));
if (search) {
update(search);
break;
}
}
return out;
}
function saveQuery(data, input) {
const name = cleanName(input.name);
if (findSearch(data, name)) {
return updateSearch(data, name, (search) => {
search[1][input.site] = input.query;
});
}
const groupName = cleanName(input.group);
const out = cloneSavedData(data);
const search = [name, { [input.site]: input.query }];
const group = out.find(([g]) => sameName(g, groupName));
if (group)
group[1].push(search);
else
out.push([groupName, [search]]);
return out;
}
function removeQuery(data, name, site) {
const groupIndex = data.findIndex(([, searches]) => searches.some(([n]) => sameName(n, name)));
if (groupIndex < 0)
return { data, removed: null };
const [group, searches] = data[groupIndex];
const index = searches.findIndex(([n]) => sameName(n, name));
const [foundName, queries] = searches[index];
if (!Object.prototype.hasOwnProperty.call(queries, site))
return { data, removed: null };
const out = cloneSavedData(data);
const outSearches = out[groupIndex][1];
const remaining = outSearches[index][1];
delete remaining[site];
const removedSearch = Object.keys(remaining).length === 0;
if (removedSearch)
outSearches.splice(index, 1);
return {
data: out,
removed: { group, groupIndex, index, name: foundName, site, query: queries[site], removedSearch },
};
}
function restoreQuery(data, removed) {
const existing = findSearch(data, removed.name);
if (existing) {
if (Object.prototype.hasOwnProperty.call(existing.search[1], removed.site))
return data;
return updateSearch(data, removed.name, (search) => {
search[1][removed.site] = removed.query;
});
}
const out = cloneSavedData(data);
let group = out.find(([g]) => sameName(g, removed.group));
if (!group) {
group = [removed.group, []];
out.splice(Math.min(removed.groupIndex, out.length), 0, group);
}
group[1].splice(Math.min(removed.index, group[1].length), 0, [removed.name, { [removed.site]: removed.query }]);
return out;
}
function editSearch(data, name, change) {
const cleanedName = change.name === undefined ? undefined : cleanName(change.name);
const cleanedGroup = change.group === undefined ? undefined : cleanName(change.group);
const found = findSearch(data, name);
if (!found)
return data;
const newName = cleanedName ?? found.search[0];
if (!sameName(newName, found.search[0]) && findSearch(data, newName))
throw new Error('Name already used');
const out = cloneSavedData(data);
const groupIndex = out.findIndex(([g]) => sameName(g, found.group));
const searches = out[groupIndex][1];
const index = searches.findIndex(([n]) => sameName(n, name));
const search = searches[index];
search[0] = newName;
if (change.query !== undefined)
search[1][change.site] = change.query;
if (cleanedGroup !== undefined && !sameName(cleanedGroup, found.group)) {
searches.splice(index, 1);
const target = out.find(([g]) => sameName(g, cleanedGroup));
if (target)
target[1].push(search);
else
out.push([cleanedGroup, [search]]);
}
return out;
}
function moveSearch(data, name, delta) {
const out = cloneSavedData(data);
for (const [, searches] of out) {
const index = searches.findIndex(([n]) => sameName(n, name));
if (index < 0)
continue;
const target = index + delta;
if (target >= 0 && target < searches.length) {
[searches[index], searches[target]] = [searches[target], searches[index]];
}
break;
}
return out;
}
function moveGroup(data, group, delta) {
const out = cloneSavedData(data);
const index = out.findIndex(([g]) => sameName(g, group));
const target = index + delta;
if (index >= 0 && target >= 0 && target < out.length) {
[out[index], out[target]] = [out[target], out[index]];
}
return out;
}
function renameGroup(data, from, to) {
const newName = cleanName(to);
const out = cloneSavedData(data);
const group = out.find(([g]) => sameName(g, from));
if (!group)
return out;
if (!sameName(from, newName) && out.some(([g]) => sameName(g, newName)))
throw new Error('Name already used');
group[0] = newName;
return out;
}
function deleteGroup(data, group) {
return cloneSavedData(data).filter(([g]) => !sameName(g, group));
}
/**
* Reads the export format of the original script into saved data. A query
* that is blank, or sits in a group with a blank label, is counted in `skipped`.
*/
function parseLegacyExport(raw) {
if (!isPlainObject(raw) || !isPlainObject(raw.profiles))
return null;
let out = [];
let skipped = 0;
for (const [host, profile] of Object.entries(raw.profiles)) {
if (siteKey(host) === '__proto__' || !isPlainObject(profile) || !Array.isArray(profile.groups))
continue;
for (const group of profile.groups) {
if (!isPlainObject(group) || typeof group.label !== 'string' || !Array.isArray(group.queries))
continue;
for (const query of group.queries) {
if (typeof query !== 'string')
continue;
if (query.trim() === '' || group.label.trim() === '') {
skipped++;
continue;
}
out = saveQuery(out, { name: query, group: group.label, site: siteKey(host), query });
}
}
}
return { data: out, skipped };
}
/**
* Adds searches and site queries from `raw` that are not saved yet. A site
* query that already exists is kept and counted as skipped.
*/
function mergeImport(data, raw) {
const legacy = Array.isArray(raw) ? null : parseLegacyExport(raw);
const incoming = Array.isArray(raw) ? parseSavedData(raw) : legacy?.data;
if (!incoming)
return null;
let out = data;
let imported = 0;
let skipped = legacy?.skipped ?? 0;
for (const [group, searches] of incoming) {
for (const [name, queries] of searches) {
for (const [site, query] of Object.entries(queries)) {
const existing = findSearch(out, name);
if (existing && Object.prototype.hasOwnProperty.call(existing.search[1], site)) {
skipped++;
}
else {
out = saveQuery(out, { name, group, site, query });
imported++;
}
}
}
}
return { data: out, imported, skipped };
}
function filterSavedData(data, text) {
const out = cloneSavedData(data);
const needle = text.toLowerCase();
if (needle === '')
return out;
return out
.map(([group, searches]) => [
group,
searches.filter(([name, queries]) => name.toLowerCase().includes(needle)
|| Object.values(queries).some((q) => q.toLowerCase().includes(needle))),
])
.filter(([, searches]) => searches.length > 0);
}
/** Reads and writes saved searches under one storage key. */
function createSavedSearchStore(gm, key = 'savedSearches') {
async function read() {
const data = parseSavedData(await gm.getValue(key));
if (data === null)
throw Object.assign(new Error(SAVED_UNREADABLE_MESSAGE), { code: 'unreadable' });
return data;
}
let queue = Promise.resolve();
return {
read,
readRaw: () => gm.getValue(key),
// Re-reads before writing so a save made in another tab is kept; updates
// from this store run one after another so they cannot overwrite each other.
update(change) {
const run = queue.then(async () => {
const next = change(await read());
await gm.setValue(key, next);
return next;
});
queue = run.catch(() => undefined);
return run;
},
};
}
if (typeof window === 'undefined' && typeof module !== 'undefined' && module.exports) {
module.exports = {
siteKey,
parseSavedData,
findSearch,
saveQuery,
removeQuery,
restoreQuery,
editSearch,
moveSearch,
moveGroup,
renameGroup,
deleteGroup,
mergeImport,
filterSavedData,
createSavedSearchStore,
};
}
"use strict";
/* exported queryKey, isQueryApplied, queryTags, convertQuery */
/**
* Saved-search query helpers: matching a query against the tags in the search
* bar, and rewriting a query into another site's sort and OR-group syntax.
* Pure string functions; no storage and no DOM.
*/
function queryTags(query) {
return parseTagString(query);
}
function queryKey(query) {
return queryTags(query).map((t) => t.toLowerCase()).sort().join(' ');
}
function isQueryApplied(query, tags) {
const wanted = queryTags(query).map((t) => t.toLowerCase());
if (wanted.length === 0)
return false;
const have = new Set(tags.map((t) => t.toLowerCase()));
return wanted.every((t) => have.has(t));
}
// Sort tag as field and direction, from either "sort:f:dir" or "order:f[_dir]".
function parseSortTag(tag) {
const sort = /^sort:([^:]+):(asc|desc)$/.exec(tag);
if (sort)
return { field: sort[1], dir: sort[2] };
const order = /^order:([^:]+?)(?:_(asc|desc))?$/.exec(tag);
if (order)
return { field: order[1], dir: order[2] === 'asc' ? 'asc' : 'desc' };
return null;
}
function formatSortTag(sort, prefix) {
if (prefix === 'sort')
return `sort:${sort.field}:${sort.dir}`;
return sort.dir === 'asc' ? `order:${sort.field}_asc` : `order:${sort.field}`;
}
// Members of an OR group written as "( a ~ b )", "{a ~ b}", "( ~a ~b )" or
// Danbooru's "(a or b)".
function groupMembers(tag) {
const m = /^(?:\(([\s\S]*)\)|\{([\s\S]*)\})$/.exec(tag);
if (!m)
return null;
const inner = m[1] ?? m[2];
if (inner.includes('~'))
return plainMembers(inner.split('~'));
// Danbooru's form needs parentheses and at least two members.
const members = m[1] !== undefined ? inner.trim().split(/\s+or\s+/) : [];
return members.length >= 2 ? plainMembers(members) : null;
}
function plainMembers(parts) {
const members = parts.map((s) => s.trim()).filter(Boolean);
// Nested groups and multi-word members cannot be rewritten safely.
if (members.some((x) => /[\s(){}~]/.test(x)))
return null;
return members;
}
// A bare "~x" OR member; a tag with a further "~" (such as "~_~") is plain.
function bareMember(tag) {
return tag.length > 1 && tag.startsWith('~') && !tag.includes('~', 1) ? tag.slice(1) : null;
}
// Danbooru's bare "~x" tags all join one group, so a second group needs parentheses.
function formatGroup(members, site, groupCount) {
switch (site) {
case 'gelbooru': return [`{${members.join(' ~ ')}}`];
case 'e621': return [`( ${members.map((x) => '~' + x).join(' ')} )`];
case 'danbooru': return groupCount > 1 ? [`(${members.join(' or ')})`] : members.map((x) => '~' + x);
default: return [`( ${members.join(' ~ ')} )`];
}
}
function convertQuery(query, to) {
const tags = queryTags(query);
// Every bare "~x" tag joins one OR group wherever it sits (Danbooru syntax);
// a single one is not a group.
const bare = tags.map(bareMember).filter((m) => m !== null);
const bareGroup = bare.length >= 2;
const groupCount = tags.filter((tag) => groupMembers(tag) !== null).length + (bareGroup ? 1 : 0);
let bareEmitted = false;
const out = [];
for (const tag of tags) {
if (bareGroup && bareMember(tag) !== null) {
if (!bareEmitted)
out.push(...formatGroup(bare, to.site, groupCount));
bareEmitted = true;
continue;
}
const members = groupMembers(tag);
if (members) {
out.push(...formatGroup(members, to.site, groupCount));
continue;
}
const sort = parseSortTag(tag);
out.push(sort ? formatSortTag(sort, to.sortPrefix) : tag);
}
return out.join(' ');
}
if (typeof window === 'undefined' && typeof module !== 'undefined' && module.exports) {
module.exports = {
queryKey,
isQueryApplied,
queryTags,
convertQuery,
};
}
"use strict";
/* exported buildSavedSearchView */
/**
* Saved-search view model: turns saved data (and, on an account site, the
* account's own list) into the groups and entries the dialog shows for the
* current site. Pure; no storage and no DOM.
*/
function buildSavedSearchView(data, site, account, accountGroup) {
const byKey = new Map();
for (const item of account ?? []) {
const key = queryKey(item.query);
if (!byKey.has(key))
byKey.set(key, item);
}
const matched = new Set();
const groups = [];
for (const [groupName, searches] of data) {
const entries = [];
for (const [name, queries] of searches) {
if (Object.prototype.hasOwnProperty.call(queries, site)) {
const query = queries[site];
const entry = { kind: 'here', name, group: groupName, query };
if (account) {
const item = byKey.get(queryKey(query));
if (item) {
entry.account = item;
matched.add(item);
}
else {
entry.kind = 'not-on-account';
}
}
entries.push(entry);
}
else {
const [otherSite] = Object.keys(queries);
if (otherSite !== undefined) {
entries.push({ kind: 'unset', name, group: groupName, query: queries[otherSite], otherSite });
}
}
}
if (entries.length > 0)
groups.push({ name: groupName, entries });
}
for (const item of account ?? []) {
if (matched.has(item))
continue;
const name = item.group || accountGroup;
let group = groups.find((g) => g.name.toLowerCase() === name.toLowerCase());
if (!group) {
group = { name, entries: [] };
groups.push(group);
}
group.entries.push({ kind: 'account-only', name: item.query, group: group.name, query: item.query, account: item });
}
return groups;
}
if (typeof window === 'undefined' && typeof module !== 'undefined' && module.exports) {
module.exports = { buildSavedSearchView };
}
"use strict";
/* exported accountBackendFor, createDanbooruBackend, createE621Backend, createGelbooruBackend, parseGelbooruSavedSearches */
const DANBOORU_PAGE_SIZE = 200;
/** Danbooru serves at most 1000 saved searches per account; one extra page confirms the end. */
const DANBOORU_MAX_PAGES = Math.ceil(1000 / DANBOORU_PAGE_SIZE) + 1;
/** Gelbooru lists 5 saved searches per page, paged by `&pid=<offset>`. */
const GELBOORU_PAGE_SIZE = 5;
/** Up to 1000 searches, read one page at a time. */
const GELBOORU_MAX_PAGES = 1000 / GELBOORU_PAGE_SIZE;
const GELBOORU_LIST_URL = '/index.php?page=tags&s=saved_search';
const GELBOORU_TOKEN_PATTERN = /saved_search&csrf-token=([0-9a-f]+)/;
const GELBOORU_REMOVE_LINK = 'a[href*="remove_save_tag_search="]';
const GELBOORU_POST_LINK = 'a[href*="page=post"]';
const GELBOORU_NOT_SAVED = 'Gelbooru did not save the search';
const GELBOORU_NOT_REMOVED = 'Gelbooru did not remove the search';
const GELBOORU_UNREADABLE = 'Saved searches could not be read';
const NO_TOKEN_MESSAGE = 'Could not save: reload the page and try again';
function accountRequest(fetchImpl, csrfToken, method, url, form) {
const headers = { Accept: 'application/json' };
const init = { method, credentials: 'same-origin', headers };
if (method !== 'GET') {
const token = csrfToken();
if (!token)
throw new Error(NO_TOKEN_MESSAGE);
headers['X-CSRF-Token'] = token;
}
if (form) {
headers['Content-Type'] = 'application/x-www-form-urlencoded';
init.body = form;
}
return fetchImpl(url, init);
}
async function failureOf(response) {
if (response.status === 422) {
try {
const body = (await response.json());
const message = body.message ?? body.reason;
if (message)
return new Error(message);
}
catch {
// Unreadable body: fall back to the status.
}
}
return new Error(`HTTP ${response.status}`);
}
/** `text` read as JSON, or null when it is not JSON. */
function jsonOrNull(text) {
try {
return { value: JSON.parse(text) };
}
catch {
return null;
}
}
/** `text` read as JSON; anything else is an unexpected response. */
function parseJsonBody(text) {
const json = jsonOrNull(text);
if (!json)
throw new Error('Unexpected response');
return json.value;
}
function savedSearchForm(query, labels) {
const form = new URLSearchParams();
form.set('saved_search[query]', query);
if (labels !== undefined)
form.set('saved_search[label_string]', labels.join(' '));
return form;
}
function isRecord(value) {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
function hasIdAndQuery(raw) {
return (typeof raw.id === 'number' || typeof raw.id === 'string') && typeof raw.query === 'string';
}
function createAccountBackend(options, fetchImpl, csrfToken) {
const send = async (method, url, form) => {
const response = await accountRequest(fetchImpl, csrfToken, method, url, form);
// A search already deleted elsewhere is gone, which is what DELETE asked for.
if (method === 'DELETE' && response.status === 404)
return response;
if (response.status === 401 || response.status === 403) {
// As in list(): the site answers a logged-out request in JSON; a challenge page is an error.
if (jsonOrNull(await response.text()))
throw new Error(`Log in to ${options.label} to change saved searches`);
throw new Error(`HTTP ${response.status}`);
}
if (!response.ok)
throw await failureOf(response);
return response;
};
const parseOne = async (response, sent) => {
const text = response.status === 204 ? '' : await response.text();
if (text.trim() === '') {
if (sent)
return sent;
throw new Error('Unexpected response');
}
const body = parseJsonBody(text);
const item = isRecord(body) ? options.parse(body) : null;
if (!item)
throw new Error('Unexpected response');
return item;
};
const sentItem = (id, query, labels) => {
const item = { id, query };
if (labels && labels.length > 0) {
item.group = labels[0];
item.labels = labels;
}
return item;
};
const itemUrl = (item) => `/saved_searches/${encodeURIComponent(item.id)}.json`;
return {
label: options.label,
accountGroup: options.accountGroup,
labelled: options.labelled,
async list() {
const items = [];
const seen = new Set();
for (let page = 1; page <= (options.pageSize ? DANBOORU_MAX_PAGES : 1); page++) {
const url = options.pageSize
? `/saved_searches.json?limit=${options.pageSize}&page=${page}`
: '/saved_searches.json';
const response = await accountRequest(fetchImpl, csrfToken, 'GET', url);
if (response.status === 401 || response.status === 403) {
// The site answers a logged-out request in JSON; a challenge page is an error.
if (jsonOrNull(await response.text()))
return 'logged-out';
throw new Error(`HTTP ${response.status}`);
}
if (!response.ok)
throw await failureOf(response);
const rows = parseJsonBody(await response.text());
if (!Array.isArray(rows))
throw new Error('Unexpected response');
let added = 0;
for (const row of rows) {
const item = isRecord(row) ? options.parse(row) : null;
if (item && !seen.has(item.id)) {
seen.add(item.id);
items.push(item);
added++;
}
}
if (!options.pageSize || rows.length < options.pageSize || added === 0)
break;
}
return items;
},
async create(query, group, restore) {
const sent = options.labelled ? restore?.labels ?? options.labelsFor(group, []) : undefined;
const form = savedSearchForm(query, sent);
if (restore)
options.restoreFields?.(form, restore);
const response = await send('POST', '/saved_searches.json', form);
return parseOne(response, null);
},
async update(item, query, group) {
const sent = group === undefined ? undefined : options.labelsFor(group, item.labels ?? []);
const response = await send(options.updateMethod, itemUrl(item), savedSearchForm(query, sent));
return parseOne(response, sentItem(item.id, query, sent ?? item.labels));
},
async remove(item) {
await send('DELETE', itemUrl(item));
},
};
}
function danbooruLabel(group) {
return group.trim().toLowerCase().replace(/\s+/g, '_');
}
function createDanbooruBackend(fetchImpl, csrfToken, name = 'Danbooru') {
return createAccountBackend({
label: name,
accountGroup: `${name} account`,
updateMethod: 'PUT',
pageSize: DANBOORU_PAGE_SIZE,
labelled: true,
parse(raw) {
if (!hasIdAndQuery(raw))
return null;
const item = { id: String(raw.id), query: raw.query };
// Only string labels count; the first is the group, as before a bad first label hid it.
const labels = Array.isArray(raw.labels) ? raw.labels : [];
if (typeof labels[0] === 'string' && labels[0] !== '') {
item.group = labels[0];
item.labels = labels.filter((l) => typeof l === 'string' && l !== '');
}
return item;
},
labelsFor(group, current) {
const first = danbooruLabel(group);
// The new first label replaces the old one and is sent once.
return [...(first ? [first] : []), ...current.slice(1).filter((label) => label !== first)];
},
}, fetchImpl, csrfToken);
}
function createE621Backend(fetchImpl, csrfToken) {
return createAccountBackend({
label: 'e621',
accountGroup: 'e621 account',
updateMethod: 'PATCH',
labelled: false,
parse(raw) {
if (raw.is_subscription === true)
return null;
if (!hasIdAndQuery(raw))
return null;
const item = { id: String(raw.id), query: raw.query };
if (typeof raw.name === 'string' && raw.name !== '')
item.name = raw.name;
if (typeof raw.is_tracked === 'boolean')
item.tracked = raw.is_tracked;
return item;
},
labelsFor: () => undefined,
restoreFields(form, restore) {
// A name e621 gave the search is one it accepts; the user's own names are never sent.
if (restore.name)
form.set('saved_search[name]', restore.name);
if (restore.tracked !== undefined)
form.set('saved_search[is_tracked]', String(restore.tracked));
},
}, fetchImpl, csrfToken);
}
/** `text` trimmed, with each run of whitespace inside it read as one space. */
function collapseSpaces(text) {
return text.trim().split(/\s+/).join(' ');
}
/** The CSRF token in Gelbooru's `saveTagSearch` script under `root`, if any. */
function gelbooruTokenIn(root) {
for (const script of Array.from(root.querySelectorAll('script'))) {
const match = GELBOORU_TOKEN_PATTERN.exec(script.textContent ?? '');
if (match)
return match[1];
}
return null;
}
/**
* One Gelbooru saved-search page: the rows it could read, how many remove
* links it has, the `pid` offsets its pager links to, and its CSRF token.
*/
function readGelbooruPage(html) {
const page = new DOMParser().parseFromString(html, 'text/html');
const removeLinks = Array.from(page.querySelectorAll(GELBOORU_REMOVE_LINK));
const items = [];
for (const remove of removeLinks) {
const id = /[?&]remove_save_tag_search=([^&]*)/.exec(remove.getAttribute('href') ?? '')?.[1] ?? '';
// The query link belongs to this row only if no other remove link comes first.
let link = null;
for (let el = remove.nextElementSibling; el && !el.matches(GELBOORU_REMOVE_LINK); el = el.nextElementSibling) {
if (el.matches(GELBOORU_POST_LINK)) {
link = el;
break;
}
}
const query = collapseSpaces(link?.textContent ?? '');
const href = link?.getAttribute('href') ?? '';
const tags = collapseSpaces(new URLSearchParams(href.slice(href.indexOf('?') + 1)).get('tags') ?? '');
if (/^\d+$/.test(id) && query !== '' && tags === query)
items.push({ id, query });
}
const pids = Array.from(page.querySelectorAll('#paginator a[href*="pid="]'), (link) => Number(/[?&]pid=(\d+)(?:&|$)/.exec(link.getAttribute('href') ?? '')?.[1] ?? NaN));
return { items, removeLinks: removeLinks.length, pids, token: gelbooruTokenIn(page) };
}
/** The account searches on one Gelbooru saved-search page, in page order (newest first). */
function parseGelbooruSavedSearches(html) {
return readGelbooruPage(html).items;
}
/** An empty reply, or a JSON `0`, `false` or `null`, means Gelbooru did not save the search. */
function isFailedSaveReply(text, contentType) {
const trimmed = text.trim();
if (trimmed === '')
return true;
if (!/json/i.test(contentType))
return false;
try {
return !JSON.parse(trimmed);
}
catch {
return false;
}
}
function createGelbooruBackend(fetchImpl, doc) {
/** The token from the last list page read; newer than the one this page was served with. */
let listedToken = null;
const csrfToken = () => listedToken ?? gelbooruTokenIn(doc);
const send = async (url, form, goneIsDone = false) => {
const init = { method: form ? 'POST' : 'GET', credentials: 'same-origin' };
if (form) {
init.headers = { 'Content-Type': 'application/x-www-form-urlencoded' };
init.body = form;
}
const response = await fetchImpl(url, init);
// A search already deleted elsewhere is gone, which is what the remove asked for.
if (goneIsDone && response.status === 404)
return response;
if (!response.ok)
throw new Error(`HTTP ${response.status}`);
return response;
};
/** Reads a list page; a remove link without a readable row fails the whole page. */
const readReply = (html) => {
const page = readGelbooruPage(html);
listedToken = page.token ?? listedToken;
if (page.removeLinks > page.items.length)
throw new Error(GELBOORU_UNREADABLE);
return page;
};
const readPage = async (pid) => readReply(await (await send(pid === 0 ? GELBOORU_LIST_URL : `${GELBOORU_LIST_URL}&pid=${pid}`)).text());
const requireToken = () => {
const token = csrfToken();
if (!token)
throw new Error(NO_TOKEN_MESSAGE);
return token;
};
// The page HTML is the same logged in and out, so the session cookie decides.
const loggedIn = () => /(?:^|;\s*)user_id=[^;]/.test(doc.cookie);
const backend = {
label: 'Gelbooru',
accountGroup: 'Gelbooru account',
labelled: false,
async list() {
if (!loggedIn())
return 'logged-out';
const items = [];
const seen = new Set();
for (let page = 0; page < GELBOORU_MAX_PAGES; page++) {
const pid = page * GELBOORU_PAGE_SIZE;
const { items: rows, pids } = await readPage(pid);
for (const item of rows) {
if (!seen.has(item.id)) {
seen.add(item.id);
items.push(item);
}
}
if (rows.length === 0 || !pids.includes(pid + GELBOORU_PAGE_SIZE))
break;
}
return items;
},
async create(query) {
const token = requireToken();
const form = new URLSearchParams();
form.set('save_tag_search', query);
const response = await send(`${GELBOORU_LIST_URL}&csrf-token=${encodeURIComponent(token)}`, form);
if (isFailedSaveReply(await response.text(), response.headers.get('Content-Type') ?? '')) {
throw new Error(GELBOORU_NOT_SAVED);
}
// The reply carries no id. Rows are newest first, so the first page holds the one just saved;
// Gelbooru may store its tags in another case or order.
const key = queryKey(query);
const saved = (await readPage(0)).items.find((item) => queryKey(item.query) === key);
if (!saved)
throw new Error(GELBOORU_NOT_SAVED);
return saved;
},
async update(item, query) {
const created = await backend.create(query, '');
// Gelbooru kept the old search for this query; removing it would delete the result.
if (created.id === item.id)
return created;
await backend.remove(item);
return created;
},
async remove(item) {
const token = requireToken();
const response = await send(`${GELBOORU_LIST_URL}&remove_save_tag_search=${encodeURIComponent(item.id)}`
+ `&csrf-token=${encodeURIComponent(token)}`, undefined, true);
if (response.status === 404)
return;
if (readReply(await response.text()).items.some((listed) => listed.id === item.id)) {
throw new Error(GELBOORU_NOT_REMOVED);
}
},
};
return backend;
}
/** `name` names the account in the dialog for a site that runs another site's software. */
function accountBackendFor(site, win, name) {
// Looked up per request, so a page without `fetch` only fails when an account call is made.
// Absolute, because a userscript manager may run this in a content script,
// where a relative URL does not resolve against the page.
const fetchImpl = (path, init) => win.fetch(win.location.origin + path, init);
const csrfToken = () => win.document.querySelector('meta[name="csrf-token"]')?.content ?? null;
if (site === 'danbooru')
return createDanbooruBackend(fetchImpl, csrfToken, name);
if (site === 'e621')
return createE621Backend(fetchImpl, csrfToken);
if (site === 'gelbooru')
return createGelbooruBackend(fetchImpl, win.document);
return null;
}
if (typeof window === 'undefined' && typeof module !== 'undefined' && module.exports) {
module.exports = {
accountBackendFor, createDanbooruBackend, createE621Backend, createGelbooruBackend, parseGelbooruSavedSearches,
};
}
"use strict";
/* exported injectSavedSearchStyles */
/**
* Styles for the saved searches dialog. Injected once, under its own id, the
* first time the dialog opens. The dialog is white like tag-search's other
* dialogs; there is no dark variant.
*/
function injectSavedSearchStyles() {
if (document.getElementById('bse-saved-styles'))
return;
const style = document.createElement('style');
style.id = 'bse-saved-styles';
style.textContent = `
#modal-saved-searches .modal-content {
max-width: 620px;
align-items: stretch;
}
.bse-saved {
display: flex;
flex-direction: column;
width: 100%;
min-width: 0;
}
.bse-saved-source {
font-size: 0.9em;
color: #555;
margin: -12px 0 12px;
}
.bse-saved input, .bse-saved select {
font: inherit;
color: #222;
background: #fff;
border: 2px solid #ccc;
box-sizing: border-box;
}
.bse-saved input:focus, .bse-saved select:focus {
border-color: #4a90e2;
outline: none;
}
.bse-saved-find {
width: 100%;
border-radius: 18px;
padding: 8px 16px;
}
.bse-saved-group {
margin-top: 16px;
}
.bse-saved-group-head {
display: flex;
justify-content: space-between;
align-items: center;
gap: 8px;
font-size: 0.82em;
font-weight: bold;
text-transform: uppercase;
color: #777;
margin-bottom: 6px;
}
.bse-saved-group-head input {
flex: 1;
min-width: 0;
border-radius: 12px;
padding: 2px 10px;
text-transform: none;
}
.bse-saved-entry, .bse-saved-edit-row {
display: flex;
align-items: center;
gap: 10px;
padding: 7px 10px;
border-radius: 10px;
border: 1.5px solid #e6e6e6;
margin-bottom: 6px;
color: #222;
}
.bse-saved-entry {
cursor: pointer;
}
.bse-saved-entry:hover, .bse-saved-entry:focus-visible {
border-color: #4a90e2;
outline: none;
}
.bse-saved-entry.applied {
background: #e3f2fd;
border-color: #2196f3;
}
.bse-saved-entry.unset, .bse-saved-entry.missing {
border-style: dashed;
color: #666;
}
.bse-saved-check {
flex: 0 0 16px;
color: #1565c0;
font-weight: bold;
}
.bse-saved-name {
font-weight: bold;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 40%;
}
.bse-saved-query {
flex: 1;
min-width: 0;
font-family: Consolas, monospace;
font-size: 0.9em;
color: #444;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.bse-saved-entry.unset .bse-saved-query, .bse-saved-entry.missing .bse-saved-query {
font-family: inherit;
font-style: italic;
}
.bse-saved-icons {
display: flex;
flex: 0 0 auto;
}
.bse-saved-icon {
background: none;
border: none;
color: #888;
padding: 0 4px;
font: inherit;
font-size: 1em;
cursor: pointer;
}
.bse-saved-icon:hover:not(:disabled) {
color: #222;
}
.bse-saved-icon:disabled {
opacity: 0.35;
cursor: default;
}
.bse-saved-edit-row input {
min-width: 0;
border-radius: 12px;
padding: 4px 10px;
}
.bse-saved-edit-name {
flex: 1 1 30%;
}
.bse-saved-edit-query {
flex: 2 1 40%;
font-family: Consolas, monospace !important;
}
.bse-saved-picker {
display: flex;
flex: 0 1 auto;
min-width: 0;
}
.bse-saved-picker select, .bse-saved-picker input {
border-radius: 16px;
padding: 6px 10px;
min-width: 0;
max-width: 100%;
}
.bse-saved-picker [hidden] {
display: none;
}
.bse-saved-row-error, .bse-saved-save-error {
color: #991b1b;
font-size: 0.9em;
margin: -2px 0 8px 10px;
}
.bse-saved-save-error:empty {
display: none;
}
.bse-saved-more {
display: block;
margin: 4px auto 0;
border-radius: 16px;
padding: 4px 16px;
border: 2px solid #ddd;
background: #f7f7f7;
color: #222;
font: inherit;
font-size: 0.9em;
cursor: pointer;
}
.bse-saved-status {
display: flex;
justify-content: flex-end;
min-height: 0;
margin-top: 8px;
}
.bse-saved-status:empty {
display: none;
}
#modal-saved-searches .modal-actions button:disabled {
opacity: 0.5;
cursor: default;
}
#modal-saved-searches .modal-actions button:disabled:hover {
border-color: color-mix(in srgb, currentColor 25%, transparent);
background: color-mix(in srgb, currentColor 8%, transparent);
}
.bse-saved-status-text {
margin-right: auto;
}
.modal-actions .bse-saved-export {
margin-right: auto;
}
.bse-saved-undo {
background: none;
border: none;
color: #0066cc;
text-decoration: underline;
font: inherit;
cursor: pointer;
padding: 0;
}
.bse-saved-save-row {
display: flex;
gap: 8px;
margin-top: 18px;
border-top: 1px solid #eee;
padding-top: 14px;
}
.bse-saved-save-row[hidden] {
display: none;
}
.bse-saved-name-input {
flex: 1;
min-width: 0;
border-radius: 16px;
padding: 6px 14px;
}
.bse-saved-save-row button {
border-radius: 16px;
padding: 7px 20px;
font: inherit;
border: 2px solid #ccc;
background: #f4f4f4;
color: #222;
cursor: pointer;
white-space: nowrap;
}
.bse-saved-save-row button:disabled {
opacity: 0.5;
cursor: default;
}
.bse-saved-empty, .bse-saved-error, .bse-saved-loading {
text-align: center;
color: #666;
padding: 26px 0;
}
.bse-saved-error {
color: #991b1b;
}
.bse-saved-error .bse-saved-more {
margin-top: 10px;
}
@media (max-width: 700px) {
.bse-saved-save-row {
flex-wrap: wrap;
}
.bse-saved-name-input {
flex-basis: 100%;
}
.bse-saved-edit-row {
flex-wrap: wrap;
}
}
`;
document.head.appendChild(style);
}
"use strict";
/* exported savedErrorMessage, savedElement, savedButton, savedIconButton, savedPickerValue, createSavedGroupPicker */
/**
* Controls shared by the saved searches dialog: element and button helpers
* and the group picker. Text reaches the DOM only through `textContent` and
* `value`. No storage and no dialog state.
*/
function savedErrorMessage(error) {
// Duck-typed: errors from GM or another realm fail `instanceof Error`.
const message = error?.message;
return typeof message === 'string' ? message : String(error);
}
function savedElement(tag, className, text) {
const el = document.createElement(tag);
el.className = className;
if (text !== undefined)
el.textContent = text;
return el;
}
function savedButton(text, className, focusKey) {
const button = savedElement('button', className, text);
button.type = 'button';
if (focusKey !== undefined)
button.dataset.focusKey = focusKey;
return button;
}
function savedIconButton(icon, label, focusKey, disabled, onClick) {
const button = savedButton(icon, 'bse-saved-icon', `${label}:${focusKey}`);
button.setAttribute('aria-label', label);
button.title = label;
button.disabled = disabled;
button.addEventListener('click', (event) => {
event.stopPropagation();
onClick();
});
return button;
}
function savedPickerValue(state) {
return 'group' in state ? state.group : state.newGroup.trim();
}
/** A group `<select>` whose "New group…" option swaps it for a name box. */
function createSavedGroupPicker(groups, initial, focusKey, onChange = () => undefined) {
const el = savedElement('span', 'bse-saved-picker');
const select = document.createElement('select');
select.dataset.focusKey = `${focusKey}:select`;
for (const group of groups) {
const option = document.createElement('option');
option.value = group;
option.textContent = group;
select.appendChild(option);
}
const newOption = document.createElement('option');
newOption.value = '';
newOption.textContent = 'New group…';
select.appendChild(newOption);
const input = savedElement('input', 'bse-saved-group-input');
input.type = 'text';
input.placeholder = 'Group name';
input.dataset.focusKey = `${focusKey}:input`;
el.append(select, input);
const existing = (group) => groups.find((g) => g.toLowerCase() === group.toLowerCase());
function showInput(on) {
select.hidden = on;
input.hidden = !on;
}
function state() {
return select.hidden ? { newGroup: input.value } : { group: select.value };
}
function choose(group) {
const match = existing(group);
if (match === undefined)
return;
select.value = match;
showInput(false);
}
select.addEventListener('change', () => {
if (select.value === '') {
showInput(true);
input.focus();
}
onChange(state());
});
input.addEventListener('input', () => onChange(state()));
const wanted = initial === undefined ? undefined : 'group' in initial ? initial.group : initial.newGroup;
if (groups.length > 0)
select.value = groups[0];
if (wanted !== undefined && existing(wanted) !== undefined) {
choose(wanted);
}
else if (groups.length === 0 || (initial !== undefined && 'newGroup' in initial)) {
// With no groups, "New group…" is the only option and can never fire change.
select.value = '';
input.value = initial !== undefined && 'newGroup' in initial ? initial.newGroup : '';
showInput(true);
}
else {
showInput(false);
}
return {
el,
state,
choose,
setDisabled(disabled) {
select.disabled = disabled;
input.disabled = disabled;
},
};
}
"use strict";
/* exported createSavedFileActions */
/**
* Import and Export for the saved searches dialog. Import merges a file into
* the store through the dialog's own serialised write; Export downloads the
* stored value exactly as stored, even when it fails validation. Neither
* touches account searches.
*/
const SAVED_EXPORT_FILE = 'booru-saved-searches.json';
const SAVED_NOT_A_FILE = 'Not a saved-searches file';
const SAVED_IMPORT_MAX_BYTES = 5 * 1024 * 1024;
function readSavedFileText(file) {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(String(reader.result));
reader.onerror = () => reject(reader.error);
reader.readAsText(file);
});
}
function createSavedFileActions(ctx) {
async function importFile(file) {
const counts = { imported: 0, skipped: 0 };
if (ctx.pending.has('import'))
return;
if (file.size > SAVED_IMPORT_MAX_BYTES) {
ctx.setError(SAVED_NOT_A_FILE);
ctx.render();
return;
}
ctx.pending.add('import');
ctx.render();
try {
const parsed = JSON.parse(await readSavedFileText(file));
const ok = await ctx.write('import-merge', null, (d) => {
const merged = mergeImport(d, parsed);
if (!merged)
throw new Error(SAVED_NOT_A_FILE);
counts.imported = merged.imported;
counts.skipped = merged.skipped;
return merged.data;
});
if (ok)
ctx.setStatus(`Imported ${counts.imported}, skipped ${counts.skipped} already saved`);
}
catch (error) {
ctx.setError(error instanceof SyntaxError
? SAVED_NOT_A_FILE
: savedErrorMessage(error));
}
finally {
ctx.pending.delete('import');
}
ctx.render();
}
async function exportFile() {
if (!ctx.store)
return;
try {
const raw = await ctx.store.readRaw();
const text = JSON.stringify(raw ?? [], null, 2);
const url = URL.createObjectURL(new Blob([text], { type: 'application/json' }));
const a = document.createElement('a');
a.href = url;
a.download = SAVED_EXPORT_FILE;
document.body.appendChild(a);
a.click();
setTimeout(() => { document.body.removeChild(a); URL.revokeObjectURL(url); }, 100);
ctx.setError('');
}
catch (error) {
ctx.setError(savedErrorMessage(error));
}
ctx.render();
}
return { importFile, exportFile };
}
"use strict";
/* exported openSavedSearchDialog */
/**
* Saved searches dialog: lists this site's saved searches, adds or removes
* their tags in the search bar, and saves, edits, moves and deletes them
* through the saved-search store. Stored names and queries reach the DOM only
* through `textContent` and `value`.
*
* On a site with an account backend (Danbooru, e621, Gelbooru) and a logged-in user,
* the account's own searches are listed too. Every write goes to the account
* first and to script storage only once the account call succeeded, and
* script storage keeps the query the account returned. Names, groups and
* order live in script storage only.
*
* The list is rebuilt on every change. Dialog state (pending writes, edit
* drafts, the typed new group) lives outside the DOM so a rebuild keeps it,
* and focus moves to the rebuilt control with the same focus key.
*/
const SAVED_PAGE_SIZE = 25;
const SAVED_UNDO_MS = 5000;
/** Group used by Save current when no group is chosen or typed. */
const SAVED_DEFAULT_GROUP = 'Saved';
const savedSearchKey = (name) => `s:${name.toLowerCase()}`;
const savedGroupKey = (name) => `g:${name.toLowerCase()}`;
/** An account-only row is keyed by its account id, so a script search of the same name is a different row. */
const savedEntryKey = (entry) => (entry.kind === 'account-only' && entry.account
? `a:${entry.account.id}`
: savedSearchKey(entry.name));
const SAVED_NAME_USED = 'Name already used';
function openSavedSearchDialog(deps) {
injectSavedSearchStyles();
const { site, siteConfig, store, account, accountCache } = deps;
let data = null;
/** The account's searches; null without a backend, when logged out, or before the list loads. */
let accountItems = null;
/** `queryKey` of each account search, computed once per item. */
const accountKeys = new WeakMap();
let loading = false;
let loadError = null;
/** Stored data failed validation; writes are blocked, Export still works. */
let unreadable = false;
let editing = null;
/** An error shown under one row or group heading, by its key. */
let rowError = null;
/** An error from Save current or Undo, shown under the save row. */
let generalError = '';
/** The result of the last import, shown beside Undo. */
let statusText = '';
/**
* The last delete; kept until its restore succeeds or Undo expires.
* `recreate` is the account search to create again, cleared once created
* so a retry after a failed store write does not create it twice;
* `created` is what the account returned, whose query the store keeps.
*/
let undo = null;
/** Keys of writes in flight; controls for those keys are disabled. */
const pending = new Set();
const shown = new Map();
/** Focus key to return to once its control is enabled again. */
let focusWanted;
// --- Fixed parts ---
const root = savedElement('div', 'bse-saved');
const source = savedElement('div', 'bse-saved-source');
const findInput = savedElement('input', 'bse-saved-find');
findInput.type = 'text';
findInput.placeholder = 'Find a saved search';
const list = savedElement('div', 'bse-saved-list');
const status = savedElement('div', 'bse-saved-status');
status.setAttribute('role', 'status');
const saveRow = savedElement('div', 'bse-saved-save-row');
const nameInput = savedElement('input', 'bse-saved-name-input');
nameInput.type = 'text';
nameInput.placeholder = 'Name for the current search';
const pickerSlot = savedElement('span', 'bse-saved-picker-slot');
const saveButton = savedButton('Save current', 'bse-saved-save');
saveRow.append(nameInput, pickerSlot, saveButton);
const saveError = savedElement('div', 'bse-saved-save-error');
const fileInput = savedElement('input', 'bse-saved-file');
fileInput.type = 'file';
fileInput.accept = '.json,application/json';
fileInput.hidden = true;
root.append(source, findInput, list, status, saveRow, saveError, fileInput);
let savePicker = null;
let savePickerKey = '';
const importButton = savedButton('Import', 'bse-saved-import', 'import');
const exportButton = savedButton('Export', 'bse-saved-export', 'export');
const closeButton = savedButton('Close', 'bse-saved-close');
const { modal, closeModal } = deps.createModal('modal-saved-searches', 'Saved searches', root, [importButton, exportButton, closeButton]);
closeButton.onclick = closeModal;
// --- Writes ---
/**
* Runs one write under `key`: the account call `remote` first, then the
* store update `change`, which gets what `remote` returned (either step
* may be absent). Before `remote`, `change` runs once on the current data
* as a dry run, so a store change that would be refused (a used or empty
* name) leaves the account alone. When `remote` fails the store is not
* written. A failure shows under the row `errorAt`, or under the save row
* when `errorAt` is null; a success clears every shown error.
* `accountDone` says whether the account call succeeded.
*/
async function writeSteps(key, errorAt, change, remote) {
if (!store || pending.has(key))
return { ok: false, accountDone: false };
pending.add(key);
render();
let accountDone = false;
let accountCalled = false;
try {
let result;
if (remote) {
if (change && data)
change(data, undefined);
accountCalled = true;
result = await remote();
accountDone = true;
}
if (change)
data = await store.update((d) => change(d, result));
rowError = null;
generalError = '';
statusText = '';
return { ok: true, accountDone };
}
catch (error) {
// The account may have changed anyway; the next open lists it again.
if (accountCalled && !accountDone)
accountCache.items = null;
const message = savedErrorMessage(error);
if (errorAt === null)
generalError = message;
else
rowError = { key: errorAt, message };
return { ok: false, accountDone };
}
finally {
pending.delete(key);
render();
}
}
async function write(key, errorAt, change, remote) {
return (await writeSteps(key, errorAt, change, remote)).ok;
}
// --- Account list ---
function accountKeyOf(item) {
let key = accountKeys.get(item);
if (key === undefined) {
key = queryKey(item.query);
accountKeys.set(item, key);
}
return key;
}
/** The account search whose tags match `query`, if the account is in use. */
function accountItemFor(query) {
const key = queryKey(query);
return accountItems?.find((item) => accountKeyOf(item) === key);
}
/** True when a script search other than `name` has a query for this site matching `item`. */
function sharedByAnother(item, name) {
const key = accountKeyOf(item);
return (data ?? []).some(([, searches]) => searches.some(([n, queries]) => !sameName(n, name)
&& Object.prototype.hasOwnProperty.call(queries, site)
&& queryKey(queries[site]) === key));
}
/**
* Applies a successful account call to the account list. The page's
* cache is changed and read back, so a write that finishes after another
* dialog changed the cache keeps that change.
*/
function changeAccountList(change) {
if (!accountItems)
return;
const cached = accountCache.items;
if (Array.isArray(cached)) {
accountCache.items = change(cached);
accountItems = accountCache.items;
}
else {
accountItems = change(accountItems);
}
}
function rememberAccount(item, replaces) {
changeAccountList((items) => (replaces
? items.map((x) => (x.id === replaces.id ? item : x))
: [...items, item]));
}
function forgetAccount(item) {
changeAccountList((items) => items.filter((x) => x.id !== item.id));
}
/** The group sent to the account: none for the backend's own fallback group. */
function accountGroupFor(group) {
return account && sameName(group, account.accountGroup) ? '' : group;
}
/** Refuses to give an account search a name that a script search already has. */
function requireFreeName(d, name) {
if (findSearch(d, name.trim()))
throw new Error(SAVED_NAME_USED);
}
function setUndo(removed, recreate) {
if (undo?.timer)
clearTimeout(undo.timer);
const timer = setTimeout(() => {
if (undo?.timer === timer) {
undo = null;
render();
}
}, SAVED_UNDO_MS);
undo = { removed, recreate, created: null, timer };
render();
}
async function deleteEntry(entry) {
const key = savedEntryKey(entry);
const linked = account ? entry.account : undefined;
// Another script search still uses this account search: leave it on the account.
const item = linked && !sharedByAnother(linked, entry.name) ? linked : undefined;
let removed = null;
const change = entry.kind === 'account-only' ? null : (d) => {
const result = removeQuery(d, entry.name, site);
removed = result.removed;
return result.data;
};
const remote = item && account
? async () => {
await account.remove(item);
forgetAccount(item);
}
: undefined;
const { ok, accountDone } = await writeSteps(key, key, change, remote);
const recreate = item
? { query: item.query, group: item.group ?? '', labels: item.labels, name: item.name, tracked: item.tracked }
: null;
if (ok && (removed || recreate))
setUndo(removed, recreate);
// The account search is gone though the store kept its query: Undo can still bring it back.
else if (!ok && accountDone && recreate)
setUndo(null, recreate);
return ok;
}
async function undoDelete() {
if (!undo)
return;
const current = undo;
// A failed restore keeps the record without expiry so Undo can be retried.
if (current.timer)
clearTimeout(current.timer);
current.timer = null;
const { recreate, removed } = current;
const remote = recreate && account
? async () => {
const item = await account.create(recreate.query, recreate.group, recreate);
rememberAccount(item);
current.recreate = null;
current.created = item;
}
: undefined;
const change = removed
? (d) => restoreQuery(d, current.created ? { ...removed, query: current.created.query } : removed)
: null;
const ok = await write('undo', null, change, remote);
if (ok && undo === current) {
undo = null;
render();
}
}
async function saveCurrent() {
if (!store || !data || !savePicker)
return;
const name = nameInput.value;
const found = findSearch(data, name.trim());
const group = savedPickerValue(savePicker.state()) || SAVED_DEFAULT_GROUP;
const query = deps.currentQuery();
let remote;
if (account && accountItems && !accountItemFor(query)) {
// Replace updates the account search the old query matched, unless
// another script search uses it too; otherwise create one.
const oldQuery = found && Object.prototype.hasOwnProperty.call(found.search[1], site) ? found.search[1][site] : undefined;
const matched = oldQuery === undefined ? undefined : accountItemFor(oldQuery);
const old = matched && !sharedByAnother(matched, name.trim()) ? matched : undefined;
const accountGroup = accountGroupFor(found?.group ?? group);
remote = async () => {
const item = old
? await account.update(old, query)
: await account.create(query, accountGroup);
rememberAccount(item, old);
return item;
};
}
const change = (d, item) => saveQuery(d, { name, group, site, query: item?.query ?? query });
if (await write('save', null, change, remote)) {
nameInput.value = '';
render();
}
}
// --- Import and export ---
const { importFile, exportFile } = createSavedFileActions({
store,
pending,
write,
render,
setError: (message) => { generalError = message; },
setStatus: (text) => { statusText = text; },
});
// --- Search bar ---
function convertedQuery(entry) {
if (entry.kind !== 'unset')
return entry.query;
return convertQuery(entry.query, { site: siteConfig.site, sortPrefix: siteConfig.sortPrefix });
}
function applyEntry(entry) {
deps.applyQuery(convertedQuery(entry));
if (entry.kind === 'unset') {
nameInput.value = entry.name;
onNameInput();
}
render();
}
function toggleEntry(entry) {
const query = convertedQuery(entry);
if (isQueryApplied(query, deps.getTags())) {
deps.removeQuery(query);
render();
}
else {
applyEntry(entry);
}
}
// --- Save row ---
function updateSaveLabel() {
const found = data ? findSearch(data, nameInput.value.trim()) : undefined;
const replaces = found !== undefined && Object.prototype.hasOwnProperty.call(found.search[1], site);
saveButton.textContent = replaces ? 'Replace' : 'Save current';
}
function onNameInput() {
const found = data ? findSearch(data, nameInput.value.trim()) : undefined;
if (found && savePicker)
savePicker.choose(found.group);
updateSaveLabel();
}
function refreshSaveRow(view) {
const groups = view.map((g) => g.name);
const key = JSON.stringify(groups);
if (!savePicker || key !== savePickerKey) {
// A typed new group survives the rebuild; a blank one does not.
const previous = savePicker?.state();
const carried = previous && 'newGroup' in previous && previous.newGroup.trim() === '' ? undefined : previous;
savePicker = createSavedGroupPicker(groups, carried, 'save-group');
savePickerKey = key;
pickerSlot.replaceChildren(savePicker.el);
}
const usable = store !== null && data !== null && !loading && loadError === null && !pending.has('save');
saveButton.disabled = !usable || deps.getTags().length === 0;
nameInput.disabled = !usable;
savePicker.setDisabled(!usable);
saveError.textContent = generalError;
importButton.disabled = !usable || pending.has('import');
exportButton.disabled = store === null;
updateSaveLabel();
}
// --- List ---
function rowErrorFor(key) {
return rowError?.key === key ? savedElement('div', 'bse-saved-row-error', rowError.message) : null;
}
function handleEditKeys(event, save) {
if (event.key === 'Enter') {
event.preventDefault();
save();
}
else if (event.key === 'Escape') {
// Cancels the edit; the dialog stays open.
event.preventDefault();
event.stopPropagation();
editing = null;
rowError = null;
render();
}
}
function startEdit(next) {
editing = next;
rowError = null;
render();
const field = list.querySelector(next.kind === 'search' ? '.bse-saved-edit-name' : '.bse-saved-group-rename');
field?.focus();
}
function finishEdit(ok) {
if (!ok)
return;
editing = null;
render();
}
function saveSearchEdit(entry, draft) {
const key = savedEntryKey(entry);
const query = queryTags(draft.query).join(' ');
if (query === '') {
void deleteEntry(entry).then(finishEdit);
return;
}
const group = savedPickerValue(draft.group);
const regrouped = !sameName(group.trim(), entry.group);
const item = account ? entry.account : undefined;
const queryChanged = queryKey(query) !== queryKey(entry.query);
let remote;
// Another script search still uses this account search. A new query gets
// its own account search; a group change alone stays in script storage,
// and the shared search keeps its label.
const shared = item !== undefined && sharedByAnother(item, entry.name);
if (item && account && (queryChanged || (regrouped && account.labelled && !shared))) {
remote = async () => {
if (shared) {
const created = await account.create(query, accountGroupFor(group));
rememberAccount(created);
return created;
}
// A group change replaces the first Danbooru label; otherwise labels stay.
const updated = await account.update(item, query, regrouped ? accountGroupFor(group) : undefined);
rememberAccount(updated, item);
return updated;
};
}
const stored = (result) => result?.query ?? query;
let change;
if (entry.kind !== 'account-only') {
change = (d, result) => editSearch(d, entry.name, { name: draft.name, group, site, query: stored(result) });
}
else if (regrouped || draft.name.trim() !== entry.name) {
// Naming or regrouping an account search keeps it in script storage.
change = (d, result) => {
requireFreeName(d, draft.name);
return saveQuery(d, { name: draft.name, group, site, query: stored(result) });
};
}
else {
change = null;
}
void write(key, key, change, remote).then(finishEdit);
}
function renderEditRow(entry, draft, groups) {
const key = savedEntryKey(entry);
const busy = pending.has(key);
const row = savedElement('div', 'bse-saved-edit-row');
const nameField = savedElement('input', 'bse-saved-edit-name');
nameField.type = 'text';
nameField.value = draft.name;
nameField.dataset.focusKey = `edit-name:${key}`;
nameField.addEventListener('input', () => { draft.name = nameField.value; });
const queryField = savedElement('input', 'bse-saved-edit-query');
queryField.type = 'text';
queryField.value = draft.query;
queryField.dataset.focusKey = `edit-query:${key}`;
queryField.addEventListener('input', () => { draft.query = queryField.value; });
const picker = createSavedGroupPicker(groups, draft.group, `edit-group:${key}`, (state) => { draft.group = state; });
nameField.disabled = busy;
queryField.disabled = busy;
picker.setDisabled(busy);
row.append(nameField, queryField, picker.el);
row.addEventListener('keydown', (event) => handleEditKeys(event, () => {
draft.name = nameField.value;
draft.query = queryField.value;
draft.group = picker.state();
saveSearchEdit(entry, draft);
}));
return row;
}
/** Move up and Move down for one entry; `viewIndex` is its place in its whole group. */
function entryMoves(entry, searches, viewIndex) {
const key = savedEntryKey(entry);
if (entry.kind === 'account-only') {
// Moving an account search keeps it in script storage, which lists it
// after the group's script searches; account order cannot change, so
// it only moves up.
const promote = (d) => {
requireFreeName(d, entry.name);
const saved = saveQuery(d, { name: entry.name, group: entry.group, site, query: entry.query });
return viewIndex === searches.length ? moveSearch(saved, entry.name, -1) : saved;
};
return { up: viewIndex > 0 ? () => void write(key, key, promote) : null, down: null };
}
const index = searches.findIndex(([n]) => sameName(n, entry.name));
const move = (delta) => () => void write(key, key, (d) => moveSearch(d, entry.name, delta));
return {
up: index > 0 ? move(-1) : null,
down: index >= 0 && index < searches.length - 1 ? move(1) : null,
};
}
function renderEntry(entry, searches, viewIndex) {
const key = savedEntryKey(entry);
const busy = pending.has(key);
const row = savedElement('div', 'bse-saved-entry');
const applied = isQueryApplied(convertedQuery(entry), deps.getTags());
if (applied)
row.classList.add('applied');
if (entry.kind === 'unset')
row.classList.add('unset');
if (entry.kind === 'not-on-account')
row.classList.add('missing');
row.tabIndex = 0;
row.setAttribute('role', 'button');
row.dataset.focusKey = `row:${key}`;
row.append(savedElement('span', 'bse-saved-check', applied ? '✓' : ''), savedElement('span', 'bse-saved-name', entry.name), savedElement('span', 'bse-saved-query', entry.kind === 'unset'
? `Not set for this site. Saved on ${entry.otherSite ?? ''}`
: entry.kind === 'not-on-account' ? 'Not on your account' : entry.query));
row.addEventListener('click', () => toggleEntry(entry));
row.addEventListener('keydown', (event) => {
if (event.target !== row || (event.key !== 'Enter' && event.key !== ' '))
return;
event.preventDefault();
toggleEntry(entry);
});
if (entry.kind !== 'unset') {
const { up, down } = entryMoves(entry, searches, viewIndex);
const icons = savedElement('span', 'bse-saved-icons');
icons.append(savedIconButton('✎', 'Edit', key, busy, () => startEdit({
kind: 'search',
key,
draft: { name: entry.name, query: entry.query, group: { group: entry.group } },
})), savedIconButton('▲', 'Move up', key, busy || !up, () => up?.()), savedIconButton('▼', 'Move down', key, busy || !down, () => down?.()), savedIconButton('🗑', 'Delete', key, busy, () => void deleteEntry(entry)));
row.appendChild(icons);
}
return row;
}
function renderGroupHead(groupName, groupIndex, groupCount, empty) {
const head = savedElement('div', 'bse-saved-group-head');
const key = savedGroupKey(groupName);
const busy = pending.has(key);
if (editing?.kind === 'group' && editing.name === groupName) {
const current = editing;
const field = savedElement('input', 'bse-saved-group-rename');
field.type = 'text';
field.value = current.draft;
field.disabled = busy;
field.dataset.focusKey = `rename:${key}`;
field.addEventListener('input', () => { current.draft = field.value; });
field.addEventListener('keydown', (event) => handleEditKeys(event, () => {
current.draft = field.value;
void write(key, key, (d) => renameGroup(d, groupName, current.draft)).then(finishEdit);
}));
head.appendChild(field);
return head;
}
head.appendChild(savedElement('span', 'bse-saved-group-name', groupName));
// A group made only of account searches has nothing in script storage to rename or move.
if (groupIndex < 0)
return head;
const move = (delta) => () => void write(key, key, (d) => moveGroup(d, groupName, delta));
const icons = savedElement('span', 'bse-saved-icons');
icons.append(savedIconButton('✎', 'Rename group', key, busy, () => startEdit({ kind: 'group', name: groupName, draft: groupName })), savedIconButton('▲', 'Move up', key, busy || groupIndex <= 0, move(-1)), savedIconButton('▼', 'Move down', key, busy || groupIndex >= groupCount - 1, move(1)));
if (empty) {
icons.appendChild(savedIconButton('🗑', 'Delete', key, busy, () => void write(key, key, (d) => deleteGroup(d, groupName))));
}
head.appendChild(icons);
return head;
}
/** Every group in order: script groups (empty ones too), then groups made only of account searches. */
function viewGroups(all) {
const view = buildSavedSearchView(all, site, accountItems, account?.accountGroup ?? '');
const groups = all.map(([name]) => ({ name, entries: view.find((g) => sameName(g.name, name))?.entries ?? [] }));
for (const g of view) {
if (!all.some(([name]) => sameName(name, g.name)))
groups.push(g);
}
return groups;
}
/** The groups and entries the find text matches; groups without a match are left out. */
function visibleGroups(all, groups) {
const text = findInput.value.trim().toLowerCase();
if (text === '')
return groups;
const names = new Set(filterSavedData(all, text).flatMap(([, searches]) => searches.map(([n]) => n.toLowerCase())));
return groups
.map((g) => ({
name: g.name,
entries: g.entries.filter((e) => (e.kind === 'account-only'
? e.query.toLowerCase().includes(text)
: names.has(e.name.toLowerCase()))),
}))
.filter((g) => g.entries.length > 0);
}
function renderList(all, groups) {
const text = findInput.value.trim();
const visible = visibleGroups(all, groups);
if (visible.length === 0) {
list.appendChild(savedElement('div', 'bse-saved-empty', text === '' ? 'No saved searches' : `No saved search matches "${text}"`));
return;
}
const groupNames = groups.map((g) => g.name);
for (const { name: groupName, entries } of visible) {
const groupIndex = all.findIndex(([g]) => sameName(g, groupName));
const searches = groupIndex < 0 ? [] : all[groupIndex][1];
const whole = groups.find((g) => sameName(g.name, groupName))?.entries ?? [];
const group = savedElement('div', 'bse-saved-group');
// Empty means no script searches; account searches shown in it stay on the account.
group.appendChild(renderGroupHead(groupName, groupIndex, all.length, groupIndex >= 0 && searches.length === 0));
const groupError = rowErrorFor(savedGroupKey(groupName));
if (groupError)
group.appendChild(groupError);
const limit = shown.get(groupName.toLowerCase()) ?? SAVED_PAGE_SIZE;
for (const entry of entries.slice(0, limit)) {
const draft = editing?.kind === 'search' && editing.key === savedEntryKey(entry) ? editing.draft : null;
group.appendChild(draft ? renderEditRow(entry, draft, groupNames) : renderEntry(entry, searches, whole.indexOf(entry)));
const error = rowErrorFor(savedEntryKey(entry));
if (error)
group.appendChild(error);
}
if (entries.length > limit) {
const more = savedButton(`Show ${SAVED_PAGE_SIZE} more (of ${entries.length})`, 'bse-saved-more', `more:${savedGroupKey(groupName)}`);
more.onclick = () => {
shown.set(groupName.toLowerCase(), limit + SAVED_PAGE_SIZE);
render();
};
group.appendChild(more);
}
list.appendChild(group);
}
}
/** Focuses the enabled control with `key`, else the dialog; returns whether `key` got focus. */
function restoreFocus(key) {
const target = key === undefined ? undefined
: [...modal.querySelectorAll('[data-focus-key]')].find((el) => el.dataset.focusKey === key);
if (target && !target.disabled) {
target.focus();
if (document.activeElement === target)
return true;
}
modal.focus();
return false;
}
function render() {
const active = document.activeElement;
const hadFocus = active !== null && modal.contains(active);
const activeKey = active && modal.contains(active) ? active.dataset.focusKey : undefined;
const focusKey = activeKey ?? (active === modal ? focusWanted : undefined);
list.replaceChildren();
status.replaceChildren();
const view = data && !loading ? viewGroups(data) : [];
if (loading) {
list.appendChild(savedElement('div', 'bse-saved-loading', 'Loading'));
}
else if (unreadable) {
list.appendChild(savedElement('div', 'bse-saved-error-text', SAVED_UNREADABLE_MESSAGE));
}
else if (loadError !== null) {
const error = savedElement('div', 'bse-saved-error');
error.appendChild(savedElement('div', 'bse-saved-error-text', `Could not load saved searches: ${loadError}`));
if (store) {
const retry = savedButton('Try again', 'bse-saved-more', 'retry');
retry.onclick = () => void load(true);
error.appendChild(retry);
}
list.appendChild(error);
}
else if (data) {
renderList(data, view);
}
if (statusText)
status.appendChild(savedElement('span', 'bse-saved-status-text', statusText));
if (undo) {
const undoButton = savedButton('Undo', 'bse-saved-undo', 'undo');
undoButton.disabled = pending.has('undo');
undoButton.onclick = () => void undoDelete();
status.appendChild(undoButton);
}
refreshSaveRow(view);
source.textContent = account && accountItems
? `${site}, from your ${account.label} account`
: `${site}, stored in this browser`;
// Loading and a load error show no save row; unreadable data keeps it, disabled.
saveRow.hidden = loading || (loadError !== null && !unreadable);
// A rebuild removes the focused control; without this, focus falls to
// the page body and Esc no longer reaches the dialog.
const lost = active === modal || !active?.isConnected || active.disabled === true;
if (hadFocus && lost && focusKey !== undefined) {
focusWanted = restoreFocus(focusKey) ? undefined : focusKey;
}
else if (hadFocus && lost) {
modal.focus();
}
}
/** Reads the store, and the account list unless the page has it already or `fresh` is set. */
async function load(fresh = false) {
if (!store) {
loadError = 'GM is not defined';
render();
return;
}
loading = true;
loadError = null;
unreadable = false;
render();
try {
data = await store.read();
accountItems = null;
if (account) {
if (fresh || accountCache.items === null) {
// Cleared first, so a failed list is read again next time.
accountCache.items = null;
accountCache.items = await account.list();
}
const listed = accountCache.items;
accountItems = listed === 'logged-out' ? null : listed;
}
}
catch (error) {
loadError = savedErrorMessage(error);
unreadable = error?.code === 'unreadable';
}
finally {
loading = false;
render();
}
}
// --- Events ---
findInput.addEventListener('input', render);
findInput.addEventListener('keydown', (event) => {
if (event.key !== 'Enter' || !data)
return;
event.preventDefault();
const entries = visibleGroups(data, viewGroups(data)).flatMap((g) => g.entries);
if (entries.length === 1)
applyEntry(entries[0]);
});
nameInput.addEventListener('input', onNameInput);
nameInput.addEventListener('keydown', (event) => {
if (event.key !== 'Enter')
return;
event.preventDefault();
if (!saveButton.disabled)
void saveCurrent();
});
saveButton.onclick = () => void saveCurrent();
importButton.onclick = () => fileInput.click();
exportButton.onclick = () => void exportFile();
fileInput.addEventListener('change', () => {
const file = fileInput.files?.[0];
fileInput.value = '';
if (!file)
return;
statusText = '';
generalError = '';
void importFile(file);
});
modal.style.display = 'flex';
findInput.focus();
void load();
}
"use strict";
/* exported SITE_CONFIG */
// --- Configuration ---
const GELBOORU_CHEATSHEET = `
<section><h4>Basic Search</h4>
<ul>
<li><code>tag1 tag2</code> — Posts with <b>tag1</b> and <b>tag2</b></li>
<li><code>( tag1 ~ tag2 )</code> — Posts with <b>tag1</b> or <b>tag2</b></li>
<li><code>-tag1</code> — Posts without <b>tag1</b></li>
<li><code>ta*1</code> — Tags starting with <b>ta</b> and ending with <b>1</b></li>
</ul>
</section>
<section><h4>Metatags</h4>
<ul>
<li><code>user:bob</code> — Posts by user <b>bob</b></li>
<li><code>rating:questionable</code> — Rated <b>questionable</b></li>
<li><code>score:>=10</code> — Score 10 or higher</li>
<li><code>width:>=1000</code> — Width 1000px or more</li>
<li><code>height:>1000</code> — Height greater than 1000px</li>
<li><code>parent:1234</code> — Has parent <b>1234</b></li>
<li><code>md5:foo</code> — Posts with MD5 hash <b>foo</b></li>
</ul>
</section>
<section><h4>Sorting</h4>
<ul>
<li><code>sort:updated:desc</code> — Sort by updated (descending)</li>
<li><code>sort:score:asc</code> — Sort by score (ascending)</li>
<li>Other types: <code>id</code>, <code>rating</code>, <code>user</code>, <code>height</code>, <code>width</code>, <code>parent</code>, <code>source</code></li>
</ul>
</section>`;
const GELBOORU_SORT_OPTIONS = [
{ value: 'score', label: 'Score' },
{ value: 'updated', label: 'Updated' },
{ value: 'id', label: 'ID' },
{ value: 'rating', label: 'Rating' },
{ value: 'user', label: 'User' },
{ value: 'height', label: 'Height' },
{ value: 'width', label: 'Width' },
{ value: 'parent', label: 'Parent' },
{ value: 'source', label: 'Source' }
];
const GELBOORU_RATINGS = [
{ value: 'safe', label: 'Safe' },
{ value: 'questionable', label: 'Questionable' },
{ value: 'explicit', label: 'Explicit' }
];
const GELBOORU_METATAG_REGEX = /^(sort:|rating:|user:|parent:|score:|md5:|width:|height:|source:|\( rating:)/;
// Danbooru and sites running its software, such as ATFBooru, share one setup.
function danbooruSite(origin, accountName) {
return {
site: 'danbooru',
autocomplete: 'danbooru',
accountName,
sortPrefix: 'order',
inputTag: 'input',
selectors: {
form: 'form[action*="posts"], form.search-form, #search-form, form:has(input[name="tags"])',
gallery: '#posts, .posts, #post-list, .post-list',
skipPattern: /^\/posts\/\d+/
},
cheatSheetUrl: origin + '/wiki_pages/help%3Acheatsheet',
cheatSheetSelector: '#c-wiki-pages #a-show .prose, .wiki-page-body, .dtext-container',
placeholder: "Enter tags...",
sortOptions: [
{ value: 'score', label: 'Score' },
{ value: 'favcount', label: 'Favorites' },
{ value: 'comment_count', label: 'Comments' },
{ value: 'id', label: 'ID' },
{ value: 'mpixels', label: 'Megapixels' },
{ value: 'filesize', label: 'File Size' },
{ value: 'landscape', label: 'Landscape' },
{ value: 'portrait', label: 'Portrait' },
{ value: 'created_at', label: 'Created' },
{ value: 'updated_at', label: 'Updated' },
{ value: 'tagcount', label: 'Tag Count' },
{ value: 'duration', label: 'Duration' },
{ value: 'random', label: 'Random' }
],
ratings: [
{ value: 'general', label: 'General' },
{ value: 'sensitive', label: 'Sensitive' },
{ value: 'questionable', label: 'Questionable' },
{ value: 'explicit', label: 'Explicit' }
],
metatagRegex: /^(order:|rating:|user:|parent:|score:|md5:|width:|height:|source:|id:|favcount:|comment_count:|type:|date:|status:|pool:|set:|\( rating:)/,
};
}
const SITE_CONFIG = {
"e621.net": {
site: 'e621',
autocomplete: 'e621',
sortPrefix: 'order',
inputTag: 'textarea',
selectors: { form: 'form.post-search-form', gallery: '#c-posts' },
cheatSheetUrl: 'https://e621.net/help/cheatsheet',
cheatSheetSelector: '#c-help #a-show .styled-dtext',
placeholder: "Enter tags...",
sortOptions: [
{ value: 'score', label: 'Score' },
{ value: 'favcount', label: 'Favorites' },
{ value: 'comment_count', label: 'Comments' },
{ value: 'id', label: 'ID' },
{ value: 'mpixels', label: 'Megapixels' },
{ value: 'filesize', label: 'File Size' },
{ value: 'landscape', label: 'Landscape' },
{ value: 'portrait', label: 'Portrait' },
{ value: 'created', label: 'Created' },
{ value: 'updated', label: 'Updated' },
{ value: 'tagcount', label: 'Tag Count' },
{ value: 'duration', label: 'Duration' },
{ value: 'random', label: 'Random' }
],
ratings: GELBOORU_RATINGS,
metatagRegex: /^(order:|rating:|user:|parent:|score:|md5:|width:|height:|source:|id:|favcount:|comment_count:|type:|date:|status:|\( rating:)/,
cheatSheetContent: `
<section><h4>Basics</h4>
<ul>
<li><code>cat dog</code> — Search for posts that are tagged with both cat and dog. Tags are separated by spaces.</li>
<li><code>red_panda african_wild_dog</code> — Words within each tag are separated by underscores.</li>
<li><code>~cat ~dog</code> — Search for posts that are tagged either cat or dog (or both). May not work well when combined with other syntaxes.</li>
<li><code>-chicken</code> — Search for posts that don't have the chicken tag.</li>
<li><code>fox -chicken</code> — Search for posts that are tagged with fox and are not tagged with chicken.</li>
<li><code>african_*</code> — Search for posts with any tag that starts with african_, such as african_wild_dog or african_golden_cat. May not work well when combined with other syntaxes. Limit one wildcard per search.</li>
<li><code>( ~cat ~tiger ~leopard ) ( ~dog ~wolf )</code> — Search for posts that are tagged with one (or more) of cat, tiger or leopard, and one (or more) of dog or wolf.</li>
</ul>
</section>
<section><h4>Sorting</h4>
<ul>
<li><code>order:id</code> — Oldest to newest</li>
<li><code>order:id_desc</code> — Newest to oldest</li>
<li><code>order:score</code> — Highest score first</li>
<li><code>order:score_asc</code> — Lowest score first</li>
<li><code>order:favcount</code> — Most favorites first</li>
<li><code>order:favcount_asc</code> — Least favorites first</li>
<li><code>order:comment_count</code> — Most comments first</li>
<li><code>order:comment_count_asc</code> — Least comments first</li>
<li><code>order:mpixels</code> — Largest resolution first</li>
<li><code>order:mpixels_asc</code> — Smallest resolution first</li>
<li><code>order:filesize</code> — Largest file size first</li>
<li><code>order:filesize_asc</code> — Smallest file size first</li>
<li><code>order:landscape</code> — Wide and short to tall and thin</li>
<li><code>order:portrait</code> — Tall and thin to wide and short</li>
<li><code>order:duration</code> — Video duration longest to shortest</li>
<li><code>order:duration_asc</code> — Video duration shortest to longest</li>
<li><code>order:random</code> — Orders posts randomly</li>
</ul>
</section>
<section><h4>User Metatags</h4>
<ul>
<li><code>user:Bob</code> — Posts uploaded by Bob</li>
<li><code>fav:Bob</code> or <code>favoritedby:Bob</code> — Posts favorited by Bob</li>
<li><code>voted:anything</code> — Posts you voted on. Only works while logged in.</li>
<li><code>votedup:anything</code> or <code>upvote:anything</code> — Posts you upvoted. Only works while logged in.</li>
<li><code>voteddown:anything</code> or <code>downvote:anything</code> — Posts you downvoted. Only works while logged in.</li>
<li><code>approver:Bob</code> — Posts approved by Bob</li>
<li><code>commenter:Bob</code> or <code>comm:Bob</code> — Posts commented on by Bob</li>
<li><code>noter:Bob</code> — Posts with notes written by Bob</li>
</ul>
</section>
<section><h4>Post Metatags - Counts</h4>
<ul>
<li><code>id:100</code> — Post with an ID of 100</li>
<li><code>score:100</code> — Posts with a score of 100</li>
<li><code>favcount:100</code> — Posts with exactly 100 favorites</li>
<li><code>comment_count:100</code> — Posts with exactly 100 comments</li>
<li><code>tagcount:2</code> — Posts with exactly 2 tags</li>
<li><code>gentags:2</code> — Posts with exactly 2 general tags</li>
<li><code>arttags:2</code> — Posts with exactly 2 artist tags</li>
<li><code>chartags:2</code> — Posts with exactly 2 character tags</li>
<li><code>copytags:2</code> — Posts with exactly 2 copyright tags</li>
<li><code>spectags:2</code> — Posts with exactly 2 species tags</li>
<li><code>invtags:2</code> — Posts with exactly 2 invalid tags</li>
<li><code>lortags:2</code> — Posts with exactly 2 lore tags</li>
<li><code>metatags:2</code> — Posts with exactly 2 meta tags</li>
</ul>
</section>
<section><h4>Rating</h4>
<ul>
<li><code>rating:safe</code> or <code>rating:s</code> — Posts rated safe</li>
<li><code>rating:questionable</code> or <code>rating:q</code> — Posts rated questionable</li>
<li><code>rating:explicit</code> or <code>rating:e</code> — Posts rated explicit</li>
</ul>
</section>
<section><h4>File Types</h4>
<ul>
<li><code>type:jpg</code> — Posts that are JPG, a type of image</li>
<li><code>type:png</code> — Posts that are PNG, a type of image</li>
<li><code>type:gif</code> — Posts that are GIF, a type of image (may be animated)</li>
<li><code>type:swf</code> — Posts that are Flash, a format used for animation</li>
<li><code>type:webm</code> — Posts that are WebM, a type of video</li>
</ul>
</section>
<section><h4>Image Size</h4>
<ul>
<li><code>width:100</code> — Posts with a width of 100 pixels</li>
<li><code>height:100</code> — Posts with a height of 100 pixels</li>
<li><code>mpixels:1</code> — Posts that are 1 megapixel (a 1000x1000 image equals 1 megapixel)</li>
<li><code>ratio:1.33</code> — Search for posts with a ratio of 4:3. All ratios are rounded to two digits, therefore 1.33 will return posts with a ratio of 4:3.</li>
<li><code>filesize:200KB</code> — Posts with a file size of 200 kilobytes. File sizes within ±5% of the value are included.</li>
<li><code>filesize:2MB</code> — Posts with a file size of 2 megabytes. File sizes within ±5% of the value are included.</li>
</ul>
</section>
<section><h4>Post Status</h4>
<ul>
<li><code>status:pending</code> — Posts that are waiting to be approved or deleted</li>
<li><code>status:active</code> — Posts that have been approved</li>
<li><code>status:deleted</code> — Posts that have been deleted</li>
<li><code>status:flagged</code> — Posts that are flagged for deletion</li>
<li><code>status:modqueue</code> — Posts that are pending or flagged</li>
<li><code>status:any</code> — All active or deleted posts</li>
</ul>
</section>
<section><h4>Dates</h4>
<ul>
<li><code>date:2012-04-27</code> or <code>date:april/27/2012</code> — Search for posts uploaded on a specific date</li>
<li><code>date:today</code> — Posts from today</li>
<li><code>date:yesterday</code> — Posts from yesterday</li>
<li><code>date:week</code> — Posts from the last 7 days</li>
<li><code>date:month</code> — Posts from the last 30 days</li>
<li><code>date:year</code> — Posts from the last 365 days</li>
<li><code>date:5_days_ago</code> — Posts from within the last 5 days</li>
<li><code>date:5_weeks_ago</code> — Posts from within the last 5 weeks</li>
<li><code>date:5_months_ago</code> — Posts from within the last 5 months</li>
<li><code>date:5_years_ago</code> — Posts from within the last 5 years</li>
<li><code>date:yesterweek</code> — Posts from last week</li>
<li><code>date:yestermonth</code> — Posts from last month</li>
<li><code>date:yesteryear</code> — Posts from last year</li>
</ul>
</section>
<section><h4>Text Searching</h4>
<ul>
<li><code>source:*example.com</code> — Posts with a source that contains "example.com", prefix matched, use wildcards as needed</li>
<li><code>source:none</code> — Posts without a source</li>
<li><code>description:whatever</code> — Posts with a description that contains the text "whatever"</li>
<li><code>description:"hello there"</code> — Posts with a description that contains the text "hello there"</li>
<li><code>note:whatever</code> — Posts with a note that contains the text "whatever"</li>
<li><code>delreason:*whatever</code> — Deleted posts that contain a reason with the text "whatever", prefix matched, use wildcards as needed</li>
</ul>
</section>
<section><h4>Parents and Children</h4>
<ul>
<li><code>ischild:true</code> — Posts that are a child</li>
<li><code>ischild:false</code> — Posts that aren't a child</li>
<li><code>isparent:true</code> — Posts that are a parent</li>
<li><code>isparent:false</code> — Posts that aren't a parent</li>
<li><code>parent:1234</code> — Posts with a parent of 1234</li>
<li><code>parent:none</code> — Posts with no parent (same as ischild:false)</li>
</ul>
</section>
<section><h4>Other</h4>
<ul>
<li><code>hassource:true</code> — Posts with a source</li>
<li><code>hassource:false</code> — Posts without a source</li>
<li><code>hasdescription:true</code> — Posts with a description</li>
<li><code>hasdescription:false</code> — Posts without a description</li>
<li><code>inpool:true</code> — Posts that are in a pool</li>
<li><code>inpool:false</code> — Posts that aren't in a pool</li>
<li><code>pool:4</code> or <code>pool:fox_and_the_grapes</code> — Posts in the pool "Fox and the Grapes"</li>
<li><code>set:17</code> or <code>set:cute_rabbits</code> — Posts in the set with the short name "cute_rabbits"</li>
<li><code>md5:02dd0...</code> — Post with the given MD5 hash. MD5 hashes will never be shared by more than one image.</li>
<li><code>duration:>120</code> — Videos with a duration of at least 120 seconds</li>
</ul>
</section>
<section><h4>Range Syntax</h4>
<ul>
<li><code>id:100</code> — Post with an ID of exactly 100</li>
<li><code>date:year..month</code> — Posts uploaded between 30 days ago and 1 year ago</li>
<li><code>filesize:200KB..300KB</code> — Posts with a file size between 200 kilobytes and 300 kilobytes</li>
<li><code>score:25..50</code> — Posts with a score between 25 and 50</li>
<li><code>score:>=100</code> — Posts with a score of 100 or greater (100+)</li>
<li><code>score:>100</code> — Posts with a score greater than 100 (101+)</li>
<li><code>favcount:<=100</code> — Posts with 100 or less favorites (0-100)</li>
<li><code>favcount:<100</code> — Posts with less than 100 favorites (0-99)</li>
</ul>
</section>
`
},
"rule34.xxx": {
site: 'rule34',
autocomplete: 'rule34',
sortPrefix: 'sort',
inputTag: 'input',
selectors: { form: '.sidebar .tag-search form', gallery: '#post-list' },
cheatSheetUrl: 'https://rule34.xxx/index.php?page=help&topic=cheatsheet',
cheatSheetSelector: '.content',
placeholder: "Enter tags...",
sortOptions: GELBOORU_SORT_OPTIONS,
ratings: GELBOORU_RATINGS,
metatagRegex: GELBOORU_METATAG_REGEX,
cheatSheetContent: GELBOORU_CHEATSHEET
},
"gelbooru.com": {
site: 'gelbooru',
autocomplete: 'gelbooru',
sortPrefix: 'sort',
inputTag: 'input',
selectors: { form: '.searchArea form', gallery: 'main .thumbnail-container', hide: '.searchArea' },
cheatSheetUrl: 'https://gelbooru.com/index.php?page=wiki&s=view&id=26263',
cheatSheetSelector: 'table td',
placeholder: 'Enter tags...',
sortOptions: GELBOORU_SORT_OPTIONS.filter(option => option.value !== 'parent'),
ratings: GELBOORU_RATINGS,
metatagRegex: /^(sort:|-?rating:|user:|parent:|score:|md5:|width:|height:|source:|fav:|pool:)/,
cheatSheetContent: GELBOORU_CHEATSHEET
.replace('( tag1 ~ tag2 )', '{tag1 ~ tag2}')
.replace('<code>parent</code>, ', ''),
},
// safebooru uses the same layout as rule34, with a same-origin endpoint.
"safebooru.org": {
site: 'rule34',
autocomplete: 'safebooru',
sortPrefix: 'sort',
inputTag: 'input',
selectors: { form: '.sidebar .tag-search form', gallery: '#post-list' },
placeholder: "Enter tags...",
sortOptions: GELBOORU_SORT_OPTIONS,
ratings: GELBOORU_RATINGS,
metatagRegex: GELBOORU_METATAG_REGEX,
cheatSheetContent: GELBOORU_CHEATSHEET
},
"danbooru.donmai.us": danbooruSite('https://danbooru.donmai.us', 'Danbooru'),
"booru.allthefallen.moe": danbooruSite('https://booru.allthefallen.moe', 'ATFBooru'),
"default": {
site: '',
sortPrefix: 'sort',
inputTag: 'input',
placeholder: "Enter tags...",
sortOptions: GELBOORU_SORT_OPTIONS,
ratings: GELBOORU_RATINGS,
metatagRegex: GELBOORU_METATAG_REGEX,
cheatSheetContent: GELBOORU_CHEATSHEET
}
};
"use strict";
/* exported injectStyles */
function injectStyles() {
if (document.getElementById('bse-styles'))
return;
const style = document.createElement('style');
style.id = 'bse-styles';
style.textContent = `
.bse-center-wrap {
display: flex;
flex-direction: column;
align-items: center;
width: 100%;
margin-bottom: 20px;
}
.bse-search-form {
width: 90vw;
max-width: 900px;
min-width: 220px;
margin: 0 auto;
}
.bse-modern-searchbar {
display: flex;
flex-direction: row;
align-items: center;
gap: 16px;
width: 100%;
margin-bottom: 10px;
justify-content: center;
}
.bse-export-btn, .bse-saved-btn, .bse-file-btn, .bse-cheat-btn, .bse-settings-btn {
border-radius: 18px;
padding: 8px 18px;
font-size: 1.1em;
border: 2px solid color-mix(in srgb, currentColor 25%, transparent) !important;
background: color-mix(in srgb, currentColor 8%, transparent) !important;
color: inherit !important;
cursor: pointer;
transition: border-color 0.2s, background 0.2s;
white-space: nowrap;
flex-shrink: 0;
}
.bse-export-btn:hover, .bse-saved-btn:hover, .bse-file-btn:hover, .bse-cheat-btn:hover, .bse-settings-btn:hover {
border-color: color-mix(in srgb, currentColor 45%, transparent) !important;
background: color-mix(in srgb, currentColor 18%, transparent) !important;
}
.bse-search-input, .bse-file-status {
flex: 1 1 0%;
min-width: 0;
border-radius: 18px;
padding: 8px 16px;
font-size: 1.1em;
border: 2px solid color-mix(in srgb, currentColor 25%, transparent);
background: color-mix(in srgb, currentColor 5%, transparent);
color: inherit;
box-shadow: 0 2px 8px 0 rgba(0,0,0,0.06);
transition: border-color 0.2s;
box-sizing: border-box;
width: 90vw;
max-width: 500px;
}
.bse-search-input:focus {
border-color: #4a90e2;
outline: none;
}
.bse-file-status {
display: flex;
align-items: center;
gap: 8px;
white-space: nowrap;
}
.bse-file-status-text {
overflow: hidden;
text-overflow: ellipsis;
}
.bse-file-spinner {
flex-shrink: 0;
width: 0.8em;
height: 0.8em;
border: 2px solid currentColor;
border-right-color: transparent;
border-radius: 50%;
animation: bse-spin 0.8s linear infinite;
}
@keyframes bse-spin {
to { transform: rotate(360deg); }
}
.bse-file-error {
margin: 0 0 10px;
text-align: center;
color: color-mix(in srgb, #e5484d 75%, currentColor);
}
.bse-drop-overlay {
position: fixed;
inset: 0;
z-index: 2147483000;
display: flex;
align-items: center;
justify-content: center;
pointer-events: none;
background: color-mix(in srgb, #4a90e2 22%, transparent);
}
.bse-drop-overlay[hidden] {
display: none;
}
.bse-drop-overlay::before {
content: "";
position: absolute;
inset: 8px;
border: 3px dashed #4a90e2;
border-radius: 10px;
}
.bse-drop-label {
padding: 12px 26px;
border-radius: 24px;
background: #4a90e2;
color: #fff;
font-size: 18px;
}
.bse-search-button {
border-radius: 18px;
padding: 8px 28px;
font-size: 1.1em;
border: 2px solid color-mix(in srgb, currentColor 25%, transparent) !important;
background: color-mix(in srgb, currentColor 8%, transparent) !important;
color: inherit !important;
cursor: pointer;
transition: border-color 0.2s, background 0.2s;
white-space: nowrap;
flex-shrink: 0;
}
.bse-search-button:hover {
border-color: color-mix(in srgb, currentColor 45%, transparent) !important;
background: color-mix(in srgb, currentColor 18%, transparent) !important;
}
.bse-tag-list {
margin: 18px 0 0 0;
padding: 0;
display: flex;
flex-direction: row;
flex-wrap: wrap;
gap: 10px;
max-width: 1000px;
justify-content: flex-start;
}
.bse-tag-item {
border-radius: 18px;
padding: 4px 18px;
display: flex;
align-items: center;
font-size: 1.08em;
font-weight: 500;
transition: background 0.2s, color 0.2s, border-color 0.2s;
/* Default neutral — overridden by type-specific classes */
background: color-mix(in srgb, currentColor 10%, transparent);
border: 1.5px solid color-mix(in srgb, currentColor 25%, transparent);
}
.bse-tag-item:not(.bse-category-tag) { color: inherit; }
.bse-tag-item.bse-category-tag {
border-color: color-mix(in srgb, currentColor 45%, transparent);
text-decoration: none;
cursor: default;
}
.bse-tag-item.bse-include-item:not(.bse-category-tag) {
background: #d1fae5 !important;
border-color: #6ee7b7 !important;
color: #065f46 !important;
}
.bse-tag-item.bse-exclude-item:not(.bse-category-tag) {
background: #fee2e2 !important;
border-color: #fca5a5 !important;
color: #991b1b !important;
}
.bse-tag-item.bse-metatag-item {
background: #fef9c3 !important;
border-color: #fde68a !important;
color: #854d0e !important;
}
.bse-tag-item .bse-remove-tag {
margin-left: 12px;
cursor: pointer;
color: #c00;
font-weight: bold;
font-size: 1.1em;
}
.modal {
display: none;
position: fixed;
z-index: 1000;
left: 0; top: 0; right: 0; bottom: 0;
background: rgba(24, 28, 36, 0.55);
backdrop-filter: blur(2.5px);
align-items: center;
justify-content: center;
transition: background 0.2s;
}
.modal[style*="display: flex"] {
display: flex !important;
}
.modal-content {
background: #fff;
color: #222;
border-radius: 18px;
padding: 32px 28px 24px 28px;
min-width: 320px;
max-width: 800px;
width: 50vw;
margin: auto;
box-shadow: 0 8px 40px 0 rgba(0,0,0,0.18);
display: flex;
flex-direction: column;
gap: 18px;
position: relative;
font-size: 1.08em;
align-items: center;
max-height: 80vh;
overflow: auto;
}
.modal-close {
position: absolute;
top: 12px;
right: 16px;
background: none;
border: none;
font-size: 1.7em;
color: #888;
cursor: pointer;
z-index: 2;
padding: 0 8px;
line-height: 1;
transition: color 0.2s;
}
.modal-close:hover {
color: #c00;
}
.modal-content h3 {
margin: 0 0 8px 0;
font-size: 1.35em;
font-weight: bold;
text-align: left;
align-self: flex-start;
color: #0066cc;
text-shadow: 1px 1px 2px rgba(0,0,0,0.1);
}
.modal-actions {
display: flex;
gap: 12px;
justify-content: flex-end;
margin-top: 8px;
flex-wrap: wrap;
width: 100%;
}
.modal-actions button {
border-radius: 16px;
padding: 8px 22px;
font-size: 1em;
border: 2px solid color-mix(in srgb, currentColor 25%, transparent);
background: color-mix(in srgb, currentColor 8%, transparent);
cursor: pointer;
transition: border-color 0.2s, background 0.2s;
font-weight: 500;
}
.modal-actions button:hover {
border-color: #4a90e2;
background: color-mix(in srgb, currentColor 18%, transparent);
}
.modal-pastebin {
background: #f6f8fa;
border: 1.5px solid #e0e0e0;
border-radius: 10px;
padding: 12px 14px;
font-size: 1em;
width: 100%;
min-width: 220px;
max-width: 100%;
box-sizing: border-box;
color: inherit;
margin-bottom: 0;
font-family: 'Fira Mono', 'Consolas', 'Menlo', 'Monaco', monospace;
overflow: auto;
box-shadow: 0 2px 8px 0 rgba(0,0,0,0.04);
resize: vertical;
height: 260px;
min-height: 120px;
max-height: 50vh;
}
.modal-content pre, .modal-doc {
background: #f6f8fa;
border: 1.5px solid #e0e0e0;
border-radius: 10px;
padding: 12px 14px;
font-size: 1em;
width: 100%;
min-width: 220px;
max-width: 100%;
box-sizing: border-box;
color: inherit;
margin-bottom: 0;
font-family: inherit;
overflow: auto;
}
.modal-content pre {
white-space: pre-wrap;
word-break: break-word;
max-height: 320px;
overflow: auto;
}
.modal-doc section {
margin-bottom: 18px;
}
.modal-doc h4 {
margin: 0 0 6px 0;
font-size: 1.08em;
color: #4a90e2;
font-weight: bold;
}
.dynamic-cheatsheet, .dynamic-cheatsheet * { color: inherit !important; line-height: 1.6; }
.dynamic-cheatsheet h1, .dynamic-cheatsheet h2,
.dynamic-cheatsheet h3, .dynamic-cheatsheet h4,
.dynamic-cheatsheet h5, .dynamic-cheatsheet h6 {
color: #4a90e2 !important;
margin: 1.2em 0 0.5em;
padding-bottom: 4px;
border-bottom: 1px solid color-mix(in srgb, currentColor 15%, transparent);
}
.dynamic-cheatsheet ul, .dynamic-cheatsheet ol {
list-style: none;
padding: 0;
margin: 0.5em 0;
}
.dynamic-cheatsheet li {
margin-bottom: 12px;
padding: 0;
}
.dynamic-cheatsheet p {
margin: 0.4em 0;
color: inherit;
}
/* Code chips — block-level so description wraps below */
.dynamic-cheatsheet code {
display: inline-block;
margin-bottom: 4px;
padding: 3px 10px;
border-radius: 12px;
font-family: 'Fira Mono', Consolas, Monaco, monospace;
font-size: 0.88em;
font-weight: 600;
}
.dynamic-cheatsheet code.bse-chip-include {
background: #d1fae5 !important;
border: 1px solid #6ee7b7;
color: #065f46 !important;
}
.dynamic-cheatsheet code.bse-chip-exclude {
background: #fee2e2 !important;
border: 1px solid #fca5a5;
color: #991b1b !important;
}
.dynamic-cheatsheet code.bse-chip-meta {
background: #fef9c3 !important;
border: 1px solid #fde68a;
color: #854d0e !important;
}
/* Fallback for unclassified code */
.dynamic-cheatsheet code:not([class]) {
background: #edf2f7 !important;
border: 1px solid #e2e8f0;
color: #2d3748 !important;
}
.dynamic-cheatsheet table {
width: 100%;
border-collapse: collapse;
margin: 0.5em 0;
}
.dynamic-cheatsheet td, .dynamic-cheatsheet th {
padding: 6px 10px;
border: 1px solid color-mix(in srgb, currentColor 15%, transparent);
text-align: left;
}
.dynamic-cheatsheet a {
color: #4a90e2 !important;
}
.modal-doc ul {
margin: 0;
padding: 0;
list-style: none !important;
}
.modal-doc b {
color: inherit;
}
.modal-tabs {
display: flex;
background: #f0f4fa;
border-radius: 999px;
padding: 4px;
gap: 8px;
margin-bottom: 16px;
width: 100%;
justify-content: flex-start;
}
.modal-tab {
border: none;
background: none;
border-radius: 999px;
padding: 8px 28px;
font-size: 1.08em;
font-weight: 500;
cursor: pointer;
transition: background 0.2s, color 0.2s, box-shadow 0.2s;
box-shadow: none;
outline: none;
margin-bottom: 0;
color: inherit;
}
.modal-tab.active {
background: #4a90e2;
color: #fff;
box-shadow: 0 2px 8px 0 rgba(74,144,226,0.12);
}
.modal-tab:not(.active):hover {
background: color-mix(in srgb, currentColor 10%, transparent);
color: inherit;
}
.modal-tab:focus {
outline: 2px solid #4a90e2;
}
/* Responsive for mobile */
@media (max-width: 700px) {
.modal-content {
min-width: 0;
max-width: 98vw;
width: 98vw;
padding: 18px 2vw 12px 2vw;
font-size: 1em;
max-height: 98vh;
}
.modal-content h3 {
font-size: 1.08em;
}
.modal-actions {
flex-direction: column;
align-items: stretch;
gap: 8px;
}
.modal-pastebin, .modal-content pre, .modal-doc {
font-size: 0.98em;
padding: 8px 6px;
width: 98vw;
min-width: 0;
max-width: 100vw;
height: 120px;
min-height: 60px;
max-height: 30vh;
}
.modal-tabs {
width: 98vw;
min-width: 0;
max-width: 100vw;
}
}
/* --- Add width/centering for modal-tabs and modal-pastebin --- */
.modal-tabs,
.modal-pastebin {
width: 90%;
max-width: 760px;
min-width: 220px;
margin-left: auto;
margin-right: auto;
}
.modal-form {
width: 90%;
max-width: 760px;
min-width: 220px;
margin-left: auto;
margin-right: auto;
display: flex;
flex-direction: column;
align-items: stretch;
height: auto;
max-height: 100%;
}
@media (max-width: 700px) {
.modal-tabs,
.modal-pastebin {
width: 98vw;
max-width: 100vw;
min-width: 0;
}
.modal-form {
width: 98vw;
max-width: 100vw;
min-width: 0;
height: auto;
max-height: 100%;
}
}
@media (max-width: 600px) {
body#body .bse-search-input, body#body .bse-file-status {
width: 100vw;
max-width: 100vw;
}
/* Phones: the field and Search share the first row, the other
buttons wrap below it. CSS order leaves the Tab order alone. */
.bse-modern-searchbar {
flex-wrap: wrap;
gap: 0 8px;
}
.bse-modern-searchbar .bse-search-input, .bse-modern-searchbar .bse-file-status {
order: -3;
flex: 1 1 180px;
width: auto;
max-width: none;
}
.bse-modern-searchbar .bse-search-button {
order: -2;
}
/* An empty full-width item that ends the first row after Search. */
.bse-modern-searchbar::before {
content: "";
order: -1;
flex-basis: 100%;
height: 0;
}
.bse-export-btn, .bse-saved-btn, .bse-file-btn, .bse-cheat-btn, .bse-settings-btn {
margin-top: 8px;
}
.bse-export-btn, .bse-saved-btn, .bse-file-btn, .bse-cheat-btn, .bse-settings-btn, .bse-search-button {
padding: 6px 12px;
}
}
.bse-sort-row {
display: flex;
flex-direction: row;
align-items: center;
gap: 18px;
width: 100%;
margin: 8px 0 0 0;
justify-content: flex-start;
}
.bse-sort-select {
appearance: none;
border-radius: 8px;
padding: 8px 32px 8px 14px;
font-size: 0.95em;
border: 1px solid color-mix(in srgb, currentColor 25%, transparent) !important;
background-color: color-mix(in srgb, currentColor 8%, transparent) !important;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='8' viewBox='0 0 12 8'%3E%3Cpath fill='%23999' d='M1 1l5 5 5-5'/%3E%3C/svg%3E") !important;
background-repeat: no-repeat !important;
background-position: right 10px center !important;
color: inherit !important;
cursor: pointer;
transition: border-color 0.2s, background-color 0.2s;
min-width: 8rem;
}
.bse-sort-select:hover {
background-color: color-mix(in srgb, currentColor 18%, transparent) !important;
border-color: color-mix(in srgb, currentColor 45%, transparent) !important;
}
.bse-sort-select:focus {
border-color: #4a90e2 !important;
outline: none !important;
}
.bse-sort-select option {
background: var(--body-background-color, #1a1a2e) !important;
color: var(--body-font-color, inherit) !important;
}
.bse-order-switch {
appearance: none;
border-radius: 8px;
padding: 8px 14px;
font-size: inherit;
border: 1px solid color-mix(in srgb, currentColor 25%, transparent) !important;
background-color: color-mix(in srgb, currentColor 8%, transparent) !important;
color: inherit !important;
cursor: pointer;
transition: border-color 0.2s, background-color 0.2s;
font-weight: 500;
white-space: nowrap;
min-width: 6rem;
text-align: center;
}
.bse-order-switch:hover {
background-color: color-mix(in srgb, currentColor 18%, transparent) !important;
border-color: color-mix(in srgb, currentColor 45%, transparent) !important;
}
.bse-order-switch:focus {
border-color: #4a90e2 !important;
outline: none !important;
}
.bse-rating-label {
display: flex;
align-items: center;
gap: 4px;
font-size: 1.08em;
font-weight: 500;
color: inherit;
background: color-mix(in srgb, currentColor 10%, transparent);
border-radius: 10px;
padding: 4px 12px;
border: 1.5px solid color-mix(in srgb, currentColor 25%, transparent);
margin-right: 4px;
cursor: pointer;
transition: background 0.2s, border-color 0.2s;
}
.bse-rating-checkbox {
accent-color: #4a90e2;
width: 1.1em;
height: 1.1em;
}
.bse-rating-label:hover {
background: #d0eaff;
border-color: #4a90e2;
}
/* Metatag Row */
.bse-metatag-row-wrap {
width: 100%;
margin: 10px 0 0 0;
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
}
.bse-metatag-row-header {
display: flex;
flex-direction: row;
align-items: center;
gap: 12px;
font-size: 1.08em;
font-weight: 600;
color: inherit;
margin-bottom: 2px;
}
.bse-metatag-toggle {
border-radius: 10px;
padding: 4px 16px;
font-size: 1em;
border: 2px solid color-mix(in srgb, currentColor 25%, transparent);
background: color-mix(in srgb, currentColor 8%, transparent);
cursor: pointer;
transition: border-color 0.2s, background 0.2s;
font-weight: 500;
color: inherit;
}
.bse-metatag-toggle:focus {
border-color: #4a90e2;
outline: none;
}
.bse-metatag-list {
display: flex;
flex-direction: row;
flex-wrap: wrap;
gap: 10px;
width: 100%;
margin-top: 2px;
justify-content: center;
margin-left: auto;
margin-right: auto;
}
.bse-metatag-item {
background: color-mix(in srgb, currentColor 10%, transparent);
border: 1.5px solid color-mix(in srgb, currentColor 25%, transparent);
color: inherit;
}
@media (max-width: 700px) {
.bse-sort-row {
flex-direction: column;
align-items: stretch;
gap: 10px;
}
.bse-metatag-row-header {
flex-direction: column;
align-items: flex-start;
gap: 4px;
}
.bse-metatag-list {
gap: 6px;
}
}
/* Include/Exclude Tag Rows */
.bse-include-row-wrap, .bse-exclude-row-wrap {
width: 100%;
margin: 10px 0 0 0;
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
}
.bse-include-row-header, .bse-exclude-row-header {
display: flex;
flex-direction: row;
align-items: center;
gap: 12px;
font-size: 1.08em;
font-weight: 600;
color: inherit;
margin-bottom: 2px;
}
.bse-include-toggle, .bse-exclude-toggle {
border-radius: 10px;
padding: 4px 16px;
font-size: 1em;
border: 2px solid color-mix(in srgb, currentColor 25%, transparent);
background: color-mix(in srgb, currentColor 8%, transparent);
cursor: pointer;
transition: border-color 0.2s, background 0.2s;
font-weight: 500;
color: inherit;
}
.bse-include-toggle:focus, .bse-exclude-toggle:focus {
border-color: #4a90e2;
outline: none;
}
.bse-include-list, .bse-exclude-list {
display: flex;
flex-direction: row;
flex-wrap: wrap;
gap: 10px;
width: 100%;
margin-top: 2px;
justify-content: center;
margin-left: auto;
margin-right: auto;
}
.bse-include-item:not(.bse-category-tag), .bse-exclude-item:not(.bse-category-tag) {
background: color-mix(in srgb, currentColor 10%, transparent);
border: 1.5px solid color-mix(in srgb, currentColor 25%, transparent);
color: inherit;
}
@media (max-width: 700px) {
.bse-include-row-header, .bse-exclude-row-header {
flex-direction: column;
align-items: flex-start;
gap: 4px;
}
.bse-include-list, .bse-exclude-list {
gap: 6px;
}
.bse-all-tags-row-header {
flex-direction: column;
align-items: flex-start;
gap: 4px;
}
.bse-all-tags-list {
gap: 6px;
}
}
/* All Tags Row */
.bse-all-tags-row-wrap {
width: 100%;
margin: 10px 0 0 0;
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
}
.bse-all-tags-row-header {
display: flex;
flex-direction: row;
align-items: center;
gap: 12px;
font-size: 1.08em;
font-weight: 600;
color: inherit;
margin-bottom: 2px;
}
.bse-all-tags-toggle {
border-radius: 10px;
padding: 4px 16px;
font-size: 1em;
border: 2px solid color-mix(in srgb, currentColor 25%, transparent);
background: color-mix(in srgb, currentColor 8%, transparent);
cursor: pointer;
transition: border-color 0.2s, background 0.2s;
font-weight: 500;
color: inherit;
}
.bse-all-tags-toggle:focus {
border-color: #4a90e2;
outline: none;
}
.bse-all-tags-list {
display: flex;
flex-direction: row;
flex-wrap: wrap;
gap: 10px;
width: 100%;
margin-top: 2px;
justify-content: center;
margin-left: auto;
margin-right: auto;
}
/* Settings Form */
.settings-form {
display: flex;
flex-direction: column;
gap: 16px;
padding: 8px 0;
}
.settings-row {
display: flex;
align-items: center;
gap: 8px;
}
.settings-label {
display: flex;
align-items: center;
gap: 8px;
font-size: 1.08em;
font-weight: 500;
color: inherit;
cursor: pointer;
}
.settings-label input[type="checkbox"] {
accent-color: #4a90e2;
width: 1.2em;
height: 1.2em;
}
/* Cheat sheet info bar */
.cheatsheet-info-bar {
margin-top: 16px;
padding: 12px;
background: #f0f4fa;
border-radius: 8px;
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
gap: 12px;
}
.cheatsheet-info-bar a {
color: #4a90e2;
text-decoration: none;
font-weight: 500;
}
.cheatsheet-info-bar a:hover {
text-decoration: underline;
}
.cheatsheet-cache-info {
font-size: 0.9em;
color: #666;
}
.cheatsheet-flush-btn {
border-radius: 12px;
padding: 6px 16px;
font-size: 0.9em;
border: 1.5px solid color-mix(in srgb, currentColor 25%, transparent);
background: color-mix(in srgb, currentColor 8%, transparent);
cursor: pointer;
transition: all 0.2s;
}
.cheatsheet-flush-btn:hover {
border-color: color-mix(in srgb, currentColor 45%, transparent);
background: color-mix(in srgb, currentColor 18%, transparent);
}
.bse-tag-suggestions {
position: absolute;
z-index: 99999;
box-sizing: border-box;
max-height: 400px;
overflow-y: auto;
list-style: none;
margin: 0;
padding: 0;
border: 1px solid color-mix(in srgb, currentColor 25%, transparent);
box-shadow: 0 3px 10px #0002;
font: inherit;
}
.bse-tag-suggestions[hidden] { display: none; }
.bse-tag-suggestions > li {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 8px 12px;
}
.bse-tag-suggestions [role="option"] { cursor: pointer; }
.bse-tag-suggestions [aria-selected="true"] {
background: color-mix(in srgb, currentColor 10%, transparent);
}
.bse-native-tag { transition: background .2s, border-color .2s; }
.bse-suggestion-alias { opacity: var(--bse-secondary-opacity, .7); font-size: .85em; }
.bse-suggestion-count { opacity: var(--bse-secondary-opacity, .7); flex-shrink: 0; }
.bse-suggestion-content {
display: flex;
justify-content: space-between;
align-items: center;
width: 100%;
gap: 12px;
text-decoration: none;
cursor: pointer;
}
.bse-suggestion-status button {
background: none;
color: inherit;
border: 0;
padding: 0;
font: inherit;
text-decoration: underline;
cursor: pointer;
}
/* Wiki search */
.bse-wiki-search-wrap {
display: flex;
justify-content: center;
margin: 20px auto;
max-width: 600px;
padding: 0 20px;
}
.bse-wiki-search-wrap form {
display: flex;
gap: 10px;
width: 100%;
align-items: center;
}
`;
document.head.appendChild(style);
}
"use strict";
function tagLuminance(color) {
return color.reduce((sum, value, i) => {
const channel = value / 255;
return sum + [0.2126, 0.7152, 0.0722][i] * (channel <= 0.04045 ? channel / 12.92 : ((channel + 0.055) / 1.055) ** 2.4);
}, 0);
}
function mixTagColor(color, background, alpha) {
const channel = (i) => background[i] * (1 - alpha) + color[i] * alpha;
return [channel(0), channel(1), channel(2)];
}
function readableTagColor(native, background) {
if (tagLuminance(background) <= 0.5)
return native;
const readable = (color) => {
// Reserve the chip's 10% tint, also keeping plain menu labels readable.
const backdrop = mixTagColor(color, background, 0.1);
return (tagLuminance(backdrop) + 0.05) / (tagLuminance(color) + 0.05) >= 4.55;
};
// A small margin above 4.5 absorbs browser rounding of mixed backgrounds.
if (readable(native))
return native;
const scaled = (factor) => [Math.round(native[0] * factor), Math.round(native[1] * factor), Math.round(native[2] * factor)];
let low = 0, high = 1;
for (let i = 0; i < 20; i++) {
const factor = (low + high) / 2;
if (readable(scaled(factor)))
low = factor;
else
high = factor;
}
return scaled(low);
}
let tagColorContext;
function readTagColor(css) {
const rgb = css.match(/^rgba?\(([^)]+)\)$/);
const srgb = css.match(/^color\(srgb ([^)]+)\)$/);
if (rgb || srgb) {
const values = (rgb || srgb)[1].trim().split(/[,\s/]+/).map(Number);
if (values.length < 3 || values.some(value => !Number.isFinite(value)))
return;
const scale = srgb ? 255 : 1;
return [values[0] * scale, values[1] * scale, values[2] * scale, values[3] ?? 1];
}
// Computed styles may also use modern spaces such as oklch/display-p3.
if (!CSS.supports('color', css))
return;
tagColorContext ??= document.createElement('canvas').getContext('2d', { willReadFrequently: true });
if (!tagColorContext)
return;
tagColorContext.clearRect(0, 0, 1, 1);
tagColorContext.fillStyle = css;
tagColorContext.fillRect(0, 0, 1, 1);
const values = tagColorContext.getImageData(0, 0, 1, 1).data;
return [values[0], values[1], values[2], values[3] / 255];
}
function tagBackground(element, canvas) {
const layers = [];
for (let node = element; node; node = node.parentElement) {
const color = readTagColor(getComputedStyle(node).backgroundColor);
if (color)
layers.push(color);
if (color?.[3] === 1)
break;
}
let background = layers.some(color => color[3] === 1) ? [255, 255, 255] : canvas();
for (const color of layers.reverse())
background = mixTagColor([color[0], color[1], color[2]], background, color[3]);
return background;
}
function refreshTagContrast() {
let canvasColor;
const canvas = () => {
if (canvasColor)
return canvasColor;
// Canvas 2D's system colors do not inherit the page's color-scheme.
const probe = document.createElement('span');
probe.style.cssText = 'display:none;color:Canvas';
document.body.append(probe);
const color = readTagColor(getComputedStyle(probe).color);
probe.remove();
return canvasColor = color ? [color[0], color[1], color[2]] : [255, 255, 255];
};
const labels = [...document.querySelectorAll('.bse-native-tag')];
for (const label of labels) {
// Sample the current native state, never the previous correction.
label.style.removeProperty('color');
label.style.removeProperty('--bse-secondary-opacity');
}
// Separate style reads from writes so hovering a tag does not repeatedly
// recalculate styles for the rest of the tag list.
const corrections = labels.map(label => {
const native = readTagColor(getComputedStyle(label).color);
if (!native)
return;
const background = tagBackground(label.classList.contains('bse-category-tag') ? label.parentElement : label, canvas);
if (tagLuminance(background) <= 0.5)
return;
const color = readableTagColor([native[0], native[1], native[2]], background);
return { label, color: native[3] !== 1 || color.some((value, i) => value !== native[i]) ? `rgb(${color.join(', ')})` : undefined };
});
for (const correction of corrections) {
if (!correction)
continue;
const { label, color } = correction;
label.style.setProperty('--bse-secondary-opacity', '1');
if (color) {
// Native tag rules on some sites use !important.
label.style.setProperty('color', color, 'important');
}
}
}
let tagContrastFrame;
function scheduleTagContrast() {
if (tagContrastFrame !== undefined)
return;
tagContrastFrame = window.requestAnimationFrame(() => {
tagContrastFrame = undefined;
refreshTagContrast();
});
}
function watchTagContrast() {
const observer = new MutationObserver(scheduleTagContrast);
observer.observe(document.documentElement, { attributes: true });
observer.observe(document.body, { attributes: true });
window.matchMedia?.('(prefers-color-scheme: dark)').addEventListener('change', scheduleTagContrast);
for (const event of ['pointerover', 'pointerout', 'focusin', 'focusout']) {
document.addEventListener(event, event => {
if (event.target instanceof Element && event.target.closest('.bse-native-tag'))
scheduleTagContrast();
});
}
}
"use strict";
/* exported normalizeTagCategory, createTagCategoryLabel, createTagCategoryRegistry, getTagMenuColors, watchTagMenuColors */
// Category identity is site-specific; native CSS owns the actual palette.
const STANDARD_TAG_CATEGORIES = {
0: 'general', 1: 'artist', 3: 'copyright', 4: 'character', 5: 'metadata',
};
const E621_TAG_CATEGORIES = {
0: 'general', 1: 'artist', 3: 'copyright', 4: 'character',
5: 'species', 6: 'invalid', 7: 'metadata', 8: 'lore',
};
function normalizeTagCategory(value, site) {
const categories = site === 'e621' ? E621_TAG_CATEGORIES : STANDARD_TAG_CATEGORIES;
if (typeof value === 'number')
return Object.hasOwn(categories, value) ? categories[value] : undefined;
if (typeof value !== 'string')
return;
if (/^\d+$/.test(value))
return normalizeTagCategory(Number(value), site);
const name = value === 'tag' ? 'general' : value === 'meta' ? 'metadata' : value;
const names = Object.values(categories);
if (names.includes(name))
return name;
if (name === 'deprecated' && site !== 'e621' && site !== 'danbooru')
return 'deprecated';
}
function tagCategoryClasses(category, site) {
if (site === 'e621' || site === 'danbooru') {
const categories = site === 'e621' ? E621_TAG_CATEGORIES : STANDARD_TAG_CATEGORIES;
const id = Object.keys(categories).find(key => categories[Number(key)] === category);
return id === undefined ? '' : `tag-type-${id} category-${id}`;
}
return 'tag-type-' + (category === 'general' && site === 'gelbooru' ? 'tag' : category);
}
function createTagCategoryLabel(category, site) {
const wrapper = document.createElement('span');
const label = document.createElement('a');
if (category) {
wrapper.className = tagCategoryClasses(category, site);
label.className = wrapper.className;
}
label.classList.add('bse-native-tag');
scheduleTagContrast();
// An anchor without href is inert, while matching the site's tag selectors.
wrapper.append(label);
return { wrapper, label };
}
function createTagCategoryRegistry(site, changed) {
const categories = new Map();
const MAX_CATEGORIES = 512;
const canonical = (name) => name.replace(/^-/, '');
function remember(name, category, notify = true) {
if (typeof name !== 'string')
return;
name = canonical(name);
const valid = normalizeTagCategory(category, site);
if (!name || !valid)
return;
const previous = categories.get(name);
categories.delete(name);
categories.set(name, valid);
if (categories.size > MAX_CATEGORIES)
categories.delete(categories.keys().next().value);
if (notify && valid !== previous)
changed(name);
}
document.querySelectorAll('[class*="tag-type-"], [class*="category-"], [data-tag-category]').forEach(node => {
const raw = node.dataset.tagCategory ?? [...node.classList].map(name => name.match(/^(?:tag-type-|category-)([a-z]+|\d+)$/)?.[1]).find(Boolean);
const category = normalizeTagCategory(raw, site);
if (!category)
return;
if (node.dataset.tagName)
remember(node.dataset.tagName, category, false);
const links = node.matches('a[href]') ? [node] : node.querySelectorAll('a[href]');
for (const link of links) {
let url;
try {
url = new URL(link.href, location.href);
}
catch {
continue;
}
const name = url.searchParams.get('tags');
if (url.origin === location.origin && name && /^-?\S+$/.test(name))
remember(name, category, false);
}
});
return {
get(name) {
if (typeof name !== 'string')
return;
name = canonical(name);
const category = categories.get(name);
if (category) {
categories.delete(name);
categories.set(name, category);
}
return category;
},
remember,
};
}
function getTagMenuColors(input) {
const body = getComputedStyle(document.body);
const root = getComputedStyle(document.documentElement);
const background = [body.backgroundColor, root.backgroundColor]
.find(color => color !== 'transparent' && color !== 'rgba(0, 0, 0, 0)');
return { background: background || 'Canvas', color: getComputedStyle(input).color };
}
function watchTagMenuColors(input, apply) {
const refresh = () => apply(getTagMenuColors(input));
// Theme selectors may use classes, inline styles or data attributes.
const observer = new MutationObserver(refresh);
observer.observe(document.documentElement, { attributes: true });
observer.observe(document.body, { attributes: true });
window.matchMedia?.('(prefers-color-scheme: dark)').addEventListener('change', refresh);
refresh();
}
"use strict";
/* exported bindTagAutocomplete */
// Suggestion requests belong to the replacement field, independently of site libraries.
function bindTagAutocomplete(input, provider, addTag, tagCategories) {
if (!provider)
return;
// Category numbering and class names follow the site the endpoint belongs to.
const site = provider === 'safebooru' ? 'rule34' : provider;
const menu = document.createElement('ul');
menu.id = 'bse-tag-suggestions';
menu.className = 'bse-tag-suggestions';
menu.setAttribute('role', 'listbox');
menu.setAttribute('aria-label', 'Tag suggestions');
menu.hidden = true;
document.body.append(menu);
watchTagMenuColors(input, colors => {
menu.style.backgroundColor = colors.background;
menu.style.color = colors.color;
});
input.setAttribute('role', 'combobox');
input.setAttribute('aria-autocomplete', 'list');
input.setAttribute('aria-controls', menu.id);
input.setAttribute('aria-expanded', 'false');
const MAX_RESULTS = 10;
const DEBOUNCE_MS = 225;
// e621's tag autocomplete needs at least three characters.
const MIN_TERM_LENGTH = provider === 'e621' ? 3 : 1;
let timer;
let controller;
let revision = 0;
let active = -1;
let suggestions = [];
let query = '';
function invalidate() {
revision++;
if (timer !== undefined)
window.clearTimeout(timer);
timer = undefined;
controller?.abort();
controller = undefined;
}
function close() {
invalidate();
menu.hidden = true;
suggestions = [];
active = -1;
input.setAttribute('aria-expanded', 'false');
input.removeAttribute('aria-activedescendant');
}
function position() {
if (menu.hidden)
return;
const rect = input.getBoundingClientRect();
menu.style.left = rect.left + window.scrollX + 'px';
menu.style.top = rect.bottom + window.scrollY + 'px';
menu.style.width = rect.width + 'px';
}
function show() {
const colors = getTagMenuColors(input);
menu.style.backgroundColor = colors.background;
menu.style.color = colors.color;
menu.hidden = false;
input.setAttribute('aria-expanded', 'true');
position();
}
function status(message, retry = false) {
active = -1;
suggestions = [];
input.removeAttribute('aria-activedescendant');
menu.replaceChildren();
const row = document.createElement('li');
row.className = 'bse-suggestion-status';
row.setAttribute('role', 'presentation');
const text = document.createElement('span');
text.setAttribute('role', 'status');
text.textContent = message;
row.append(text);
if (retry) {
const button = document.createElement('button');
button.type = 'button';
button.textContent = 'Retry';
button.addEventListener('mousedown', e => e.preventDefault());
button.addEventListener('click', () => {
input.focus();
schedule(true);
});
row.append(button);
}
menu.append(row);
show();
}
function highlight(index) {
active = index;
[...menu.querySelectorAll('[role="option"]')].forEach((item, i) => {
item.setAttribute('aria-selected', String(i === active));
});
const selected = menu.querySelector('[aria-selected="true"]');
if (selected) {
input.setAttribute('aria-activedescendant', selected.id);
selected.scrollIntoView?.({ block: 'nearest' });
}
else
input.removeAttribute('aria-activedescendant');
scheduleTagContrast();
}
function select(index) {
const suggestion = suggestions[index];
if (!suggestion)
return;
const exclusion = query.startsWith('-') ? '-' : '';
close();
addTag(exclusion + suggestion.value.replace(/^-/, ''));
input.value = '';
input.focus();
}
function render(items) {
if (!items.length) {
status('No suggestions');
return;
}
active = -1;
suggestions = items;
menu.replaceChildren();
input.removeAttribute('aria-activedescendant');
items.forEach((item, index) => {
const row = document.createElement('li');
row.id = menu.id + '-' + index;
row.setAttribute('role', 'option');
row.setAttribute('aria-selected', 'false');
const { wrapper, label } = createTagCategoryLabel(item.category, site);
row.className = wrapper.className;
label.classList.add('bse-suggestion-content');
const name = document.createElement('span');
// An alias row shows the typed name first, then the tag it resolves to.
name.textContent = (item.antecedent ?? item.label).replace(/^-/, '').replace(/_/g, ' ');
if (item.antecedent) {
const alias = document.createElement('span');
alias.className = 'bse-suggestion-alias';
alias.textContent = '→ ' + item.label.replace(/^-/, '').replace(/_/g, ' ');
name.append(' ', alias);
}
label.append(name);
row.append(label);
if (item.count !== undefined) {
const count = document.createElement('span');
count.className = 'bse-suggestion-count';
count.textContent = formatPostCount(item.count);
label.append(count);
}
row.addEventListener('pointerdown', e => { e.preventDefault(); select(index); });
row.addEventListener('click', () => select(index));
row.addEventListener('mouseenter', () => highlight(index));
menu.append(row);
});
show();
}
// Maps one entry of the provider's response onto the shared row fields.
function parseEntry(item) {
switch (provider) {
case 'gelbooru':
return { value: item.value, label: item.label ?? item.value, category: item.category, count: item.post_count };
case 'e621':
return { value: item.name, label: item.name, category: item.category, count: item.post_count, antecedent: item.antecedent_name };
case 'danbooru': {
// Danbooru puts category and post_count either on the entry or on its tag.
const tag = item.tag && typeof item.tag === 'object' ? item.tag : {};
return { value: item.value, label: item.label ?? item.value, category: item.category ?? tag.category,
count: item.post_count ?? tag.post_count, antecedent: item.antecedent };
}
default: {
// Rule34 and Safebooru append the post count to the label: "landscape (10718)".
const label = typeof item.label === 'string' ? item.label : item.value;
const suffix = typeof label === 'string' ? label.match(/ \((\d+)\)$/) : null;
return { value: item.value, label: suffix ? label.slice(0, -suffix[0].length) : label,
category: item.type, count: suffix?.[1] };
}
}
}
function normalize(data) {
if (!Array.isArray(data))
throw new Error('Invalid autocomplete response');
const items = [];
const seen = new Set();
for (const entry of data) {
if (!entry || typeof entry !== 'object')
continue;
const { value, label, category: rawCategory, count: rawCount, antecedent } = parseEntry(entry);
if (typeof value !== 'string' || !value.trim() || seen.has(value))
continue;
const count = typeof rawCount === 'number' || (typeof rawCount === 'string' && /^\d+$/.test(rawCount)) ? Number(rawCount) : undefined;
const category = normalizeTagCategory(rawCategory, site) ?? tagCategories.get(value);
tagCategories.remember(value, category);
seen.add(value);
items.push({
value, label: typeof label === 'string' ? label : value, category,
count: count !== undefined && Number.isFinite(count) && count >= 0 ? count : undefined,
antecedent: typeof antecedent === 'string' && antecedent ? antecedent : undefined,
});
if (items.length === MAX_RESULTS)
break;
}
return items;
}
function requestUrl(term) {
const name = term.replace(/^-/, '');
switch (provider) {
case 'gelbooru':
return location.origin + '/index.php?page=autocomplete2&' + new URLSearchParams({ term, type: 'tag_query', limit: String(MAX_RESULTS) });
case 'e621':
return location.origin + '/tags/autocomplete.json?' + new URLSearchParams({ 'search[name_matches]': name, expiry: '7' });
case 'danbooru':
return location.origin + '/autocomplete.json?' + new URLSearchParams({
'search[query]': name, 'search[type]': 'tag_query', limit: String(MAX_RESULTS), version: '3',
});
default: {
// Rule34's API mirror omits types; its native endpoint includes them.
const base = provider === 'rule34' ? 'https://ac.rule34.xxx' : location.origin;
return base + '/autocomplete.php?q=' + encodeURIComponent(name);
}
}
}
async function request(currentRevision, term) {
controller = new AbortController();
const currentController = controller;
status('Loading suggestions…');
try {
const response = await fetch(requestUrl(term), { signal: currentController.signal });
if (!response.ok)
throw new Error('Autocomplete HTTP status ' + response.status);
const data = await response.json();
if (currentRevision !== revision || currentController.signal.aborted || input.value.trim() !== term)
return;
render(normalize(data));
}
catch (error) {
if (currentRevision !== revision || currentController.signal.aborted)
return;
console.warn('[Booru Tag Search] Autocomplete request failed', error);
status('Suggestions unavailable.', true);
}
}
function schedule(immediate = false) {
close();
query = input.value.trim();
if (query.replace(/^-/, '').length < MIN_TERM_LENGTH)
return;
const currentRevision = revision;
const term = query;
if (immediate)
void request(currentRevision, term);
else
timer = window.setTimeout(() => { timer = undefined; void request(currentRevision, term); }, DEBOUNCE_MS);
}
input.addEventListener('input', () => schedule());
input.addEventListener('keydown', event => {
const keyEvent = event;
if (keyEvent.isComposing)
return;
if (keyEvent.key === 'Escape') {
close();
return;
}
if (menu.hidden || !suggestions.length)
return;
if (keyEvent.key === 'ArrowDown' || keyEvent.key === 'ArrowUp') {
event.preventDefault();
event.stopImmediatePropagation();
const next = keyEvent.key === 'ArrowDown' ? Math.min(active + 1, suggestions.length - 1) : Math.max(active - 1, 0);
highlight(next);
}
else if (keyEvent.key === 'Enter' && active >= 0 && !keyEvent.shiftKey) {
event.preventDefault();
event.stopImmediatePropagation();
select(active);
}
}, true);
input.addEventListener('blur', event => {
const target = event.relatedTarget;
if (!(target instanceof Node) || !menu.contains(target))
close();
});
menu.addEventListener('focusout', event => {
if (event.relatedTarget !== input && (!(event.relatedTarget instanceof Node) || !menu.contains(event.relatedTarget)))
close();
});
input.form?.addEventListener('submit', close, true);
window.addEventListener('pageshow', close);
window.addEventListener('scroll', position, true);
window.addEventListener('resize', position);
return { close };
}
"use strict";
/* exported createMd5, hashFile */
// MD5 (RFC 1321), fed in chunks so a large file never has to sit in memory
// whole. Boorus index posts by the MD5 of the original file, and Web Crypto
// has no MD5.
const MD5_SHIFTS = [
7, 12, 17, 22, 7, 12, 17, 22, 7, 12, 17, 22, 7, 12, 17, 22,
5, 9, 14, 20, 5, 9, 14, 20, 5, 9, 14, 20, 5, 9, 14, 20,
4, 11, 16, 23, 4, 11, 16, 23, 4, 11, 16, 23, 4, 11, 16, 23,
6, 10, 15, 21, 6, 10, 15, 21, 6, 10, 15, 21, 6, 10, 15, 21,
];
// floor(|sin(i + 1)| * 2^32), the table RFC 1321 defines.
const MD5_CONSTANTS = Array.from({ length: 64 }, (_, i) => Math.floor(Math.abs(Math.sin(i + 1)) * 2 ** 32) | 0);
function createMd5() {
const state = new Int32Array([0x67452301, 0xefcdab89 | 0, 0x98badcfe | 0, 0x10325476]);
const words = new Int32Array(16);
const pending = new Uint8Array(64);
let pendingLength = 0;
let totalLength = 0;
function processBlock(source, offset) {
for (let i = 0; i < 16; i++) {
const p = offset + i * 4;
words[i] = source[p] | (source[p + 1] << 8) | (source[p + 2] << 16) | (source[p + 3] << 24);
}
let a = state[0], b = state[1], c = state[2], d = state[3];
for (let i = 0; i < 64; i++) {
let f, g;
if (i < 16) {
f = (b & c) | (~b & d);
g = i;
}
else if (i < 32) {
f = (d & b) | (~d & c);
g = (5 * i + 1) & 15;
}
else if (i < 48) {
f = b ^ c ^ d;
g = (3 * i + 5) & 15;
}
else {
f = c ^ (b | ~d);
g = (7 * i) & 15;
}
const sum = (a + f + MD5_CONSTANTS[i] + words[g]) | 0;
const shift = MD5_SHIFTS[i];
a = d;
d = c;
c = b;
b = (b + ((sum << shift) | (sum >>> (32 - shift)))) | 0;
}
state[0] += a;
state[1] += b;
state[2] += c;
state[3] += d;
}
function update(bytes) {
totalLength += bytes.length;
let i = 0;
if (pendingLength > 0) {
i = Math.min(64 - pendingLength, bytes.length);
pending.set(bytes.subarray(0, i), pendingLength);
pendingLength += i;
if (pendingLength < 64)
return;
processBlock(pending, 0);
pendingLength = 0;
}
for (; i + 64 <= bytes.length; i += 64)
processBlock(bytes, i);
if (i < bytes.length) {
pending.set(bytes.subarray(i), 0);
pendingLength = bytes.length - i;
}
}
function digest() {
const bitLength = totalLength * 8;
const padding = new Uint8Array((pendingLength < 56 ? 56 : 120) - pendingLength + 8);
padding[0] = 0x80;
const view = new DataView(padding.buffer);
view.setUint32(padding.length - 8, bitLength % 2 ** 32, true);
view.setUint32(padding.length - 4, Math.floor(bitLength / 2 ** 32), true);
update(padding);
const out = new DataView(new ArrayBuffer(16));
state.forEach((word, i) => out.setInt32(i * 4, word, true));
return Array.from(new Uint8Array(out.buffer), byte => byte.toString(16).padStart(2, '0')).join('');
}
return { update, digest };
}
const HASH_CHUNK_BYTES = 4 * 1024 * 1024;
/** MD5 of a file, read in chunks; onProgress gets the fraction done after each chunk. */
async function hashFile(file, onProgress) {
const md5 = createMd5();
for (let offset = 0; offset < file.size; offset += HASH_CHUNK_BYTES) {
const end = Math.min(offset + HASH_CHUNK_BYTES, file.size);
md5.update(new Uint8Array(await file.slice(offset, end).arrayBuffer()));
onProgress(end / file.size);
}
return md5.digest();
}
if (typeof window === 'undefined' && typeof module !== 'undefined' && module.exports) {
module.exports = { createMd5, hashFile };
}
"use strict";
/* exported md5SearchTags, isUploadPage, bindFileDrop */
// Search by file: a file dropped anywhere on the page, or picked with the
// File button, is hashed and searched as md5:<hash>.
/** The tags that find the post whose original file has this MD5. */
function md5SearchTags(hash, site) {
// Danbooru hides deleted posts unless the search asks for them.
return site === 'danbooru' ? [`md5:${hash}`, 'status:any'] : [`md5:${hash}`];
}
/** Upload pages have their own drop zone, so the page-wide drop stays off there. */
function isUploadPage(loc) {
return /^\/uploads(\/|$)/.test(loc.pathname) || new URLSearchParams(loc.search).get('s') === 'add';
}
/**
* Shows the overlay while a file from outside the page is dragged over the
* window, and hands the dropped file to onDrop. Drags that start on the page
* (thumbnails, links) are ignored, and so is any drag or drop the site's own
* handlers already accepted with preventDefault.
*/
function bindFileDrop(win, overlay, handlers) {
let depth = 0;
let dragFromPage = false;
win.document.addEventListener('dragstart', () => { dragFromPage = true; });
win.document.addEventListener('dragend', () => { dragFromPage = false; });
const carriesFile = (e) => !dragFromPage && !!e.dataTransfer && Array.from(e.dataTransfer.types).includes('Files');
const hide = () => {
depth = 0;
overlay.hidden = true;
};
win.addEventListener('dragenter', e => {
if (!carriesFile(e))
return;
if (depth++ === 0)
handlers.onEnter();
});
win.addEventListener('dragleave', e => {
if (!carriesFile(e))
return;
if (--depth <= 0)
hide();
});
win.addEventListener('dragover', e => {
if (!carriesFile(e))
return;
if (e.defaultPrevented) {
overlay.hidden = true;
return;
}
e.preventDefault();
e.dataTransfer.dropEffect = 'copy';
overlay.hidden = false;
});
win.addEventListener('drop', e => {
if (!carriesFile(e))
return;
hide();
if (e.defaultPrevented)
return;
e.preventDefault();
handlers.onDrop(e.dataTransfer.files[0] ?? null);
});
}
if (typeof window === 'undefined' && typeof module !== 'undefined' && module.exports) {
module.exports = { md5SearchTags, isUploadPage, bindFileDrop };
}
"use strict";
/* exported createSearchSection */
function createCollapsibleRow(prefix, title, startExpanded) {
const rowWrap = document.createElement('div');
rowWrap.className = `bse-${prefix}-row-wrap`;
const rowHeader = document.createElement('div');
rowHeader.className = `bse-${prefix}-row-header`;
const rowTitle = document.createElement('span');
rowTitle.textContent = title;
const toggle = document.createElement('button');
toggle.type = 'button';
toggle.className = `bse-${prefix}-toggle`;
toggle.textContent = startExpanded ? 'See less' : 'See more';
rowHeader.appendChild(rowTitle);
rowHeader.appendChild(toggle);
const list = document.createElement('div');
list.className = `bse-${prefix}-list`;
rowWrap.appendChild(rowHeader);
rowWrap.appendChild(list);
let expanded = startExpanded;
list.style.display = expanded ? '' : 'none';
toggle.onclick = () => {
expanded = !expanded;
list.style.display = expanded ? '' : 'none';
toggle.textContent = expanded ? 'See less' : 'See more';
};
return { rowWrap, list };
}
// --- Search Bar Creation ---
function createSearchSection({ siteConfig, getTags, setTags, ui, tagCategories, addTag, addNormalizedInput, showExportModal, showSavedSearchesModal, showCheatSheetModal, showSettingsModal, renderTags }) {
const site = siteConfig.site;
const centerWrap = document.createElement('div');
centerWrap.className = 'bse-center-wrap';
const searchForm = document.createElement('form');
searchForm.className = 'bse-search-form';
searchForm.method = 'GET';
searchForm.action = '';
const searchBarContainer = document.createElement('div');
searchBarContainer.className = 'bse-modern-searchbar';
const inputTag = siteConfig.inputTag || 'input';
const searchInput = document.createElement(inputTag);
// Named only on submit: Rule34's and Safebooru's awesomplete.min.js
// attach their own menu to every *[name="tags"] on window.onload.
searchInput.placeholder = siteConfig.placeholder || 'Enter tags...';
searchInput.className = 'bse-search-input';
// Prevent the browser from resurrecting the submitted value (e.g.
// "solo futanari pussy") on back-navigation — it would otherwise
// get glommed into a single tag when the user types a space.
searchInput.autocomplete = 'off';
if (inputTag === 'textarea') {
searchInput.rows = 1;
searchInput.id = 'tags';
}
else {
searchInput.type = 'text';
}
// --- Searchbar Buttons ---
const exportBtn = document.createElement('button');
exportBtn.type = 'button';
exportBtn.textContent = 'Export';
exportBtn.className = 'bse-export-btn';
exportBtn.title = 'Export tags';
exportBtn.onclick = showExportModal;
const savedBtn = document.createElement('button');
savedBtn.type = 'button';
savedBtn.textContent = 'Saved';
savedBtn.className = 'bse-saved-btn';
savedBtn.onclick = showSavedSearchesModal;
const cheatBtn = document.createElement('button');
cheatBtn.type = 'button';
cheatBtn.textContent = '?';
cheatBtn.className = 'bse-cheat-btn';
cheatBtn.title = 'Show cheat sheet';
cheatBtn.onclick = showCheatSheetModal;
const settingsBtn = document.createElement('button');
settingsBtn.type = 'button';
settingsBtn.textContent = '⚙';
settingsBtn.className = 'bse-settings-btn';
settingsBtn.title = 'Settings';
settingsBtn.onclick = showSettingsModal;
const searchButton = document.createElement('button');
searchButton.type = 'submit';
searchButton.textContent = 'Search';
searchButton.className = 'bse-search-button';
// --- Search by file ---
const fileBtn = document.createElement('button');
fileBtn.type = 'button';
fileBtn.textContent = 'File';
fileBtn.className = 'bse-file-btn';
fileBtn.title = 'Search by file MD5';
const filePicker = document.createElement('input');
filePicker.type = 'file';
filePicker.hidden = true;
fileBtn.onclick = () => filePicker.click();
filePicker.addEventListener('change', () => {
const file = filePicker.files?.[0];
filePicker.value = '';
if (file)
void searchByFile(file);
});
// Takes the field's place while a file is hashed.
const fileStatus = document.createElement('span');
fileStatus.className = 'bse-file-status';
fileStatus.style.display = 'none';
const fileSpinner = document.createElement('span');
fileSpinner.className = 'bse-file-spinner';
const fileStatusText = document.createElement('span');
fileStatusText.className = 'bse-file-status-text';
fileStatus.append(fileSpinner, fileStatusText);
const fileError = document.createElement('p');
fileError.className = 'bse-file-error';
fileError.hidden = true;
// --- New Sort/Order/Ratings Row ---
const sortRow = document.createElement('div');
sortRow.className = 'bse-sort-row';
// Sort dropdown
const sortSelect = document.createElement('select');
sortSelect.className = 'bse-sort-select';
const emptyOption = document.createElement('option');
emptyOption.value = '';
emptyOption.textContent = 'Sort';
sortSelect.appendChild(emptyOption);
const sortOptions = siteConfig.sortOptions;
sortOptions.forEach((opt) => {
const option = document.createElement('option');
option.value = opt.value;
option.textContent = opt.label;
sortSelect.appendChild(option);
});
sortRow.appendChild(sortSelect);
// Order switch
const orderSwitch = document.createElement('button');
orderSwitch.type = 'button';
orderSwitch.className = 'bse-order-switch';
orderSwitch.dataset.state = 'desc';
orderSwitch.textContent = 'Descent \u2193';
orderSwitch.style.display = 'none';
orderSwitch.addEventListener('click', function () {
// Flip direction, then push the change into the tag list.
const next = orderSwitch.dataset.state === 'desc' ? 'asc' : 'desc';
orderSwitch.dataset.state = next;
orderSwitch.textContent = next === 'asc' ? 'Ascent \u2191' : 'Descent \u2193';
syncMetatagsFromUI();
});
sortRow.appendChild(orderSwitch);
// Ratings checkboxes
const ratings = siteConfig.ratings;
ratings.forEach((r) => {
const label = document.createElement('label');
label.className = 'bse-rating-label';
const checkbox = document.createElement('input');
checkbox.type = 'checkbox';
checkbox.value = r.value;
checkbox.className = 'bse-rating-checkbox';
label.appendChild(checkbox);
label.appendChild(document.createTextNode(' ' + r.label));
sortRow.appendChild(label);
});
// Cache rating checkboxes — queried once, reused everywhere
const ratingCheckboxes = sortRow.querySelectorAll('.bse-rating-checkbox');
// --- Collapsible Tag Rows ---
const { rowWrap: includeRowWrap, list: includeList } = createCollapsibleRow('include', 'Include Tags', true);
const { rowWrap: excludeRowWrap, list: excludeList } = createCollapsibleRow('exclude', 'Exclude Tags', true);
const { rowWrap: metatagRowWrap, list: metatagList } = createCollapsibleRow('metatag', 'Metatags', false);
const { rowWrap: allTagsRowWrap, list: allTagsList } = createCollapsibleRow('all-tags', 'All Tags', true);
// --- Event Bindings ---
function bindInputEvents() {
searchInput.addEventListener('input', function () {
let value = searchInput.value;
// Only split on comma or space if not inside parentheses
const endsWithSeparator = /[ ,]$/.test(value);
if (endsWithSeparator) {
// Parse the input to check if we're inside a parenthetical group
// This handles both grouping parentheses like "( rating:safe ~ rating:questionable )"
// and tag name parentheses like "silvia_(artist)"
let parenLevel = 0;
let inGroupParens = false;
let lastChar = '';
for (let i = 0; i < value.length - 1; i++) { // -1 to exclude the trailing separator
const char = value[i];
if ((char === '(' || (site === 'gelbooru' && char === '{')) && (i === 0 || lastChar === ' ')) {
// Opening paren at start or after space - likely a grouping paren
parenLevel++;
inGroupParens = true;
}
else if ((char === ')' || (site === 'gelbooru' && char === '}')) && parenLevel > 0) {
parenLevel--;
if (parenLevel === 0) {
inGroupParens = false;
}
}
if (char !== ' ')
lastChar = char;
}
// Only split if we're not inside grouping parentheses
if (parenLevel === 0 && !inGroupParens) {
value = value.replace(/[ ,]+$/, '').trim();
addNormalizedInput(value);
searchInput.value = '';
}
}
});
// Only submit form on Enter if input is empty and there are tags
searchInput.addEventListener('keydown', function (event) {
const e = event;
if (e.key === 'Enter' && !e.shiftKey) {
// If input is empty and there are tags, submit
if (searchInput.value.trim() === '' && getTags().length > 0) {
e.preventDefault();
searchInput.value = getTags().join(' ');
submitSearch();
}
else if (searchInput.value.trim() !== '') {
// No suggestion selected, add raw input as tag
e.preventDefault();
const value = searchInput.value.trim();
addNormalizedInput(value);
searchInput.value = '';
tagAutocomplete?.close();
}
}
});
}
function buildAndApplyMetatags() {
const prefix = siteConfig.sortPrefix;
const prefixes = [prefix + ':', 'rating:', '( rating:'];
const excludesEveryRating = site === 'gelbooru' && siteConfig.ratings.every(rating => getTags().includes(`-rating:${rating.value}`));
if (site === 'gelbooru')
prefixes.push('-rating:');
setTags(getTags().filter(tag => !prefixes.some(p => tag.startsWith(p))));
if (sortSelect.value) {
const order = orderSwitch.dataset.state || 'desc';
if (prefix === 'order') {
getTags().push(order === 'asc' ? `order:${sortSelect.value}_asc` : `order:${sortSelect.value}`);
}
else {
getTags().push(`sort:${sortSelect.value}:${order}`);
}
}
const checkedRatings = [...ratingCheckboxes].filter(cb => cb.checked).map(cb => cb.value);
if (checkedRatings.length === 1) {
getTags().push(`rating:${checkedRatings[0]}`);
}
else if (checkedRatings.length > 1) {
if (site === 'gelbooru') {
getTags().push(...siteConfig.ratings.filter(r => !checkedRatings.includes(r.value)).map(r => `-rating:${r.value}`));
}
else {
getTags().push('( ' + checkedRatings.map(r => `rating:${r}`).join(' ~ ') + ' )');
}
}
else if (excludesEveryRating) {
getTags().push(...siteConfig.ratings.map(rating => `-rating:${rating.value}`));
}
}
function bindFormEvents() {
searchForm.addEventListener('submit', function (e) {
e.preventDefault();
const pending = searchInput.value.trim();
if (pending)
addNormalizedInput(pending);
buildAndApplyMetatags();
if (getTags().length > 0) {
searchInput.value = getTags().join(' ');
}
submitSearch();
});
}
function submitSearch() {
searchInput.name = 'tags';
searchForm.submit();
}
function syncMetatagsFromUI() {
buildAndApplyMetatags();
renderTags();
}
function showFileError(message) {
fileError.textContent = message;
fileError.hidden = message === '';
}
function showFileStatus(text) {
searchInput.style.display = text === null ? '' : 'none';
fileStatus.style.display = text === null ? 'none' : '';
fileStatusText.textContent = text ?? '';
}
// A newer drop or pick supersedes a hash still running.
let fileSearchRun = 0;
async function searchByFile(file) {
const run = ++fileSearchRun;
showFileError('');
if (!file) {
showFileStatus(null);
showFileError("Couldn't read the dropped item. Drop a file saved on your computer.");
return;
}
showFileStatus(`Hashing ${file.name}`);
let hash;
try {
hash = await hashFile(file, done => {
if (run === fileSearchRun)
showFileStatus(`Hashing ${file.name} ${Math.round(done * 100)}%`);
});
}
catch {
if (run !== fileSearchRun)
return;
showFileStatus(null);
showFileError(`Couldn't read ${file.name}. Drop a file saved on your computer.`);
return;
}
if (run !== fileSearchRun)
return;
// A hash matches at most one post, so other tags could only hide it.
setTags(md5SearchTags(hash, site));
renderTags();
showFileStatus(null);
searchInput.value = getTags().join(' ');
submitSearch();
}
function bindFileSearch() {
searchInput.addEventListener('input', () => showFileError(''));
if (isUploadPage(location))
return;
const dropOverlay = document.createElement('div');
dropOverlay.className = 'bse-drop-overlay';
dropOverlay.hidden = true;
const dropLabel = document.createElement('span');
dropLabel.className = 'bse-drop-label';
dropLabel.textContent = 'Drop to search by MD5';
dropOverlay.appendChild(dropLabel);
document.body.appendChild(dropOverlay);
bindFileDrop(window, dropOverlay, {
onEnter: () => showFileError(''),
onDrop: file => void searchByFile(file),
});
}
sortSelect.addEventListener('change', syncMetatagsFromUI);
ratingCheckboxes.forEach(cb => {
cb.addEventListener('change', syncMetatagsFromUI);
});
// --- Assemble ---
searchBarContainer.appendChild(exportBtn);
searchBarContainer.appendChild(savedBtn);
searchBarContainer.appendChild(searchInput);
searchBarContainer.appendChild(fileStatus);
searchBarContainer.appendChild(fileBtn);
searchBarContainer.appendChild(filePicker);
searchBarContainer.appendChild(cheatBtn);
searchBarContainer.appendChild(settingsBtn);
searchBarContainer.appendChild(searchButton);
searchForm.appendChild(searchBarContainer);
searchForm.appendChild(fileError);
// Insert the new row below the search bar, above the tag list
searchForm.appendChild(sortRow);
centerWrap.appendChild(searchForm);
centerWrap.appendChild(includeRowWrap);
centerWrap.appendChild(excludeRowWrap);
centerWrap.appendChild(metatagRowWrap);
centerWrap.appendChild(allTagsRowWrap);
// --- Bind Events ---
bindInputEvents();
bindFormEvents();
bindFileSearch();
const tagAutocomplete = bindTagAutocomplete(searchInput, siteConfig.autocomplete, addTag, tagCategories);
// Store UI references for renderTags and cross-scope access
Object.assign(ui, {
includeList, excludeList, includeRowWrap, excludeRowWrap,
metatagList, metatagRowWrap, allTagsList, allTagsRowWrap,
sortSelect, orderSwitch, ratingCheckboxes
});
// The pills as one query, with the sort and rating controls folded in.
function currentQuery() {
syncMetatagsFromUI();
return getTags().join(' ');
}
return { centerWrap, searchForm, searchInput, currentQuery };
}
"use strict";
function main() {
'use strict';
// --- State ---
let tags = [];
const ui = {};
const siteConfig = SITE_CONFIG[location.hostname.replace(/^www\./, '')] || SITE_CONFIG['default'];
const site = siteConfig.site;
if (site === 'gelbooru') {
const params = new URLSearchParams(location.search);
if (params.get('page') !== 'post' || params.get('s') !== 'list')
return;
}
const isWikiPage = site !== 'gelbooru'
&& (location.pathname.includes('/wiki') || location.pathname.includes('/help') || document.title.includes('Wiki'));
// --- Settings State ---
let settings = {
showIncludeExclude: true,
showMetatags: true,
showAllTags: true,
colorChipsByCategory: false
};
// Load settings from localStorage
function loadSettings() {
const saved = localStorage.getItem('booru-enhancer-settings');
if (saved) {
try {
settings = { ...settings, ...JSON.parse(saved) };
}
catch (e) {
console.warn('Failed to load settings:', e);
}
}
}
// Save settings to localStorage
function saveSettings() {
try {
localStorage.setItem('booru-enhancer-settings', JSON.stringify(settings));
}
catch (e) {
console.warn('Failed to save settings:', e);
}
}
loadSettings();
watchTagContrast();
const tagCategories = createTagCategoryRegistry(site, name => {
if (settings.colorChipsByCategory === true && tags.some(tag => tag.replace(/^-/, '') === name))
renderTags();
});
// --- Tag Management ---
function addTag(tag) {
if (!tag || tags.includes(tag))
return;
// Cancel out contradicting include/exclude (ass + -ass = remove both)
const opposite = tag.startsWith('-') ? tag.slice(1) : `-${tag}`;
const oppIdx = tags.indexOf(opposite);
if (oppIdx !== -1) {
tags.splice(oppIdx, 1);
renderTags();
return;
}
tags.push(tag);
renderTags();
}
// Entry point for raw user input (typed or pasted, as opposed to
// autocomplete-selected): URL-decode it, then split — a pasted
// "cat%2bdog" becomes the two tags "cat" and "dog".
function addNormalizedInput(raw) {
for (const tag of parseTagString(normalizeTagInput(raw))) {
addTag(tag);
}
}
function getTagsFromURL() {
const params = new URLSearchParams(window.location.search);
// URLSearchParams already decodes one layer; normalizeTagInput mops up
// double-encoded tags= values (%252b) that survive it.
const tagString = normalizeTagInput(params.get('tags') || '');
return convertSortSyntax(parseTagString(tagString), siteConfig.sortPrefix);
}
function syncUiOnRemove(tag) {
const { sortSelect, orderSwitch, ratingCheckboxes } = ui;
const isSortTag = siteConfig.sortPrefix === 'order' ? /^order:[a-z_]+(_asc)?$/.test(tag) : /^sort:[a-z_]+:(asc|desc)$/.test(tag);
if (isSortTag && sortSelect && orderSwitch) {
sortSelect.value = '';
orderSwitch.style.display = 'none';
}
if (/^rating:(safe|questionable|explicit|general|sensitive)$/.test(tag) && ratingCheckboxes) {
ratingCheckboxes.forEach(cb => {
if (cb.value === tag.split(':')[1])
cb.checked = false;
});
}
}
function createTagPill(tag, className, onRemove) {
const category = settings.colorChipsByCategory === true && className !== 'bse-metatag-item' ? tagCategories.get(tag) : undefined;
const colored = category ? createTagCategoryLabel(category, site) : undefined;
const tagEl = colored?.label ?? document.createElement('span');
tagEl.className = `bse-tag-item ${className}`;
if (colored) {
tagEl.className += ' bse-category-tag bse-native-tag ' + colored.wrapper.className;
}
tagEl.textContent = tag;
const removeBtn = document.createElement('span');
removeBtn.className = 'bse-remove-tag';
removeBtn.textContent = '×';
removeBtn.onclick = () => {
if (onRemove)
onRemove(tag);
const tagIdx = tags.indexOf(tag);
if (tagIdx !== -1) {
tags.splice(tagIdx, 1);
renderTags();
}
};
tagEl.appendChild(removeBtn);
return colored?.wrapper ?? tagEl;
}
function renderTags() {
const { metatagList, metatagRowWrap, includeList, excludeList, includeRowWrap, excludeRowWrap, allTagsRowWrap, allTagsList, sortSelect, orderSwitch, ratingCheckboxes } = ui;
const metatagRegex = siteConfig.metatagRegex;
const sortRegex = siteConfig.sortPrefix === 'order'
? /^order:([a-z_]+?)(_asc)?$/
: /^sort:([a-z_]+):(asc|desc)$/;
// Clear all lists
if (includeList)
includeList.innerHTML = '';
if (excludeList)
excludeList.innerHTML = '';
if (metatagList)
metatagList.innerHTML = '';
if (allTagsList)
allTagsList.innerHTML = '';
// Single pass: classify tags and build UI
let hasIncludeTags = false;
let hasExcludeTags = false;
let hasMetatags = false;
let sortType = '';
let sortOrder = 'desc';
let foundSort = false;
const ratingsSet = new Set();
const excludedRatings = new Set();
for (const tag of tags) {
const isMeta = metatagRegex.test(tag);
if (isMeta) {
// Metatag pill
hasMetatags = true;
if (metatagList && settings.showMetatags) {
metatagList.appendChild(createTagPill(tag, 'bse-metatag-item', syncUiOnRemove));
}
// Extract sort info
const sortMatch = tag.match(sortRegex);
if (sortMatch) {
sortType = sortMatch[1];
sortOrder = siteConfig.sortPrefix === 'order'
? (sortMatch[2] ? 'asc' : 'desc')
: sortMatch[2];
foundSort = true;
}
// Extract rating info
const ratingMatch = tag.match(/^rating:(safe|questionable|explicit|general|sensitive)$/);
if (ratingMatch) {
ratingsSet.add(ratingMatch[1]);
}
else {
if (site === 'gelbooru' && /^-rating:/.test(tag)) {
excludedRatings.add(tag.slice('-rating:'.length));
}
const ratingOrMatch = tag.match(/^\(\s*([^)]+)\s*\)$/);
if (ratingOrMatch) {
for (const part of ratingOrMatch[1].split('~')) {
const m = part.trim().match(/^rating:(safe|questionable|explicit|general|sensitive)$/);
if (m)
ratingsSet.add(m[1]);
}
}
}
}
else {
// Regular tag — include or exclude
const isExclude = tag.startsWith('-');
if (isExclude) {
hasExcludeTags = true;
if (excludeList && settings.showIncludeExclude) {
excludeList.appendChild(createTagPill(tag, 'bse-exclude-item'));
}
}
else {
hasIncludeTags = true;
if (includeList && settings.showIncludeExclude) {
includeList.appendChild(createTagPill(tag, 'bse-include-item'));
}
}
// All-tags list
if (allTagsList && settings.showAllTags) {
const cls = isExclude ? 'bse-exclude-item' : 'bse-include-item';
allTagsList.appendChild(createTagPill(tag, cls, syncUiOnRemove));
}
}
}
// Toggle row visibility
if (includeRowWrap)
includeRowWrap.style.display = hasIncludeTags ? '' : 'none';
if (excludeRowWrap)
excludeRowWrap.style.display = hasExcludeTags ? '' : 'none';
if (metatagRowWrap)
metatagRowWrap.style.display = hasMetatags && settings.showMetatags ? '' : 'none';
if (allTagsRowWrap)
allTagsRowWrap.style.display = (hasIncludeTags || hasExcludeTags) && settings.showAllTags ? '' : 'none';
// Sync sort dropdown
if (sortSelect && orderSwitch) {
sortSelect.value = foundSort ? sortType : '';
if (foundSort) {
orderSwitch.style.display = '';
orderSwitch.dataset.state = sortOrder;
orderSwitch.textContent = sortOrder === 'asc' ? 'Ascent \u2191' : 'Descent \u2193';
}
else {
orderSwitch.style.display = 'none';
}
}
// Sync rating checkboxes
if (site === 'gelbooru' && excludedRatings.size) {
if (!ratingsSet.size)
siteConfig.ratings.forEach(rating => ratingsSet.add(rating.value));
excludedRatings.forEach(rating => ratingsSet.delete(rating));
}
if (ratingCheckboxes) {
ratingCheckboxes.forEach(cb => {
cb.checked = ratingsSet.has(cb.value);
});
}
}
// --- Modal Creation ---
function createModal(id, title, contentNode, actions) {
// Remove any existing modal with this id
const old = document.getElementById(id);
if (old)
old.remove();
const modal = document.createElement('div');
modal.className = 'modal';
modal.id = id;
modal.tabIndex = -1;
modal.style.display = 'none';
// Modal overlay
const modalContent = document.createElement('div');
modalContent.className = 'modal-content';
// Close (×) button
const closeX = document.createElement('button');
closeX.className = 'modal-close';
closeX.type = 'button';
closeX.innerHTML = '×';
closeX.title = 'Close';
closeX.onclick = () => { modal.style.display = 'none'; };
modalContent.appendChild(closeX);
// Title
const h3 = document.createElement('h3');
h3.textContent = title;
modalContent.appendChild(h3);
modalContent.appendChild(contentNode);
const actionsDiv = document.createElement('div');
actionsDiv.className = 'modal-actions';
actions.forEach((btn) => actionsDiv.appendChild(btn));
modalContent.appendChild(actionsDiv);
modal.appendChild(modalContent);
document.body.appendChild(modal);
// Modal close on Esc/click outside
function closeModal() { modal.style.display = 'none'; }
modal.addEventListener('mousedown', e => { if (e.target === modal)
closeModal(); });
modal.addEventListener('keydown', (event) => {
const e = event;
if (e.key === 'Escape')
closeModal();
});
return { modal, closeModal };
}
// --- Saved Searches ---
let savedSearchStore;
let savedSearchAccount;
const accountCache = { items: null };
/** Set by setupSite; the Saved button that opens the dialog only exists after it. */
let currentQuery;
function applySavedQuery(query) {
queryTags(query).forEach(addTag);
}
function removeSavedQuery(query) {
const drop = new Set(queryTags(query).map(tag => tag.toLowerCase()));
tags = tags.filter(tag => !drop.has(tag.toLowerCase()));
renderTags();
}
function showSavedSearchesModal() {
// One store per page, so its writes stay serialised across dialog opens.
if (savedSearchStore === undefined) {
savedSearchStore = typeof GM !== 'undefined' && GM ? createSavedSearchStore(GM) : null;
}
if (savedSearchAccount === undefined)
savedSearchAccount = accountBackendFor(site, window, siteConfig.accountName);
openSavedSearchDialog({
site: siteKey(location.hostname), siteConfig, store: savedSearchStore, account: savedSearchAccount, accountCache,
getTags: () => tags, applyQuery: applySavedQuery, removeQuery: removeSavedQuery,
currentQuery: () => currentQuery(), createModal,
});
}
// --- Export Modal ---
function showExportModal() {
// --- Export Data Preparation ---
const website = location.hostname;
const timestamp = new Date().toISOString();
const rawTags = tags.join(' ');
const jsonExport = JSON.stringify({ website, tags: [...tags], timestamp }, null, 2);
// --- Modal Elements ---
const tabWrap = document.createElement('div');
tabWrap.className = 'modal-tabs';
const rawTab = document.createElement('button');
rawTab.textContent = 'Raw';
rawTab.type = 'button';
rawTab.className = 'modal-tab';
const jsonTab = document.createElement('button');
jsonTab.textContent = 'JSON';
jsonTab.type = 'button';
jsonTab.className = 'modal-tab';
tabWrap.appendChild(rawTab);
tabWrap.appendChild(jsonTab);
const textarea = document.createElement('textarea');
textarea.readOnly = true;
textarea.className = 'modal-pastebin';
textarea.value = rawTags;
// --- Action Buttons ---
const copyBtn = document.createElement('button');
copyBtn.textContent = 'Copy';
copyBtn.type = 'button';
copyBtn.onclick = function () {
const markCopied = () => {
copyBtn.textContent = 'Copied!';
setTimeout(() => { copyBtn.textContent = 'Copy'; }, 1200);
};
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(textarea.value).then(markCopied).catch(() => {
textarea.focus();
textarea.select();
});
}
else {
textarea.focus();
textarea.select();
}
};
const exportBtn = document.createElement('button');
exportBtn.textContent = 'Export to file';
exportBtn.type = 'button';
exportBtn.style.display = 'none';
exportBtn.onclick = function () {
const blob = new Blob([jsonExport], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'booru-tags.json';
document.body.appendChild(a);
a.click();
setTimeout(() => { document.body.removeChild(a); URL.revokeObjectURL(url); }, 100);
};
const closeBtn = document.createElement('button');
closeBtn.textContent = 'Close';
closeBtn.type = 'button';
// --- Tab Switch Logic ---
function showRaw() {
textarea.value = rawTags;
copyBtn.style.display = '';
exportBtn.style.display = 'none';
rawTab.classList.add('active');
jsonTab.classList.remove('active');
}
function showJSON() {
textarea.value = jsonExport;
copyBtn.style.display = 'none';
exportBtn.style.display = '';
rawTab.classList.remove('active');
jsonTab.classList.add('active');
}
rawTab.onclick = showRaw;
jsonTab.onclick = showJSON;
// --- Modal Content (Refactored) ---
const formWrap = document.createElement('div');
formWrap.className = 'modal-form';
formWrap.appendChild(tabWrap);
formWrap.appendChild(textarea);
// The modal-actions div is handled by createModal, so do not add buttons here
// --- Modal Actions ---
const actions = [copyBtn, exportBtn, closeBtn];
const modalObj = createModal('modal-export', 'Export Tags', formWrap, actions);
closeBtn.onclick = modalObj.closeModal;
modalObj.modal.style.display = 'flex';
modalObj.modal.focus();
// Default to raw view
showRaw();
}
// --- Settings Modal ---
function showSettingsModal() {
const formWrap = document.createElement('div');
formWrap.className = 'modal-form settings-form';
// Include/Exclude toggle
const includeExcludeRow = document.createElement('div');
includeExcludeRow.className = 'settings-row';
const includeExcludeLabel = document.createElement('label');
includeExcludeLabel.className = 'settings-label';
const includeExcludeCheckbox = document.createElement('input');
includeExcludeCheckbox.type = 'checkbox';
includeExcludeCheckbox.checked = settings.showIncludeExclude;
includeExcludeLabel.appendChild(includeExcludeCheckbox);
includeExcludeLabel.appendChild(document.createTextNode(' Show Include/Exclude Tags'));
includeExcludeRow.appendChild(includeExcludeLabel);
// Metatags toggle
const metatagsRow = document.createElement('div');
metatagsRow.className = 'settings-row';
const metatagsLabel = document.createElement('label');
metatagsLabel.className = 'settings-label';
const metatagsCheckbox = document.createElement('input');
metatagsCheckbox.type = 'checkbox';
metatagsCheckbox.checked = settings.showMetatags;
metatagsLabel.appendChild(metatagsCheckbox);
metatagsLabel.appendChild(document.createTextNode(' Show Metatags'));
metatagsRow.appendChild(metatagsLabel);
// All Tags toggle
const allTagsRow = document.createElement('div');
allTagsRow.className = 'settings-row';
const allTagsLabel = document.createElement('label');
allTagsLabel.className = 'settings-label';
const allTagsCheckbox = document.createElement('input');
allTagsCheckbox.type = 'checkbox';
allTagsCheckbox.checked = settings.showAllTags;
allTagsLabel.appendChild(allTagsCheckbox);
allTagsLabel.appendChild(document.createTextNode(' Show All Tags'));
allTagsRow.appendChild(allTagsLabel);
formWrap.appendChild(includeExcludeRow);
formWrap.appendChild(metatagsRow);
formWrap.appendChild(allTagsRow);
const colorRow = document.createElement('div');
colorRow.className = 'settings-row';
const colorLabel = document.createElement('label');
colorLabel.className = 'settings-label';
const colorCheckbox = document.createElement('input');
colorCheckbox.type = 'checkbox';
colorCheckbox.checked = settings.colorChipsByCategory === true;
colorLabel.append(colorCheckbox, document.createTextNode(' Color chips by category'));
colorRow.append(colorLabel);
formWrap.append(colorRow);
// Action buttons
const saveBtn = document.createElement('button');
saveBtn.textContent = 'Save';
saveBtn.type = 'button';
saveBtn.onclick = function () {
settings.showIncludeExclude = includeExcludeCheckbox.checked;
settings.showMetatags = metatagsCheckbox.checked;
settings.showAllTags = allTagsCheckbox.checked;
settings.colorChipsByCategory = colorCheckbox.checked;
saveSettings();
renderTags();
modalObj.closeModal();
};
const cancelBtn = document.createElement('button');
cancelBtn.textContent = 'Cancel';
cancelBtn.type = 'button';
const actions = [saveBtn, cancelBtn];
const modalObj = createModal('modal-settings', 'Settings', formWrap, actions);
cancelBtn.onclick = modalObj.closeModal;
modalObj.modal.style.display = 'flex';
modalObj.modal.focus();
}
// --- Dynamic Cheat Sheet Cache ---
const CACHE_DURATION = 7 * 24 * 60 * 60 * 1000; // 7 days in milliseconds
async function fetchCheatSheet(url) {
try {
const response = await fetch(url, {
credentials: 'same-origin',
headers: {
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8'
}
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const html = await response.text();
const parser = new DOMParser();
const doc = parser.parseFromString(html, 'text/html');
const selector = siteConfig.cheatSheetSelector || null;
const cheatsheetSection = selector && doc.querySelector(selector);
const content = cheatsheetSection ? cleanCheatSheetHTML(cheatsheetSection) : '';
return content;
}
catch (error) {
console.error('Failed to fetch cheat sheet:', error);
return null;
}
}
async function getCachedCheatSheet(siteKey) {
const cacheKey = `booru-cheatsheet-${siteKey}`;
const cached = localStorage.getItem(cacheKey);
if (cached) {
try {
const data = JSON.parse(cached);
const now = Date.now();
if (data.timestamp && (now - data.timestamp) < CACHE_DURATION && data.version === SCRIPT_VERSION) {
return data.content ?? null;
}
}
catch (e) {
console.error('Failed to parse cached cheat sheet:', e);
}
}
const url = siteConfig.cheatSheetUrl || '';
if (url) {
const content = await fetchCheatSheet(url);
if (content) {
// Cache the result
const cacheData = {
content: content,
timestamp: Date.now(),
url: url,
version: SCRIPT_VERSION
};
try {
localStorage.setItem(cacheKey, JSON.stringify(cacheData));
}
catch (e) {
console.error('Failed to cache cheat sheet:', e);
}
return content;
}
}
// Fallback to static content
return null;
}
function clearCheatSheetCache() {
// Derive the distinct site keys from SITE_CONFIG so this can't drift.
const sites = new Set(Object.values(SITE_CONFIG).map(cfg => cfg.site).filter(Boolean));
sites.forEach(s => localStorage.removeItem(`booru-cheatsheet-${s}`));
}
// --- Cheat Sheet Modal ---
async function showCheatSheetModal() {
// Show loading state
const loadingDiv = document.createElement('div');
loadingDiv.className = 'modal-doc';
loadingDiv.innerHTML = '<p style="text-align: center;">Loading cheat sheet...</p>';
const closeBtn = document.createElement('button');
closeBtn.textContent = 'Close';
closeBtn.type = 'button';
const modalObj = createModal('modal-cheat', 'Cheat Sheet', loadingDiv, [closeBtn]);
closeBtn.onclick = modalObj.closeModal;
modalObj.modal.style.display = 'flex';
modalObj.modal.focus();
// Try to get dynamic content
let cheatSheetContent = null;
if (site) {
cheatSheetContent = await getCachedCheatSheet(site);
}
// If dynamic fetch failed, use static content
if (!cheatSheetContent) {
cheatSheetContent = siteConfig.cheatSheetContent || `
<section><h4>Basic Search</h4>
<ul>
<li><code>tag1 tag2</code> — Posts with <b>tag1</b> and <b>tag2</b></li>
<li><code>( tag1 ~ tag2 )</code> — Posts with <b>tag1</b> or <b>tag2</b></li>
<li><code>night~</code> — Fuzzy search for <b>night</b> (e.g. <b>night</b>, <b>fight</b>, <b>bright</b>)</li>
<li><code>-tag1</code> — Exclude posts with <b>tag1</b></li>
<li><code>ta*1</code> — Tags starting with <b>ta</b> and ending with <b>1</b></li>
</ul>
</section>
<section><h4>Metatags</h4>
<ul>
<li><code>user:bob</code> — Uploaded by user <b>bob</b></li>
<li><code>md5:foo</code> — Posts with MD5 <b>foo</b></li>
<li><code>md5:foo*</code> — MD5 starts with <b>foo</b></li>
<li><code>rating:questionable</code> — Rated <b>questionable</b></li>
<li><code>-rating:questionable</code> — Not rated <b>questionable</b></li>
<li><code>parent:1234</code> — Has parent <b>1234</b> (includes 1234)</li>
<li><code>width:>=1000 height:>1000</code> — Width ≥ 1000, Height > 1000</li>
<li><code>score:>=10</code> — Score ≥ 10</li>
</ul>
</section>
<section><h4>Sorting</h4>
<ul>
<li><code>sort:updated:desc</code> — Sort by <b>updated</b> (descending)</li>
<li>Other sortable types: <code>id</code>, <code>score</code>, <code>rating</code>, <code>user</code>, <code>height</code>, <code>width</code>, <code>parent</code>, <code>source</code>, <code>updated</code></li>
<li>Can be sorted by both <b>asc</b> or <b>desc</b></li>
</ul>
</section>
<section><h4>Notes</h4>
<ul>
<li>Combining the same metatags (with colons) usually does not work.</li>
<li>You can combine different metatags (e.g. <code>rating:questionable parent:100</code>).</li>
</ul>
</section>
`;
}
// Update modal content
const docWrap = document.createElement('div');
docWrap.className = 'modal-doc';
docWrap.innerHTML = cheatSheetContent;
// Add reference link and cache info
const infoBar = document.createElement('div');
infoBar.className = 'cheatsheet-info-bar';
const refLink = document.createElement('a');
refLink.href = siteConfig.cheatSheetUrl || '#';
refLink.target = '_blank';
refLink.textContent = 'View on site →';
refLink.className = 'cheatsheet-ref-link';
const cacheInfo = document.createElement('span');
cacheInfo.className = 'cheatsheet-cache-info';
// Check cache status
const cacheKey = `booru-cheatsheet-${site}`;
const cached = localStorage.getItem(cacheKey);
if (cached) {
try {
const data = JSON.parse(cached);
if (typeof data.timestamp === 'number') {
const age = Date.now() - data.timestamp;
const days = Math.floor(age / (24 * 60 * 60 * 1000));
const hours = Math.floor((age % (24 * 60 * 60 * 1000)) / (60 * 60 * 1000));
cacheInfo.textContent = `Cached: ${days}d ${hours}h ago`;
}
else {
cacheInfo.textContent = 'Using static content';
}
}
catch {
cacheInfo.textContent = 'Using static content';
}
}
else {
cacheInfo.textContent = 'Using static content';
}
const flushBtn = document.createElement('button');
flushBtn.textContent = 'Refresh Cache';
flushBtn.type = 'button';
flushBtn.className = 'cheatsheet-flush-btn';
flushBtn.onclick = async () => {
clearCheatSheetCache();
modalObj.closeModal();
await showCheatSheetModal(); // Reload the modal
};
infoBar.appendChild(refLink);
infoBar.appendChild(cacheInfo);
infoBar.appendChild(flushBtn);
docWrap.appendChild(infoBar);
// Update modal with new content
const modalContent = modalObj.modal.querySelector('.modal-content');
if (!modalContent)
return;
const oldDoc = modalContent.querySelector('.modal-doc');
if (oldDoc) {
oldDoc.replaceWith(docWrap);
}
}
// Load tags from the URL, render them, and keep the live input cleared —
// the browser resurrects the submitted "a b c" value on back-navigation
// (bfcache and regular session-history restore), which would otherwise be
// treated as one giant tag when the user types the next space.
function initTagField(field) {
tags = getTagsFromURL();
renderTags();
field.value = '';
window.addEventListener('pageshow', function () {
field.value = '';
});
}
function setupSite() {
const sel = siteConfig.selectors;
if (!sel)
return false;
// Skip pages that shouldn't have the enhanced search
if (sel.skipPattern && sel.skipPattern.test(location.pathname))
return false;
const originalForm = document.querySelector(sel.form);
const gallery = document.querySelector(sel.gallery);
if (!originalForm || !gallery)
return false;
const section = createSearchSection({
siteConfig, getTags: () => tags, setTags: next => { tags = next; }, ui,
tagCategories, addTag, addNormalizedInput, showExportModal, showSavedSearchesModal,
showCheatSheetModal, showSettingsModal, renderTags
});
const { centerWrap, searchForm, searchInput } = section;
currentQuery = section.currentQuery;
searchForm.action = originalForm.action || '/posts';
searchForm.method = originalForm.method || 'GET';
if (!gallery.parentNode)
return false;
gallery.parentNode.insertBefore(centerWrap, gallery);
const originalArea = sel.hide ? document.querySelector(sel.hide) : originalForm;
if (originalArea)
originalArea.style.display = 'none';
initTagField(searchInput);
return true;
}
function setupWiki() {
// For wiki pages, create a simple centered search input
const content = document.querySelector('#content') || document.querySelector('main') || document.body;
if (!content)
return false;
// Create simple search form
const searchWrap = document.createElement('div');
searchWrap.className = 'bse-wiki-search-wrap';
const searchForm = document.createElement('form');
searchForm.action = '/posts';
searchForm.method = 'GET';
const searchInput = document.createElement('input');
searchInput.type = 'text';
searchInput.name = 'tags';
searchInput.placeholder = 'Search posts...';
searchInput.className = 'bse-search-input';
searchInput.autocomplete = 'off';
const searchButton = document.createElement('button');
searchButton.type = 'submit';
searchButton.textContent = 'Search';
searchButton.className = 'bse-search-button';
searchForm.appendChild(searchInput);
searchForm.appendChild(searchButton);
searchWrap.appendChild(searchForm);
// Insert at the top of content
content.insertBefore(searchWrap, content.firstChild);
return true;
}
function setupGeneric() {
const originalForm = findSearchForm();
if (!originalForm)
return false;
const searchField = originalForm.querySelector('input[name="tags"], textarea[name="tags"]');
if (!searchField)
return false;
const tagList = document.createElement('div');
tagList.className = 'bse-tag-list';
searchField.insertAdjacentElement('afterend', tagList);
initTagField(searchField);
searchField.addEventListener('keydown', function (event) {
const e = event;
if (e.key === 'Enter' && !e.shiftKey) {
const value = searchField.value.trim();
if (value) {
e.preventDefault();
if (!tags.includes(value)) {
tags.push(value);
renderTags();
}
searchField.value = '';
}
else if (tags.length > 0) {
e.preventDefault();
searchField.value = tags.join(' ');
originalForm.submit();
}
}
});
originalForm.addEventListener('submit', function () {
if (tags.length > 0) {
searchField.value = tags.join(' ');
}
});
return true;
}
function findSearchForm() {
let form = document.querySelector('.sidebar form input[name="tags"], .sidebar form textarea[name="tags"]');
if (form)
return form.closest('form');
form = document.querySelector('form input[name="tags"], form textarea[name="tags"]');
return form ? form.closest('form') : null;
}
// --- Style Injection ---
// --- Main Entrypoint ---
let initialized = false;
function init() {
if (initialized)
return;
const setupSuccess = isWikiPage
? setupWiki()
: siteConfig.selectors
? setupSite()
: setupGeneric();
if (setupSuccess !== false) {
injectStyles();
initialized = true;
}
}
// --- Run ---
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
}
else {
init();
}
// Also try after a short delay in case of dynamic content
setTimeout(() => {
if (!initialized) {
init();
}
}, 1000);
}
// Browser-only bootstrap; skipped when this file is require()d in Node tests.
if (typeof window !== 'undefined')
main();