RisuRealm Wishlist

给 RisuRealm 卡片和详情页添加心愿单按钮,并支持本地保存、筛选、导入导出和批量操作。

Du musst eine Erweiterung wie Tampermonkey, Greasemonkey oder Violentmonkey installieren, um dieses Skript zu installieren.

Um dieses Skript zu installieren, müssen Sie eine Erweiterung wie Tampermonkey oder Violentmonkey installieren.

Um dieses Skript zu installieren, müssen Sie eine Erweiterung wie Tampermonkey oder Violentmonkey installieren.

Um dieses Skript zu installieren, müssen Sie eine Erweiterung wie Tampermonkey oder Userscripts installieren.

Um dieses Skript zu installieren, müssen Sie eine Erweiterung wie Tampermonkey installieren.

Sie müssten eine Skript Manager Erweiterung installieren damit sie dieses Skript installieren können

(Ich habe schon ein Skript Manager, Lass mich es installieren!)

Um dieses Design zu installieren, müssen Sie eine Erweiterung wie Stylus installieren.

Um dieses Design zu installieren, müssen Sie eine Erweiterung wie Stylus installieren.

Um dieses Design zu installieren, müssen Sie eine Erweiterung wie Stylus installieren.

Um diesen Stil zu installieren, müssen Sie eine Erweiterung für den Benutzerstil Verwaltung installieren.

Um diesen Stil zu installieren, müssen Sie eine Erweiterung für den Benutzerstil Verwaltung installieren.

Um diesen Stil zu installieren, müssen Sie eine Erweiterung für den Benutzerstil Verwaltung installieren.

(Ich habe bereits einen Benutzerstil Verwaltung, ich möchte ihn installieren!)

// ==UserScript==
// @name         RisuRealm Wishlist
// @namespace    https://realm.risuai.net/
// @version      1.4.10
// @license      MIT
// @description  给 RisuRealm 卡片和详情页添加心愿单按钮,并支持本地保存、筛选、导入导出和批量操作。
// @description:en Add heart wishlist buttons to RisuRealm cards and detail pages, with local save, filters, import/export, and batch actions.
// @description:zh-CN 给 RisuRealm 卡片和详情页添加心愿单按钮,并支持本地保存、筛选、导入导出和批量操作。
// @description:ko RisuRealm 카드와 상세 페이지에 위시리스트 버튼을 추가하고, 로컬 저장, 필터, 가져오기/내보내기, 일괄 작업을 지원합니다.
// @description:ja RisuRealm のカードと詳細ページにウィッシュリストボタンを追加し、ローカル保存、絞り込み、インポート/エクスポート、一括操作に対応します。
// @match        https://realm.risuai.net/*
// @icon         https://realm.risuai.net/favicon.ico
// @run-at       document-idle
// @grant        GM_addStyle
// @grant        GM_getValue
// @grant        GM_setValue
// ==/UserScript==

