Greasy Fork

Greasy Fork is available in English.

AO3: Reading Time & Quality Score

Add reading time, chapter reading time, and quality scores to AO3 works with color coding, score normalization and sorting.

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

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Greasemonkey 油猴子Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Userscripts ,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

// ==UserScript==
// @name        AO3: Reading Time & Quality Score
// @description  Add reading time, chapter reading time, and quality scores to AO3 works with color coding, score normalization and sorting.
// @author      BlackBatCat
// @version     2.3
// @match       *://archiveofourown.org/
// @match       *://archiveofourown.org/tags/*/works*
// @match       *://archiveofourown.org/works*
// @match       *://archiveofourown.org/chapters/*
// @match       *://archiveofourown.org/users/*
// @match       *://archiveofourown.org/collections/*
// @match       *://archiveofourown.org/bookmarks*
// @match       *://archiveofourown.org/series/*
// @license     MIT
// @grant       none
// @namespace http://greasyfork.icu/users/1498004
// ==/UserScript==

(function () {
  "use strict";

  // DEFAULT CONFIGURATION
  const DEFAULTS = {
    // Feature Toggles
    enableReadingTime: true,
    enableQualityScore: true,
    enableChapterStats: true,
    // Reading Time Settings
    wpm: 375,
    alwaysCountReadingTime: true,
    readingTimeLvl1: 120,
    readingTimeLvl2: 360,
    // Quality Score Settings
    alwaysCountQualityScore: true,
    alwaysSortQualityScore: false,
    hideHitcount: false,
    useNormalization: false,
    userMaxScore: 32,
    minKudosToShowScore: 100,
    colorThresholdLow: 10,
    colorThresholdHigh: 20,
    // Shared Color Settings
    colorStyle: "background", // "none", "background", or "text"
    colorGreen: "#3e8fb0",
    colorYellow: "#f6c177",
    colorRed: "#eb6f92",
    colorText: "#ffffff",
    // Icon Settings
    useIcons: false,
    iconColor: "", // Empty = inherit from page, or set custom color
    // Chapter Time Settings
    chapterTimeStyle: "default", // "default", "colored", or "timeonly"
  };

  // Current config, loaded from localStorage
  let CONFIG = { ...DEFAULTS };

  // Variables to track the state of the page
  let countable = false;
  let sortable = false;
  let statsPage = false;

  // --- HELPER FUNCTIONS ---
  const $ = (selector, root = document) => root.querySelectorAll(selector);
  const $1 = (selector, root = document) => root.querySelector(selector);

  // Load user settings from localStorage
  const loadUserSettings = () => {
    if (typeof Storage === "undefined") return;

    const savedConfig = localStorage.getItem("ao3_reading_quality_config");
    if (savedConfig) {
      try {
        const parsedConfig = JSON.parse(savedConfig);
        CONFIG = { ...DEFAULTS, ...parsedConfig };
      } catch (e) {
        console.error("Error loading saved config, using defaults:", e);
        CONFIG = { ...DEFAULTS };
      }
    }
  };

  // Save all settings to localStorage
  const saveAllSettings = () => {
    if (typeof Storage !== "undefined") {
      localStorage.setItem(
        "ao3_reading_quality_config",
        JSON.stringify(CONFIG)
      );
    }
  };

  // Save a specific setting
  const saveSetting = (key, value) => {
    CONFIG[key] = value;
    saveAllSettings();
  };

  // Reset all settings to defaults
  const resetAllSettings = () => {
    if (confirm("Reset all settings to defaults?")) {
      if (typeof Storage !== "undefined") {
        localStorage.removeItem("ao3_reading_quality_config");
      }
      CONFIG = { ...DEFAULTS };
      if (CONFIG.enableReadingTime && countable) calculateReadtime();
      if (CONFIG.enableQualityScore && countable) countRatio();
      if (CONFIG.enableChapterStats) calculateChapterStats();
    }
  };

  // Robust number extraction from element
  const getNumberFromElement = (element) => {
    if (!element) return NaN;
    let text =
      element.getAttribute("data-ao3e-original") || element.textContent;
    if (text === null) return NaN;
    let cleanText = text.replace(/[,\s  ]/g, "");
    if (element.matches("dd.chapters")) {
      cleanText = cleanText.split("/")[0];
    }
    const number = parseInt(cleanText, 10);
    return isNaN(number) ? NaN : number;
  };

  // Apply color styling based on colorStyle setting
  const applyColorStyling = (element, color) => {
    if (CONFIG.colorStyle === "background") {
      element.style.backgroundColor = color;
      element.style.color = CONFIG.colorText;
      element.style.padding = "0 4px";
    } else if (CONFIG.colorStyle === "text") {
      element.style.color = color;
      element.style.backgroundColor = "";
      element.style.padding = "";
    } else {
      // colorStyle === "none"
      element.style.backgroundColor = "";
      element.style.color = "inherit";
      element.style.padding = "";
    }
  };

  // Add CSS to ensure icons work with skins that style stats
  const addIconStyles = () => {
    const style = document.createElement("style");
    style.id = "ao3-userscript-icon-styles";

    const iconColor = CONFIG.iconColor || "currentColor";
    style.textContent = `
      .stats dd.readtime::before,
      dl.statistics dt.readtime::before {
        display: inline-block !important;
        width: 1em !important;
        height: 1em !important;
        min-width: 1em !important;
        min-height: 1em !important;
        margin-right: 5px !important;
        background-color: ${iconColor} !important;
        ${CONFIG.iconColor ? "filter: none !important;" : ""}
        -webkit-mask-image: url("https://raw.githubusercontent.com/Wolfbatcat/ao3-userscripts/373d8c4cde1210ac54eb0c6ce74cfe0415c2814a/assets/icon_readingtime.svg") !important;
        mask-image: url("https://raw.githubusercontent.com/Wolfbatcat/ao3-userscripts/373d8c4cde1210ac54eb0c6ce74cfe0415c2814a/assets/icon_readingtime.svg") !important;
        -webkit-mask-size: contain !important;
        mask-size: contain !important;
        -webkit-mask-repeat: no-repeat !important;
        mask-repeat: no-repeat !important;
        -webkit-mask-position: center center !important;
        mask-position: center center !important;
        content: "" !important;
        /* vertical-align: text-bottom !important; */
        transform: translate(0, 1px) !important;
      }

      .stats dd.kudoshits::before,
      dl.statistics dt.kudoshits::before {
        display: inline-block !important;
        width: 1em !important;
        height: 1em !important;
        min-width: 1em !important;
        min-height: 1em !important;
        margin-right: 5px !important;
        background-color: ${iconColor} !important;
        ${CONFIG.iconColor ? "filter: none !important;" : ""}
        -webkit-mask-image: url("https://raw.githubusercontent.com/Wolfbatcat/ao3-userscripts/373d8c4cde1210ac54eb0c6ce74cfe0415c2814a/assets/icon_score-sparkles.svg") !important;
        mask-image: url("https://raw.githubusercontent.com/Wolfbatcat/ao3-userscripts/373d8c4cde1210ac54eb0c6ce74cfe0415c2814a/assets/icon_score-sparkles.svg") !important;
        -webkit-mask-size: contain !important;
        mask-size: contain !important;
        -webkit-mask-repeat: no-repeat !important;
        mask-repeat: no-repeat !important;
        -webkit-mask-position: center center !important;
        mask-position: center center !important;
        content: "" !important;
        /* vertical-align: text-bottom !important; */
        transform: translate(0, 1px) !important;
      }

      dl.stats dd {
        justify-content: center;
        position: relative;
      }

      .stats dd.readtime::after {
        display: none;
        position: absolute;
        top: 2em;
        left: 50%;
        transform: translateX(-50%);
        z-index: 99999;
        padding: 0.3em;
        font-size: 0.8em;
        line-height: 1;
        text-align: center;
        content: "Time";
        white-space: nowrap;
        pointer-events: none;
      }
      .stats dd.kudoshits::after {
        display: none;
        position: absolute;
        top: 2em;
        left: 50%;
        transform: translateX(-50%);
        z-index: 99999;
        padding: 0.3em;
        font-size: 0.8em;
        line-height: 1;
        text-align: center;
        content: "Score";
        white-space: nowrap;
        pointer-events: none;
      }

      .stats dd:hover::after {
        display: inline-block;
      }

      .statistics .stats dd:last-of-type::after,
      .index .stats dd:last-of-type:has(a[href$=bookmarks])::after,
      .stats dd.inspired::after,
      .tagset .index .stats dd:last-of-type::after {
        right: 0;
        left: auto;
        transform: none;
      }

      .stats a,
      .stats a:visited {
        border: none;
        color: inherit;
      }

      .stats dt.readtime,
      .stats dt.kudoshits,
      dl.statistics dt.readtime,
      dl.statistics dt.kudoshits {
        font-size: 0 !important;
        line-height: 0 !important;
      }

      dl.statistics dt.readtime::before,
      dl.statistics dt.kudoshits::before {
        font-size: 1rem !important;
        line-height: normal !important;
      }

      .notice.ao3-chapter-stats {
        list-style: none;
      }

      .notice.ao3-chapter-stats li {
        list-style: none;
        margin: 0;
      }

      .ao3-chapter-stats-default,
      .ao3-chapter-stats-timeonly {
        font-style: italic;
        text-align: center;
        opacity: 0.9;
        margin: 1em 0;
        font-size: 1.2em;
      }
    `;

    document.head.appendChild(style);
  };

  // --- READING TIME FUNCTIONS ---
  const checkCountable = () => {
    const foundStats = $("dl.stats");
    if (foundStats.length === 0) return;

    for (const stat of foundStats) {
      const li = stat.closest("li.work, li.bookmark");
      if (li) {
        countable = true;
        sortable = true;
        return;
      }
      if (stat.closest(".statistics")) {
        countable = true;
        sortable = true;
        statsPage = true;
        return;
      }
      if (stat.closest("dl.work")) {
        countable = true;
        return;
      }
    }
  };

  const calculateReadtime = () => {
    if (!countable || !CONFIG.enableReadingTime) return;
    $("dl.stats").forEach((statsElement) => {
      if ($1("dt.readtime", statsElement)) return;
      const wordsElement = $1("dd.words", statsElement);
      if (!wordsElement) return;
      const words_count = getNumberFromElement(wordsElement);
      if (isNaN(words_count)) return;
      const minutes = words_count / CONFIG.wpm;
      const hrs = Math.floor(minutes / 60);
      const mins = (minutes % 60).toFixed(0);
      const minutes_print = hrs > 0 ? hrs + "h" + mins + "m" : mins + "m";

      const readtime_label = document.createElement("dt");
      readtime_label.className = "readtime";

      if (!CONFIG.useIcons) {
        readtime_label.textContent = "Time:";
      }

      const readtime_value = document.createElement("dd");
      readtime_value.className = "readtime";

      let color;
      if (minutes < CONFIG.readingTimeLvl1) {
        color = CONFIG.colorGreen;
      } else if (minutes < CONFIG.readingTimeLvl2) {
        color = CONFIG.colorYellow;
      } else {
        color = CONFIG.colorRed;
      }

      Object.assign(readtime_value.style, {
        fontSize: "1em",
        lineHeight: "inherit",
        display: "inline-block",
        verticalAlign: "baseline",
      });

      if (CONFIG.useIcons) {
        const textSpan = document.createElement("span");
        textSpan.textContent = minutes_print;
        textSpan.style.borderRadius = "4px";
        textSpan.style.display = "inline-block";
        textSpan.style.verticalAlign = "baseline";

        applyColorStyling(textSpan, color);
        readtime_value.appendChild(textSpan);
      } else {
        readtime_value.textContent = minutes_print;
        readtime_value.style.borderRadius = "4px";

        applyColorStyling(readtime_value, color);
      }

      wordsElement.insertAdjacentElement("afterend", readtime_label);
      readtime_label.insertAdjacentElement("afterend", readtime_value);
    });
  };

  // --- QUALITY SCORE FUNCTIONS ---
  const calculateWordBasedScore = (kudos, hits, words) => {
    if (hits === 0 || words === 0 || kudos === 0) return 0;
    const effectiveChapters = words / 5000;
    const adjustedHits = hits / Math.sqrt(effectiveChapters);
    return (100 * kudos) / adjustedHits;
  };

  const countRatio = () => {
    if (!countable || !CONFIG.enableQualityScore) return;
    $("dl.stats").forEach((statsElement) => {
      if ($1("dt.kudoshits", statsElement)) return;
      const hitsElement = $1("dd.hits", statsElement);
      const kudosElement = $1("dd.kudos", statsElement);
      const wordsElement = $1("dd.words", statsElement);
      const parentLi = statsElement.closest("li");
      try {
        const hits = getNumberFromElement(hitsElement);
        const kudos = getNumberFromElement(kudosElement);
        const words = getNumberFromElement(wordsElement);
        if (isNaN(hits) || isNaN(kudos) || isNaN(words)) return;
        if (kudos < CONFIG.minKudosToShowScore) {
          if (statsElement.querySelector("dt.kudoshits"))
            statsElement.querySelector("dt.kudoshits").remove();
          if (statsElement.querySelector("dd.kudoshits"))
            statsElement.querySelector("dd.kudoshits").remove();
          return;
        }
        let rawScore = calculateWordBasedScore(kudos, hits, words);
        if (kudos < 10) rawScore = 1;
        let displayScore = rawScore;
        let thresholdLow = CONFIG.colorThresholdLow;
        let thresholdHigh = CONFIG.colorThresholdHigh;
        if (CONFIG.useNormalization) {
          displayScore = (rawScore / CONFIG.userMaxScore) * 100;
          displayScore = Math.min(100, displayScore);
          displayScore = Math.ceil(displayScore);
          thresholdLow = Math.ceil(
            (CONFIG.colorThresholdLow / CONFIG.userMaxScore) * 100
          );
          thresholdHigh = Math.ceil(
            (CONFIG.colorThresholdHigh / CONFIG.userMaxScore) * 100
          );
        } else {
          displayScore = Math.round(displayScore * 10) / 10;
        }
        const ratioLabel = document.createElement("dt");
        ratioLabel.className = "kudoshits";

        if (!CONFIG.useIcons) {
          ratioLabel.textContent = "Score:";
        }
        const ratioValue = document.createElement("dd");
        ratioValue.className = "kudoshits";

        let color;
        if (displayScore >= thresholdHigh) {
          color = CONFIG.colorGreen;
        } else if (displayScore >= thresholdLow) {
          color = CONFIG.colorYellow;
        } else {
          color = CONFIG.colorRed;
        }

        Object.assign(ratioValue.style, {
          fontSize: "1em",
          lineHeight: "inherit",
          display: "inline-block",
          verticalAlign: "baseline",
        });

        if (CONFIG.useIcons) {
          const textSpan = document.createElement("span");
          textSpan.textContent = displayScore;
          textSpan.style.borderRadius = "4px";
          textSpan.style.display = "inline-block";
          textSpan.style.verticalAlign = "baseline";

          applyColorStyling(textSpan, color);
          ratioValue.appendChild(textSpan);
        } else {
          ratioValue.textContent = displayScore;
          ratioValue.style.borderRadius = "4px";

          applyColorStyling(ratioValue, color);
        }

        hitsElement.insertAdjacentElement("afterend", ratioValue);
        hitsElement.insertAdjacentElement("afterend", ratioLabel);
        if (CONFIG.hideHitcount && !statsPage && hitsElement) {
          hitsElement.style.display = "none";
        }
        if (parentLi) parentLi.setAttribute("kudospercent", displayScore);
      } catch (error) {
        console.error("Error calculating score:", error);
      }
    });
  };

  const sortByRatio = (ascending = false) => {
    if (!sortable) return;
    $("dl.stats").forEach((statsElement) => {
      const parentLi = statsElement.closest("li");
      const list = parentLi?.parentElement;
      if (!list) return;
      const listElements = Array.from(list.children);
      listElements.sort((a, b) => {
        const aPercent = parseFloat(a.getAttribute("kudospercent")) || 0;
        const bPercent = parseFloat(b.getAttribute("kudospercent")) || 0;
        return ascending ? aPercent - bPercent : bPercent - aPercent;
      });
      list.innerHTML = "";
      list.append(...listElements);
    });
  };

  // --- CHAPTER STATISTICS FUNCTIONS ---
  const calculateChapterStats = () => {
    if (!CONFIG.enableChapterStats) return;

    // Check if we're on a works/chapters page
    const WORKS_PAGE_REGEX =
      /^https?:\/\/archiveofourown\.org\/(?:.*\/)?(works|chapters)(\/|$)/;
    if (!WORKS_PAGE_REGEX.test(window.location.href)) return;

    const chaptersContainer = $1("#chapters");
    if (!chaptersContainer) return;

    // Find all chapter divs - works with both multi-chapter and single-chapter works
    // Single-chapter: #chapters > div.userstuff
    // Multi-chapter: #chapters > .chapter
    const chapters = $("#chapters > .chapter");
    const singleChapter = $1("#chapters > div.userstuff");

    let chaptersToProcess = [];
    if (chapters.length > 0) {
      chaptersToProcess = Array.from(chapters);
    } else if (singleChapter) {
      chaptersToProcess = [{ userstuff: singleChapter, isSingle: true }];
    }

    if (chaptersToProcess.length === 0) return;

    chaptersToProcess.forEach((chapter, index) => {
      let userstuff;
      if (chapter.isSingle) {
        userstuff = chapter.userstuff;
        // Check if already processed
        if (
          userstuff.previousElementSibling &&
          userstuff.previousElementSibling.classList.contains("notice")
        ) {
          return;
        }
      } else {
        // Multi-chapter work
        if ($1(".notice", chapter)) {
          return;
        }
        userstuff = $1("div.userstuff", chapter);
      }

      if (!userstuff) return;

      const clone = userstuff.cloneNode(true);

      const elementsToRemove = clone.querySelectorAll(
        "h3.landmark, script, style"
      );
      elementsToRemove.forEach((el) => el.remove());

      const text = clone.textContent
        .trim()
        .replace(/\s+/g, " ")
        .replace(/[^\w\s'-]/g, "");

      const words = text.split(/\s+/).filter((word) => {
        return word.length > 0 && /[a-zA-Z]/.test(word);
      });

      const wordCount = words.length;

      if (wordCount === 0) return;

      const minutes = wordCount / CONFIG.wpm;
      const hrs = Math.floor(minutes / 60);
      const mins = Math.ceil(minutes % 60);

      // Full format for all styles
      let timeLongStr;
      if (hrs > 0) {
        timeLongStr =
          mins > 0
            ? `${hrs} hour${hrs > 1 ? "s" : ""} ${mins} minute${
                mins > 1 ? "s" : ""
              }`
            : `${hrs} hour${hrs > 1 ? "s" : ""}`;
      } else {
        timeLongStr = `${mins} minute${mins > 1 ? "s" : ""}`;
      }

      // Time only format
      let timeOnlyStr;
      if (hrs > 0) {
        timeOnlyStr =
          mins > 0
            ? `${hrs} hour${hrs > 1 ? "s" : ""}, ${mins} minute${
                mins > 1 ? "s" : ""
              }`
            : `${hrs} hour${hrs > 1 ? "s" : ""}`;
      } else {
        timeOnlyStr = `${mins} minute${mins > 1 ? "s" : ""}`;
      }

      let statsDiv;

      if (CONFIG.chapterTimeStyle === "default") {
        // Default style
        statsDiv = document.createElement("p");
        statsDiv.className = "ao3-chapter-stats-default";
        statsDiv.textContent = `~${timeLongStr} (${wordCount.toLocaleString()} words)`;
      } else if (CONFIG.chapterTimeStyle === "colored") {
        // Colored notice box style
        statsDiv = document.createElement("ul");
        statsDiv.className = "notice ao3-chapter-stats";

        const listItem = document.createElement("li");
        listItem.textContent = `~${timeLongStr} (${wordCount.toLocaleString()} words)`;

        statsDiv.appendChild(listItem);
      } else {
        // Time only style
        statsDiv = document.createElement("p");
        statsDiv.className = "ao3-chapter-stats-timeonly";
        statsDiv.textContent = `~${timeOnlyStr}`;
      }

      // Find insertion point: after notes (if exist), before chapter text
      // Always insert outside of .preface to maintain consistent width
      if (chapter.isSingle) {
        const chapterNotes = $1("#chapters .notes");
        if (chapterNotes) {
          chapterNotes.insertAdjacentElement("afterend", statsDiv);
        } else {
          userstuff.insertAdjacentElement("beforebegin", statsDiv);
        }
      } else {
        // Multi-chapter: insert after the entire preface container, not inside it
        const prefaceContainer = $1(".chapter.preface", chapter);
        if (prefaceContainer) {
          prefaceContainer.insertAdjacentElement("afterend", statsDiv);
        } else {
          // Fallback: before userstuff
          userstuff.insertAdjacentElement("beforebegin", statsDiv);
        }
      }
    });
  };

  // --- SETTINGS POPUP ---
  const showSettingsPopup = () => {
    // Get AO3 input field background color
    let inputBg = "#fffaf5"; // fallback
    const testInput = document.createElement("input");
    document.body.appendChild(testInput);
    try {
      const computedStyle = window.getComputedStyle(testInput);
      const computedBg = computedStyle.backgroundColor;
      if (
        computedBg &&
        computedBg !== "rgba(0, 0, 0, 0)" &&
        computedBg !== "transparent"
      ) {
        inputBg = computedBg;
      }
    } catch (e) {}
    testInput.remove();
    const popup = document.createElement("div");
    popup.style.cssText = `
            position: fixed;
            top: 50%;
            left: 50%;
            transform: translate(-50%, -50%);
            background: ${inputBg};
            padding: 20px;
            border-radius: 8px;
            box-shadow: 0 0 20px rgba(0,0,0,0.2);
            z-index: 10000;
            width: 90%;
            max-width: 600px;
            max-height: 80vh;
            overflow-y: auto;
            font-family: inherit;
            font-size: inherit;
            box-sizing: border-box;
        `;
    // Ensure headings inherit font family and add tooltip styles
    const style = document.createElement("style");
    style.textContent = `
      #ao3-rtqs-popup .settings-section {
        background: rgba(0,0,0,0.03);
        border-radius: 6px;
        padding: 15px;
        margin-bottom: 20px;
        border-left: 4px solid currentColor;
      }
      #ao3-rtqs-popup .section-title {
        margin-top: 0;
        margin-bottom: 15px;
        font-size: 1.2em;
        font-weight: bold;
        color: inherit;
        opacity: 0.85;
        font-family: inherit;
      }
      #ao3-rtqs-popup .setting-group {
        margin-bottom: 15px;
      }
      #ao3-rtqs-popup .setting-label {
        display: block;
        margin-bottom: 6px;
        font-weight: bold;
        color: inherit;
        opacity: 0.9;
      }
      #ao3-rtqs-popup .checkbox-label {
        display: block;
        font-weight: normal;
        color: inherit;
        margin-bottom: 8px;
      }
      #ao3-rtqs-popup .radio-label {
        display: block;
        font-weight: normal;
        color: inherit;
        margin-left: 20px;
        margin-bottom: 8px;
      }
      #ao3-rtqs-popup .subsettings {
        padding-left: 20px;
        margin-top: 10px;
      }
      #ao3-rtqs-popup .two-column {
        display: grid;
        grid-template-columns: 1fr 1fr;
        gap: 15px;
      }
      #ao3-rtqs-popup .button-group {
        display: flex;
        justify-content: space-between;
        gap: 10px;
        margin-top: 20px;
      }
      #ao3-rtqs-popup .button-group button {
        flex: 1;
        padding: 10px;
        color: inherit;
        opacity: 0.9;
      }
      #ao3-rtqs-popup .reset-link {
        text-align: center;
        margin-top: 10px;
        color: inherit;
        opacity: 0.7;
      }
      #ao3-rtqs-popup .symbol.question {
        font-size: 0.5em;
        vertical-align: middle;
      }
      #ao3-rtqs-popup input[type="text"],
      #ao3-rtqs-popup input[type="number"],
      #ao3-rtqs-popup input[type="color"],
      #ao3-rtqs-popup select,
      #ao3-rtqs-popup textarea {
        width: 100%;
        box-sizing: border-box;
      }
      #ao3-rtqs-popup input[type="text"]:focus,
      #ao3-rtqs-popup input[type="number"]:focus,
      #ao3-rtqs-popup input[type="color"]:focus,
      #ao3-rtqs-popup select:focus,
      #ao3-rtqs-popup textarea:focus {
        background: ${inputBg} !important;
      }
    `;
    document.head.appendChild(style);

    popup.id = "ao3-rtqs-popup";
    const form = document.createElement("form");

    // Calculate values for display
    const displayThresholdLow = CONFIG.useNormalization
      ? Math.ceil((CONFIG.colorThresholdLow / CONFIG.userMaxScore) * 100)
      : CONFIG.colorThresholdLow;

    const displayThresholdHigh = CONFIG.useNormalization
      ? Math.ceil((CONFIG.colorThresholdHigh / CONFIG.userMaxScore) * 100)
      : CONFIG.colorThresholdHigh;

    form.innerHTML = `
        <h3 style="text-align: center; margin-top: 0; color: inherit;">⏱️ Reading Time & Quality Score ⭐</h3>

        <div class="settings-section">
          <h4 class="section-title">📚 Reading Time</h4>
          <div class="setting-group">
            <label class="checkbox-label">
              <input type="checkbox" id="enableReadingTime" ${
                CONFIG.enableReadingTime ? "checked" : ""
              }>
              Enable Reading Time
            </label>
          </div>
          <div id="readingTimeSettings" class="subsettings" style="${
            CONFIG.enableReadingTime ? "" : "display: none;"
          }">
            <div class="setting-group">
              <label class="checkbox-label">
                <input type="checkbox" id="alwaysCountReadingTime" ${
                  CONFIG.alwaysCountReadingTime ? "checked" : ""
                }>
                Calculate automatically
              </label>
            </div>
            <div class="setting-group">
              <label class="checkbox-label">
                <input type="checkbox" id="enableChapterStats" ${
                  CONFIG.enableChapterStats ? "checked" : ""
                }>
                Show chapter reading times
                <span class="symbol question" title="Show word count and reading time at the start of each chapter"><span>?</span></span>
              </label>
            </div>
            <div class="setting-group">
              <label class="setting-label">
                Words per minute
                <span class="symbol question" title="Average reading speed is 200-300 wpm. 375 is for faster readers."><span>?</span></span>
              </label>
              <input type="number" id="wpm" value="${
                CONFIG.wpm
              }" min="100" max="1000" step="25">
            </div>
            <div class="setting-group">
              <label class="setting-label">
                Yellow threshold (minutes)
                <span class="symbol question" title="Works taking less than this many minutes will be colored green"><span>?</span></span>
              </label>
              <input type="number" id="readingTimeLvl1" value="${
                CONFIG.readingTimeLvl1
              }" min="5" max="240" step="5">
            </div>
            <div class="setting-group">
              <label class="setting-label">
                Red threshold (minutes)
                <span class="symbol question" title="Works taking more than this many minutes will be colored red"><span>?</span></span>
              </label>
              <input type="number" id="readingTimeLvl2" value="${
                CONFIG.readingTimeLvl2
              }" min="30" max="480" step="10">
            </div>
          </div>
        </div>

        <div class="settings-section">
          <h4 class="section-title">💖 Quality Score</h4>
          <div class="setting-group">
            <label class="checkbox-label">
              <input type="checkbox" id="enableQualityScore" ${
                CONFIG.enableQualityScore ? "checked" : ""
              }>
              Enable Quality Score
            </label>
          </div>
          <div id="qualityScoreSettings" class="subsettings" style="${
            CONFIG.enableQualityScore ? "" : "display: none;"
          }">
            <div class="setting-group">
              <label class="checkbox-label">
                <input type="checkbox" id="alwaysCountQualityScore" ${
                  CONFIG.alwaysCountQualityScore ? "checked" : ""
                }>
                Calculate automatically
              </label>
            </div>
            <div class="setting-group">
              <label class="checkbox-label">
                <input type="checkbox" id="alwaysSortQualityScore" ${
                  CONFIG.alwaysSortQualityScore ? "checked" : ""
                }>
                Sort by score automatically
              </label>
            </div>
            <div class="setting-group">
              <label class="checkbox-label">
                <input type="checkbox" id="hideHitcount" ${
                  CONFIG.hideHitcount ? "checked" : ""
                }>
                Hide hit count
              </label>
            </div>
            <div class="setting-group">
              <label class="setting-label">Minimum kudos to show score</label>
              <input type="number" id="minKudosToShowScore" value="${
                CONFIG.minKudosToShowScore
              }" min="0" max="10000" step="1">
            </div>
            <div class="setting-group">
              <label class="checkbox-label">
                <input type="checkbox" id="useNormalization" ${
                  CONFIG.useNormalization ? "checked" : ""
                }>
                Normalize scores to 100%
                <span class="symbol question" title="Scales the raw score so your 'Best Possible Raw Score' equals 100%. Makes scores from different fandoms more comparable."><span>?</span></span>
              </label>
            </div>
            <div id="userMaxScoreContainer" class="setting-group" style="${
              CONFIG.useNormalization ? "" : "display: none;"
            }">
              <label class="setting-label">
                Best Possible Raw Score <span id="normalizationLabel">${
                  CONFIG.useNormalization ? "(for 100%)" : ""
                }</span>
                <span class="symbol question" title="The highest score you've seen in your fandom. Used to scale other scores to percentages."><span>?</span></span>
              </label>
              <input type="number" id="userMaxScore" value="${
                CONFIG.userMaxScore
              }" min="1" max="100" step="1">
            </div>
            <div class="setting-group">
              <label class="setting-label">
                Good Score <span id="thresholdLowLabel">${
                  CONFIG.useNormalization ? "(%)" : ""
                }</span>
                <span class="symbol question" title="Scores at or above this threshold will be colored yellow"><span>?</span></span>
              </label>
              <input type="number" id="colorThresholdLow" value="${displayThresholdLow}" min="0.1" max="100" step="0.1">
            </div>
            <div class="setting-group">
              <label class="setting-label">
                Excellent Score <span id="thresholdHighLabel">${
                  CONFIG.useNormalization ? "(%)" : ""
                }</span>
                <span class="symbol question" title="Scores at or above this threshold will be colored green"><span>?</span></span>
              </label>
              <input type="number" id="colorThresholdHigh" value="${displayThresholdHigh}" min="0.1" max="100" step="0.1">
            </div>
          </div>
        </div>

        <div class="settings-section">
          <h4 class="section-title">🎨 Visual Styling</h4>
          
          <div class="setting-group">
            <label class="setting-label">Color Style:</label>
            <label class="radio-label">
              <input type="radio" name="colorStyle" value="none" ${
                CONFIG.colorStyle === "none" ? "checked" : ""
              }>
              Default text
            </label>
            <label class="radio-label">
              <input type="radio" name="colorStyle" value="text" ${
                CONFIG.colorStyle === "text" ? "checked" : ""
              }>
              Colored text
            </label>
            <label class="radio-label">
              <input type="radio" name="colorStyle" value="background" ${
                CONFIG.colorStyle === "background" ? "checked" : ""
              }>
              Colored backgrounds
            </label>
          </div>

          <div id="colorPickerSettings" class="subsettings" style="${
            CONFIG.colorStyle !== "none" ? "" : "display: none;"
          }">
            <div class="two-column">
              <div class="setting-group">
                <label class="setting-label">Green</label>
                <input type="color" id="colorGreen" value="${
                  CONFIG.colorGreen
                }">
              </div>
              <div class="setting-group">
                <label class="setting-label">Yellow</label>
                <input type="color" id="colorYellow" value="${
                  CONFIG.colorYellow
                }">
              </div>
              <div class="setting-group">
                <label class="setting-label">Red</label>
                <input type="color" id="colorRed" value="${CONFIG.colorRed}">
              </div>
              <div class="setting-group" id="textColorContainer" style="${
                CONFIG.colorStyle === "background" ? "" : "display: none;"
              }">
                <label class="setting-label">Text color</label>
                <input type="color" id="colorText" value="${CONFIG.colorText}">
              </div>
            </div>
          </div>

          <div class="setting-group">
            <label class="setting-label">Icons:</label>
            <label class="checkbox-label">
              <input type="checkbox" id="useIcons" ${
                CONFIG.useIcons ? "checked" : ""
              }>
              Use icons instead of text labels
              <span class="symbol question" title="Replace 'Time:' and 'Score:' labels with icons"><span>?</span></span>
            </label>
          </div>

          <div id="iconColorSettings" class="subsettings" style="${
            CONFIG.useIcons ? "" : "display: none;"
          }">
            <div class="setting-group">
              <label class="checkbox-label">
                <input type="checkbox" id="useCustomIconColor" ${
                  CONFIG.iconColor ? "checked" : ""
                }>
                Use custom icon color
                <span class="symbol question" title="When unchecked, icons will inherit color from your site skin. When checked, you can set a specific color."><span>?</span></span>
              </label>
            </div>
            <div id="customIconColorPicker" class="setting-group" style="${
              CONFIG.iconColor ? "" : "display: none;"
            }">
              <label class="setting-label">Icon color</label>
              <input type="color" id="iconColor" value="${
                CONFIG.iconColor || "#000000"
              }">
            </div>
          </div>

          <div class="setting-group">
            <label class="setting-label">Chapter Reading Time</label>
            <label class="radio-label">
              <input type="radio" name="chapterTimeStyle" value="default" ${
                CONFIG.chapterTimeStyle === "default" ? "checked" : ""
              }>
              Default
            </label>
            <label class="radio-label">
              <input type="radio" name="chapterTimeStyle" value="colored" ${
                CONFIG.chapterTimeStyle === "colored" ? "checked" : ""
              }>
              Notice
            </label>
            <label class="radio-label">
              <input type="radio" name="chapterTimeStyle" value="timeonly" ${
                CONFIG.chapterTimeStyle === "timeonly" ? "checked" : ""
              }>
              Time Only
            </label>
          </div>
        </div>

        <div class="button-group">
          <button type="submit">Save</button>
          <button type="button" id="closePopup">Close</button>
        </div>
        <div class="reset-link">
          <a href="#" id="resetSettingsLink">Reset to Default Settings</a>
        </div>
      `;

    // Toggle color picker settings and text color visibility
    const colorStyleRadios = form.querySelectorAll('input[name="colorStyle"]');
    const colorPickerSettingsDiv = form.querySelector("#colorPickerSettings");
    const textColorContainer = form.querySelector("#textColorContainer");

    const toggleColorSettings = () => {
      const selectedStyle = form.querySelector(
        'input[name="colorStyle"]:checked'
      ).value;
      colorPickerSettingsDiv.style.display =
        selectedStyle !== "none" ? "block" : "none";
      textColorContainer.style.display =
        selectedStyle === "background" ? "block" : "none";
    };

    colorStyleRadios.forEach((radio) => {
      radio.addEventListener("change", toggleColorSettings);
    });

    // Toggle icon settings
    const useIconsCheckbox = form.querySelector("#useIcons");
    const iconColorSettings = form.querySelector("#iconColorSettings");
    const toggleIconSettings = () => {
      iconColorSettings.style.display = useIconsCheckbox.checked
        ? "block"
        : "none";
    };
    useIconsCheckbox.addEventListener("change", toggleIconSettings);

    // Toggle custom icon color picker
    const useCustomIconColorCheckbox = form.querySelector(
      "#useCustomIconColor"
    );
    const customIconColorPicker = form.querySelector("#customIconColorPicker");
    const toggleCustomIconColor = () => {
      customIconColorPicker.style.display = useCustomIconColorCheckbox.checked
        ? "block"
        : "none";
    };
    useCustomIconColorCheckbox.addEventListener(
      "change",
      toggleCustomIconColor
    );

    // Toggle reading time settings
    const readingTimeCheckbox = form.querySelector("#enableReadingTime");
    const readingTimeSettings = form.querySelector("#readingTimeSettings");
    const toggleReadingTimeSettings = () => {
      readingTimeSettings.style.display = readingTimeCheckbox.checked
        ? "block"
        : "none";
    };
    readingTimeCheckbox.addEventListener("change", toggleReadingTimeSettings);

    // Toggle quality score settings
    const qualityScoreCheckbox = form.querySelector("#enableQualityScore");
    const qualityScoreSettings = form.querySelector("#qualityScoreSettings");
    const toggleQualityScoreSettings = () => {
      qualityScoreSettings.style.display = qualityScoreCheckbox.checked
        ? "block"
        : "none";
    };
    qualityScoreCheckbox.addEventListener("change", toggleQualityScoreSettings);

    // Toggle normalization labels, convert values, and show/hide userMaxScore
    const normCheckbox = form.querySelector("#useNormalization");
    const normLabel = form.querySelector("#normalizationLabel");
    const thresholdLowLabel = form.querySelector("#thresholdLowLabel");
    const thresholdHighLabel = form.querySelector("#thresholdHighLabel");
    const thresholdLowInput = form.querySelector("#colorThresholdLow");
    const thresholdHighInput = form.querySelector("#colorThresholdHigh");
    const userMaxScoreInput = form.querySelector("#userMaxScore");
    const userMaxScoreContainer = form.querySelector("#userMaxScoreContainer");

    const toggleNormalization = () => {
      if (normCheckbox.checked) {
        normLabel.textContent = "(for 100%)";
        thresholdLowLabel.textContent = "(%)";
        thresholdHighLabel.textContent = "(%)";
        userMaxScoreContainer.style.display = "block";
        // Convert current raw thresholds to percentages
        thresholdLowInput.value = Math.ceil(
          (parseFloat(thresholdLowInput.value) /
            parseFloat(userMaxScoreInput.value)) *
            100
        );
        thresholdHighInput.value = Math.ceil(
          (parseFloat(thresholdHighInput.value) /
            parseFloat(userMaxScoreInput.value)) *
            100
        );
      } else {
        normLabel.textContent = "";
        thresholdLowLabel.textContent = "";
        thresholdHighLabel.textContent = "";
        userMaxScoreContainer.style.display = "none";
        // Convert current percentages back to raw values
        thresholdLowInput.value = Math.round(
          (parseFloat(thresholdLowInput.value) / 100) *
            parseFloat(userMaxScoreInput.value)
        );
        thresholdHighInput.value = Math.round(
          (parseFloat(thresholdHighInput.value) / 100) *
            parseFloat(userMaxScoreInput.value)
        );
      }
    };
    normCheckbox.addEventListener("change", toggleNormalization);

    // Add event listeners for reset and close
    form
      .querySelector("#resetSettingsLink")
      .addEventListener("click", function (e) {
        e.preventDefault();
        resetAllSettings();
        popup.remove();
      });
    form
      .querySelector("#closePopup")
      .addEventListener("click", () => popup.remove());

    // Form submission
    form.addEventListener("submit", (e) => {
      e.preventDefault();

      // Collect all values first
      let userMaxScoreValue = parseFloat(
        form.querySelector("#userMaxScore").value
      );
      let thresholdLowValue = parseFloat(
        form.querySelector("#colorThresholdLow").value
      );
      let thresholdHighValue = parseFloat(
        form.querySelector("#colorThresholdHigh").value
      );
      const isNormalizationEnabled =
        form.querySelector("#useNormalization").checked;

      // If normalization is enabled, convert percentages back to raw scores before saving
      if (isNormalizationEnabled) {
        thresholdLowValue = (thresholdLowValue / 100) * userMaxScoreValue;
        thresholdHighValue = (thresholdHighValue / 100) * userMaxScoreValue;
      }

      // Update config object with all settings
      CONFIG.enableReadingTime =
        form.querySelector("#enableReadingTime").checked;
      CONFIG.enableQualityScore = form.querySelector(
        "#enableQualityScore"
      ).checked;
      CONFIG.enableChapterStats = form.querySelector(
        "#enableChapterStats"
      ).checked;
      CONFIG.alwaysCountReadingTime = form.querySelector(
        "#alwaysCountReadingTime"
      ).checked;
      CONFIG.wpm = parseInt(form.querySelector("#wpm").value);
      CONFIG.readingTimeLvl1 = parseInt(
        form.querySelector("#readingTimeLvl1").value
      );
      CONFIG.readingTimeLvl2 = parseInt(
        form.querySelector("#readingTimeLvl2").value
      );
      CONFIG.alwaysCountQualityScore = form.querySelector(
        "#alwaysCountQualityScore"
      ).checked;
      CONFIG.alwaysSortQualityScore = form.querySelector(
        "#alwaysSortQualityScore"
      ).checked;
      CONFIG.hideHitcount = form.querySelector("#hideHitcount").checked;
      CONFIG.minKudosToShowScore = parseInt(
        form.querySelector("#minKudosToShowScore").value
      );
      CONFIG.useNormalization = isNormalizationEnabled;
      CONFIG.userMaxScore = userMaxScoreValue;
      CONFIG.colorThresholdLow = thresholdLowValue;
      CONFIG.colorThresholdHigh = thresholdHighValue;
      CONFIG.colorStyle = form.querySelector(
        'input[name="colorStyle"]:checked'
      ).value;
      CONFIG.colorGreen = form.querySelector("#colorGreen").value;
      CONFIG.colorYellow = form.querySelector("#colorYellow").value;
      CONFIG.colorRed = form.querySelector("#colorRed").value;
      CONFIG.colorText = form.querySelector("#colorText").value;
      CONFIG.useIcons = form.querySelector("#useIcons").checked;
      CONFIG.iconColor = form.querySelector("#useCustomIconColor").checked
        ? form.querySelector("#iconColor").value
        : "";
      CONFIG.chapterTimeStyle = form.querySelector(
        'input[name="chapterTimeStyle"]:checked'
      ).value;

      // Save the entire config object
      saveAllSettings();

      popup.remove();
      location.reload();
    });

    popup.appendChild(form);
    document.body.appendChild(popup);
  };

  // --- SHARED MENU SYSTEM ---
  function initSharedMenu() {
    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 menu items
    const menu = document.querySelector("#scriptconfig .dropdown-menu");
    if (menu) {
      const showMenuOptions = isAllowedMenuPage();

      // Always add settings menu item
      const settingsItem = document.createElement("li");
      const settingsLink = document.createElement("a");
      settingsLink.href = "javascript:void(0);";
      settingsLink.id = "opencfg_reading_quality";
      settingsLink.textContent = "Reading Time & Quality Score";
      settingsLink.addEventListener("click", showSettingsPopup);
      settingsItem.appendChild(settingsLink);
      menu.appendChild(settingsItem);

      // Add separator if we have conditional items
      if (CONFIG.enableReadingTime || CONFIG.enableQualityScore) {
        const separator = document.createElement("li");
        separator.innerHTML = "<hr style='margin: 5px 0;'>";
        menu.appendChild(separator);
      }

      // Reading Time manual calculation only if 'Calculate automatically' is unchecked
      if (CONFIG.enableReadingTime && !CONFIG.alwaysCountReadingTime) {
        const readingTimeItem = document.createElement("li");
        const readingTimeLink = document.createElement("a");
        readingTimeLink.href = "javascript:void(0);";
        readingTimeLink.textContent = "Reading Time: Calculate Times";
        readingTimeLink.addEventListener("click", calculateReadtime);
        readingTimeItem.appendChild(readingTimeLink);
        menu.appendChild(readingTimeItem);
      }

      // Quality Score manual calculation only if 'Calculate automatically' is unchecked
      if (CONFIG.enableQualityScore && !CONFIG.alwaysCountQualityScore) {
        const qualityScoreItem = document.createElement("li");
        const qualityScoreLink = document.createElement("a");
        qualityScoreLink.href = "javascript:void(0);";
        qualityScoreLink.textContent = "Quality Score: Calculate Scores";
        qualityScoreLink.addEventListener("click", countRatio);
        qualityScoreItem.appendChild(qualityScoreLink);
        menu.appendChild(qualityScoreItem);
      }

      // Sort by Score only if 'Sort by score automatically' is unchecked AND not on actual works pages AND allowed by showMenuOptions
      const isWorksPage = /^\/works\/(\d+)(\/chapters\/\d+)?(\/|$)/.test(
        window.location.pathname
      );
      if (
        showMenuOptions &&
        CONFIG.enableQualityScore &&
        !CONFIG.alwaysSortQualityScore &&
        !isWorksPage
      ) {
        const sortScoreItem = document.createElement("li");
        const sortScoreLink = document.createElement("a");
        sortScoreLink.href = "javascript:void(0);";
        sortScoreLink.textContent = "Quality Score: Sort by Score";
        sortScoreLink.addEventListener("click", () => sortByRatio());
        sortScoreItem.appendChild(sortScoreLink);
        menu.appendChild(sortScoreItem);
      }
    }
  }

  function isAllowedMenuPage() {
    const path = window.location.pathname;
    if (/^\/works\/(\d+)(\/chapters\/\d+)?(\/|$)/.test(path)) return false;
    if (
      /^\/users\/[^\/]+\/bookmarks(\/|$)/.test(path) ||
      /^\/bookmarks(\/|$)/.test(path)
    )
      return true;
    if (/^\/users\/[^\/]+\/pseuds\/[^\/]+\/bookmarks(\/|$)/.test(path))
      return true;
    if (/^\/users\/[^\/]+\/?$/.test(path)) return true;
    if (/^\/users\/[^\/]+\/pseuds\/[^\/]+\/works(\/|$)/.test(path)) return true;
    if (/^\/tags\/[^\/]+\/works(\/|$)/.test(path)) return true;
    if (/^\/collections\/[^\/]+(\/|$)/.test(path)) return true;
    if (/^\/works(\/|$)/.test(path)) return true;
    return false;
  }

  // --- INITIALIZATION ---
  const init = () => {
    checkCountable();
    initSharedMenu();
    if (CONFIG.alwaysCountReadingTime) setTimeout(calculateReadtime, 100);
    if (CONFIG.alwaysCountQualityScore) {
      setTimeout(() => {
        countRatio();
        if (CONFIG.alwaysSortQualityScore) sortByRatio();
      }, 100);
    }
    if (CONFIG.enableChapterStats) {
      setTimeout(calculateChapterStats, 150);
    }
  };

  loadUserSettings();
  addIconStyles();

  console.log("[AO3: Reading Time & Quality Score] loaded.");

  if (document.readyState === "loading") {
    document.addEventListener("DOMContentLoaded", init);
  } else {
    init();
  }
})();