Greasy Fork is available in English.
Add reading time and quality scores to AO3 works with color coding, normalization, and sorting. Highly customizable display and calculation options.
当前为
// ==UserScript==
// @name AO3: Reading Time & Quality Score
// @description Add reading time and quality scores to AO3 works with color coding, normalization, and sorting. Highly customizable display and calculation options.
// @author BlackBatCat
// @version 2.1
// @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,
// 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
};
// 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();
}
};
// 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";
// If using icons, inject CSS for pseudo-element icons (to match skin styling)
if (CONFIG.useIcons) {
// Use mask-image for SVG coloring - inherit page color by default, or use custom color
const iconColor = CONFIG.iconColor || "currentColor";
style.textContent = `
/* Add icons using pseudo-elements to match skin styling */
.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: translateY(-0.1em) !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: translateY(-0.1em) !important;
}
/* Hide ALL dt content when using icons - both in stats and statistics contexts */
.stats dt.readtime,
.stats dt.kudoshits,
dl.statistics dt.readtime,
dl.statistics dt.kudoshits {
font-size: 0 !important;
line-height: 0 !important;
}
/* But keep the ::before pseudo-elements visible on statistics pages */
dl.statistics dt.readtime::before,
dl.statistics dt.kudoshits::before {
font-size: 1rem !important;
line-height: normal !important;
}
`;
} else {
// Ensure no conflicts when icons are disabled
style.textContent = `
.stats dd.readtime::before,
.stats dd.kudoshits::before,
dt.readtime::before,
dt.kudoshits::before,
dl.statistics dt.readtime::before,
dl.statistics dt.kudoshits::before {
display: none !important;
content: none !important;
}
`;
}
document.head.appendChild(style);
};
// --- READING TIME FUNCTIONS ---
const checkCountable = () => {
const foundStats = $("dl.stats");
if (foundStats.length === 0) return;
// Cache common parent selectors for efficiency
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) => {
// Check if readtime already exists to avoid duplicates
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";
// Create label - leave empty if using icons (CSS will handle the icon)
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";
// Determine color once
let color;
if (minutes < CONFIG.readingTimeLvl1) {
color = CONFIG.colorGreen;
} else if (minutes < CONFIG.readingTimeLvl2) {
color = CONFIG.colorYellow;
} else {
color = CONFIG.colorRed;
}
// Common styles for dd element
Object.assign(readtime_value.style, {
fontSize: "1em",
lineHeight: "inherit",
display: "inline-block",
verticalAlign: "baseline",
});
// If using icons, wrap text in span for background
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);
}
// Insert after words_value
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) => {
// Check if score already exists to avoid duplicates
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;
// Hide score if kudos below threshold
if (kudos < CONFIG.minKudosToShowScore) {
// Remove any previous score elements
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;
// Normalize thresholds if normalization is enabled
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); // round up, no decimals
thresholdLow = Math.ceil(
(CONFIG.colorThresholdLow / CONFIG.userMaxScore) * 100
);
thresholdHigh = Math.ceil(
(CONFIG.colorThresholdHigh / CONFIG.userMaxScore) * 100
);
} else {
displayScore = Math.round(displayScore * 10) / 10;
}
// Create label - leave empty if using icons (CSS will handle the icon)
const ratioLabel = document.createElement("dt");
ratioLabel.className = "kudoshits";
if (!CONFIG.useIcons) {
ratioLabel.textContent = "Score:";
}
const ratioValue = document.createElement("dd");
ratioValue.className = "kudoshits";
// Determine color once
let color;
if (displayScore >= thresholdHigh) {
color = CONFIG.colorGreen;
} else if (displayScore >= thresholdLow) {
color = CONFIG.colorYellow;
} else {
color = CONFIG.colorRed;
}
// Common styles for dd element
Object.assign(ratioValue.style, {
fontSize: "1em",
lineHeight: "inherit",
display: "inline-block",
verticalAlign: "baseline",
});
// If using icons, wrap text in span for background
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);
});
};
// --- 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 Settings ⭐</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="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" style="margin-left: 20px;">
<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>
<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.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
: "";
// 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);
}
}
}
// Helper: check if current page is one of the allowed types for menu options
function isAllowedMenuPage() {
const path = window.location.pathname;
// Remove sort option from actual works pages (e.g., /works/ID or /works/ID/chapters/ID)
if (/^\/works\/(\d+)(\/chapters\/\d+)?(\/|$)/.test(path)) return false;
// User bookmarks: /users/USERNAME/bookmarks or /bookmarks
if (
/^\/users\/[^\/]+\/bookmarks(\/|$)/.test(path) ||
/^\/bookmarks(\/|$)/.test(path)
)
return true;
// User pseuds bookmarks: /users/USERNAME/pseuds/PSEUD/bookmarks
if (/^\/users\/[^\/]+\/pseuds\/[^\/]+\/bookmarks(\/|$)/.test(path))
return true;
// User profile: /users/USERNAME (no trailing /works etc)
if (/^\/users\/[^\/]+\/?$/.test(path)) return true;
// User pseuds works: /users/USERNAME/pseuds/PSEUD/works
if (/^\/users\/[^\/]+\/pseuds\/[^\/]+\/works(\/|$)/.test(path)) return true;
// Tag works: /tags/ANYTHING/works
if (/^\/tags\/[^\/]+\/works(\/|$)/.test(path)) return true;
// Collections: /collections/ANYTHING
if (/^\/collections\/[^\/]+(\/|$)/.test(path)) return true;
// Works index: /works
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);
}
};
loadUserSettings();
// Add icon styles
addIconStyles();
// Show startup message
console.log("[AO3: Reading Time & Quality Score] loaded.");
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", init);
} else {
init();
}
})();