Adds sort and filter buttons to Gelbooru search: by score, random, period, rating and size. No tag syntax needed.
Fra
// ==UserScript==
// @name SortBooru
// @name:ru SortBooru
// @name:zh-CN SortBooru
// @name:ja SortBooru
// @name:es SortBooru
// @name:pt-BR SortBooru
// @namespace sortbooru
// @version 1.1
// @description Adds sort and filter buttons to Gelbooru search: by score, random, period, rating and size. No tag syntax needed.
// @description:ru Кнопки сортировки и фильтров для поиска Gelbooru: по счёту, случайно, за период, по рейтингу и размеру. Знать теги не нужно.
// @description:zh-CN 为 Gelbooru 搜索添加排序和筛选按钮:按评分、随机、时间段、分级和尺寸,无需了解标签语法。
// @description:ja Gelbooru の検索に並べ替えと絞り込みのボタンを追加します。スコア順、ランダム、期間、レーティング、サイズ。タグの知識は不要です。
// @description:es Añade botones para ordenar y filtrar las búsquedas de Gelbooru: por puntuación, al azar, por periodo, clasificación y tamaño. Sin escribir etiquetas.
// @description:pt-BR Adiciona botões para ordenar e filtrar as buscas do Gelbooru: por pontuação, aleatório, período, classificação e tamanho. Sem digitar tags.
// @author vasyok100
// @license MIT
// @icon https://gelbooru.com/favicon.png
// @match https://gelbooru.com/index.php*
// @match https://*.gelbooru.com/index.php*
// @grant GM_getValue
// @grant GM_setValue
// @grant GM_registerMenuCommand
// @grant GM_unregisterMenuCommand
// @run-at document-end
// @noframes
// ==/UserScript==
(function () {
'use strict';
/* ------------------------------------------------------------------ */
/* Storage */
/* ------------------------------------------------------------------ */
const hasGM = typeof GM_getValue === 'function' && typeof GM_setValue === 'function';
const store = {
get(key, fallback) {
try {
if (hasGM) return GM_getValue(key, fallback);
const raw = localStorage.getItem('sortbooru:' + key);
return raw == null ? fallback : JSON.parse(raw);
} catch (e) {
return fallback;
}
},
set(key, value) {
try {
if (hasGM) GM_setValue(key, value);
else localStorage.setItem('sortbooru:' + key, JSON.stringify(value));
} catch (e) {
// Storage is unavailable: settings simply won't persist.
}
},
};
/* ------------------------------------------------------------------ */
/* Text */
/* ------------------------------------------------------------------ */
// Ratings keep the names Gelbooru shows on every post. `sep` joins a filter
// name and its value on a button ("Period: week", "期間:1週間").
const STRINGS = {
ru: {
sep: ': ',
bar: 'Сортировка и фильтры',
sort: 'Сортировка',
sorts: { new: 'Сначала новые', old: 'Сначала старые', score: 'По счёту', random: 'Случайно' },
shuffle: 'Перемешать',
period: 'Период',
periods: { all: 'всё время', day: 'сутки', week: 'неделя', month: 'месяц', year: 'год' },
periodSince: (id) => 'с поста ' + id,
periodFail: 'Gelbooru не ответил, попробуйте ещё раз.',
rating: 'Рейтинг',
ratingWithout: (name) => 'без ' + name,
score: 'Счёт',
from: 'от',
to: 'до',
scoreMin: (n) => 'от ' + n,
scoreMax: (n) => 'до ' + n,
size: 'Размер',
sizes: { any: 'любой', highres: 'большие', absurdres: 'очень большие', fhd: 'обои Full HD', uhd: 'обои 4K' },
width: 'ширина от ',
height: 'высота от ',
type: 'Тип',
types: { any: 'любой', static: 'без анимации', animated: 'анимация' },
clear: 'Очистить',
apply: 'Применить',
reset: 'Сбросить',
remember: 'Запоминать фильтры для новых поисков',
applyPinned: 'Применить запомненные',
empty: 'С этими фильтрами ничего не нашлось.',
emptyReset: 'Сбросить фильтры',
},
en: {
sep: ': ',
bar: 'Sorting and filters',
sort: 'Sort',
sorts: { new: 'Newest', old: 'Oldest', score: 'Top score', random: 'Random' },
shuffle: 'Shuffle',
period: 'Period',
periods: { all: 'all time', day: 'day', week: 'week', month: 'month', year: 'year' },
periodSince: (id) => 'since #' + id,
periodFail: 'Gelbooru did not respond, try again.',
rating: 'Rating',
ratingWithout: (name) => 'no ' + name,
score: 'Score',
from: 'from',
to: 'to',
scoreMin: (n) => 'from ' + n,
scoreMax: (n) => 'up to ' + n,
size: 'Size',
sizes: { any: 'any', highres: 'large', absurdres: 'very large', fhd: 'Full HD wallpapers', uhd: '4K wallpapers' },
width: 'width ≥ ',
height: 'height ≥ ',
type: 'Type',
types: { any: 'any', static: 'static', animated: 'animated' },
clear: 'Clear',
apply: 'Apply',
reset: 'Reset',
remember: 'Remember filters for new searches',
applyPinned: 'Apply remembered',
empty: 'Nothing matches these filters.',
emptyReset: 'Reset filters',
},
'zh-CN': {
sep: ':',
bar: '排序和筛选',
sort: '排序',
sorts: { new: '最新', old: '最早', score: '按评分', random: '随机' },
shuffle: '重新随机',
period: '时间段',
periods: { all: '全部时间', day: '一天', week: '一周', month: '一个月', year: '一年' },
periodSince: (id) => '自 #' + id + ' 起',
periodFail: 'Gelbooru 没有响应,请重试。',
rating: '分级',
ratingWithout: (name) => '不含 ' + name,
score: '评分',
from: '最低',
to: '最高',
scoreMin: (n) => n + ' 以上',
scoreMax: (n) => n + ' 以下',
size: '尺寸',
sizes: { any: '不限', highres: '高分辨率', absurdres: '超高分辨率', fhd: 'Full HD 壁纸', uhd: '4K 壁纸' },
width: '宽度 ≥ ',
height: '高度 ≥ ',
type: '类型',
types: { any: '不限', static: '静态', animated: '动态' },
clear: '清除',
apply: '应用',
reset: '重置',
remember: '在新的搜索中记住筛选条件',
applyPinned: '应用记住的筛选',
empty: '没有符合这些筛选条件的结果。',
emptyReset: '重置筛选',
},
ja: {
sep: ':',
bar: '並べ替えと絞り込み',
sort: '並べ替え',
sorts: { new: '新しい順', old: '古い順', score: 'スコア順', random: 'ランダム' },
shuffle: 'シャッフル',
period: '期間',
periods: { all: 'すべて', day: '1日', week: '1週間', month: '1か月', year: '1年' },
periodSince: (id) => '#' + id + ' 以降',
periodFail: 'Gelbooru から応答がありません。もう一度お試しください。',
rating: 'レーティング',
ratingWithout: (name) => name + ' 以外',
score: 'スコア',
from: '最小',
to: '最大',
scoreMin: (n) => n + ' 以上',
scoreMax: (n) => n + ' 以下',
size: 'サイズ',
sizes: { any: '指定なし', highres: '高解像度', absurdres: '超高解像度', fhd: 'Full HD 壁紙', uhd: '4K 壁紙' },
width: '幅 ≥ ',
height: '高さ ≥ ',
type: '種類',
types: { any: '指定なし', static: '静止画', animated: 'アニメーション' },
clear: 'クリア',
apply: '適用',
reset: 'リセット',
remember: '新しい検索でも絞り込みを保持',
applyPinned: '保存した絞り込みを適用',
empty: 'この条件に一致する投稿はありません。',
emptyReset: '絞り込みをリセット',
},
es: {
sep: ': ',
bar: 'Orden y filtros',
sort: 'Orden',
sorts: { new: 'Más nuevos', old: 'Más antiguos', score: 'Por puntuación', random: 'Al azar' },
shuffle: 'Mezclar',
period: 'Periodo',
periods: { all: 'todo el tiempo', day: 'día', week: 'semana', month: 'mes', year: 'año' },
periodSince: (id) => 'desde #' + id,
periodFail: 'Gelbooru no respondió, inténtalo de nuevo.',
rating: 'Clasificación',
ratingWithout: (name) => 'sin ' + name,
score: 'Puntuación',
from: 'desde',
to: 'hasta',
scoreMin: (n) => 'desde ' + n,
scoreMax: (n) => 'hasta ' + n,
size: 'Tamaño',
sizes: { any: 'cualquiera', highres: 'grandes', absurdres: 'muy grandes', fhd: 'fondos Full HD', uhd: 'fondos 4K' },
width: 'ancho ≥ ',
height: 'alto ≥ ',
type: 'Tipo',
types: { any: 'cualquiera', static: 'estático', animated: 'animado' },
clear: 'Borrar',
apply: 'Aplicar',
reset: 'Restablecer',
remember: 'Recordar filtros en búsquedas nuevas',
applyPinned: 'Aplicar filtros guardados',
empty: 'Nada coincide con estos filtros.',
emptyReset: 'Restablecer filtros',
},
'pt-BR': {
sep: ': ',
bar: 'Ordem e filtros',
sort: 'Ordem',
sorts: { new: 'Mais novos', old: 'Mais antigos', score: 'Por pontuação', random: 'Aleatório' },
shuffle: 'Embaralhar',
period: 'Período',
periods: { all: 'todo o período', day: 'dia', week: 'semana', month: 'mês', year: 'ano' },
periodSince: (id) => 'desde #' + id,
periodFail: 'O Gelbooru não respondeu, tente de novo.',
rating: 'Classificação',
ratingWithout: (name) => 'sem ' + name,
score: 'Pontuação',
from: 'de',
to: 'até',
scoreMin: (n) => 'a partir de ' + n,
scoreMax: (n) => 'até ' + n,
size: 'Tamanho',
sizes: { any: 'qualquer', highres: 'grandes', absurdres: 'muito grandes', fhd: 'wallpaper Full HD', uhd: 'wallpaper 4K' },
width: 'largura ≥ ',
height: 'altura ≥ ',
type: 'Tipo',
types: { any: 'qualquer', static: 'estático', animated: 'animado' },
clear: 'Limpar',
apply: 'Aplicar',
reset: 'Redefinir',
remember: 'Lembrar filtros em novas buscas',
applyPinned: 'Aplicar filtros salvos',
empty: 'Nada corresponde a esses filtros.',
emptyReset: 'Redefinir filtros',
},
};
const RATING_NAMES = { general: 'General', sensitive: 'Sensitive', questionable: 'Questionable', explicit: 'Explicit' };
// The first of the browser's languages that has a translation; any Chinese
// gets Simplified, any Portuguese gets Brazilian, everything else English.
function pickLanguage() {
const langs = navigator.languages && navigator.languages.length ? navigator.languages : [navigator.language || ''];
for (const lang of langs) {
const base = String(lang).toLowerCase().split('-')[0];
if (base === 'zh') return 'zh-CN';
if (base === 'pt') return 'pt-BR';
if (STRINGS[base]) return base;
}
return 'en';
}
const LANG = pickLanguage();
const L = STRINGS[LANG];
/* ------------------------------------------------------------------ */
/* Search query model */
/* ------------------------------------------------------------------ */
const RATINGS = ['general', 'sensitive', 'questionable', 'explicit'];
const RATING_ALIASES = { g: 'general', s: 'sensitive', q: 'questionable', e: 'explicit' };
const SORT_KEYS = ['id', 'score', 'random', 'updated', 'width', 'height', 'rating', 'user', 'source'];
const SIZE_PRESETS = {
highres: { tag: 'highres', w: 0, h: 0 },
absurdres: { tag: 'absurdres', w: 0, h: 0 },
fhd: { tag: '', w: 1920, h: 1080 },
uhd: { tag: '', w: 3840, h: 2160 },
};
const TYPE_TAGS = { static: '-animated', animated: 'animated' }; // every video is also tagged animated
const PERIOD_DAYS = { day: 1, week: 7, month: 30, year: 365 };
// A search is the user's own tags (`rest`, kept verbatim and in order)
// plus the settings this script manages. Everything Gelbooru supports but
// the bar doesn't show stays in `rest` untouched.
function parseQuery(text) {
const q = { rest: [], sort: null, ratings: null, score: null, size: null, type: null, since: null };
const size = { tag: '', w: 0, h: 0 };
const score = { min: null, max: null };
const excluded = new Set();
let only = null;
let depth = 0;
for (const token of String(text || '').trim().split(/\s+/)) {
if (!token) continue;
// Keep OR groups like {a ~ b} intact: metatags don't work inside them anyway.
if (depth > 0 || token[0] === '{') {
depth = Math.max(0, depth + count(token, '{') - count(token, '}'));
q.rest.push(token);
continue;
}
const t = token.toLowerCase();
let m;
if (t === 'all') continue;
if ((m = /^sort:([a-z_]+)(?::(\w+))?$/.exec(t)) && SORT_KEYS.includes(m[1])) {
q.sort = m[1] === 'random'
? { key: 'random', dir: 'desc', seed: /^\d+$/.test(m[2] || '') ? +m[2] : null }
: { key: m[1], dir: m[2] === 'asc' ? 'asc' : 'desc', seed: null };
} else if ((m = /^(-?)rating:([a-z]+)$/.exec(t)) && (RATINGS.includes(m[2]) || RATING_ALIASES[m[2]])) {
const rating = RATING_ALIASES[m[2]] || m[2];
if (m[1]) excluded.add(rating);
else only = rating;
} else if ((m = /^score:(>=|<=|>|<)?(-?\d+)$/.exec(t))) {
const n = +m[2];
if (m[1] === '>=' || m[1] === '>') score.min = n + (m[1] === '>' ? 1 : 0);
else if (m[1] === '<=' || m[1] === '<') score.max = n - (m[1] === '<' ? 1 : 0);
else score.min = score.max = n;
} else if ((m = /^(width|height):(>=?)(\d+)$/.exec(t))) {
size[m[1] === 'width' ? 'w' : 'h'] = +m[3] + (m[2] === '>' ? 1 : 0);
} else if ((t === 'highres' || t === 'absurdres') && !size.tag) {
size.tag = t;
} else if ((t === 'animated' || t === '-animated') && !q.type) {
q.type = t === 'animated' ? 'animated' : 'static';
} else if ((m = /^id:(>=?)(\d+)$/.exec(t))) {
q.since = +m[2] - (m[1] === '>=' ? 1 : 0);
} else {
q.rest.push(token);
}
}
if (q.sort && q.sort.key === 'id' && q.sort.dir === 'desc') q.sort = null; // the site default
if (only || excluded.size) {
const allowed = new Set(only ? [only] : RATINGS);
excluded.forEach((r) => allowed.delete(r));
q.ratings = allowed.size && allowed.size < RATINGS.length ? allowed : null;
}
if (size.tag || size.w || size.h) q.size = size;
if (score.min != null || score.max != null) q.score = score;
return q;
}
function serializeQuery(q) {
const out = q.rest.slice();
if (q.ratings) {
// Gelbooru can't OR two ratings, but it can exclude several:
// "general or sensitive" becomes -rating:questionable -rating:explicit.
if (q.ratings.size === 1) out.push('rating:' + [...q.ratings][0]);
else RATINGS.forEach((r) => { if (!q.ratings.has(r)) out.push('-rating:' + r); });
}
if (q.score) {
if (q.score.min != null) out.push('score:>=' + q.score.min);
if (q.score.max != null) out.push('score:<=' + q.score.max);
}
if (q.size) {
if (q.size.tag) out.push(q.size.tag);
if (q.size.w) out.push('width:>=' + q.size.w);
if (q.size.h) out.push('height:>=' + q.size.h);
}
if (q.type) out.push(TYPE_TAGS[q.type]);
if (q.since != null) out.push('id:>' + q.since);
if (q.sort) out.push(sortTag(q.sort));
return out.join(' ');
}
function sortTag(sort) {
if (sort.key === 'random') return sort.seed == null ? 'sort:random' : 'sort:random:' + sort.seed;
if (sort.dir === 'asc') return 'sort:' + sort.key + ':asc';
return sort.key === 'score' ? 'sort:score' : 'sort:' + sort.key + ':desc';
}
function hasFilters(q) {
return !!(q.sort || q.ratings || q.score || q.size || q.type || q.since != null);
}
function derive(q, patch) {
return Object.assign({
rest: q.rest.slice(),
sort: q.sort && { ...q.sort },
ratings: q.ratings && new Set(q.ratings),
score: q.score && { ...q.score },
size: q.size && { ...q.size },
type: q.type,
since: q.since,
}, patch);
}
function withoutFilters(q) {
return derive(q, { sort: null, ratings: null, score: null, size: null, type: null, since: null });
}
function sizePreset(size) {
if (!size) return 'any';
for (const [name, p] of Object.entries(SIZE_PRESETS)) {
if (p.tag === size.tag && p.w === size.w && p.h === size.h) return name;
}
return 'custom';
}
function describeSize(size) {
const parts = [];
if (size.tag) parts.push(size.tag);
if (size.w && size.h) parts.push(size.w + '×' + size.h + '+');
else if (size.w) parts.push(L.width + size.w);
else if (size.h) parts.push(L.height + size.h);
return parts.join(', ');
}
function randomSeed() {
return Math.floor(Math.random() * 10001); // Gelbooru accepts seeds 0–10000
}
function count(text, ch) {
return text.split(ch).length - 1;
}
function listUrl(q) {
const params = new URLSearchParams({ page: 'post', s: 'list', tags: serializeQuery(q) || 'all' });
return '/index.php?' + params;
}
/* ------------------------------------------------------------------ */
/* Periods */
/* ------------------------------------------------------------------ */
// Gelbooru has no date search, but post ids grow with upload time, so
// "last week" becomes "id greater than the last post uploaded before the
// week started". Uploads are close to linear over days, so interpolating
// between known posts finds that id in a few page loads. Results are
// cached; the cache also maps ids back to period names for the bar.
const DAY = 864e5;
const PERIOD_TTL = 3 * 3600e3;
const POSTED = /Posted:\s*(\d{4})-(\d\d)-(\d\d) (\d\d):(\d\d):(\d\d)/;
const FIRST_POST = /<a[^>]*\sid="p(\d+)"/;
const inFlight = {};
function periodCache() {
const c = store.get('periods', null);
return {
starts: (c && c.starts) || {},
known: (c && Array.isArray(c.known)) ? c.known : [],
};
}
function rememberPeriodStart(name, id) {
const c = periodCache();
c.starts[name] = { id, at: Date.now() };
c.known = c.known.filter(([known]) => known !== id).concat([[id, name]]).slice(-60);
store.set('periods', c);
}
function periodOf(since) {
if (since == null) return 'all';
const hit = periodCache().known.find(([id]) => id === since);
return hit ? hit[1] : 'custom';
}
async function getHtml(path) {
const res = await fetch(new URL(path, location.origin).href, { credentials: 'same-origin' });
if (!res.ok) throw new Error('HTTP ' + res.status);
return res.text();
}
async function newestPostId() {
const m = FIRST_POST.exec(await getHtml('/index.php?page=post&s=list&tags=all'));
if (!m) throw new Error('No posts found');
return +m[1];
}
// Upload time of post `id`, or of the nearest older post if it was deleted.
// Times are read in the server's timezone; only differences matter here.
async function postTime(id) {
for (let attempt = 0; attempt < 3; attempt++) {
const m = POSTED.exec(await getHtml('/index.php?page=post&s=view&id=' + id));
if (m) return { id, t: Date.UTC(+m[1], m[2] - 1, +m[3], +m[4], +m[5], +m[6]) };
const older = FIRST_POST.exec(await getHtml('/index.php?page=post&s=list&tags=' + encodeURIComponent('id:<' + id)));
if (!older) break;
id = +older[1];
}
throw new Error('No upload date for post ' + id);
}
async function findPeriodStart(days) {
const newest = await postTime(await newestPostId());
const target = newest.t - days * DAY;
const tolerance = Math.max(days * DAY * 0.01, 3600e3);
let hi = newest;
let lo = null;
let guess = newest.id - Math.round(days * 6500); // rough uploads per day, refined below
for (let step = 0; step < 8; step++) {
guess = Math.min(Math.max(guess, 1), hi.id - 1);
const p = await postTime(guess);
if (Math.abs(p.t - target) <= tolerance) return p.id;
if (p.t > target) {
if (p.id < hi.id) hi = p;
} else if (!lo || p.id > lo.id) {
lo = p;
}
if (lo) {
if (hi.t <= lo.t) break;
guess = lo.id + Math.round((target - lo.t) / (hi.t - lo.t) * (hi.id - lo.id));
if (guess <= lo.id || guess >= hi.id) break;
} else {
// Still inside the period: step further back at the observed rate.
const rate = (newest.id - hi.id) / Math.max(newest.t - hi.t, 1);
guess = hi.id - Math.round((hi.t - target) * rate * 1.2) - 1;
}
}
return lo ? lo.id : hi.id;
}
function periodStart(name) {
const cached = periodCache().starts[name];
if (cached && Date.now() - cached.at < PERIOD_TTL) return Promise.resolve(cached.id);
if (!inFlight[name]) {
inFlight[name] = findPeriodStart(PERIOD_DAYS[name])
.then((id) => {
rememberPeriodStart(name, id);
return id;
})
.finally(() => { delete inFlight[name]; });
}
return inFlight[name];
}
/* ------------------------------------------------------------------ */
/* Remembered settings */
/* ------------------------------------------------------------------ */
function loadPin() {
const pin = store.get('pin', null);
return pin && pin.on ? pin : null;
}
function savePin(q) {
const period = periodOf(q.since);
store.set('pin', {
on: true,
tags: serializeQuery(derive(q, { rest: [], since: null })),
period: PERIOD_DAYS[period] ? period : null,
});
}
function clearPin() {
store.set('pin', { on: false });
}
// The remembered settings as a patch for derive().
function pinPatch(pin) {
const p = parseQuery(pin.tags);
const patch = { sort: p.sort, ratings: p.ratings, score: p.score, size: p.size, type: p.type, since: null };
if (pin.period) {
const cached = periodCache().starts[pin.period];
if (cached) patch.since = cached.id;
if (!cached || Date.now() - cached.at > PERIOD_TTL) periodStart(pin.period).catch(() => {});
}
return patch;
}
function pinIsEmpty(pin) {
return !pin.tags && !pin.period;
}
// Tag links (sidebar, post pages, the Posts menu) carry the remembered
// settings, so clicking a tag doesn't drop them. Pagination is left alone:
// it already belongs to the current search.
function applyPinToLinks(patch) {
for (const a of document.querySelectorAll('a[href*="s=list"]')) {
if (a.closest('.sb-bar, #paginator, .pagination')) continue;
let url;
try {
url = new URL(a.href, location.href);
} catch (e) {
continue;
}
const p = url.searchParams;
if (url.origin !== location.origin || p.get('page') !== 'post' || p.get('s') !== 'list' || p.has('pid')) continue;
const q = parseQuery(p.get('tags'));
if (hasFilters(q)) continue;
if (!a.dataset.sbHref) a.dataset.sbHref = a.getAttribute('href');
p.set('tags', serializeQuery(derive(q, patch)) || 'all');
a.href = url.href;
}
}
function restoreLinks() {
for (const a of document.querySelectorAll('a[data-sb-href]')) {
a.setAttribute('href', a.dataset.sbHref);
delete a.dataset.sbHref;
}
}
function hookSearchForm() {
const input = document.getElementById('tags-search');
if (!input || !input.form) return;
input.form.addEventListener('submit', () => {
const pin = loadPin();
if (!pin) return;
const q = parseQuery(input.value);
if (hasFilters(q)) savePin(q);
else if (!pinIsEmpty(pin)) input.value = serializeQuery(derive(q, pinPatch(pin)));
});
}
function go(q) {
if (loadPin()) savePin(q);
location.assign(listUrl(q));
}
/* ------------------------------------------------------------------ */
/* UI */
/* ------------------------------------------------------------------ */
// One row of filter buttons under the search box. A button shows the
// filter's name, or "Name: value" once it is set; its menu opens below.
// Single-choice menus are plain links and apply on click. Rating (several
// choices) and score (two numbers) apply when their menu closes.
const CSS = `
.searchArea.sb-host { height: auto !important; min-width: 0; }
.sb-bar {
--sb-chip: #f1f1f1;
--sb-chip-hover: #e8e8e8;
--sb-chip-open: #dedede;
--sb-text: #333;
--sb-muted: #888;
--sb-active: #e5eefc;
--sb-active-hover: #d8e6fb;
--sb-active-text: #0a5bcf;
--sb-accent: #0773fb;
--sb-link: #337ab7;
--sb-panel: #fff;
--sb-line: #dcdcdc;
--sb-line-soft: #eee;
--sb-hover: #eef4fd;
--sb-field: #fff;
--sb-field-line: #ccc;
--sb-error: #a43535;
position: relative;
display: flex;
flex-direction: column;
gap: 6px;
min-width: 0;
margin: 8px 0 0 44px;
color: var(--sb-text);
font-size: 12px;
line-height: 16px;
}
.sb-bar.sb-dark {
--sb-chip: #2c2c2c;
--sb-chip-hover: #353535;
--sb-chip-open: #3d3d3d;
--sb-text: #d6d6d6;
--sb-muted: #8c8c8c;
--sb-active: #1b365c;
--sb-active-hover: #21416d;
--sb-active-text: #a8cbff;
--sb-link: #8fbcff;
--sb-panel: #262626;
--sb-line: #3e3e3e;
--sb-line-soft: #333;
--sb-hover: #2f3845;
--sb-field: #1c1c1c;
--sb-field-line: #4a4a4a;
--sb-error: #ff8a80;
}
.sb-row { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; }
.sb-bar .sb-trigger {
display: inline-flex;
align-items: center;
gap: 6px;
box-sizing: border-box;
height: 26px;
margin: 0;
padding: 0 8px 0 10px;
border: 0;
border-radius: 0;
background: var(--sb-chip);
color: var(--sb-text) !important;
font: inherit;
text-decoration: none;
white-space: nowrap;
cursor: pointer;
}
.sb-bar .sb-trigger:hover { background: var(--sb-chip-hover); }
.sb-bar .sb-trigger[aria-expanded="true"] { background: var(--sb-chip-open); }
.sb-bar .sb-trigger.sb-active { background: var(--sb-active); color: var(--sb-active-text) !important; }
.sb-bar .sb-trigger.sb-active:hover,
.sb-bar .sb-trigger.sb-active[aria-expanded="true"] { background: var(--sb-active-hover); }
.sb-bar .sb-trigger svg { flex: none; width: 10px; height: 10px; opacity: .65; }
.sb-bar .sb-trigger.sb-icon { width: 26px; padding: 0; justify-content: center; }
.sb-bar .sb-trigger.sb-icon svg { width: 12px; height: 12px; opacity: 1; }
.sb-sep { flex: none; width: 1px; height: 16px; margin: 0 5px; background: var(--sb-line); }
.sb-bar a.sb-reset { margin-left: 6px; color: var(--sb-link) !important; text-decoration: none; }
.sb-bar a.sb-reset:hover { text-decoration: underline; }
.sb-panel {
position: absolute;
z-index: 2000;
box-sizing: border-box;
min-width: 160px;
max-width: calc(100vw - 16px);
padding: 4px 0;
border: 1px solid var(--sb-line);
background: var(--sb-panel);
box-shadow: 0 6px 18px rgba(0, 0, 0, .08);
}
.sb-dark .sb-panel { box-shadow: 0 6px 18px rgba(0, 0, 0, .45); }
.sb-panel .sb-item {
display: flex;
align-items: center;
gap: 8px;
box-sizing: border-box;
width: 100%;
margin: 0;
padding: 6px 16px 6px 10px;
border: 0;
background: none;
color: var(--sb-text) !important;
font: inherit;
text-align: left;
text-decoration: none;
white-space: nowrap;
cursor: pointer;
}
.sb-panel .sb-item:focus,
.sb-panel .sb-item:focus-within { background: var(--sb-hover); outline: none; }
.sb-panel .sb-item svg { flex: none; width: 12px; height: 12px; color: var(--sb-accent); visibility: hidden; }
.sb-panel .sb-item[aria-checked="true"] svg { visibility: visible; }
.sb-panel .sb-item:disabled { color: var(--sb-muted) !important; cursor: progress; }
.sb-panel input[type="checkbox"] { flex: none; margin: 0; accent-color: var(--sb-accent); cursor: pointer; outline: none; }
.sb-range { display: flex; align-items: center; gap: 6px; padding: 6px 10px; color: var(--sb-muted); }
.sb-panel input.sb-num {
box-sizing: border-box;
width: 72px;
height: 26px;
padding: 0 6px;
border: 1px solid var(--sb-field-line) !important;
border-radius: 0;
background: var(--sb-field) !important;
color: var(--sb-text) !important;
font: inherit;
-moz-appearance: textfield;
appearance: textfield;
}
.sb-panel input.sb-num::-webkit-outer-spin-button,
.sb-panel input.sb-num::-webkit-inner-spin-button { -webkit-appearance: none; margin: 0; }
.sb-panel input.sb-num:focus { border-color: var(--sb-accent) !important; outline: none; }
.sb-foot {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
margin-top: 4px;
padding: 8px 10px 4px;
border-top: 1px solid var(--sb-line-soft);
}
.sb-foot .sb-clear {
margin: 0;
padding: 0;
border: 0;
background: none;
color: var(--sb-link) !important;
font: inherit;
cursor: pointer;
}
.sb-foot .sb-apply {
height: 24px;
margin: 0;
padding: 0 14px;
border: 0;
border-radius: 0;
background: var(--sb-accent);
color: #fff !important;
font: inherit;
font-weight: bold;
cursor: pointer;
}
.sb-panel .sb-error { max-width: 220px; padding: 6px 10px; color: var(--sb-error); white-space: normal; }
.sb-note { color: var(--sb-muted); }
.sb-note a { color: var(--sb-link) !important; }
.sb-bar .sb-trigger:focus-visible,
.sb-bar a.sb-reset:focus-visible,
.sb-foot button:focus-visible {
outline: 2px solid var(--sb-accent);
outline-offset: 1px;
}
@media (max-width: 850px) {
.sb-bar { margin: 10px 0 0; }
.sb-bar .sb-trigger { height: 32px; }
.sb-bar .sb-trigger.sb-icon { width: 32px; }
.sb-panel .sb-item { padding: 10px 18px 10px 12px; }
.sb-panel input.sb-num { height: 32px; }
.sb-foot .sb-apply { height: 30px; }
}
`;
const ICONS = {
chevron: 'M4 6.5 8 10.5l4-4',
check: 'M3.5 8.5 6.5 11.5 12.5 4.5',
shuffle: 'M13 8a5 5 0 1 1-1.5-3.6M13 2.5V5h-2.5',
};
function el(tag, attrs, ...children) {
const node = document.createElement(tag);
for (const [key, value] of Object.entries(attrs || {})) {
if (value == null || value === false) continue;
if (key === 'class') node.className = value;
else node.setAttribute(key, value === true ? '' : value);
}
for (const child of children.flat()) {
if (child == null || child === false) continue;
node.append(child instanceof Node ? child : document.createTextNode(child));
}
return node;
}
function icon(name) {
const ns = 'http://www.w3.org/2000/svg';
const svg = document.createElementNS(ns, 'svg');
svg.setAttribute('viewBox', '0 0 16 16');
svg.setAttribute('aria-hidden', 'true');
const path = document.createElementNS(ns, 'path');
path.setAttribute('d', ICONS[name]);
path.setAttribute('fill', 'none');
path.setAttribute('stroke', 'currentColor');
path.setAttribute('stroke-width', '1.7');
path.setAttribute('stroke-linecap', 'round');
path.setAttribute('stroke-linejoin', 'round');
svg.append(path);
return svg;
}
const cap = (text) => text.charAt(0).toUpperCase() + text.slice(1);
/* ---------- menus ---------- */
let openMenu = null; // { trigger, panel, onClose }
function closeMenu(apply = true) {
if (!openMenu) return;
const menu = openMenu;
openMenu = null;
menu.panel.remove();
menu.trigger.setAttribute('aria-expanded', 'false');
document.removeEventListener('pointerdown', onOutsidePointer, true);
document.removeEventListener('keydown', onMenuKey, true);
window.removeEventListener('resize', onResize);
if (apply && menu.onClose) menu.onClose();
}
function onOutsidePointer(e) {
if (openMenu && !openMenu.panel.contains(e.target) && !openMenu.trigger.contains(e.target)) closeMenu();
}
// Phones resize the page when the keyboard opens for the score fields, so
// the menu follows its button instead of closing.
function onResize() {
if (openMenu) place(openMenu);
}
// Below the button, at least as wide as it, kept inside the window.
function place({ trigger, panel }) {
const bar = trigger.closest('.sb-bar');
const b = bar.getBoundingClientRect();
const t = trigger.getBoundingClientRect();
panel.style.minWidth = Math.max(160, Math.ceil(t.width)) + 'px';
panel.style.top = Math.round(t.bottom - b.top + 4) + 'px';
panel.style.left = Math.round(t.left - b.left) + 'px';
const overflow = panel.getBoundingClientRect().right - (document.documentElement.clientWidth - 8);
if (overflow > 0) panel.style.left = Math.round(Math.max(t.left - b.left - overflow, 8 - b.left)) + 'px';
}
function onMenuKey(e) {
if (!openMenu) return;
if (e.key === 'Escape') {
e.preventDefault();
const { trigger } = openMenu;
closeMenu();
trigger.focus();
} else if ((e.key === 'ArrowDown' || e.key === 'ArrowUp') && !e.target.matches('input.sb-num')) {
const items = [...openMenu.panel.querySelectorAll('.sb-item')]
.map((item) => (item.matches('label') ? item.querySelector('input') : item))
.filter((item) => !item.disabled);
if (!items.length) return;
e.preventDefault();
const at = items.indexOf(document.activeElement);
const next = at < 0 ? 0 : (at + (e.key === 'ArrowDown' ? 1 : -1) + items.length) % items.length;
items[next].focus();
}
}
function toggleMenu(trigger, build) {
const reopening = openMenu && openMenu.trigger === trigger;
closeMenu();
if (reopening) return;
const bar = trigger.closest('.sb-bar');
const panel = el('div', { class: 'sb-panel', role: 'group', 'aria-label': trigger.dataset.name });
const menu = { trigger, panel, onClose: null };
build(panel, menu);
bar.append(panel);
place(menu);
// Pointing at an item focuses it, so mouse and keyboard share one highlight.
panel.addEventListener('mouseover', (e) => {
const item = e.target.closest('.sb-item');
const target = item && (item.matches('label') ? item.querySelector('input') : item);
if (target && !target.disabled && target !== document.activeElement) target.focus({ preventScroll: true });
});
panel.addEventListener('focusout', (e) => {
const next = e.relatedTarget;
if (openMenu === menu && next && !panel.contains(next) && next !== trigger) closeMenu();
});
trigger.setAttribute('aria-expanded', 'true');
openMenu = menu;
document.addEventListener('pointerdown', onOutsidePointer, true);
document.addEventListener('keydown', onMenuKey, true);
window.addEventListener('resize', onResize);
const focus = panel.querySelector('[aria-checked="true"]') || panel.querySelector('input, .sb-item');
if (focus) focus.focus({ preventScroll: true });
}
function menuButton(name, label, text, active, build) {
const button = el('button', {
type: 'button',
class: 'sb-trigger' + (active ? ' sb-active' : ''),
'aria-haspopup': 'true',
'aria-expanded': 'false',
'data-sb': name,
'data-name': label,
}, text, icon('chevron'));
button.addEventListener('click', () => toggleMenu(button, build));
button.addEventListener('keydown', (e) => {
if (e.key === 'ArrowDown' && !openMenu) {
e.preventDefault();
toggleMenu(button, build);
}
});
return button;
}
// A filter button reads "Rating" until it is set, then "Rating: General".
function trigger(name, label, value, build) {
return menuButton(name, label, value ? label + L.sep + value : label, !!value, build);
}
function choice(text, target, checked) {
return el('a', { class: 'sb-item', href: listUrl(target), role: 'menuitemradio', 'aria-checked': String(!!checked) },
icon('check'), el('span', {}, text));
}
// The row for a value typed in the search box that matches no menu entry.
function customChoice(text) {
return el('span', { class: 'sb-item', role: 'menuitemradio', 'aria-checked': 'true', tabindex: '-1' },
icon('check'), el('span', {}, text));
}
function footer(clear, apply) {
const clearButton = el('button', { type: 'button', class: 'sb-clear' }, L.clear);
const applyButton = el('button', { type: 'button', class: 'sb-apply' }, L.apply);
clearButton.addEventListener('click', clear);
applyButton.addEventListener('click', apply);
return el('div', { class: 'sb-foot' }, clearButton, applyButton);
}
/* ---------- sort ---------- */
const SORTS = {
new: { is: (s) => !s, sort: () => null },
old: { is: (s) => !!s && s.key === 'id' && s.dir === 'asc', sort: () => ({ key: 'id', dir: 'asc', seed: null }) },
score: { is: (s) => !!s && s.key === 'score' && s.dir === 'desc', sort: () => ({ key: 'score', dir: 'desc', seed: null }) },
random: { is: (s) => !!s && s.key === 'random', sort: () => ({ key: 'random', dir: 'desc', seed: randomSeed() }) },
};
function sortControls(q) {
const current = Object.keys(SORTS).find((name) => SORTS[name].is(q.sort));
// Sorts typed by hand (sort:width, sort:score:asc…) are shown as typed.
const text = current ? L.sorts[current] : sortTag(q.sort);
const button = menuButton('sort', L.sort, text, false, (panel) => {
for (const name of Object.keys(SORTS)) {
panel.append(choice(L.sorts[name], derive(q, { sort: SORTS[name].sort() }), name === current));
}
if (!current) panel.append(customChoice(text));
});
if (current !== 'random') return [button];
const shuffle = el('a', {
class: 'sb-trigger sb-icon',
href: listUrl(derive(q, { sort: SORTS.random.sort() })),
'aria-label': L.shuffle,
}, icon('shuffle'));
return [button, shuffle];
}
/* ---------- filters ---------- */
function freshPeriodStart(name) {
const cached = periodCache().starts[name];
return cached && Date.now() - cached.at < PERIOD_TTL ? cached.id : null;
}
function periodControl(q) {
const current = periodOf(q.since);
const value = current === 'all' ? null : current === 'custom' ? L.periodSince(q.since) : L.periods[current];
return trigger('period', L.period, value, (panel) => {
for (const name of ['all', 'day', 'week', 'month', 'year']) {
const text = cap(L.periods[name]);
const known = name === 'all' ? null : freshPeriodStart(name);
if (name === 'all' || known != null) {
panel.append(choice(text, derive(q, { since: known }), name === current));
continue;
}
// Not looked up yet: find where the period starts, then go there.
const item = el('button', { type: 'button', class: 'sb-item', role: 'menuitemradio', 'aria-checked': String(name === current) },
icon('check'), el('span', {}, text));
item.addEventListener('click', async () => {
item.disabled = true;
item.lastChild.textContent = text + '…';
const error = panel.querySelector('.sb-error');
if (error) error.remove();
try {
go(derive(q, { since: await periodStart(name) }));
} catch (e) {
item.disabled = false;
item.lastChild.textContent = text;
panel.append(el('div', { class: 'sb-error', role: 'status' }, L.periodFail));
}
});
panel.append(item);
}
if (current === 'custom') panel.append(customChoice(cap(value)));
});
}
function ratingValue(ratings) {
if (!ratings) return null;
if (ratings.size === RATINGS.length - 1) {
return L.ratingWithout(RATING_NAMES[RATINGS.find((r) => !ratings.has(r))]);
}
return RATINGS.filter((r) => ratings.has(r)).map((r) => RATING_NAMES[r]).join(', ');
}
function ratingControl(q) {
return trigger('rating', L.rating, ratingValue(q.ratings), (panel, menu) => {
const boxes = RATINGS.map((r) => {
const box = el('input', { type: 'checkbox', checked: !!(q.ratings && q.ratings.has(r)), autocomplete: 'off' });
panel.append(el('label', { class: 'sb-item' }, box, el('span', {}, RATING_NAMES[r])));
return [r, box];
});
const chosen = () => {
const set = new Set(boxes.filter(([, box]) => box.checked).map(([r]) => r));
return set.size && set.size < RATINGS.length ? set : null; // none or all = no filter
};
menu.onClose = () => {
if (ratingValue(chosen()) !== ratingValue(q.ratings)) go(derive(q, { ratings: chosen() }));
};
panel.append(footer(() => boxes.forEach(([, box]) => { box.checked = false; }), () => closeMenu()));
});
}
function scoreValue(score) {
if (!score) return null;
const { min, max } = score;
if (min != null && max != null) return min === max ? String(min) : min + '–' + max;
return min != null ? L.scoreMin(min) : L.scoreMax(max);
}
function scoreControl(q) {
return trigger('score', L.score, scoreValue(q.score), (panel, menu) => {
const field = (value, name) => el('input', {
class: 'sb-num',
type: 'number',
step: '1',
inputmode: 'numeric',
autocomplete: 'off',
'aria-label': L.score + ' ' + name,
value: value != null ? String(value) : null,
});
const min = field(q.score && q.score.min, L.from);
const max = field(q.score && q.score.max, L.to);
panel.append(el('div', { class: 'sb-range' }, L.from, min, L.to, max));
const read = (input) => (input.value.trim() === '' || !isFinite(input.value) ? null : Math.trunc(+input.value));
const typed = () => {
let lo = read(min);
let hi = read(max);
if (lo != null && hi != null && lo > hi) [lo, hi] = [hi, lo];
return lo == null && hi == null ? null : { min: lo, max: hi };
};
menu.onClose = () => {
if (scoreValue(typed()) !== scoreValue(q.score)) go(derive(q, { score: typed() }));
};
panel.addEventListener('keydown', (e) => {
if (e.key === 'Enter' && e.target.matches('input.sb-num')) closeMenu();
});
panel.append(footer(() => { min.value = ''; max.value = ''; min.focus(); }, () => closeMenu()));
});
}
function sizeControl(q) {
const current = sizePreset(q.size);
const value = current === 'any' ? null : current === 'custom' ? describeSize(q.size) : L.sizes[current];
return trigger('size', L.size, value, (panel) => {
panel.append(choice(cap(L.sizes.any), derive(q, { size: null }), current === 'any'));
for (const name of Object.keys(SIZE_PRESETS)) {
panel.append(choice(cap(L.sizes[name]), derive(q, { size: { ...SIZE_PRESETS[name] } }), name === current));
}
if (current === 'custom') panel.append(customChoice(value));
});
}
function typeControl(q) {
return trigger('type', L.type, q.type ? L.types[q.type] : null, (panel) => {
for (const name of ['any', 'static', 'animated']) {
panel.append(choice(cap(L.types[name]), derive(q, { type: name === 'any' ? null : name }), (q.type || 'any') === name));
}
});
}
/* ---------- bar ---------- */
function showNote(bar, ...content) {
let note = bar.querySelector('.sb-note');
if (!note) {
note = el('div', { class: 'sb-note', role: 'status' });
bar.append(note);
}
note.replaceChildren(...content);
}
function renderBar(q) {
const pin = loadPin();
const bar = el('div', {
class: 'sb-bar' + (document.querySelector('link[href*="gridStyle-dark"]') ? ' sb-dark' : ''),
role: 'group',
'aria-label': L.bar,
// Gelbooru's pages say lang="en"; this picks the right fonts for
// Chinese and Japanese and the right voice for screen readers.
lang: LANG,
});
const row = el('div', { class: 'sb-row' },
sortControls(q),
el('span', { class: 'sb-sep', 'aria-hidden': 'true' }),
periodControl(q),
ratingControl(q),
scoreControl(q),
sizeControl(q),
typeControl(q));
if (hasFilters(q)) {
row.append(el('a', { class: 'sb-reset', href: listUrl(withoutFilters(q)) }, L.reset));
} else if (pin && !pinIsEmpty(pin)) {
row.append(el('a', { class: 'sb-reset', href: listUrl(derive(q, pinPatch(pin))) }, L.applyPinned));
}
bar.append(row);
// Keep the remembered filters in step with what the bar's links lead to.
bar.addEventListener('click', (e) => {
const a = e.target.closest('a[href]');
if (a && loadPin()) savePin(parseQuery(new URL(a.href).searchParams.get('tags')));
});
if (hasFilters(q) && !document.querySelector('.thumbnail-preview')) {
showNote(bar, L.empty + ' ', el('a', { href: listUrl(withoutFilters(q)) }, L.emptyReset));
}
return bar;
}
function mount() {
closeMenu(false);
const old = document.querySelector('.sb-bar');
if (old) old.remove();
const q = parseQuery(new URLSearchParams(location.search).get('tags'));
const bar = renderBar(q);
const area = document.querySelector('.searchArea');
if (area) {
area.classList.add('sb-host');
area.append(bar);
} else {
const main = document.querySelector('main') || document.body;
main.prepend(bar);
}
}
/* ---------- Tampermonkey menu ---------- */
let menuCommand = null;
function registerMenuCommand() {
if (typeof GM_registerMenuCommand !== 'function') return;
if (menuCommand != null && typeof GM_unregisterMenuCommand === 'function') GM_unregisterMenuCommand(menuCommand);
menuCommand = GM_registerMenuCommand((loadPin() ? '✓ ' : '') + L.remember, () => {
if (loadPin()) {
clearPin();
restoreLinks();
} else {
savePin(isList ? parseQuery(new URLSearchParams(location.search).get('tags')) : parseQuery(''));
const pin = loadPin();
if (!pinIsEmpty(pin)) applyPinToLinks(pinPatch(pin));
}
registerMenuCommand();
if (isList) mount();
});
}
/* ------------------------------------------------------------------ */
/* Start */
/* ------------------------------------------------------------------ */
const params = new URLSearchParams(location.search);
const isList = params.get('page') === 'post' && params.get('s') === 'list';
document.head.append(el('style', { id: 'sortbooru-style' }, CSS));
if (isList) mount();
hookSearchForm();
registerMenuCommand();
const pin = loadPin();
if (pin && !pinIsEmpty(pin)) applyPinToLinks(pinPatch(pin));
// Coming back with the Back button can restore a page with a menu still
// open or a period still "finding…".
window.addEventListener('pageshow', (e) => {
if (e.persisted && isList) mount();
});
})();