AO3: Advanced Blocker

[In Development] Block works based off of tags, authors, word counts, languages, completion status and more. Now with primary pairing filtering!

当前为 2025-10-15 提交的版本,查看 最新版本

// ==UserScript==
// @name          AO3: Advanced Blocker
// @description   [In Development] Block works based off of tags, authors, word counts, languages, completion status and more. Now with primary pairing filtering!
// @author        BlackBatCat
// @version       2.8
// @license       MIT
// @match         *://archiveofourown.org/tags/*/works*
// @match         *://archiveofourown.org/works
// @match         *://archiveofourown.org/works?*
// @match         *://archiveofourown.org/works/search*
// @match         *://archiveofourown.org/users/*
// @match         *://archiveofourown.org/collections/*
// @match         *://archiveofourown.org/bookmarks*
// @match         *://archiveofourown.org/series/*
// @run-at        document-end
// @namespace 
// ==/UserScript==

(function () {
  "use strict";

  // Username detection
  let cachedUsername = null;
  function detectUsername(config) {
    if (cachedUsername) return cachedUsername;
    if (config.username) {
      cachedUsername = config.username;
      return config.username;
    }
    // Try user menu first
    const userLink = document.querySelector(
      'li.user.logged-in a[href^="/users/"]'
    );
    if (userLink) {
      const username = userLink.textContent.trim();
      if (username && config.username !== username) {
        config.username = username;
        saveConfig(config);
      }
      cachedUsername = username;
      return username;
    }
    // Fallback: try to parse from URL
    const urlMatch = window.location.href.match(/\/users\/([^\/]+)/);
    if (urlMatch && urlMatch[1]) {
      const username = urlMatch[1];
      if (config.username !== username) {
        config.username = username;
        saveConfig(config);
      }
      cachedUsername = username;
      return username;
    }
    return null;
  }

  window.ao3Blocker = {};
  // Startup message
  try {
    console.log("[AO3: Advanced Blocker] loaded.");
  } catch (e) {}

  // CSS namespace for all classes
  const CSS_NAMESPACE = "ao3-blocker";

  // Default configuration values and option definitions
  const DEFAULTS = {
    tagBlacklist: "",
    tagWhitelist: "",
    tagHighlights: "",
    highlightColor: "#eb6f92",
    minWords: "",
    maxWords: "",
    blockComplete: false,
    blockOngoing: false,
    authorBlacklist: "",
    titleBlacklist: "",
    summaryBlacklist: "",
    showReasons: true,
    showPlaceholders: true,
    debugMode: false,
    allowedLanguages: "",
    maxCrossovers: "3",
    disableOnMyContent: true,
    enableHighlightingOnMyContent: false,
    username: null,
    primaryRelationships: "",
    primaryCharacters: "",
    primaryRelpad: "1",
    primaryCharpad: "5",
  };

  // Storage key for single config object
  const STORAGE_KEY = "ao3_advanced_blocker_config";

  // Custom styles for the script
  const STYLE = `
  html body .ao3-blocker-hidden {
    display: none;
  }

  .ao3-blocker-cut {
    display: none;
  }

  .ao3-blocker-cut::after {
    clear: both;
    content: '';
    display: block;
  }

  .ao3-blocker-reason {
    margin-left: 5px;
  }

  .ao3-blocker-hide-reasons .ao3-blocker-reason {
    display: none;
  }

  .ao3-blocker-unhide .ao3-blocker-cut {
    display: block;
  }

  .ao3-blocker-fold {
    align-items: center;
    display: flex;
    justify-content: space-between !important;
    gap: 10px !important;
    width: 100% !important;
  }

  .ao3-blocker-unhide .ao3-blocker-fold {
      border-bottom: 1px dashed;
      border-bottom-color: inherit;
      margin-bottom: 15px;
      padding-bottom: 5px;
  }

  button.ao3-blocker-toggle {
    margin-left: auto;
    min-width: inherit;
    min-height: inherit;
    display: flex;
    align-items: center;
    justify-content: center;
    gap: 0.2em;
    min-width: 80px !important;
    margin-left: 10px !important;
    flex-shrink: 0 !important;
    white-space: nowrap !important;
    padding: 4px 8px !important;
  }

  .ao3-blocker-note {
    flex: 1 !important;
    min-width: 0 !important;
    word-wrap: break-word !important;
    overflow-wrap: break-word !important;
    /* Create space for the icon on the left */
    margin-left: 2em !important;
    position: relative !important;
    display: block !important;
  }

  .ao3-blocker-fold .ao3-blocker-note .ao3-blocker-icon {
    position: absolute !important;
    left: -1.5em !important;
    margin-right: 0 !important;
    display: block !important;
    float: none !important;
    vertical-align: top !important;
    width: 1.2em !important;
    height: 1.2em !important;
  }

  .ao3-blocker-toggle span {
    width: 1em !important;
    height: 1em !important;
    display: inline-block;
    vertical-align: -0.15em;
    margin-right: 0.2em;
    background-color: currentColor;
  }

  /* Settings menu styles */
  .ao3-blocker-menu-dialog {
    position: fixed;
    top: 50%;
    left: 50%;
    transform: translate(-50%, -50%);
    padding: 20px;
    border-radius: 8px;
    box-shadow: 0 0 20px rgba(0,0,0,0.2);
    z-index: 10000;
    width: 90%;
    max-width: 800px;
    max-height: 80vh;
    overflow-y: auto;
    font-family: inherit;
    font-size: inherit;
    color: inherit;
    box-sizing: border-box;
  }

  .ao3-blocker-menu-dialog .settings-section {
    background: rgba(0,0,0,0.03);
    border-radius: 6px;
    padding: 15px;
    margin-bottom: 20px;
    border-left: 4px solid currentColor;
  }

  .ao3-blocker-menu-dialog .section-title {
    margin-top: 0;
    margin-bottom: 15px;
    font-size: 1.2em;
    font-weight: bold;
    font-family: inherit;
    color: inherit;
    opacity: 0.85;
  }

  .ao3-blocker-menu-dialog .setting-group {
    margin-bottom: 15px;
  }

  .ao3-blocker-menu-dialog .setting-label {
    display: block;
    margin-bottom: 6px;
    font-weight: bold;
    color: inherit;
    opacity: 0.9;
  }

  .ao3-blocker-menu-dialog .setting-description {
    display: block;
    margin-bottom: 8px;
    font-size: 0.9em;
    color: inherit;
    opacity: 0.6;
    line-height: 1.4;
  }

  .ao3-blocker-menu-dialog .two-column {
    display: grid;
    grid-template-columns: 1fr 1fr;
    gap: 15px;
  }

  .ao3-blocker-menu-dialog .button-group {
    display: flex;
    justify-content: space-between;
    gap: 10px;
    margin-top: 20px;
  }

  .ao3-blocker-menu-dialog .button-group button {
    flex: 1;
    padding: 10px;
    color: inherit;
    opacity: 0.9;
  }

  .ao3-blocker-menu-dialog .reset-link {
    text-align: center;
    margin-top: 10px;
    color: inherit;
    opacity: 0.7;
  }

  .ao3-blocker-menu-dialog textarea {
    width: 100%;
    min-height: 100px;
    resize: vertical;
    box-sizing: border-box;
  }

  /* Highlighted works (left border using pseudo-element) */
  .ao3-blocker-highlight {
    position: relative !important;
  }

  .ao3-blocker-highlight::before {
    content: '' !important;
    position: absolute !important;
    left: 0 !important;
    top: 0 !important;
    right: 0 !important;
    bottom: 0 !important;
    box-shadow: inset 4px 0 0 0 var(--ao3-blocker-highlight-color, #eb6f92) !important;
    pointer-events: none !important;
    border-radius: inherit !important;
  }
  /* Tooltip icon style for settings menu (scoped) */
  .ao3-blocker-menu-dialog .symbol.question {
    font-size: 0.5em;
    vertical-align: middle;
  }
  /* Lighter placeholder text for menu input fields */
  .ao3-blocker-menu-dialog input::placeholder,
  .ao3-blocker-menu-dialog textarea::placeholder {
    opacity: 0.6 !important;
  }
  
  /* Form elements use page background color when focused */
  .ao3-blocker-menu-dialog input[type="text"],
  .ao3-blocker-menu-dialog input[type="number"],
  .ao3-blocker-menu-dialog input[type="color"],
  .ao3-blocker-menu-dialog select,
  .ao3-blocker-menu-dialog textarea {
    width: 100%;
    box-sizing: border-box;
  }
`;

  // Load configuration from single object storage
  function loadConfig() {
    try {
      const stored = localStorage.getItem(STORAGE_KEY);
      return stored ? { ...DEFAULTS, ...JSON.parse(stored) } : { ...DEFAULTS };
    } catch (e) {
      console.error("[AO3 Advanced Blocker] Failed to load config:", e);
    }
    return { ...DEFAULTS };
  }

  // Save configuration to single object storage
  function saveConfig(config) {
    try {
      localStorage.setItem(STORAGE_KEY, JSON.stringify(config));
      return true;
    } catch (e) {
      console.error("[AO3 Advanced Blocker] Failed to save config:", e);
      return false;
    }
  }

  // Robust detection for "my content" pages (dashboard, bookmarks, works, readings/history)
  function isMyContentPage(username) {
    if (!username || !username.trim()) return false;
    const escapedUsername = username.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
    const path = window.location.pathname;
    // Consolidated regex: dashboard, bookmarks, works, readings (history)
    const myContentRegex = new RegExp(
      `^/users/${escapedUsername}(?:/pseuds/[^/]+)?(?:/(?:bookmarks|works|readings))?/?(?:$|[?#])`,
      "i"
    );
    if (myContentRegex.test(path)) return true;
    // Also check for user_id in query string (case-insensitive)
    const params = new URLSearchParams(window.location.search);
    const userId = params.get("user_id");
    if (userId && userId.toLowerCase() === username.toLowerCase()) return true;
    return false;
  }

  // Parse chapter status from text content
  function parseChaptersStatus(chaptersText) {
    if (!chaptersText) return null;

    // Clean the text and look for the pattern
    const cleaned = chaptersText.replace(/ /gi, " ").trim();

    // Pattern for "current / total" or "current / ?"
    const match = cleaned.match(/^(\d+)\s*\/\s*([\d\?]+)/);
    if (match) {
      let chaptersNum = match[1].trim();
      let chaptersDenom = match[2].trim();

      if (chaptersDenom === "?") {
        return "ongoing";
      } else {
        const current = parseInt(chaptersNum.replace(/\D/g, ""), 10);
        const total = parseInt(chaptersDenom.replace(/\D/g, ""), 10);
        if (!isNaN(current) && !isNaN(total)) {
          if (current < total) {
            return "ongoing";
          } else if (current === total) {
            return "complete";
          } else if (current > total) {
            return "ongoing";
          }
        } else {
          return "ongoing";
        }
      }
    }

    // If no match found, assume ongoing
    return "ongoing";
  }

  // Extract tags by category using CSS class selectors
  function getCategorizedTags(container) {
    const tags = {
      ratings: [],
      warnings: [],
      categories: [],
      fandoms: [],
      relationships: [],
      characters: [],
      freeforms: [],
    };

    // Work page structure - ALWAYS try these first
    tags.ratings = selectTextsIn(
      container,
      ".rating.tags a.tag, .rating.tags .text"
    );
    tags.warnings = selectTextsIn(
      container,
      ".warning.tags a.tag, .warning.tags .text"
    );
    tags.categories = selectTextsIn(
      container,
      ".category.tags a.tag, .category.tags .text"
    );
    tags.fandoms = selectTextsIn(container, ".fandom.tags a.tag");
    tags.relationships = selectTextsIn(container, ".relationship.tags a.tag");
    tags.characters = selectTextsIn(container, ".character.tags a.tag");
    tags.freeforms = selectTextsIn(container, ".freeform.tags a.tag");

    // Only use blurb structure as fallback if NO tags found at all
    const hasAnyTags =
      tags.ratings.length > 0 ||
      tags.warnings.length > 0 ||
      tags.relationships.length > 0;
    if (!hasAnyTags) {
      tags.relationships = selectTextsIn(container, "li.relationships a.tag");
      tags.characters = selectTextsIn(container, "li.characters a.tag");
      tags.freeforms = selectTextsIn(container, "li.freeforms a.tag");

      // Required tags in blurbs
      tags.ratings = selectTextsIn(container, ".rating .text");
      tags.warnings = selectTextsIn(container, ".warnings .text");
      tags.categories = selectTextsIn(container, ".category .text");
      tags.fandoms = selectTextsIn(container, ".fandoms a.tag");
    }

    return tags;
  }

  // Convert categorized tags to flat array for filtering
  function getAllTagsFlat(categorizedTags) {
    return [
      ...categorizedTags.ratings,
      ...categorizedTags.warnings,
      ...categorizedTags.categories,
      ...categorizedTags.fandoms,
      ...categorizedTags.relationships,
      ...categorizedTags.characters,
      ...categorizedTags.freeforms,
    ];
  }

  // Normalize text by removing punctuation and standardizing whitespace
  function normalizeText(text) {
    return text
      .toLowerCase()
      .replace(/[^\w\s]/g, " ") // Replace punctuation with spaces
      .replace(/\s+/g, " ") // Normalize multiple spaces
      .trim();
  }

  // Escape special regex characters
  function escapeRegex(string) {
    return string.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
  }

  // Get the matched substring from text using the pattern
  function getMatchedSubstring(text, pattern) {
    let regex;
    if (typeof pattern === "string") {
      regex = new RegExp(escapeRegex(pattern), "i");
    } else {
      if (pattern.hasWildcard) {
        regex = new RegExp(pattern.regex.source, "i");
      } else {
        regex = new RegExp(escapeRegex(pattern.text), "i");
      }
    }
    const match = text.match(regex);
    return match ? match[0] : null;
  }

  // Initialize configuration processing directly
  function initConfig() {
    // Config is now available
    const config = loadConfig();

    // Process configuration for runtime use with simple regex pre-compilation
    const compilePattern = (pattern) => {
      // Check for wildcards BEFORE normalization (since * gets removed by normalizeText)
      const hasWildcard = pattern.includes("*");

      if (hasWildcard) {
        // Split on *, normalize each part, then reconstruct regex
        const parts = pattern.split("*").map((part) => {
          const normalized = normalizeText(part);
          // Escape regex special characters in the normalized part
          return normalized.replace(/[.+^${}()|[\]\\]/g, "\\$&");
        });
        // Join parts with .* for wildcard matching
        const regexPattern = parts.join(".*");
        // Store original pattern for display and normalized for matching
        const normalized = normalizeText(pattern.replace(/\*/g, ""));
        return {
          originalText: pattern,
          text: normalized,
          regex: new RegExp(regexPattern, "i"),
          hasWildcard: true,
        };
      }

      // No wildcard - store both original and normalized
      const normalized = normalizeText(pattern);
      return { originalText: pattern, text: normalized, hasWildcard: false };
    };

    window.ao3Blocker.config = {
      showReasons: config.showReasons,
      showPlaceholders: config.showPlaceholders,
      authorBlacklist: (typeof config.authorBlacklist === "string"
        ? config.authorBlacklist
        : ""
      )
        .toLowerCase()
        .split(/,(?:\s)?/g)
        .map((i) => i.trim())
        .filter(Boolean),
      titleBlacklist: (typeof config.titleBlacklist === "string"
        ? config.titleBlacklist
        : ""
      )
        .split(/,(?:\s)?/g)
        .map((i) => i.trim())
        .filter(Boolean)
        .map(compilePattern),
      tagBlacklist: (typeof config.tagBlacklist === "string"
        ? config.tagBlacklist
        : ""
      )
        .split(/,(?:\s)?/g)
        .map((i) => i.trim())
        .filter(Boolean)
        .map(compilePattern),
      tagWhitelist: (typeof config.tagWhitelist === "string"
        ? config.tagWhitelist
        : ""
      )
        .split(/,(?:\s)?/g)
        .map((i) => i.trim())
        .filter(Boolean)
        .map(compilePattern),
      tagHighlights: (typeof config.tagHighlights === "string"
        ? config.tagHighlights
        : ""
      )
        .split(/,(?:\s)?/g)
        .map((i) => i.trim())
        .filter(Boolean)
        .map(compilePattern),
      summaryBlacklist: (typeof config.summaryBlacklist === "string"
        ? config.summaryBlacklist
        : ""
      )
        .split(/,(?:\s)?/g)
        .map((i) => i.trim())
        .filter(Boolean)
        .map(compilePattern),

      highlightColor: config.highlightColor,
      debugMode: config.debugMode,
      allowedLanguages: (typeof config.allowedLanguages === "string"
        ? config.allowedLanguages
        : ""
      )
        .toLowerCase()
        .split(/,(?:\s)?/g)
        .map((s) => s.trim())
        .filter(Boolean),
      maxCrossovers: (function () {
        const val = config.maxCrossovers;
        const parsed = parseInt(val, 10);
        return val === undefined || val === null || val === "" || isNaN(parsed)
          ? null
          : parsed;
      })(),
      minWords: (function () {
        const v = config.minWords;
        const n = parseInt((v || "").toString().replace(/[,_\s]/g, ""), 10);
        return Number.isFinite(n) ? n : null;
      })(),
      maxWords: (function () {
        const v = config.maxWords;
        const n = parseInt((v || "").toString().replace(/[,_\s]/g, ""), 10);
        return Number.isFinite(n) ? n : null;
      })(),
      blockComplete: config.blockComplete,
      blockOngoing: config.blockOngoing,
      // Primary Pairing Config - normalize for case/punctuation insensitive matching
      primaryRelationships: (typeof config.primaryRelationships === "string"
        ? config.primaryRelationships
        : ""
      )
        .split(",")
        .map((s) => s.trim())
        .filter(Boolean)
        .map((s) => normalizeText(s)),
      primaryCharacters: (typeof config.primaryCharacters === "string"
        ? config.primaryCharacters
        : ""
      )
        .split(",")
        .map((s) => s.trim())
        .filter(Boolean)
        .map((s) => normalizeText(s)),
      primaryRelpad: (function () {
        const val = config.primaryRelpad;
        const parsed = parseInt(val, 10);
        return val === undefined || val === null || val === "" || isNaN(parsed)
          ? 1
          : Math.max(1, parsed);
      })(),
      primaryCharpad: (function () {
        const val = config.primaryCharpad;
        const parsed = parseInt(val, 10);
        return val === undefined || val === null || val === "" || isNaN(parsed)
          ? 5
          : Math.max(1, parsed);
      })(),
      disableOnMyContent: !!config.disableOnMyContent,
      enableHighlightingOnMyContent: !!config.enableHighlightingOnMyContent,
      username: config.username || null,
    };

    addStyle();
    // Set the highlight color CSS variable globally
    document.documentElement.style.setProperty(
      "--ao3-blocker-highlight-color",
      window.ao3Blocker.config.highlightColor || "#eb6f92"
    );
    checkWorks();
  }

  // Initialize when DOM is ready
  if (document.readyState === "loading") {
    document.addEventListener("DOMContentLoaded", initConfig);
  } else {
    initConfig();
  }

  // --- SHARED INITIALIZATION ---
  function initBlockerMenu() {
    const menuContainer = document.getElementById("scriptconfig");
    if (!menuContainer) {
      const headerMenu = document.querySelector(
        "ul.primary.navigation.actions"
      );
      const searchItem = headerMenu
        ? headerMenu.querySelector("li.search")
        : null;
      if (!headerMenu || !searchItem) return;

      // Create menu container
      const newMenuContainer = document.createElement("li");
      newMenuContainer.className = "dropdown";
      newMenuContainer.id = "scriptconfig";

      const title = document.createElement("a");
      title.className = "dropdown-toggle";
      title.href = "/";
      title.setAttribute("data-toggle", "dropdown");
      title.setAttribute("data-target", "#");
      title.textContent = "Userscripts";
      newMenuContainer.appendChild(title);

      const menu = document.createElement("ul");
      menu.className = "menu dropdown-menu";
      newMenuContainer.appendChild(menu);

      // Insert before search item
      headerMenu.insertBefore(newMenuContainer, searchItem);
    }

    // Add Advanced Blocker menu item
    const menu = document.querySelector("#scriptconfig .dropdown-menu");
    if (menu) {
      const menuItem = document.createElement("li");
      const menuLink = document.createElement("a");
      menuLink.href = "javascript:void(0);";
      menuLink.id = "opencfg_advanced_blocker";
      menuLink.textContent = "Advanced Blocker";
      menuLink.addEventListener("click", showBlockerMenu);
      menuItem.appendChild(menuLink);
      menu.appendChild(menuItem);
    }
  }

  // Initialize menu when DOM is ready
  if (document.readyState === "loading") {
    document.addEventListener("DOMContentLoaded", initBlockerMenu);
  } else {
    initBlockerMenu();
  }

  // addStyle() - Apply the custom stylesheet to AO3
  function addStyle() {
    const style = document.createElement("style");
    style.className = CSS_NAMESPACE;
    style.textContent = STYLE;
    document.head.appendChild(style);
  }

  // showBlockerMenu() - Show the settings menu
  function showBlockerMenu() {
    // Remove any existing menu dialogs
    const existingMenus = document.querySelectorAll(
      `.${CSS_NAMESPACE}-menu-dialog`
    );
    existingMenus.forEach((menu) => menu.remove());

    // Get AO3 input field background color
    let inputBg = "#fffaf5";
    const testInput = document.createElement("input");
    document.body.appendChild(testInput);
    try {
      const computedBg = window.getComputedStyle(testInput).backgroundColor;
      if (
        computedBg &&
        computedBg !== "rgba(0, 0, 0, 0)" &&
        computedBg !== "transparent"
      ) {
        inputBg = computedBg;
      }
    } catch (e) {}
    testInput.remove();

    // Load current config for the menu
    const config = loadConfig();

    // Create the settings dialog
    const dialog = document.createElement("div");
    dialog.className = `${CSS_NAMESPACE}-menu-dialog`;
    Object.assign(dialog.style, {
      position: "fixed",
      top: "50%",
      left: "50%",
      transform: "translate(-50%, -50%)",
      background: inputBg,
      padding: "20px",
      borderRadius: "8px",
      boxShadow: "0 0 20px rgba(0,0,0,0.2)",
      zIndex: "10000",
      width: "90%",
      maxWidth: "800px",
      maxHeight: "80vh",
      overflowY: "auto",
      fontFamily: "inherit",
      fontSize: "inherit",
      color: "inherit",
      boxSizing: "border-box",
    });

    // --- Build the menu content ---
    dialog.innerHTML = `
      <h3 style="text-align: center; margin-top: 0; color: inherit;">🛡️ Advanced Blocker 🛡️</h3>

      <!-- 1. Tag Filtering -->
      <div class="settings-section">
        <h4 class="section-title">Tag Filtering 📖</h4>
        <div class="setting-group">
          <label class="setting-label" for="tag-blacklist-input">Blacklist Tags</label>
          <span class="setting-description ao3-blocker-inline-help" style="display:block;">
            Matches any AO3 tag: ratings, warnings, fandoms, ships, characters, freeforms. * for wildcards.
          </span>
          <textarea id="tag-blacklist-input" placeholder="Abandoned*, Reader, Podfic, Genderswap" title="Blocks if any tag matches. * for wildcards.">${
            config.tagBlacklist
          }</textarea>
        </div>
        <div class="setting-group">
          <label class="setting-label" for="tag-whitelist-input">Whitelist Tags</label>
          <span class="setting-description ao3-blocker-inline-help" style="display:block;">
            Always shows the work even if it matches the blacklist. * for wildcards.
          </span>
          <textarea id="tag-whitelist-input" placeholder="*Happy Ending*, Fluff" title="Always shows the work, even if blacklisted. * for wildcards.">${
            config.tagWhitelist
          }</textarea>
        </div>
        <div class="two-column">
          <div class="setting-group">
            <label class="setting-label" for="tag-highlights-input">Highlight Tags
              <span class="symbol question" title="Make these works stand out. * for wildcards."><span>?</span></span>
            </label>
            <textarea id="tag-highlights-input" placeholder="*Fix-It*, Enemies to Lovers" title="Makes these works standout. * for wildcards.">${
              config.tagHighlights
            }</textarea>
          </div>
          <div class="setting-group">
            <label class="setting-label" for="highlight-color-input">Highlight Color
            </label>
            <input type="color" id="highlight-color-input" value="${
              config.highlightColor || "#eb6f92"
            }" title="Pick the highlight color.">
          </div>
        </div>
      </div>

      <!-- 2. Primary Pairing Filtering -->
      <div class="settings-section">
        <h4 class="section-title">Primary Pairing Filtering 💕</h4>
        <div class="setting-group">
          <label class="setting-label" for="primary-relationships-input">Primary Relationships
            <span class="symbol question" title="Only show works where these relationships are in the first few relationship tags."><span>?</span></span>
          </label>
          <textarea id="primary-relationships-input" placeholder="Luo Binghe/Shen Yuan | Shen Qingqiu, Lan Zhan | Lan Wangji/Wei Ying | Wei Wuxian" title="Case/punctuation insensitive.">${
            config.primaryRelationships
          }</textarea>
        </div>
        <div class="setting-group">
          <label class="setting-label" for="primary-characters-input">Primary Characters
            <span class="symbol question" title="Only show works where these characters are in the first few character tags."><span>?</span></span>
          </label>
          <textarea id="primary-characters-input" placeholder="Shen Yuan | Shen Qingqiu, Luo Binghe" title="Case/punctuation insensitive.">${
            config.primaryCharacters
          }</textarea>
        </div>
        <div class="two-column">
          <div class="setting-group">
            <label class="setting-label" for="primary-relpad-input">Relationship Tag Window
              <span class="symbol question" title="Check only the first X relationship tags."><span>?</span></span>
            </label>
            <input type="number" id="primary-relpad-input" min="1" max="10" value="${
              config.primaryRelpad || 1
            }" title="Check only the first X relationship tags.">
          </div>
          <div class="setting-group">
            <label class="setting-label" for="primary-charpad-input">Character Tag Window
              <span class="symbol question" title="Check only the first X character tags."><span>?</span></span>
            </label>
            <input type="number" id="primary-charpad-input" min="1" max="10" value="${
              config.primaryCharpad || 5
            }" title="Check only the first X character tags.">
          </div>
        </div>
      </div>

      <!-- 3. Work Filtering -->
      <div class="settings-section">
        <h4 class="section-title">Work Filtering 📝</h4>
        <div class="two-column">
          <div>
            <div class="setting-group">
              <label class="setting-label" for="allowed-languages-input">Allowed Languages
                <span class="symbol question" title="Only show these languages. Leave empty for all."><span>?</span></span>
              </label>
              <input id="allowed-languages-input" type="text"
                     placeholder="English, Русский, 中文-普通话 國語"
                     value="${config.allowedLanguages || ""}"
                     title="Only show these languages. Leave empty for all.">
            </div>
            <div class="setting-group">
              <label class="setting-label" for="min-words-input">Min Words
                <span class="symbol question" title="Hide works under this many words."><span>?</span></span>
              </label>
              <input id="min-words-input" type="text" style="width:100%;" placeholder="1000" value="${
                config.minWords || ""
              }" title="Hide works under this many words.">
            </div>
            <div class="setting-group">
              <label class="checkbox-label" for="block-ongoing-checkbox">
                <input type="checkbox" id="block-ongoing-checkbox" ${
                  config.blockOngoing ? "checked" : ""
                }>
                Block Ongoing Works
                <span class="symbol question" title="Hide works that are ongoing."><span>?</span></span>
              </label>
            </div>
          </div>
          <div>
            <div class="setting-group">
              <label class="setting-label" for="max-crossovers-input">Max Fandoms
                <span class="symbol question" title="Hide works with more than this many fandoms."><span>?</span></span>
              </label>
              <input id="max-crossovers-input" type="number" min="1" step="1"
                     value="${config.maxCrossovers || ""}"
                     title="Hide works with more than this many fandoms.">
            </div>
            <div class="setting-group">
              <label class="setting-label" for="max-words-input">Max Words
                <span class="symbol question" title="Hide works over this many words."><span>?</span></span>
              </label>
              <input id="max-words-input" type="text" style="width:100%;" placeholder="100000" value="${
                config.maxWords || ""
              }" title="Hide works over this many words.">
            </div>
            <div class="setting-group">
              <label class="checkbox-label" for="block-complete-checkbox">
                <input type="checkbox" id="block-complete-checkbox" ${
                  config.blockComplete ? "checked" : ""
                }>
                Block Complete Works
                <span class="symbol question" title="Hide works that are marked as complete."><span>?</span></span>
              </label>
            </div>
          </div>
        </div>
      </div>

      <!-- 4. Author & Content Filtering -->
      <div class="settings-section">
        <h4 class="section-title">Author & Content Filtering ✍️</h4>
        <div class="two-column">
          <div class="setting-group">
            <label class="setting-label" for="author-blacklist-input">Blacklist Authors
              <span class="symbol question" title="Match the author name exactly."><span>?</span></span>
            </label>
            <textarea id="author-blacklist-input" placeholder="DetectiveMittens, BlackBatCat" title="Match the author name exactly.">${
              config.authorBlacklist
            }</textarea>
          </div>
          <div class="setting-group">
            <label class="setting-label" for="title-blacklist-input">Blacklist Titles
              <span class="symbol question" title="Blocks if the title contains your text. * works."><span>?</span></span>
            </label>
            <textarea id="title-blacklist-input" placeholder="oneshot, prompt, 2025" title="Blocks if the title contains your text. * works.">${
              config.titleBlacklist
            }</textarea>
          </div>
        </div>
        <div class="setting-group">
          <label class="setting-label" for="summary-blacklist-input">Blacklist Summary
            <span class="symbol question" title="Blocks if the summary has these words/phrases. * for wildcards."><span>?</span></span>
          </label>
          <textarea id="summary-blacklist-input" placeholder="oneshot, prompt, 2025" title="Blocks if the summary has these words/phrases. * for wildcards">${
            config.summaryBlacklist
          }</textarea>
        </div>
      </div>

      <!-- 5. Display Options -->
      <div class="settings-section">
        <h4 class="section-title">Display Options ⚙️</h4>
        <div class="two-column">
          <div>
            <div class="setting-group">
              <label class="checkbox-label">
                <input type="checkbox" id="show-placeholders-checkbox" ${
                  config.showPlaceholders ? "checked" : ""
                }>
                Show Work Placeholder
                <span class="symbol question" title="Leave a stub you can click to reveal. If disabled, hides the work completely."><span>?</span></span>
              </label>
            </div>
            <div class="setting-group" id="show-reasons-group" style="display: ${
              config.showPlaceholders ? "block" : "none"
            }; margin-left: 1.5em;">
              <label class="checkbox-label">
                <input type="checkbox" id="show-reasons-checkbox" ${
                  config.showReasons ? "checked" : ""
                }>
                Show Block Reason
                <span class="symbol question" title="List what triggered the block."><span>?</span></span>
              </label>
            </div>
          </div>
          <div>
            <div class="setting-group">
              <label class="checkbox-label">
                <input type="checkbox" id="disable-on-my-content-checkbox" ${
                  config.disableOnMyContent ? "checked" : ""
                }>
                Disable on My Content
                <span class="symbol question" title="Don't block or highlight works on your dashboard, bookmarks, history, and works pages. Automatically includes all your pseuds."><span>?</span></span>
              </label>
            </div>
            <!-- Username input removed: now auto-detected -->
            <div class="setting-group" id="enable-highlighting-group" style="display: ${
              config.disableOnMyContent ? "block" : "none"
            }; margin-left: 1.5em;">
              <label class="checkbox-label">
                <input type="checkbox" id="enable-highlighting-on-my-content-checkbox" ${
                  config.enableHighlightingOnMyContent ? "checked" : ""
                }>
                Enable Highlighting
                <span class="symbol question" title="Re-enable tag highlighting on your own pages."><span>?</span></span>
              </label>
            </div>
          </div>
        </div>
      </div>

      <!-- 6. Import/Export & Reset -->
      <div class="button-group">
        <button id="blocker-save">Save Settings</button>
        <button id="blocker-cancel">Cancel</button>
      </div>

      <div class="reset-link">
        <a href="#" id="resetBlockerSettingsLink">Reset to Default Settings</a>
      </div>

      <div class="reset-link" style="margin-top:18px;">
        <button id="ao3-export" style="margin-right:8px;">Export Settings</button>
        <input type="file" id="ao3-import" accept="application/json" style="display:none;">
        <button id="ao3-import-btn">Import Settings</button>
      </div>
    `;

    // --- Export Settings ---
    const exportButton = dialog.querySelector("#ao3-export");
    exportButton.addEventListener("click", function () {
      try {
        const config = loadConfig();
        const now = new Date();
        const pad = (n) => n.toString().padStart(2, "0");
        const yyyy = now.getFullYear();
        const mm = pad(now.getMonth() + 1);
        const dd = pad(now.getDate());
        const dateStr = `${yyyy}-${mm}-${dd}`;
        const filename = `ao3_advanced_blocker_config_${dateStr}.json`;
        const blob = new Blob([JSON.stringify(config, null, 2)], {
          type: "application/json",
        });
        const url = URL.createObjectURL(blob);
        const a = document.createElement("a");
        a.href = url;
        a.download = filename;
        document.body.appendChild(a);
        a.click();
        setTimeout(() => {
          document.body.removeChild(a);
          URL.revokeObjectURL(url);
        }, 100);
      } catch (e) {
        alert("Export failed: " + (e && e.message ? e.message : e));
      }
    });

    // --- Import Settings ---
    const importButton = dialog.querySelector("#ao3-import-btn");
    const importInput = dialog.querySelector("#ao3-import");
    importButton.addEventListener("click", function () {
      importInput.value = "";
      importInput.click();
    });
    importInput.addEventListener("change", function (e) {
      const file = e.target.files && e.target.files[0];
      if (!file) return;
      const reader = new FileReader();
      reader.onload = function (evt) {
        try {
          const importedConfig = JSON.parse(evt.target.result);
          if (typeof importedConfig !== "object" || !importedConfig)
            throw new Error("Invalid JSON");

          // Validate and merge with defaults
          const validConfig = { ...DEFAULTS };
          Object.keys(validConfig).forEach((key) => {
            if (importedConfig.hasOwnProperty(key)) {
              validConfig[key] = importedConfig[key];
            }
          });

          if (saveConfig(validConfig)) {
            alert("Settings imported! Reloading...");
            location.reload();
          } else {
            throw new Error("Failed to save imported settings");
          }
        } catch (err) {
          alert("Import failed: " + (err && err.message ? err.message : err));
        }
      };
      reader.readAsText(file);
    });

    document.body.appendChild(dialog);

    // Add focused input styling using detected page background
    const focusStyle = document.createElement("style");
    focusStyle.textContent = `
      .ao3-blocker-menu-dialog input[type="text"]:focus,
      .ao3-blocker-menu-dialog input[type="number"]:focus,
      .ao3-blocker-menu-dialog input[type="color"]:focus,
      .ao3-blocker-menu-dialog select:focus,
      .ao3-blocker-menu-dialog textarea:focus {
        background: ${inputBg} !important;
      }
    `;
    document.head.appendChild(focusStyle);

    // Toggle username input and highlighting checkbox visibility based on checkbox
    const disableOnMyContentCheckbox = dialog.querySelector(
      "#disable-on-my-content-checkbox"
    );
    const enableHighlightingGroup = dialog.querySelector(
      "#enable-highlighting-group"
    );
    disableOnMyContentCheckbox.addEventListener("change", (e) => {
      const isChecked = e.target.checked;
      enableHighlightingGroup.style.display = isChecked ? "block" : "none";
    });

    // Toggle show reasons visibility based on show placeholders checkbox
    const showPlaceholdersCheckbox = dialog.querySelector(
      "#show-placeholders-checkbox"
    );
    const showReasonsGroup = dialog.querySelector("#show-reasons-group");
    showPlaceholdersCheckbox.addEventListener("change", (e) => {
      showReasonsGroup.style.display = e.target.checked ? "block" : "none";
    });

    // Save button handler
    const saveButton = dialog.querySelector("#blocker-save");
    saveButton.addEventListener("click", () => {
      // Collect values from form inputs
      const updatedConfig = {
        tagBlacklist: dialog.querySelector("#tag-blacklist-input").value || "",
        tagWhitelist: dialog.querySelector("#tag-whitelist-input").value || "",
        tagHighlights:
          dialog.querySelector("#tag-highlights-input").value || "",
        authorBlacklist:
          dialog.querySelector("#author-blacklist-input").value || "",
        titleBlacklist:
          dialog.querySelector("#title-blacklist-input").value || "",
        summaryBlacklist:
          dialog.querySelector("#summary-blacklist-input").value || "",
        showReasons: dialog.querySelector("#show-reasons-checkbox").checked,
        showPlaceholders: dialog.querySelector("#show-placeholders-checkbox")
          .checked,
        debugMode: config.debugMode, // Preserve existing debug mode setting (not in UI)
        highlightColor:
          dialog.querySelector("#highlight-color-input").value ||
          DEFAULTS.highlightColor,
        allowedLanguages:
          dialog.querySelector("#allowed-languages-input").value || "",
        maxCrossovers:
          dialog.querySelector("#max-crossovers-input").value || "",
        minWords: dialog.querySelector("#min-words-input").value || "",
        maxWords: dialog.querySelector("#max-words-input").value || "",
        blockComplete: dialog.querySelector("#block-complete-checkbox").checked,
        blockOngoing: dialog.querySelector("#block-ongoing-checkbox").checked,
        disableOnMyContent: dialog.querySelector(
          "#disable-on-my-content-checkbox"
        ).checked,
        enableHighlightingOnMyContent: dialog.querySelector(
          "#enable-highlighting-on-my-content-checkbox"
        ).checked,
        // username is auto-detected and preserved
        username: config.username || null,
        primaryRelationships:
          dialog.querySelector("#primary-relationships-input").value || "",
        primaryCharacters:
          dialog.querySelector("#primary-characters-input").value || "",
        primaryRelpad:
          dialog.querySelector("#primary-relpad-input").value ||
          DEFAULTS.primaryRelpad,
        primaryCharpad:
          dialog.querySelector("#primary-charpad-input").value ||
          DEFAULTS.primaryCharpad,
      };

      // Save using our custom storage system
      if (saveConfig(updatedConfig)) {
        // Force hard reload with cache busting
        location.href =
          location.href + (location.search ? "&" : "?") + "t=" + Date.now();
      } else {
        alert("Error saving settings.");
      }

      dialog.remove();
    });

    // Cancel button handler
    const cancelButton = dialog.querySelector("#blocker-cancel");
    cancelButton.addEventListener("click", () => {
      dialog.remove();
    });

    // Reset link handler
    const resetLink = dialog.querySelector("#resetBlockerSettingsLink");
    resetLink.addEventListener("click", function (e) {
      e.preventDefault();
      if (confirm("Are you sure you want to reset all settings to default?")) {
        // preserve detected username
        const config = loadConfig();
        const username = config.username || null;
        const newDefaults = { ...DEFAULTS, username };
        if (saveConfig(newDefaults)) {
          alert("Settings reset! Reloading...");
          location.reload();
        }
      }
    });
  }

  // Blocking logic using CSS classes

  function getWordCount(workElement) {
    // Extract word count from the work element
    const wordsElement = workElement.querySelector("dd.words");
    if (!wordsElement) return null;

    let txt = wordsElement.textContent.trim();
    txt = txt.replace(/(?<=\d)[ ,](?=\d{3}(\D|$))/g, "");
    txt = txt.replace(/[^\d]/g, "");
    const n = parseInt(txt, 10);
    return Number.isFinite(n) ? n : null;
  }

  function getCut(workElement) {
    const cut = document.createElement("div");
    cut.className = `${CSS_NAMESPACE}-cut`;

    // Move all children that aren't fold or cut elements
    const children = Array.from(workElement.children);
    children.forEach((child) => {
      if (
        !child.classList.contains(`${CSS_NAMESPACE}-fold`) &&
        !child.classList.contains(`${CSS_NAMESPACE}-cut`)
      ) {
        cut.appendChild(child);
      }
    });

    return cut;
  }

  function getFold(reasons) {
    const fold = document.createElement("div");
    fold.className = `${CSS_NAMESPACE}-fold`;

    const note = document.createElement("span");
    note.className = `${CSS_NAMESPACE}-note`;

    let message = "";
    const config = window.ao3Blocker && window.ao3Blocker.config;
    const showReasons = config && config.showReasons !== false;
    let iconHtml = "";

    if (showReasons && reasons && reasons.length > 0) {
      const parts = [];
      reasons.forEach((reason) => {
        if (reason.completionStatus) {
          parts.push(`<em>${reason.completionStatus}</em>`);
        }
        if (reason.wordCount) {
          parts.push(`<em>${reason.wordCount}</em>`);
        }
        if (reason.tags && reason.tags.length > 0) {
          parts.push(`<em>Tags: ${reason.tags.join(", ")}</em>`);
        }
        if (reason.authors && reason.authors.length > 0) {
          parts.push(`<em>Author: ${reason.authors.join(", ")}</em>`);
        }
        if (reason.titles && reason.titles.length > 0) {
          parts.push(`<em>Title: ${reason.titles.join(", ")}</em>`);
        }
        if (reason.summaryTerms && reason.summaryTerms.length > 0) {
          parts.push(`<em>Summary: ${reason.summaryTerms.join(", ")}</em>`);
        }
        if (reason.language) {
          parts.push(`<em>Language: ${reason.language}</em>`);
        }
        if (reason.crossovers !== undefined) {
          parts.push(`<em>Fandoms: ${reason.crossovers}</em>`);
        }
        if (reason.primaryPairing) {
          parts.push(`<em>${reason.primaryPairing}</em>`);
        }
      });
      message = parts.join("; ");
      const iconUrl =
        "https://raw.githubusercontent.com/Wolfbatcat/ao3-userscripts/1de22a3e33d769774a828c9c0a03b667dcfd4999/assets/icon_show-hide-hidden.svg";
      iconHtml = `<span class="${CSS_NAMESPACE}-icon" style="display:inline-block;width:1.2em;height:1.2em;vertical-align:-0.15em;margin-right:0.3em;background-color:currentColor;mask:url('${iconUrl}') no-repeat center/contain;-webkit-mask:url('${iconUrl}') no-repeat center/contain;"></span>`;
    } else if (reasons && reasons.length > 0) {
      // Fallback message when showReasons is false but work is blocked
      message = "<em>Hidden by filters.</em>";
      const iconUrl =
        "https://raw.githubusercontent.com/Wolfbatcat/ao3-userscripts/1de22a3e33d769774a828c9c0a03b667dcfd4999/assets/icon_show-hide-hidden.svg";
      iconHtml = `<span class="${CSS_NAMESPACE}-icon" style="display:inline-block;width:1.2em;height:1.2em;vertical-align:-0.15em;margin-right:0.3em;background-color:currentColor;mask:url('${iconUrl}') no-repeat center/contain;-webkit-mask:url('${iconUrl}') no-repeat center/contain;"></span>`;
    }

    note.innerHTML = `${iconHtml}${message}`;
    fold.appendChild(note);
    fold.appendChild(getToggleButton());

    return fold;
  }

  function getToggleButton() {
    const iconHide =
      "https://raw.githubusercontent.com/Wolfbatcat/ao3-userscripts/1de22a3e33d769774a828c9c0a03b667dcfd4999/assets/icon_show-hide-hidden.svg";
    const iconEye =
      "https://raw.githubusercontent.com/Wolfbatcat/ao3-userscripts/1de22a3e33d769774a828c9c0a03b667dcfd4999/assets/icon_show-hide-visible.svg";
    const showIcon = `<span style="display:inline-block;width:1.2em;height:1.2em;vertical-align:-0.15em;margin-right:0.2em;background-color:currentColor;mask:url('${iconEye}') no-repeat center/contain;-webkit-mask:url('${iconEye}') no-repeat center/contain;"></span>`;
    const hideIcon = `<span style="display:inline-block;width:1.2em;height:1.2em;vertical-align:-0.15em;margin-right:0.2em;background-color:currentColor;mask:url('${iconHide}') no-repeat center/contain;-webkit-mask:url('${iconHide}') no-repeat center/contain;"></span>`;

    const button = document.createElement("button");
    button.className = `${CSS_NAMESPACE}-toggle`;
    button.innerHTML = showIcon + "Show";

    const unhideClassFragment = `${CSS_NAMESPACE}-unhide`;

    button.addEventListener("click", (event) => {
      const work = event.target.closest(`.${CSS_NAMESPACE}-work`);
      const note = work.querySelector(`.${CSS_NAMESPACE}-note`);
      let message = note.innerHTML;
      const iconRegex = new RegExp(
        "<span[^>]*class=[\"']" +
          CSS_NAMESPACE +
          "-icon[\"'][^>]*><\\/span>\\s*",
        "i"
      );
      message = message.replace(iconRegex, "");

      if (work.classList.contains(unhideClassFragment)) {
        work.classList.remove(unhideClassFragment);
        note.innerHTML = `<span class="${CSS_NAMESPACE}-icon" style="display:inline-block;width:1.2em;height:1.2em;vertical-align:-0.15em;margin-right:0.3em;background-color:currentColor;mask:url('${iconHide}') no-repeat center/contain;-webkit-mask:url('${iconHide}') no-repeat center/contain;"></span>${message}`;
        event.target.innerHTML = showIcon + "Show";
      } else {
        work.classList.add(unhideClassFragment);
        note.innerHTML = `<span class="${CSS_NAMESPACE}-icon" style="display:inline-block;width:1.2em;height:1.2em;vertical-align:-0.15em;margin-right:0.3em;background-color:currentColor;mask:url('${iconEye}') no-repeat center/contain;-webkit-mask:url('${iconEye}') no-repeat center/contain;"></span>${message}`;
        event.target.innerHTML = hideIcon + "Hide";
      }
    });

    return button;
  }

  function blockWork(workElement, reasons, config) {
    if (!reasons) return;

    if (config.showPlaceholders) {
      const fold = getFold(reasons);
      const cut = getCut(workElement);

      workElement.classList.add(`${CSS_NAMESPACE}-work`);
      workElement.innerHTML = "";
      workElement.appendChild(fold);
      workElement.appendChild(cut);

      if (!config.showReasons) {
        workElement.classList.add(`${CSS_NAMESPACE}-hide-reasons`);
      }
    } else {
      workElement.classList.add(`${CSS_NAMESPACE}-hidden`);
    }
  }

  // Fast pattern matching with pre-compiled regex for wildcards
  // Note: patterns are already normalized during config initialization
  function matchPattern(text, pattern, exactMatch = false) {
    const normalizedText = normalizeText(text);

    // If it's a simple string pattern (already normalized)
    if (typeof pattern === "string") {
      return exactMatch
        ? normalizedText === pattern
        : normalizedText.includes(pattern);
    }

    // If it's a compiled pattern object (text already normalized)
    if (!pattern.hasWildcard) {
      return exactMatch
        ? normalizedText === pattern.text
        : normalizedText.includes(pattern.text);
    }

    // Use pre-compiled regex for wildcards (pattern already normalized)
    if (exactMatch) {
      // For exact matching with wildcards, the regex should match the entire string
      const exactRegex = new RegExp("^" + pattern.regex.source + "$", "i");
      return exactRegex.test(normalizedText);
    }

    return pattern.regex.test(normalizedText);
  }

  function isTagWhitelisted(tags, whitelist) {
    return tags.some((tag) => {
      return whitelist.some((pattern) => {
        if (
          (typeof pattern === "string" && !pattern.trim()) ||
          (pattern && pattern.text && !pattern.text.trim())
        )
          return false;

        return matchPattern(tag, pattern, true); // Use exact matching for tags
      });
    });
  }

  // Check if work matches primary relationship/character requirements
  function checkPrimaryPairing(categorizedTags, config) {
    const primaryRelationships = config.primaryRelationships || [];
    const primaryCharacters = config.primaryCharacters || [];
    const relpad = config.primaryRelpad || 1;
    const charpad = config.primaryCharpad || 5;

    // If no primary pairing settings, skip check
    if (primaryRelationships.length === 0 && primaryCharacters.length === 0) {
      return null;
    }

    // Get relationship and character tags from categorized data and normalize them
    const relationshipTags = categorizedTags.relationships
      .slice(0, relpad)
      .map((tag) => normalizeText(tag));
    const characterTags = categorizedTags.characters
      .slice(0, charpad)
      .map((tag) => normalizeText(tag));

    let missingRelationships = [];
    let missingCharacters = [];

    // Check relationships - OR logic: any match passes
    // Note: both primaryRelationships and relationshipTags are now normalized
    if (primaryRelationships.length > 0) {
      const hasPrimaryRelationship = primaryRelationships.some((rel) =>
        relationshipTags.includes(rel)
      );
      if (!hasPrimaryRelationship) {
        missingRelationships = primaryRelationships;
      }
    }

    // Check characters - OR logic: any match passes
    // Note: both primaryCharacters and characterTags are now normalized
    if (primaryCharacters.length > 0) {
      const hasPrimaryCharacter = primaryCharacters.some((char) =>
        characterTags.includes(char)
      );
      if (!hasPrimaryCharacter) {
        missingCharacters = primaryCharacters;
      }
    }

    // If both are missing, create combined reason
    if (missingRelationships.length > 0 && missingCharacters.length > 0) {
      return {
        primaryPairing: `Missing relationship(s) and character(s)`,
      };
    } else if (missingRelationships.length > 0) {
      return {
        primaryPairing: `Missing relationship(s)`,
      };
    } else if (missingCharacters.length > 0) {
      return {
        primaryPairing: `Missing character(s)`,
      };
    }

    return null;
  }

  // Determine blocking reasons for a work based on all criteria
  function getBlockReason(_ref, _ref2) {
    const completionStatus = _ref.completionStatus;

    const authors = _ref.authors === undefined ? [] : _ref.authors,
      title = _ref.title === undefined ? "" : _ref.title,
      categorizedTags =
        _ref.categorizedTags === undefined
          ? {
              relationships: [],
              characters: [],
              freeforms: [],
              ratings: [],
              warnings: [],
              categories: [],
              fandoms: [],
            }
          : _ref.categorizedTags,
      summary = _ref.summary === undefined ? "" : _ref.summary,
      language = _ref.language === undefined ? "" : _ref.language,
      fandomCount = _ref.fandomCount === undefined ? 0 : _ref.fandomCount,
      wordCount = _ref.wordCount === undefined ? null : _ref.wordCount;
    const authorBlacklist =
        _ref2.authorBlacklist === undefined ? [] : _ref2.authorBlacklist,
      titleBlacklist =
        _ref2.titleBlacklist === undefined ? [] : _ref2.titleBlacklist,
      tagBlacklist = _ref2.tagBlacklist === undefined ? [] : _ref2.tagBlacklist,
      tagWhitelist = _ref2.tagWhitelist === undefined ? [] : _ref2.tagWhitelist,
      summaryBlacklist =
        _ref2.summaryBlacklist === undefined ? [] : _ref2.summaryBlacklist,
      allowedLanguages =
        _ref2.allowedLanguages === undefined ? [] : _ref2.allowedLanguages,
      maxCrossovers =
        _ref2.maxCrossovers === undefined ? 0 : _ref2.maxCrossovers,
      minWords = _ref2.minWords === undefined ? null : _ref2.minWords,
      maxWords = _ref2.maxWords === undefined ? null : _ref2.maxWords;
    const blockComplete =
      _ref2.blockComplete === undefined ? false : _ref2.blockComplete;
    const blockOngoing =
      _ref2.blockOngoing === undefined ? false : _ref2.blockOngoing;

    // Get flat array of all tags for blacklist/whitelist (same behavior as before)
    const allTags = getAllTagsFlat(categorizedTags);

    // If whitelisted, don't block regardless of other conditions
    if (isTagWhitelisted(allTags, tagWhitelist)) {
      return null;
    }

    const reasons = [];

    // Primary Pairing Check (uses categorized tags) - OR logic
    const primaryPairingReason = checkPrimaryPairing(categorizedTags, _ref2);
    if (primaryPairingReason) {
      reasons.push(primaryPairingReason);
    }

    // Completion status filter
    if (blockComplete && completionStatus === "complete") {
      reasons.push({ completionStatus: "Status: Complete" });
    }
    if (blockOngoing && completionStatus === "ongoing") {
      reasons.push({ completionStatus: "Status: Ongoing" });
    }

    // Language allowlist: if set and work language not included, block
    if (allowedLanguages.length > 0) {
      const lang = (language || "").toLowerCase().trim();
      // Skip language check if language is unknown (typically a series)
      if (lang && lang !== "unknown") {
        const allowed = allowedLanguages.includes(lang);
        if (!allowed) {
          reasons.push({ language: language || "unknown" }); // Use the original text for display
        }
      }
    }

    // Max crossovers: if set and fandomCount exceeds, block
    if (
      typeof maxCrossovers === "number" &&
      maxCrossovers > 0 &&
      fandomCount > maxCrossovers
    ) {
      reasons.push({ crossovers: fandomCount });
    }

    // Word count filter (after whitelist check, before other reasons)
    if (minWords != null || maxWords != null) {
      const wc = wordCount;
      const wcHit = (function () {
        if (wc == null) return null;
        if (minWords != null && wc < minWords)
          return { over: false, limit: minWords };
        if (maxWords != null && wc > maxWords)
          return { over: true, limit: maxWords };
        return null;
      })();
      if (wcHit) {
        const wcStr = wc?.toLocaleString?.() ?? wc;
        reasons.push({
          wordCount: `Words: ${wcStr}`,
        });
      }
    }

    // Check for blocked tags (collect all matching tags) - uses flat array
    const blockedTags = [];
    allTags.forEach((tag) => {
      tagBlacklist.forEach((pattern) => {
        if (
          (typeof pattern === "string" && pattern.trim()) ||
          (pattern && pattern.text && pattern.text.trim())
        ) {
          if (matchPattern(tag, pattern, true)) {
            // Use exact matching for tags
            blockedTags.push(tag);
          }
        }
      });
    });
    if (blockedTags.length > 0) {
      reasons.push({ tags: blockedTags });
    }

    // Check for blocked authors (collect all matching authors)
    const blockedAuthors = [];
    authors.forEach((author) => {
      authorBlacklist.forEach((blacklistedAuthor) => {
        if (
          blacklistedAuthor.trim() &&
          author.toLowerCase() === blacklistedAuthor
        ) {
          blockedAuthors.push(author);
        }
      });
    });
    if (blockedAuthors.length > 0) {
      reasons.push({ authors: blockedAuthors });
    }

    // Check for blocked title
    const blockedTitles = new Set();
    titleBlacklist.forEach((pattern) => {
      if (
        (typeof pattern === "string" && pattern.trim()) ||
        (pattern && pattern.text && pattern.text.trim())
      ) {
        if (matchPattern(title, pattern, false)) {
          // Use substring matching for titles
          // Get the actual matched substring from the title
          const matched = getMatchedSubstring(title, pattern);
          if (matched) blockedTitles.add(matched);
        }
      }
    });
    if (blockedTitles.size > 0) {
      reasons.push({ titles: Array.from(blockedTitles) });
    }

    // Check for blocked summary terms
    const blockedSummaryTerms = [];
    summaryBlacklist.forEach((pattern) => {
      if (
        (typeof pattern === "string" && pattern.trim()) ||
        (pattern && pattern.text && pattern.text.trim())
      ) {
        if (matchPattern(summary, pattern, false)) {
          // Use substring matching for summaries
          // Get the actual matched substring from the summary
          const matched = getMatchedSubstring(summary, pattern);
          if (matched) blockedSummaryTerms.push(matched);
        }
      }
    });
    if (blockedSummaryTerms.length > 0) {
      reasons.push({ summaryTerms: blockedSummaryTerms });
    }

    return reasons.length > 0 ? reasons : null;
  }

  function getText(element) {
    return (element.textContent || element.innerText || "").trim();
  }
  function selectTextsIn(root, selector) {
    const elements = root.querySelectorAll(selector);
    return Array.from(elements).map(getText);
  }

  // Extract work data from blurb elements
  function selectFromBlurb(blurbElement) {
    const fandoms = blurbElement.querySelectorAll("h5.fandoms.heading a.tag");

    // Get completion status using the same unified parsing
    let completionStatus = null;
    const chaptersNode = blurbElement.querySelector("dd.chapters");
    if (chaptersNode) {
      let chaptersText = "";
      const a = chaptersNode.querySelector("a");
      if (a) {
        // Blurb with link format
        chaptersText = a.textContent.trim();
        let raw = chaptersNode.innerHTML;
        raw = raw.replace(/<a[^>]*>.*?<\/a>/, "");
        raw = raw.replace(/&nbsp;/gi, " ");
        const match = raw.match(/\/\s*([\d\?]+)/);
        if (match) {
          chaptersText += "/" + match[1].trim();
        }
      } else {
        // Simple blurb format
        chaptersText = chaptersNode.textContent.replace(/&nbsp;/gi, " ").trim();
      }
      completionStatus = parseChaptersStatus(chaptersText);
    }

    // Use CSS class-based tag categorization
    const categorizedTags = getCategorizedTags(blurbElement);

    return {
      authors: selectTextsIn(blurbElement, "a[rel=author]"),
      categorizedTags: categorizedTags,
      tags: getAllTagsFlat(categorizedTags),
      title: selectTextsIn(blurbElement, ".header .heading a:first-child")[0],
      summary: selectTextsIn(blurbElement, "blockquote.summary")[0],
      language: selectTextsIn(blurbElement, "dd.language")[0],
      fandomCount: fandoms.length,
      wordCount: getWordCount(blurbElement),
      completionStatus: completionStatus,
    };
  }

  function checkWorks() {
    const debugMode = window.ao3Blocker.config.debugMode;
    const config = window.ao3Blocker.config;
    let blocked = 0;
    let total = 0;

    if (debugMode) {
      console.groupCollapsed("Advanced Blocker");
      if (!config) {
        console.warn("Exiting due to missing config.");
        return;
      }
    }

    // Check if we're on user's own content pages (dashboard, bookmarks, works, readings)
    let isOnMyContent = false;
    let username = config.username || detectUsername(config);
    if (config.disableOnMyContent && username) {
      isOnMyContent = isMyContentPage(username);
      if (isOnMyContent) {
        if (debugMode) {
          console.info("Advanced Blocker: On user's own content page.");
          console.log("Path:", window.location.pathname);
          console.log(
            "Blocking disabled. Highlighting:",
            config.enableHighlightingOnMyContent ? "enabled" : "disabled"
          );
        }
        // If highlighting is not enabled on user content, exit completely
        if (!config.enableHighlightingOnMyContent) {
          return;
        }
      }
    }

    const blurbs = document.querySelectorAll("li.blurb");
    blurbs.forEach((blurbEl) => {
      const isWorkOrBookmark =
        (blurbEl.classList.contains("work") ||
          blurbEl.classList.contains("bookmark")) &&
        !blurbEl.classList.contains("picture");
      let reason = null;
      let blockables = selectFromBlurb(blurbEl);

      if (debugMode && isWorkOrBookmark) {
        console.log(
          `[Advanced Blocker][DEBUG] Work ID: ${blurbEl.id || "(no id)"}`
        );
        console.log(
          `[Advanced Blocker][DEBUG] Parsed completionStatus:`,
          blockables.completionStatus
        );
        console.log(
          `[Advanced Blocker][DEBUG] blockComplete:`,
          config.blockComplete,
          `blockOngoing:`,
          config.blockOngoing
        );
        console.log(`[Advanced Blocker][DEBUG] All blockables:`, blockables);
      }

      if (isWorkOrBookmark) {
        // Only check for blocking if not on user's own content
        if (!isOnMyContent) {
          reason = getBlockReason(blockables, config);
          total++;
        }
      }

      if (reason) {
        blockWork(blurbEl, reason, config);
        blocked++;
        if (debugMode) {
          console.groupCollapsed(`- blocked ${blurbEl.id}`);
          console.log(blurbEl.innerHTML, reason);
          console.groupEnd();
        }
      } else if (debugMode && isWorkOrBookmark) {
        console.groupCollapsed(`  skipped ${blurbEl.id}`);
        console.log(blurbEl.innerHTML);
        console.groupEnd();
      }

      // Highlighting uses exact tag matching with wildcard support
      const allTags =
        blockables.tags || getAllTagsFlat(blockables.categorizedTags || {});
      allTags.forEach((tag) => {
        // Check if tag matches any highlight pattern (supports wildcards)
        if (
          config.tagHighlights.some((highlightPattern) =>
            matchPattern(tag, highlightPattern, true)
          )
        ) {
          blurbEl.classList.add("ao3-blocker-highlight");
          if (debugMode) {
            console.groupCollapsed(`? highlighted ${blurbEl.id}`);
            console.log(blurbEl.innerHTML);
            console.groupEnd();
          }
        }
      });
    });

    if (debugMode) {
      console.log(`Blocked ${blocked} out of ${total} works`);
      console.groupEnd();
    }
  }
})();