WTR Lab Smart Quotes

Replaces straight quotes with curly/smart quotes for a better reading experience on wtr-lab.com, without breaking site functionality.

您需要先安装一个扩展,例如 篡改猴Greasemonkey暴力猴,之后才能安装此脚本。

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

您需要先安装一个扩展,例如 篡改猴暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴Userscripts ,之后才能安装此脚本。

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

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

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

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

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

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

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

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

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

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

// ==UserScript==
// @name         WTR Lab Smart Quotes
// @namespace    http://tampermonkey.net/
// @version      3.6
// @description  Replaces straight quotes with curly/smart quotes for a better reading experience on wtr-lab.com, without breaking site functionality.
// @author       MasuRii
// @match        https://wtr-lab.com/en/novel/*/*/*
// @icon         https://www.google.com/s2/favicons?sz=64&domain=wtr-lab.com
// @license      MIT
// @grant        GM_registerMenuCommand
// @grant        GM_getValue
// @grant        GM_setValue
// @run-at       document-idle
// ==/UserScript==

(function() {
    'use strict';

    // --- CONFIGURATION ---
    const CHAPTER_BODY_SELECTOR = '.chapter-body';
    const PROCESSED_MARKER = 'data-smart-quotes-processed';
    const LOGGING_STORAGE_KEY = 'wtrLabSmartQuotes_loggingEnabled';
    const ENABLED_STORAGE_KEY = 'wtrLabSmartQuotes_enabled';

    // --- STATE MANAGEMENT ---
    let loggingEnabled = GM_getValue(LOGGING_STORAGE_KEY, false);
    let smartQuotesEnabled = GM_getValue(ENABLED_STORAGE_KEY, true);
    let observer = null; // Observer instance for reliable toggling

    /**
     * Debounce utility to limit the rate at which a function gets called.
     * @param {Function} func The function to debounce.
     * @param {number} delay The delay in milliseconds.
     * @returns {Function} The debounced function.
     */
    function debounce(func, delay) {
        let timeout;
        return function(...args) {
            clearTimeout(timeout);
            timeout = setTimeout(() => func.apply(this, args), delay);
        };
    }

    // --- MENU COMMANDS ---
    function toggleLogging() {
        loggingEnabled = !loggingEnabled;
        GM_setValue(LOGGING_STORAGE_KEY, loggingEnabled);
        alert(`WTR Lab Smart Quotes logging is now ${loggingEnabled ? 'ENABLED' : 'DISABLED'}.`);
    }

    function toggleSmartQuotes() {
        smartQuotesEnabled = !smartQuotesEnabled;
        GM_setValue(ENABLED_STORAGE_KEY, smartQuotesEnabled);

        const chapterBodies = document.querySelectorAll(CHAPTER_BODY_SELECTOR);

        if (smartQuotesEnabled) {
            // If enabling, re-process all chapters and start observing
            chapterBodies.forEach(el => {
                el.removeAttribute(PROCESSED_MARKER); // Allow re-processing
                applySmartQuotes(el);
            });
            initializeObserver();
        } else {
            // If disabling, stop observing first, then revert all chapters
            if (observer) {
                observer.disconnect();
            }
            chapterBodies.forEach(revertSmartQuotes);
        }

        alert(`WTR Lab Smart Quotes is now ${smartQuotesEnabled ? 'ENABLED' : 'DISABLED'}.`);
    }

    GM_registerMenuCommand('Toggle Smart Quotes', toggleSmartQuotes);
    GM_registerMenuCommand('Toggle Logging', toggleLogging);

    /**
     * Reverts curly quotes back to straight quotes.
     * @param {HTMLElement} targetElement The container element to process.
     */
    function revertSmartQuotes(targetElement) {
        if (!targetElement) return;

        const paragraphs = targetElement.querySelectorAll('p');

        const revertNode = (node) => {
            const walker = document.createTreeWalker(node, NodeFilter.SHOW_TEXT, null, false);
            let textNode;
            while ((textNode = walker.nextNode())) {
                let text = textNode.nodeValue;
                if (/[“”‘’—]/.test(text)) {
                    text = text.replace(/[“”]/g, '"').replace(/[‘’]/g, "'").replace(/—/g, '--');
                    textNode.nodeValue = text;
                }
            }
        };

        if (paragraphs.length === 0) {
            revertNode(targetElement);
        } else {
            paragraphs.forEach(revertNode);
        }

        targetElement.removeAttribute(PROCESSED_MARKER); // Remove marker
        if (loggingEnabled) {
            console.log('WTR Lab Smart Quotes reverted on chapter.', targetElement);
        }
    }


    /**
     * Replaces straight quotes with curly quotes using safe DOM traversal.
     * Processes each paragraph independently.
     * @param {HTMLElement} targetElement The container element to process (e.g., .chapter-body).
     */
    function applySmartQuotes(targetElement) {
        if (!smartQuotesEnabled || !targetElement || targetElement.hasAttribute(PROCESSED_MARKER)) {
            return;
        }

        const paragraphs = targetElement.querySelectorAll('p');

        if (paragraphs.length === 0) {
            if (loggingEnabled) console.warn('WTR Lab Smart Quotes: No <p> tags found in chapter body. Processing as a single block.');
            processNode(targetElement);
        } else {
            paragraphs.forEach(processNode);
        }

        targetElement.setAttribute(PROCESSED_MARKER, 'true');

        if (loggingEnabled) {
            console.log('WTR Lab Smart Quotes script applied to chapter.', targetElement);
        }
    }

    /**
     * Converts straight quotes to curly quotes and double hyphens to em-dashes.
     * Based on the principles of SmartyPants.
     * @param {string} text The input string.
     * @returns {string} The processed string with smart typography.
     */
    function smarten(text) {
        if (!text) return '';

        // The order of these replacements is important.
        return text
            // Special case for apostrophes in years like '70s
            .replace(/'(\d+s)/g, '\u2019$1')
            // Opening single quotes: at the start of a line, or after a space, dash, or opening bracket/quote.
            .replace(/(^|[-\u2014\s(\[【"“])'/g, '$1\u2018')
            // All remaining single quotes are closing quotes or apostrophes.
            .replace(/'/g, '\u2019')
            // Opening double quotes: at the start of a line, or after a space, dash, or opening bracket/quote.
            .replace(/(^|[-\u2014\s(\[【'‘])"/g, '$1\u201c')
            // All remaining double quotes are closing quotes.
            .replace(/"/g, '\u201d')
            // Em-dashes
            .replace(/--/g, '\u2014');
    }

    /**
     * The core logic that processes a single DOM node (like a paragraph)
     * and replaces its quotes, while preserving inner HTML tags.
     * @param {HTMLElement} p The paragraph element to process.
     */
    function processNode(p) {
        const textNodes = [];
        const walker = document.createTreeWalker(p, NodeFilter.SHOW_TEXT, null, false);
        let node;
        while ((node = walker.nextNode())) {
            textNodes.push(node);
        }

        if (textNodes.length === 0) {
            return;
        }

        const fullText = textNodes.map(n => n.nodeValue).join('');
        const smartenedText = smarten(fullText);

        // Only proceed if text has changed to avoid unnecessary DOM manipulation
        if (fullText === smartenedText) {
            return;
        }

        let currentIndex = 0;
        for (const textNode of textNodes) {
            const originalLength = textNode.nodeValue.length;
            if (originalLength > 0) {
                textNode.nodeValue = smartenedText.substring(currentIndex, currentIndex + originalLength);
                currentIndex += originalLength;
            }
        }
    }

    /**
     * Finds and processes all chapter bodies currently in the DOM.
     */
    function processAllVisibleChapters() {
        if (!smartQuotesEnabled) return;
        const chapterBodies = document.querySelectorAll(CHAPTER_BODY_SELECTOR);
        chapterBodies.forEach(applySmartQuotes);
    }

    /**
     * Initializes and starts the MutationObserver to watch for new chapters and text changes.
     */
    function initializeObserver() {
        if (observer) observer.disconnect(); // Ensure no multiple observers are running

        const observerOptions = {
            childList: true,
            subtree: true,
            characterData: true
        };

        const debouncedProcessAll = debounce(() => {
            if (loggingEnabled) console.log('WTR Smart Quotes: Reprocessing visible chapters due to DOM changes.');

            // Disconnect the observer to prevent the script from reacting to its own changes.
            observer.disconnect();

            // Apply the smart quotes.
            processAllVisibleChapters();

            // Reconnect the observer to watch for future external changes.
            observer.observe(document.body, observerOptions);
        }, 300);

        observer = new MutationObserver((mutationsList) => {
            if (!smartQuotesEnabled) return;

            let needsReprocessing = false;
            for (const mutation of mutationsList) {
                // Ignore mutations caused by script UIs to prevent loops
                if (mutation.target.closest && (mutation.target.closest('.wtr-replacer-ui') || mutation.target.closest('.wtr-add-term-float-btn'))) {
                    continue;
                }

                if (mutation.type === 'childList') {
                    for (const node of mutation.addedNodes) {
                        if (node.nodeType === Node.ELEMENT_NODE && (node.matches(CHAPTER_BODY_SELECTOR) || node.querySelector(CHAPTER_BODY_SELECTOR))) {
                            needsReprocessing = true;
                            break;
                        }
                    }
                } else if (mutation.type === 'characterData') {
                    const chapterBody = mutation.target.parentElement?.closest(CHAPTER_BODY_SELECTOR);
                    if (chapterBody && chapterBody.hasAttribute(PROCESSED_MARKER)) {
                        if (loggingEnabled) console.log('WTR Smart Quotes: Detected external text change. Un-marking chapter for reprocessing.', chapterBody);
                        chapterBody.removeAttribute(PROCESSED_MARKER);
                        needsReprocessing = true;
                    }
                }
                if (needsReprocessing) break;
            }

            if (needsReprocessing) {
                debouncedProcessAll();
            }
        });

        observer.observe(document.body, observerOptions);
    }

    // --- EXECUTION LOGIC ---
    if (smartQuotesEnabled) {
        initializeObserver();
        setTimeout(processAllVisibleChapters, 500);
    }

})();