(function () {
  'use strict';

  const STORAGE_KEY = 'rrw:wishlist:v1';
  const NON_COMMERCIAL_KEY = 'rrw:noncommercial:v1';
  const CARD_SELECTOR = [
    'a[href^="/character/"]',
    'a[href^="/preset/"]',
    'a[href^="/module/"]',
    'a[href*="/character/"]',
    'a[href*="/preset/"]',
    'a[href*="/module/"]',
  ].join(',');
  const DETAIL_PATH_RE = /^\/(character|preset|module)\//;

  const css = `
    .rrw-card,
    .rrw-detail-card {
      position: relative !important;
    }

    .rrw-heart {
      align-items: center;
      backdrop-filter: blur(8px);
      background: rgba(17, 24, 39, 0.72);
      border: 1px solid rgba(255, 255, 255, 0.28);
      border-radius: 999px;
      box-shadow: 0 8px 24px rgba(0, 0, 0, 0.28);
      color: #fff;
      cursor: pointer;
      display: inline-flex;
      font-size: 19px;
      font-weight: 700;
      height: 36px;
      justify-content: center;
      line-height: 1;
      position: absolute;
      right: 10px;
      top: 10px;
      transition: transform 140ms ease, background 140ms ease, color 140ms ease, border-color 140ms ease;
      user-select: none;
      width: 36px;
      z-index: 20;
    }

    .rrw-heart:hover {
      transform: scale(1.08);
    }

    .rrw-heart[aria-pressed="true"] {
      background: rgba(244, 63, 94, 0.95);
      border-color: rgba(255, 255, 255, 0.65);
      color: #fff;
    }

    .rrw-heart:focus-visible,
    .rrw-detail-button:focus-visible,
    .rrw-toolbar button:focus-visible,
    .rrw-panel button:focus-visible,
    .rrw-panel a:focus-visible {
      outline: 2px solid #f43f5e;
      outline-offset: 2px;
    }

    .rrw-card.rrw-hidden {
      display: none !important;
    }

    html.rrw-filtering a[href^="/character/"]:not([data-rrw-saved="1"]),
    html.rrw-filtering a[href^="/preset/"]:not([data-rrw-saved="1"]),
    html.rrw-filtering a[href^="/module/"]:not([data-rrw-saved="1"]),
    html.rrw-filtering a[href*="/character/"]:not([data-rrw-saved="1"]),
    html.rrw-filtering a[href*="/preset/"]:not([data-rrw-saved="1"]),
    html.rrw-filtering a[href*="/module/"]:not([data-rrw-saved="1"]) {
      display: none !important;
    }

    .rrw-detail-button {
      align-items: center;
      appearance: none;
      backdrop-filter: blur(8px);
      background: rgba(17, 24, 39, 0.72);
      border: 1px solid rgba(255, 255, 255, 0.28);
      border-radius: 999px;
      box-shadow: 0 8px 24px rgba(0, 0, 0, 0.28);
      color: #fff;
      cursor: pointer;
      display: inline-flex;
      font-size: 19px;
      font-weight: 700;
      height: 36px;
      justify-content: center;
      line-height: 1;
      padding: 0;
      position: absolute;
      right: 10px;
      top: 10px;
      transition: transform 140ms ease, background 140ms ease, color 140ms ease, border-color 140ms ease;
      user-select: none;
      width: 36px;
      z-index: 20;
    }

    .rrw-detail-button:hover {
      transform: scale(1.08);
    }

    .rrw-detail-button[aria-pressed="true"] {
      background: rgba(244, 63, 94, 0.95);
      border-color: rgba(255, 255, 255, 0.65);
      color: #fff;
    }

    .rrw-toolbar {
      align-items: center;
      background: rgba(17, 24, 39, 0.9);
      border: 1px solid rgba(255, 255, 255, 0.18);
      border-radius: 999px;
      bottom: 18px;
      box-shadow: 0 18px 48px rgba(0, 0, 0, 0.35);
      color: #fff;
      display: flex;
      gap: 6px;
      padding: 7px;
      position: fixed;
      right: 18px;
      z-index: 2147483646;
    }

    .rrw-toolbar button,
    .rrw-panel button {
      background: rgba(255, 255, 255, 0.1);
      border: 1px solid rgba(255, 255, 255, 0.16);
      border-radius: 999px;
      color: inherit;
      cursor: pointer;
      font: 13px/1.2 system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
      padding: 8px 11px;
    }

    .rrw-toolbar button:hover,
    .rrw-panel button:hover {
      background: rgba(255, 255, 255, 0.18);
    }

    .rrw-toolbar button[aria-pressed="true"],
    .rrw-panel button[aria-pressed="true"] {
      background: #f43f5e;
      border-color: #fb7185;
    }

    .rrw-panel-backdrop {
      background: rgba(0, 0, 0, 0.38);
      inset: 0;
      position: fixed;
      z-index: 2147483646;
    }

    .rrw-panel {
      background: #111827;
      border: 1px solid rgba(255, 255, 255, 0.16);
      border-radius: 14px;
      box-shadow: 0 24px 80px rgba(0, 0, 0, 0.45);
      color: #fff;
      display: flex;
      flex-direction: column;
      max-height: min(76vh, 680px);
      max-width: min(92vw, 560px);
      min-height: 260px;
      padding: 14px;
      position: fixed;
      right: 18px;
      top: 64px;
      width: 560px;
      z-index: 2147483647;
    }

    .rrw-panel-header {
      align-items: center;
      display: flex;
      gap: 10px;
      justify-content: space-between;
      margin-bottom: 10px;
    }

    .rrw-panel-title {
      font: 700 17px/1.3 system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
    }

    .rrw-panel-actions {
      display: flex;
      flex-wrap: wrap;
      gap: 6px;
      justify-content: flex-end;
    }

    .rrw-tabs {
      display: flex;
      flex: 1 1 180px;
      gap: 6px;
      min-width: 180px;
    }

    .rrw-tab {
      border-radius: 10px !important;
      font-weight: 700 !important;
      justify-content: center;
      min-width: 0;
      padding: 8px 9px !important;
      width: 100%;
    }

    .rrw-tab[aria-pressed="true"] {
      background: #f43f5e;
      border-color: #fb7185;
      color: #fff;
    }

    .rrw-filterbar {
      display: grid;
      gap: 8px;
      margin-bottom: 10px;
    }

    .rrw-selectbar,
    .rrw-tag-modes {
      align-items: center;
      display: flex;
      flex-wrap: wrap;
      gap: 6px;
    }

    .rrw-tag-modes {
      margin-bottom: 6px;
    }

    .rrw-selected-count {
      color: rgba(255, 255, 255, 0.68);
      font: 12px/1.4 system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
      margin-left: auto;
    }

    .rrw-tag-list {
      display: flex;
      flex-wrap: wrap;
      gap: 6px;
      max-height: 78px;
      overflow: auto;
    }

    .rrw-tag {
      border-radius: 999px !important;
      font-size: 12px !important;
      padding: 5px 8px !important;
    }

    .rrw-tag[aria-pressed="true"],
    .rrw-tag-mode[aria-pressed="true"] {
      background: #f43f5e;
      border-color: #fb7185;
      color: #fff;
    }

    .rrw-list {
      display: grid;
      gap: 8px;
      overflow: auto;
      padding-right: 3px;
    }

    .rrw-group {
      display: grid;
      gap: 8px;
    }

    .rrw-group + .rrw-group {
      margin-top: 10px;
    }

    .rrw-group-title {
      align-items: center;
      color: rgba(255, 255, 255, 0.82);
      display: flex;
      font: 700 13px/1.3 system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
      justify-content: space-between;
      padding: 2px 2px 0;
    }

    .rrw-group-items {
      display: grid;
      gap: 8px;
    }

    .rrw-empty {
      align-items: center;
      color: rgba(255, 255, 255, 0.68);
      display: flex;
      flex: 1;
      font: 14px/1.5 system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
      justify-content: center;
      text-align: center;
    }

    .rrw-item {
      align-items: center;
      background: rgba(255, 255, 255, 0.07);
      border: 1px solid rgba(255, 255, 255, 0.1);
      border-radius: 10px;
      color: inherit;
      display: grid;
      gap: 10px;
      grid-template-columns: 24px 54px minmax(0, 1fr) auto;
      padding: 8px;
      text-decoration: none;
    }

    .rrw-item-check {
      accent-color: #f43f5e;
      height: 16px;
      width: 16px;
    }

    .rrw-item img {
      aspect-ratio: 1;
      border-radius: 8px;
      height: 54px;
      object-fit: cover;
      width: 54px;
    }

    .rrw-item-main {
      min-width: 0;
    }

    .rrw-item-title,
    .rrw-item-meta {
      overflow: hidden;
      text-overflow: ellipsis;
      white-space: nowrap;
    }

    .rrw-item-title {
      font: 700 14px/1.3 system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
    }

    .rrw-item-meta {
      color: rgba(255, 255, 255, 0.68);
      font: 12px/1.4 system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
      margin-top: 3px;
    }

    .rrw-item-tags {
      color: rgba(255, 255, 255, 0.52);
      font: 11px/1.4 system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
      margin-top: 3px;
      overflow: hidden;
      text-overflow: ellipsis;
      white-space: nowrap;
    }

    .rrw-remove {
      color: rgba(255, 255, 255, 0.72);
      height: 34px;
      padding: 0 !important;
      width: 34px;
    }

    html.rrw-mobile .rrw-panel {
      bottom: 72px;
      box-sizing: border-box;
      left: 10px;
      max-height: 72vh;
      max-height: calc(100dvh - 86px);
      max-width: none;
      right: auto;
      top: auto;
      width: calc(var(--rrw-mobile-width, 100vw) - 20px);
    }

    html.rrw-mobile .rrw-toolbar {
      border-radius: 16px;
      bottom: 12px;
      box-sizing: border-box;
      flex-wrap: wrap;
      left: 12px;
      max-width: calc(var(--rrw-mobile-width, 100vw) - 24px);
      right: auto;
      top: auto;
    }

    html.rrw-mobile .rrw-toolbar button {
      flex: 1 1 auto;
      min-width: 0;
    }

    html.rrw-mobile .rrw-panel-header {
      align-items: flex-start;
      flex-direction: column;
    }

    html.rrw-mobile .rrw-panel-actions {
      justify-content: flex-start;
      width: 100%;
    }

    html.rrw-mobile .rrw-item {
      gap: 8px;
      grid-template-columns: 24px 44px minmax(0, 1fr) 34px;
    }

    html.rrw-mobile .rrw-item img {
      height: 44px;
      width: 44px;
    }

    html.rrw-mobile .rrw-detail-button {
      right: 10px;
      top: 10px;
    }
  `;

  addStyle(css);

  let state = normalizeStore(readStore());
  let filterEnabled = false;
  let nonCommercialDownload = readBool(NON_COMMERCIAL_KEY);
  let panelFilter = 'character';
  let tagFilterMode = 'any';
  let tagExpanded = false;
  const selectedPaths = new Set();
  const selectedTags = new Set();
  let toolbar;
  let panel;
  let detailButton;
  let observerScheduled = false;

  bootstrap();

  function bootstrap() {
    syncMobileClass();
    renderToolbar();
    renderDetailButton();
    scanCards();
    observePage();
    patchHistory();
    window.addEventListener('resize', syncMobileClass, { passive: true });
    window.addEventListener('popstate', scheduleScan);
  }

  function syncMobileClass() {
    const screenMin = Math.min(
      (window.screen && window.screen.width) || Infinity,
      (window.screen && window.screen.height) || Infinity
    );
    const screenCssMin = screenMin / Math.max(1, window.devicePixelRatio || 1);
    const screenWidth = screenMin <= 640 ? screenMin : screenCssMin;
    const width = Math.min(
      window.innerWidth || Infinity,
      document.documentElement.clientWidth || Infinity,
      screenWidth
    );

    document.documentElement.style.setProperty(
      '--rrw-mobile-width',
      `${Math.max(0, width === Infinity ? screenWidth : width)}px`
    );
    document.documentElement.classList.toggle('rrw-mobile', width <= 640);
  }

  function addStyle(text) {
    if (typeof GM_addStyle === 'function') {
      GM_addStyle(text);
      return;
    }

    const style = document.createElement('style');
    style.textContent = text;
    document.head.append(style);
  }

  function readStore() {
    try {
      if (typeof GM_getValue === 'function') return GM_getValue(STORAGE_KEY, null);
    } catch (_) {
      // Fall through to localStorage.
    }

    try {
      return localStorage.getItem(STORAGE_KEY);
    } catch (_) {
      return null;
    }
  }

  function writeStore(nextState) {
    state = normalizeStore(nextState);
    persistStore();
    refreshAll();
  }

  function persistStore() {
    const raw = JSON.stringify(state);

    try {
      if (typeof GM_setValue === 'function') {
        GM_setValue(STORAGE_KEY, raw);
      } else {
        localStorage.setItem(STORAGE_KEY, raw);
      }
    } catch (_) {
      try {
        localStorage.setItem(STORAGE_KEY, raw);
      } catch (error) {
        console.error('[RisuRealm Wishlist] Failed to save wishlist', error);
      }
    }
  }

  function readBool(key) {
    try {
      const value = typeof GM_getValue === 'function' ? GM_getValue(key, false) : localStorage.getItem(key);
      return value === true || value === 'true';
    } catch (_) {
      return false;
    }
  }

  function writeBool(key, value) {
    try {
      if (typeof GM_setValue === 'function') GM_setValue(key, Boolean(value));
      else localStorage.setItem(key, String(Boolean(value)));
    } catch (_) {
      try {
        localStorage.setItem(key, String(Boolean(value)));
      } catch (error) {
        console.error('[RisuRealm Wishlist] Failed to save setting', error);
      }
    }
  }

  function normalizeStore(raw) {
    let parsed = raw;

    if (typeof raw === 'string') {
      try {
        parsed = JSON.parse(raw);
      } catch (_) {
        parsed = null;
      }
    }

    const rawItems = parsed && typeof parsed === 'object' && parsed.items && typeof parsed.items === 'object'
      ? parsed.items
      : {};
    const items = {};

    Object.entries(rawItems).forEach(([path, item]) => {
      if (!item || typeof item !== 'object') return;
      items[path] = { ...item, tags: normalizeTags(item.tags || []) };
    });

    return { version: 1, items };
  }

  function normalizeTags(tags) {
    const seen = new Set();
    return (Array.isArray(tags) ? tags : [])
      .map((tag) => String(tag || '').trim())
      .filter((tag) => {
        const key = tag.toLowerCase();
        if (!tag || seen.has(key)) return false;
        seen.add(key);
        return true;
      })
      .slice(0, 60);
  }

  function tagKey(tag) {
    return String(tag || '').trim().toLowerCase();
  }

  function canonicalPath(href) {
    try {
      const url = new URL(href, location.origin);
      return url.pathname.replace(/\/$/, '');
    } catch (_) {
      return href;
    }
  }

  function typeFromPath(path) {
    const match = path.match(/^\/([^/]+)\//);
    return match ? match[1] : 'item';
  }

  function idFromPath(path) {
    const match = String(path || '').match(/\/(?:character|preset|module)\/([^/?#]+)/);
    return match ? match[1] : '';
  }

  function currentDetailPath() {
    const path = canonicalPath(location.href);
    return DETAIL_PATH_RE.test(path) ? path : '';
  }

  function detectCurrentPageType(fallbackPath) {
    const typeLabels = new Set(['character', 'preset', 'module', 'lorebook']);
    const lines = (document.body.innerText || '')
      .split('\n')
      .map((line) => line.trim().toLowerCase())
      .filter(Boolean);
    const visibleType = lines.find((line) => typeLabels.has(line));

    if (visibleType === 'lorebook') return 'module';
    return visibleType || typeFromPath(fallbackPath);
  }

  function isCard(anchor) {
    const path = canonicalPath(anchor.getAttribute('href') || anchor.href);
    if (!DETAIL_PATH_RE.test(path)) return false;

    const text = (anchor.innerText || '').trim();
    const className = String(anchor.className || '');
    const rect = anchor.getBoundingClientRect();
    const hasCardClass = /\bborder\b/.test(className) && /\bflex\b/.test(className) && /\brounded-md\b/.test(className);
    const hasCardContent = Boolean(anchor.querySelector('img')) && /\bBy\s+/i.test(text);

    return hasCardClass && hasCardContent && rect.width >= 160 && rect.height >= 100;
  }

  function extractMeta(card) {
    const path = canonicalPath(card.getAttribute('href') || card.href);
    const lines = (card.innerText || '')
      .split('\n')
      .map((line) => line.trim())
      .filter(Boolean);
    const img = card.querySelector('img');
    const image = img ? img.currentSrc || img.src || img.getAttribute('src') || '' : '';
    const alt = img ? (img.alt || '').trim() : '';
    const title = alt || lines.find((line) => !/^By\s+/i.test(line)) || path.split('/').pop() || 'Untitled';
    const authorLine = lines.find((line) => /^By\s+/i.test(line)) || '';
    const author = authorLine.replace(/^By\s+/i, '').trim();

    return {
      addedAt: new Date().toISOString(),
      author,
      image,
      path,
      tags: extractTags(card, title, author),
      title,
      type: detectCurrentPageType(path),
      url: new URL(path, location.origin).href,
    };
  }

  function extractCurrentPageMeta() {
    const path = currentDetailPath();
    const lines = (document.body.innerText || '')
      .split('\n')
      .map((line) => line.trim())
      .filter(Boolean);
    const titleFromDocument = document.title.replace(/^RisuRealm\s*-\s*/i, '').trim();
    const title = titleFromDocument
      || lines.find((line) => !/^(Upload|Account|Download|Options|Help|RisuRealm|Click to preview|Comments are disabled)/i.test(line) && !/^By\s+/i.test(line))
      || path.split('/').pop()
      || 'Untitled';
    const authorLine = lines.find((line) => /^By\s+/i.test(line)) || '';
    const author = authorLine.replace(/^By\s+/i, '').trim();
    const image = findBestPageImage(title);
    const container = findDetailCardContainer();
    const download = findDownloadLink(container || document);

    return {
      addedAt: state.items[path] ? state.items[path].addedAt : new Date().toISOString(),
      author,
      downloadName: download.name,
      downloadType: download.type,
      downloadUrl: download.url,
      image,
      path,
      tags: extractTags(container || document.body, title, author),
      title,
      type: detectCurrentPageType(path),
      url: new URL(path, location.origin).href,
    };
  }

  function findDownloadLink(root) {
    const link = root && root.querySelector ? root.querySelector('a[href*="/api/v1/download/"]') : null;
    if (!link) return { name: '', type: '', url: '' };
    const url = new URL(link.getAttribute('href'), location.origin).href;
    return {
      name: link.getAttribute('download') || '',
      type: downloadTypeFromUrl(url),
      url,
    };
  }

  function downloadTypeFromUrl(url) {
    if (url.includes('/charx-v3/')) return 'charx';
    if (/\/(?:png|json)-v[23]\//.test(url)) return 'normal';
    if (/\/preset-(?:risu-v1|st-chat)\//.test(url)) return 'preset';
    if (/\/(?:module-v1|lorebook-v[23])\//.test(url)) return 'module';
    return '';
  }

  function extractTags(root, title, author) {
    const ignored = new Set([title, author, `By ${author}`, '♡', '♥', 'No creator comments'].filter(Boolean));
    const lines = (root && root.innerText ? root.innerText : '')
      .split('\n')
      .map((line) => line.trim())
      .filter(Boolean);

    return normalizeTags(lines.filter((line) => isTagLine(line, ignored)));
  }

  function isTagLine(line, ignored) {
    if (ignored.has(line)) return false;
    if (line.length > 40 || /\s/.test(line)) return false;
    if (/^By\s+/i.test(line) || /^https?:\/\//i.test(line)) return false;
    if (/^[\d.,kKmM]+$/.test(line)) return false;
    if (/[()[\]{}<>`"“”]/.test(line)) return false;
    return /^[\p{L}\p{N}_+#&.'-]+$/u.test(line);
  }

  function findBestPageImage(title) {
    const images = Array.from(document.images)
      .map((img) => ({
        alt: (img.alt || '').trim(),
        area: (img.naturalWidth || img.width || 0) * (img.naturalHeight || img.height || 0),
        src: img.currentSrc || img.src || img.getAttribute('src') || '',
      }))
      .filter((img) => img.src && !img.src.startsWith('data:'));
    const exactAlt = images.find((img) => img.alt && img.alt === title);
    if (exactAlt) return exactAlt.src;

    images.sort((a, b) => b.area - a.area);
    return images[0] ? images[0].src : '';
  }

  function scanCards() {
    document.querySelectorAll(CARD_SELECTOR).forEach((card) => {
      if (!(card instanceof HTMLAnchorElement)) return;
      if (!isCard(card)) {
        resetCardButton(card);
        return;
      }
      if (card.dataset.rrwReady === '1') {
        updateCard(card);
        return;
      }

      card.dataset.rrwReady = '1';
      card.classList.add('rrw-card');

      const heart = document.createElement('span');
      heart.className = 'rrw-heart';
      heart.setAttribute('role', 'button');
      heart.setAttribute('tabindex', '0');
      heart.addEventListener('pointerdown', stopCardOpen, true);
      heart.addEventListener('click', (event) => {
        stopCardOpen(event);
        toggleCard(card);
      }, true);
      heart.addEventListener('keydown', (event) => {
        if (event.key !== 'Enter' && event.key !== ' ') return;
        stopCardOpen(event);
        toggleCard(card);
      }, true);

      card.append(heart);
      updateCard(card);
    });

    applyFilter();
    updateToolbar();
  }

  function resetCardButton(card) {
    Array.from(card.children).forEach((child) => {
      if (child.classList && child.classList.contains('rrw-heart')) child.remove();
    });
    card.classList.remove('rrw-card', 'rrw-hidden');
    delete card.dataset.rrwReady;
    delete card.dataset.rrwSaved;
  }

  function stopCardOpen(event) {
    event.preventDefault();
    event.stopPropagation();
  }

  function toggleCard(card) {
    const meta = extractMeta(card);
    const next = { ...state, items: { ...state.items } };

    if (next.items[meta.path]) {
      delete next.items[meta.path];
    } else {
      next.items[meta.path] = meta;
    }

    writeStore(next);
  }

  function updateCard(card) {
    const path = canonicalPath(card.getAttribute('href') || card.href);
    const saved = Boolean(state.items[path]);
    const heart = card.querySelector('.rrw-heart');

    card.dataset.rrwSaved = saved ? '1' : '0';
    if (saved) refreshSavedMeta(extractMeta(card));

    if (heart) {
      heart.textContent = saved ? '♥' : '♡';
      heart.setAttribute('aria-label', saved ? '从心愿单移除' : '加入心愿单');
      heart.setAttribute('title', saved ? '从心愿单移除' : '加入心愿单');
      heart.setAttribute('aria-pressed', String(saved));
    }
  }

  function refreshSavedMeta(meta) {
    const current = state.items[meta.path];
    if (!current) return;

    const tags = normalizeTags([...(current.tags || []), ...(meta.tags || [])]);
    const changed = tags.join('\n') !== itemTags(current).join('\n')
      || (!current.image && meta.image)
      || (!current.author && meta.author)
      || (!current.downloadUrl && meta.downloadUrl);
    if (!changed) return;

    state.items[meta.path] = {
      ...current,
      author: current.author || meta.author,
      downloadName: current.downloadName || meta.downloadName,
      downloadType: current.downloadType || meta.downloadType,
      downloadUrl: current.downloadUrl || meta.downloadUrl,
      image: current.image || meta.image,
      tags,
      title: current.title || meta.title,
      type: current.type || meta.type,
      url: current.url || meta.url,
    };
    persistStore();
    renderPanel();
  }

  function findDetailCardContainer() {
    const path = currentDetailPath();
    if (!path) return null;

    const title = document.title.replace(/^RisuRealm\s*-\s*/i, '').trim();
    const candidates = Array.from(document.querySelectorAll('main, article, section, div'))
      .filter((el) => {
        if (toolbar && toolbar.contains(el)) return false;
        if (panel && panel.contains(el)) return false;
        if (detailButton && detailButton.contains(el)) return false;

        const rect = el.getBoundingClientRect();
        if (rect.width < 160 || rect.height < 120) return false;

        const text = (el.innerText || '').trim();
        if (!text || text.length > 20000) return false;
        if (title && !text.includes(title)) return false;
        if (!/\bBy\s+/i.test(text) && !text.includes('By ')) return false;

        const hasAction = /\bDownload\b|\bOptions\b|Click to/i.test(text);
        const hasImage = Boolean(el.querySelector('img'));
        return hasAction || hasImage;
      })
      .map((el) => {
        const rect = el.getBoundingClientRect();
        return { el, area: rect.width * rect.height, y: rect.top };
      })
      .sort((a, b) => a.area - b.area || a.y - b.y);

    return candidates[0] ? candidates[0].el : null;
  }

  function renderDetailButton() {
    const path = currentDetailPath();
    const container = findDetailCardContainer();

    if (!path || !container) {
      if (detailButton) {
        detailButton.remove();
        detailButton = null;
      }
      return;
    }

    if (!detailButton) {
      detailButton = document.createElement('button');
      detailButton.className = 'rrw-detail-button';
      detailButton.type = 'button';
      detailButton.addEventListener('click', toggleCurrentPage);
    }

    container.classList.add('rrw-detail-card');
    if (detailButton.parentElement !== container) container.append(detailButton);
    updateDetailButton();
  }

  function toggleCurrentPage() {
    const meta = extractCurrentPageMeta();
    const next = { ...state, items: { ...state.items } };

    if (next.items[meta.path]) {
      delete next.items[meta.path];
    } else {
      next.items[meta.path] = meta;
    }

    writeStore(next);
  }

  function updateDetailButton() {
    if (!detailButton) return;

    const path = currentDetailPath();
    const saved = Boolean(path && state.items[path]);
    const text = saved ? '♥' : '♡';

    if (detailButton.textContent !== text) detailButton.textContent = text;
    detailButton.setAttribute('aria-label', saved ? '从心愿单移除' : '加入心愿单');
    detailButton.setAttribute('title', saved ? '从心愿单移除' : '加入心愿单');
    detailButton.setAttribute('aria-pressed', String(saved));
  }

  function renderToolbar() {
    if (toolbar) return;

    toolbar = document.createElement('div');
    toolbar.className = 'rrw-toolbar';
    toolbar.innerHTML = `
      <button type="button" data-rrw-action="open">♡ 心愿单 <span data-rrw-count>0</span></button>
      <button type="button" data-rrw-action="filter" aria-pressed="false">只看心愿</button>
    `;
    toolbar.addEventListener('click', (event) => {
      const button = event.target.closest('button[data-rrw-action]');
      if (!button) return;

      if (button.dataset.rrwAction === 'open') {
        openPanel();
        return;
      }

      filterEnabled = !filterEnabled;
      button.setAttribute('aria-pressed', String(filterEnabled));
      applyFilter();
    });
    document.body.append(toolbar);
    updateToolbar();
  }

  function updateToolbar() {
    if (!toolbar) return;
    const count = Object.values(state.items).filter((item) => {
      const key = wishlistGroupKey(item);
      return key === 'character' || key === 'preset';
    }).length;
    const countNode = toolbar.querySelector('[data-rrw-count]');
    if (countNode) countNode.textContent = String(count);
    const filterButton = toolbar.querySelector('[data-rrw-action="filter"]');
    if (filterButton) filterButton.setAttribute('aria-pressed', String(filterEnabled));
  }

  function openPanel() {
    closePanel();

    const backdrop = document.createElement('div');
    backdrop.className = 'rrw-panel-backdrop';
    backdrop.addEventListener('click', closePanel);

    panel = document.createElement('section');
    panel.className = 'rrw-panel';
    panel.setAttribute('role', 'dialog');
    panel.setAttribute('aria-label', 'RisuRealm 心愿单');
    document.body.append(backdrop, panel);

    renderPanel();
  }

  function closePanel() {
    document.querySelectorAll('.rrw-panel, .rrw-panel-backdrop').forEach((node) => node.remove());
    panel = null;
  }

  function renderPanel() {
    if (!panel) return;

    const items = Object.values(state.items).sort((a, b) => String(b.addedAt || '').localeCompare(String(a.addedAt || '')));
    const characterItems = items.filter((item) => wishlistGroupKey(item) === 'character');
    const presetItems = items.filter((item) => wishlistGroupKey(item) === 'preset');
    const visibleTotal = characterItems.length + presetItems.length;
    const tabItems = panelFilter === 'preset' ? presetItems : characterItems;
    const activeItems = tabItems.filter(matchesTagFilter);
    const selectedCount = getSelectedItems().length;
    panel.innerHTML = '';

    const header = document.createElement('div');
    header.className = 'rrw-panel-header';

    const title = document.createElement('div');
    title.className = 'rrw-panel-title';
    title.textContent = `心愿单 (${visibleTotal})`;

    const actions = document.createElement('div');
    actions.className = 'rrw-panel-actions';

    const clearButton = document.createElement('button');
    clearButton.type = 'button';
    clearButton.textContent = '清空';
    clearButton.addEventListener('click', () => {
      if (!items.length) return;
      if (confirm('确定清空 RisuRealm 心愿单吗?')) {
        selectedPaths.clear();
        selectedTags.clear();
        writeStore({ version: 1, items: {} });
      }
    });

    const exportSelectedButton = document.createElement('button');
    exportSelectedButton.type = 'button';
    exportSelectedButton.textContent = '导出所选';
    exportSelectedButton.addEventListener('click', exportSelectedWishlist);

    const downloadSelectedButton = document.createElement('button');
    downloadSelectedButton.type = 'button';
    downloadSelectedButton.textContent = '直接下载所选';
    downloadSelectedButton.addEventListener('click', downloadSelectedItems);

    const nonCommercialButton = document.createElement('button');
    nonCommercialButton.type = 'button';
    nonCommercialButton.textContent = `非商业下载: ${nonCommercialDownload ? '开' : '关'}`;
    nonCommercialButton.title = '开启后 API 下载会附加 non_commercial=true';
    nonCommercialButton.setAttribute('aria-pressed', String(nonCommercialDownload));
    nonCommercialButton.addEventListener('click', () => {
      nonCommercialDownload = !nonCommercialDownload;
      writeBool(NON_COMMERCIAL_KEY, nonCommercialDownload);
      renderPanel();
    });

    const importButton = document.createElement('button');
    importButton.type = 'button';
    importButton.textContent = '导入';
    importButton.addEventListener('click', importWishlist);

    const closeButton = document.createElement('button');
    closeButton.type = 'button';
    closeButton.textContent = '关闭';
    closeButton.addEventListener('click', closePanel);

    actions.append(importButton, exportSelectedButton, nonCommercialButton, downloadSelectedButton, clearButton, closeButton);
    header.append(title, actions);
    panel.append(header);

    if (visibleTotal) {
      panel.append(createFilterBar(characterItems, presetItems, activeItems, selectedCount));
    }

    if (!visibleTotal) {
      const empty = document.createElement('div');
      empty.className = 'rrw-empty';
      empty.textContent = '还没有加入心愿单的角色卡或预设。';
      panel.append(empty);
      return;
    }

    if (!activeItems.length) {
      const empty = document.createElement('div');
      empty.className = 'rrw-empty';
      empty.textContent = selectedTags.size
        ? '没有符合当前 tag 筛选的项目。'
        : panelFilter === 'preset'
        ? '还没有加入心愿单的预设。'
        : '还没有加入心愿单的角色卡。';
      panel.append(empty);
      return;
    }

    const list = document.createElement('div');
    list.className = 'rrw-list';
    activeItems.forEach((item) => {
      list.append(createWishlistRow(item));
    });

    panel.append(list);
  }

  function createWishlistTab(filter, label) {
    const button = document.createElement('button');
    button.className = 'rrw-tab';
    button.type = 'button';
    button.textContent = label;
    button.setAttribute('aria-pressed', String(panelFilter === filter));
    button.addEventListener('click', () => {
      panelFilter = filter;
      renderPanel();
    });
    return button;
  }

  function createFilterBar(characterItems, presetItems, activeItems, selectedCount) {
    const bar = document.createElement('div');
    bar.className = 'rrw-filterbar';

    const selectBar = document.createElement('div');
    selectBar.className = 'rrw-selectbar';
    const tabItems = panelFilter === 'preset' ? presetItems : characterItems;

    const tabs = document.createElement('div');
    tabs.className = 'rrw-tabs';
    tabs.append(
      createWishlistTab('character', `角色卡 (${characterItems.length})`),
      createWishlistTab('preset', `预设 (${presetItems.length})`),
    );

    const allSelected = activeItems.length > 0 && activeItems.every((item) => selectedPaths.has(item.path));
    const selectAllButton = document.createElement('button');
    selectAllButton.type = 'button';
    selectAllButton.textContent = allSelected ? '取消全选' : '全选当前';
    selectAllButton.addEventListener('click', () => {
      activeItems.forEach((item) => {
        if (allSelected) selectedPaths.delete(item.path);
        else selectedPaths.add(item.path);
      });
      renderPanel();
    });

    const clearSelectedButton = document.createElement('button');
    clearSelectedButton.type = 'button';
    clearSelectedButton.textContent = '清除选择';
    clearSelectedButton.addEventListener('click', () => {
      selectedPaths.clear();
      renderPanel();
    });

    const tags = collectTags(tabItems);
    const tagToggleButton = document.createElement('button');
    tagToggleButton.type = 'button';
    tagToggleButton.textContent = selectedTags.size ? `Tag (${selectedTags.size})` : 'Tag';
    tagToggleButton.setAttribute('aria-expanded', String(tagExpanded));
    tagToggleButton.setAttribute('aria-pressed', String(tagExpanded || selectedTags.size > 0));
    tagToggleButton.addEventListener('click', () => {
      tagExpanded = !tagExpanded;
      renderPanel();
    });

    const count = document.createElement('span');
    count.className = 'rrw-selected-count';
    count.textContent = `已选 ${selectedCount}`;
    selectBar.append(tabs, selectAllButton, clearSelectedButton, tagToggleButton, count);
    bar.append(selectBar);

    if (!tagExpanded || !tags.length) return bar;

    const modes = document.createElement('div');
    modes.className = 'rrw-tag-modes';
    modes.append(createTagModeButton('any', '并集'), createTagModeButton('all', '交集'));

    const clearTags = document.createElement('button');
    clearTags.type = 'button';
    clearTags.textContent = '清除 tag';
    clearTags.addEventListener('click', () => {
      selectedTags.clear();
      renderPanel();
    });
    modes.append(clearTags);

    const tagList = document.createElement('div');
    tagList.className = 'rrw-tag-list';
    tags.forEach((tag) => tagList.append(createTagButton(tag)));
    bar.append(modes, tagList);
    return bar;
  }

  function createTagModeButton(mode, label) {
    const button = document.createElement('button');
    button.className = 'rrw-tag-mode';
    button.type = 'button';
    button.textContent = label;
    button.setAttribute('aria-pressed', String(tagFilterMode === mode));
    button.addEventListener('click', () => {
      tagFilterMode = mode;
      renderPanel();
    });
    return button;
  }

  function createTagButton(tag) {
    const button = document.createElement('button');
    const key = tagKey(tag);
    button.className = 'rrw-tag';
    button.type = 'button';
    button.textContent = tag;
    button.setAttribute('aria-pressed', String(selectedTags.has(key)));
    button.addEventListener('click', () => {
      if (selectedTags.has(key)) selectedTags.delete(key);
      else selectedTags.add(key);
      renderPanel();
    });
    return button;
  }

  function collectTags(items) {
    const tags = new Map();
    items.forEach((item) => {
      itemTags(item).forEach((tag) => {
        const key = tagKey(tag);
        if (!tags.has(key)) tags.set(key, tag);
      });
    });
    return Array.from(tags.values()).sort((a, b) => a.localeCompare(b));
  }

  function matchesTagFilter(item) {
    if (!selectedTags.size) return true;
    const tags = new Set(itemTags(item).map(tagKey));
    if (tagFilterMode === 'all') {
      return Array.from(selectedTags).every((tag) => tags.has(tag));
    }
    return Array.from(selectedTags).some((tag) => tags.has(tag));
  }

  function itemTags(item) {
    return normalizeTags(item && item.tags);
  }

  function getSelectedItems() {
    return Object.values(state.items).filter((item) => selectedPaths.has(item.path));
  }

  function wishlistGroupKey(item) {
    const type = String(item.type || typeFromPath(item.path || '')).toLowerCase();
    if (type === 'character' || type === 'normal' || type === 'charx') return 'character';
    if (type === 'preset') return 'preset';
    if (type === 'module' || type === 'lorebook') return 'module';
    return 'other';
  }

  function createWishlistRow(item) {
    const row = document.createElement('a');
    row.className = 'rrw-item';
    row.href = item.url || new URL(item.path, location.origin).href;

    const checkbox = document.createElement('input');
    checkbox.className = 'rrw-item-check';
    checkbox.type = 'checkbox';
    checkbox.checked = selectedPaths.has(item.path);
    checkbox.title = '选择';
    checkbox.addEventListener('click', (event) => {
      event.stopPropagation();
    });
    checkbox.addEventListener('change', (event) => {
      event.preventDefault();
      event.stopPropagation();
      if (checkbox.checked) selectedPaths.add(item.path);
      else selectedPaths.delete(item.path);
      renderPanel();
    });

    const img = document.createElement('img');
    img.alt = '';
    img.loading = 'lazy';
    img.src = item.image || '';

    const main = document.createElement('div');
    main.className = 'rrw-item-main';

    const itemTitle = document.createElement('div');
    itemTitle.className = 'rrw-item-title';
    itemTitle.textContent = item.title || 'Untitled';

    const meta = document.createElement('div');
    meta.className = 'rrw-item-meta';
    meta.textContent = item.author ? `By ${item.author}` : item.type || '';

    const tags = document.createElement('div');
    tags.className = 'rrw-item-tags';
    tags.textContent = itemTags(item).join(' · ');

    const remove = document.createElement('button');
    remove.className = 'rrw-remove';
    remove.type = 'button';
    remove.textContent = '×';
    remove.title = '移除';
    remove.setAttribute('aria-label', `移除 ${item.title || item.path}`);
    remove.addEventListener('click', (event) => {
      event.preventDefault();
      event.stopPropagation();
      const next = { ...state, items: { ...state.items } };
      delete next.items[item.path];
      selectedPaths.delete(item.path);
      writeStore(next);
    });

    main.append(itemTitle, meta);
    if (tags.textContent) main.append(tags);
    row.append(checkbox, img, main, remove);
    return row;
  }

  function exportWishlist(items, name) {
    const exportItems = (items && items.length ? items : Object.values(state.items));
    const payload = {
      exportedAt: new Date().toISOString(),
      source: 'RisuRealm Wishlist',
      version: 1,
      items: Object.fromEntries(exportItems.map((item) => [item.path, item])),
    };
    const blob = new Blob([JSON.stringify(payload, null, 2)], { type: 'application/json;charset=utf-8' });
    const link = document.createElement('a');
    const date = new Date().toISOString().slice(0, 10);

    link.href = URL.createObjectURL(blob);
    link.download = `${name || 'risurealm-wishlist'}-${date}.json`;
    document.body.append(link);
    link.click();
    link.remove();
    setTimeout(() => URL.revokeObjectURL(link.href), 1000);
  }

  function exportSelectedWishlist() {
    const items = getSelectedItems();
    if (!items.length) {
      alert('请先勾选要导出的项目。');
      return;
    }
    exportWishlist(items, 'risurealm-wishlist-selected');
  }

  async function downloadSelectedItems() {
    const items = getSelectedItems();
    if (!items.length) {
      alert('请先勾选要下载的项目。');
      return;
    }
    if (items.length > 8 && !confirm(`将直接下载 ${items.length} 个文件,浏览器可能会请求允许多个下载。继续吗?`)) return;

    const failed = [];
    for (const item of items) {
      const error = await downloadItem(item);
      if (error) failed.push(`${item.title || item.path} - ${error}`);
    }
    if (failed.length) alert(`以下项目下载失败:\n${failed.slice(0, 10).join('\n')}${failed.length > 10 ? '\n...' : ''}`);
  }

  async function downloadItem(item) {
    let lastError = '没有可用下载格式';
    for (const candidate of downloadCandidates(item)) {
      try {
        const response = await fetch(candidate.url);
        if (!response.ok) {
          lastError = await downloadErrorMessage(response, candidate);
          continue;
        }
        const blob = await response.blob();
        triggerDownload(blob, candidate.name);
        return '';
      } catch (error) {
        lastError = error && error.message ? error.message : '网络错误';
      }
    }
    return lastError;
  }

  async function downloadErrorMessage(response, candidate) {
    let message = '';
    try {
      const body = await response.clone().json();
      message = body.message || body.error || '';
    } catch (_) {
      try {
        message = (await response.text()).slice(0, 120).trim();
      } catch (_) {
        message = '';
      }
    }
    return `${candidate.format || 'download'} ${response.status}${message ? ` ${message}` : ''}`;
  }

  function downloadCandidates(item) {
    const id = idFromPath(item.path);
    if (!id) return [];

    const title = safeFileName(item.title || id);
    const type = String(item.downloadType || item.type || typeFromPath(item.path)).toLowerCase();
    const candidates = [];
    const addFormat = (format, ext) => {
      candidates.push({ format, name: `${title}.${ext}`, url: `/api/v1/download/${format}/${id}` });
    };

    if (item.downloadUrl) {
      candidates.push({
        format: downloadFormatFromUrl(item.downloadUrl),
        name: item.downloadName || downloadNameFromUrl(title, item.downloadUrl),
        url: item.downloadUrl,
      });
    }
    if (type === 'charx') addFormat('charx-v3', 'charx');
    if (type === 'charx' || type === 'normal' || type === 'character') {
      addFormat('png-v3', 'png');
      addFormat('json-v3', 'json');
      addFormat('json-v2', 'json');
    }
    if (type === 'preset') {
      addFormat('preset-risu-v1', 'risupreset');
      addFormat('preset-st-chat', 'json');
    }
    if (type === 'module' || type === 'lorebook') {
      addFormat('module-v1', 'json');
      addFormat('lorebook-v3', 'json');
      addFormat('lorebook-v2', 'json');
    }

    const seen = new Set();
    return candidates.filter((candidate) => {
      const url = new URL(candidate.url, location.origin).href;
      const downloadUrl = withApiDownloadOptions(url);
      if (seen.has(downloadUrl)) return false;
      seen.add(downloadUrl);
      candidate.url = downloadUrl;
      return true;
    });
  }

  function withApiDownloadOptions(url) {
    const normalized = new URL(url, location.origin);
    if (normalized.pathname.includes('/api/v1/download/')) {
      normalized.searchParams.set('cors', 'true');
      if (nonCommercialDownload) normalized.searchParams.set('non_commercial', 'true');
    }
    return normalized.href;
  }

  function downloadFormatFromUrl(url) {
    const match = String(url || '').match(/\/api\/v1\/download\/([^/]+)\//);
    return match ? match[1] : '';
  }

  function downloadNameFromUrl(title, url) {
    const format = downloadFormatFromUrl(url);
    if (format === 'charx-v3') return `${title}.charx`;
    if (format.startsWith('png-')) return `${title}.png`;
    if (format === 'preset-risu-v1') return `${title}.risupreset`;
    if (format) return `${title}.json`;
    return `${title}.download`;
  }

  function triggerDownload(blob, name) {
    const link = document.createElement('a');
    link.href = URL.createObjectURL(blob);
    link.download = name;
    document.body.append(link);
    link.click();
    link.remove();
    setTimeout(() => URL.revokeObjectURL(link.href), 1000);
  }

  function safeFileName(name) {
    return String(name || 'risurealm-card').replace(/[\\/:*?"<>|]+/g, '_').slice(0, 120);
  }

  function importWishlist() {
    const input = document.createElement('input');
    input.type = 'file';
    input.accept = 'application/json,.json';
    input.addEventListener('change', async () => {
      const file = input.files && input.files[0];
      input.remove();
      if (!file) return;

      try {
        const imported = parseImportPayload(await file.text());
        const count = Object.keys(imported.items).length;
        if (!count) {
          alert('没有找到可导入的心愿单项目。');
          return;
        }

        const nextItems = { ...state.items, ...imported.items };
        const addedCount = Object.keys(nextItems).length - Object.keys(state.items).length;
        if (!confirm(`导入 ${count} 条心愿单项目?当前列表会合并,重复项目会以导入文件为准。`)) return;
        writeStore({ version: 1, items: nextItems });
        alert(`导入完成:新增 ${addedCount} 条,总计 ${Object.keys(nextItems).length} 条。`);
      } catch (error) {
        console.error('[RisuRealm Wishlist] Import failed', error);
        alert('导入失败:请选择由 RisuRealm Wishlist 导出的 JSON 文件。');
      }
    });
    document.body.append(input);
    input.click();
  }

  function parseImportPayload(text) {
    const parsed = JSON.parse(text);
    const rawItems = Array.isArray(parsed)
      ? parsed
      : parsed && typeof parsed === 'object'
        ? parsed.items
        : null;

    if (!rawItems || typeof rawItems !== 'object') {
      throw new Error('Invalid wishlist payload');
    }

    const entries = Array.isArray(rawItems)
      ? rawItems.map((item) => [item && item.path, item])
      : Object.entries(rawItems);
    const items = {};

    entries.forEach(([path, item]) => {
      if (!item || typeof item !== 'object') return;
      const normalizedPath = canonicalPath(item.path || path || item.url || '');
      if (!DETAIL_PATH_RE.test(normalizedPath)) return;
      items[normalizedPath] = {
        addedAt: item.addedAt || new Date().toISOString(),
        author: String(item.author || ''),
        downloadName: String(item.downloadName || ''),
        downloadType: String(item.downloadType || ''),
        downloadUrl: String(item.downloadUrl || ''),
        image: String(item.image || ''),
        path: normalizedPath,
        tags: normalizeTags(item.tags || []),
        title: String(item.title || normalizedPath.split('/').pop() || 'Untitled'),
        type: item.type || typeFromPath(normalizedPath),
        url: new URL(normalizedPath, location.origin).href,
      };
    });

    return { version: 1, items };
  }

  function refreshAll() {
    document.querySelectorAll('a.rrw-card').forEach(updateCard);
    renderDetailButton();
    applyFilter();
    updateToolbar();
    renderPanel();
  }

  function applyFilter() {
    document.documentElement.classList.toggle('rrw-filtering', filterEnabled && !currentDetailPath());
  }

  function scheduleScan() {
    if (observerScheduled) return;
    observerScheduled = true;
    requestAnimationFrame(() => {
      observerScheduled = false;
      renderDetailButton();
      scanCards();
    });
  }

  function observePage() {
    const observer = new MutationObserver(scheduleScan);
    observer.observe(document.body, { childList: true, subtree: true });
  }

  function patchHistory() {
    ['pushState', 'replaceState'].forEach((method) => {
      const original = history[method];
      history[method] = function patchedHistoryMethod() {
        const result = original.apply(this, arguments);
        scheduleScan();
        return result;
      };
    });
  }
})();