☰

SortBooru

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

您需要先安裝使用者腳本管理器擴展,如 Tampermonkey、Greasemonkey 或 Violentmonkey 之後才能安裝該腳本。

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

您需要先安裝使用者腳本管理器擴充功能,如 Tampermonkey或 Violentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 Tampermonkey或 Userscripts 後才能安裝該腳本。

你需要先安裝一款使用者腳本管理器擴展,比如 Tampermonkey,才能安裝此腳本

您需要先安裝使用者腳本管理器擴充功能後才能安裝該腳本。

(我已經安裝了使用者腳本管理器,讓我安裝!)

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

(我已經安裝了使用者樣式管理器,讓我安裝!)

// ==UserScript==
// @name              SortBooru
// @name:ru           SortBooru
// @name:zh-CN        SortBooru
// @name:ja           SortBooru
// @name:es           SortBooru
// @name:pt-BR        SortBooru
// @namespace         sortbooru
// @version           1.2
// @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-start
// @noframes
// ==/UserScript==

(function () {
  'use strict';

  /* ------------------------------------------------------------------ */
  /* Storage                                                             */
  /* ------------------------------------------------------------------ */

  const hasGM = typeof GM_getValue === 'function' && typeof GM_setValue === 'function';

  // Values are read from storage once per page and then kept in memory.
  const memory = new Map();

  const store = {
    get(key, fallback) {
      if (memory.has(key)) return memory.get(key);
      let value = fallback;
      try {
        if (hasGM) {
          value = GM_getValue(key, fallback);
        } else {
          const raw = localStorage.getItem('sortbooru:' + key);
          if (raw != null) value = JSON.parse(raw);
        }
      } catch (e) {
        // Unreadable storage: use the fallback.
      }
      memory.set(key, value);
      return value;
    },
    set(key, value) {
      memory.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". The script remembers posts it has seen as (id, upload
  // time) points; upload times never change, so the points stay valid and
  // most lookups need one or two page loads. Results are cached; the cache
  // also maps ids back to period names for the bar.

  const DAY = 864e5;
  const HOUR = 36e5;
  const FETCH_TIMEOUT = 10e3;
  const MAX_POINTS = 60;
  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 = {};

  // A found start is reused for about 1% of its period: a day is checked
  // again after 15 minutes, a year after a day.
  const periodTtl = (name) => Math.min(Math.max(PERIOD_DAYS[name] * DAY * 0.01, 15 * 60e3), DAY);

  function periodCache() {
    const c = store.get('periods', null) || {};
    return {
      starts: c.starts || {},
      known: Array.isArray(c.known) ? c.known : [],
      points: Array.isArray(c.points) ? c.points : [],
      clock: c.clock || null, // server time minus this computer's time, and when it was measured
    };
  }

  function updatePeriodCache(change) {
    const c = periodCache();
    change(c);
    store.set('periods', c);
  }

  function rememberPeriodStart(name, id) {
    updatePeriodCache((c) => {
      c.starts[name] = { id, at: Date.now() };
      c.known = c.known.filter(([known]) => known !== id).concat([[id, name]]).slice(-60);
    });
  }

  function rememberPoint({ id, t }) {
    updatePeriodCache((c) => {
      c.points = c.points.filter(([known]) => known !== id).concat([[id, t]]).slice(-MAX_POINTS);
    });
  }

  function freshPeriodStart(name) {
    const cached = periodCache().starts[name];
    return cached && Date.now() - cached.at < periodTtl(name) ? cached.id : null;
  }

  function periodOf(since) {
    if (since == null) return 'all';
    const hit = periodCache().known.find(([id]) => id === since);
    return hit ? hit[1] : 'custom';
  }

  // Loads a Gelbooru page only until `pattern` turns up, then stops the
  // download: the upload date sits at ~60% of a post page and the first
  // thumbnail at ~45% of a list page.
  async function findInPage(path, pattern) {
    const abort = new AbortController();
    const timer = setTimeout(() => abort.abort(), FETCH_TIMEOUT);
    try {
      const res = await fetch(new URL(path, location.origin).href, { credentials: 'same-origin', signal: abort.signal });
      if (!res.ok) throw new Error('HTTP ' + res.status);
      if (!res.body || !res.body.getReader) return pattern.exec(await res.text());
      const reader = res.body.getReader();
      const decoder = new TextDecoder();
      let text = '';
      for (;;) {
        const { done, value } = await reader.read();
        if (done) return pattern.exec(text + decoder.decode());
        text += decoder.decode(value, { stream: true });
        const m = pattern.exec(text);
        if (m) return m;
      }
    } finally {
      clearTimeout(timer);
      abort.abort();
    }
  }

  // Upload time of post `id`, or of the nearest older post if it was deleted
  // (or doesn't exist yet). Times are in the server's timezone.
  async function postTime(id) {
    for (let attempt = 0; attempt < 3; attempt++) {
      const m = await findInPage('/index.php?page=post&s=view&id=' + id, POSTED);
      if (m) {
        const point = { id, t: Date.UTC(+m[1], m[2] - 1, +m[3], +m[4], +m[5], +m[6]) };
        rememberPoint(point);
        return point;
      }
      const older = await findInPage('/index.php?page=post&s=list&tags=' + encodeURIComponent('id:<' + id), FIRST_POST);
      if (!older) break;
      id = +older[1];
    }
    throw new Error('No upload date for post ' + id);
  }

  async function newestPost() {
    const m = await findInPage('/index.php?page=post&s=list&tags=all', FIRST_POST);
    if (!m) throw new Error('No posts found');
    return postTime(+m[1]);
  }

  // The current time on Gelbooru's clock. Measured from the newest post once
  // a day, which also absorbs this computer's clock being off.
  function serverNow() {
    const { clock } = periodCache();
    if (clock && Date.now() - clock.at < DAY) return Promise.resolve(Date.now() + clock.offset);
    if (!inFlight.clock) {
      inFlight.clock = (async () => {
        const newest = await newestPost();
        const offset = newest.t - Date.now();
        updatePeriodCache((c) => { c.clock = { offset, at: Date.now() }; });
        return Date.now() + offset;
      })().finally(() => { delete inFlight.clock; });
    }
    return inFlight.clock;
  }

  // Uploads per millisecond over the remembered points (~6500 a day if
  // nothing is known yet).
  function uploadRate(points) {
    if (points.length >= 2) {
      const first = points[0];
      const last = points[points.length - 1];
      if (last.t - first.t > HOUR) return (last.id - first.id) / (last.t - first.t);
    }
    return 6500 / DAY;
  }

  function knownPoints() {
    return periodCache().points.map(([id, t]) => ({ id, t })).sort((a, b) => a.id - b.id);
  }

  // The last known post uploaded by `target` and the first one after it.
  function around(points, target) {
    let lo = null;
    let hi = null;
    for (const p of points) {
      if (p.t <= target) lo = p;
      else if (!hi) hi = p;
    }
    return { lo, hi };
  }

  async function findPeriodStart(name) {
    const days = PERIOD_DAYS[name];
    const target = (await serverNow()) - days * DAY;
    const tolerance = Math.max(days * DAY * 0.01, HOUR);

    for (let step = 0; step < 8; step++) {
      const points = knownPoints();
      let close = null;
      for (const p of points) {
        if (Math.abs(p.t - target) <= tolerance && (!close || Math.abs(p.t - target) < Math.abs(close.t - target))) close = p;
      }
      if (close) return close.id;

      const { lo, hi } = around(points, target);
      if (lo && hi && hi.id - lo.id <= 1) return lo.id;

      let probe;
      if (lo && hi) {
        const guess = lo.id + Math.round((target - lo.t) / (hi.t - lo.t) * (hi.id - lo.id));
        probe = await postTime(Math.min(Math.max(guess, lo.id + 1), hi.id - 1));
      } else if (lo || hi) {
        // Only one side is known: step towards the target at the usual pace,
        // a little further when going back so the next probe lands past it.
        const from = lo || hi;
        probe = await postTime(Math.max(1, from.id + Math.round((target - from.t) * uploadRate(points) * (lo ? 1 : 1.2))));
      } else {
        probe = await newestPost();
      }
      if (points.some((p) => p.id === probe.id)) break; // nothing new between the known points
    }
    const { lo } = around(knownPoints(), target);
    return lo ? lo.id : 0;
  }

  function periodStart(name) {
    const known = freshPeriodStart(name);
    if (known != null) return Promise.resolve(known);
    if (!inFlight[name]) {
      inFlight[name] = findPeriodStart(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 > periodTtl(pin.period)) 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*="page=post"][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);

  const saveData = () => !!(navigator.connection && navigator.connection.saveData);

  /* ---------- 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 (once per frame at most).
  let placing = 0;
  function onResize() {
    if (placing) return;
    placing = requestAnimationFrame(() => {
      placing = 0;
      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 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));
        // Resting on the item (pointing at it focuses it) starts the lookup
        // early, so it's often done by the time of the click.
        let intent = 0;
        item.addEventListener('focus', () => {
          if (!saveData()) intent = setTimeout(() => periodStart(name).catch(() => {}), 150);
        });
        item.addEventListener('blur', () => clearTimeout(intent));
        item.addEventListener('mouseleave', () => clearTimeout(intent));
        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')));
    });
    return bar;
  }

  // Chrome and Edge start loading a menu link's page once the pointer has
  // rested on it for ~200 ms (or on press), so the click lands on a page that
  // is already on its way. Other browsers ignore the rules.
  function enablePrefetch() {
    const supported = window.HTMLScriptElement && HTMLScriptElement.supports && HTMLScriptElement.supports('speculationrules');
    if (!supported || saveData() || document.getElementById('sortbooru-prefetch')) return;
    const rules = { prefetch: [{ source: 'document', where: { selector_matches: '.sb-bar a[href]' }, eagerness: 'moderate' }] };
    document.head.append(el('script', { type: 'speculationrules', id: 'sortbooru-prefetch' }, JSON.stringify(rules)));
  }

  function mount() {
    closeMenu(false);
    const old = document.querySelector('.sb-bar');
    if (old) old.remove();
    if (!document.getElementById('sortbooru-style')) {
      (document.head || document.documentElement).append(el('style', { id: 'sortbooru-style' }, CSS));
    }
    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);
    }
    enablePrefetch();
    // The bar goes in before the results are parsed, so check for an empty
    // result once the page is complete.
    afterParse(() => {
      if (hasFilters(q) && bar.isConnected && !document.querySelector('.thumbnail-preview')) {
        showNote(bar, L.empty + ' ', el('a', { href: listUrl(withoutFilters(q)) }, L.emptyReset));
      }
    });
  }

  /* ---------- 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                                                               */
  /* ------------------------------------------------------------------ */

  // The script starts before the page is parsed. Gelbooru blocks the end of
  // every page on jQuery and its own scripts, so waiting for the whole page
  // would show the bar late and push the results down when it appears.

  function afterParse(callback) {
    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', callback, { once: true });
    else callback();
  }

  // Calls `callback` with what `find` returns as soon as it exists while the
  // page is being parsed, or at the end of parsing at the latest.
  function whenParsed(find, callback) {
    let done = false;
    const attempt = () => {
      if (done) return true;
      const found = find();
      if (found) {
        done = true;
        callback(found);
      }
      return done;
    };
    if (attempt()) return;
    const observer = new MutationObserver(() => {
      if (attempt()) observer.disconnect();
    });
    observer.observe(document, { childList: true, subtree: true });
    afterParse(() => {
      observer.disconnect();
      attempt();
    });
  }

  const params = new URLSearchParams(location.search);
  const isList = params.get('page') === 'post' && params.get('s') === 'list';

  if (isList) {
    // Mount once the search area is complete: something already follows it.
    whenParsed(() => {
      const area = document.querySelector('.searchArea');
      return (area && area.nextSibling) || document.readyState !== 'loading';
    }, mount);
  }
  whenParsed(() => {
    const input = document.getElementById('tags-search');
    return input && input.form;
  }, hookSearchForm);
  registerMenuCommand();

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