☰

SortBooru

Gelbooru の検索に並べ替えと絞り込みのボタンを追加します。スコア順、ランダム、期間、レーティング、サイズ。タグの知識は不要です。

2026/10/17のページです。最新版はこちら

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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