☰

SortBooru

Adds sort and filter buttons to Gelbooru search: by score, random, period, rating and size. No tag syntax needed.

As of 2026-10-17. See the latest version.

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

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

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

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

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

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

(I already have a user script manager, let me install it!)

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

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

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

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

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

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

(I already have a user style manager, let me install it!)

// ==UserScript==
// @name              SortBooru
// @name:ru           SortBooru
// @name:zh-CN        SortBooru
// @name:ja           SortBooru
// @name:es           SortBooru
// @name:pt-BR        SortBooru
// @namespace         sortbooru
// @version           1.0
// @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.
  const STRINGS = {
    ru: {
      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: 'до',
      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: {
      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',
      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',
    },
  };

  const RATING_NAMES = { general: 'General', sensitive: 'Sensitive', questionable: 'Questionable', explicit: 'Explicit' };

  const L = /^ru\b/i.test(navigator.language || '') ? STRINGS.ru : STRINGS.en;

  /* ------------------------------------------------------------------ */
  /* 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 + ': ' + 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.from + ' ' + min : L.to + ' ' + 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,
    });

    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();
  });
})();