Cleaner Pro S Special Limited Edition

Clean and format text

// ==UserScript==
// @name        Cleaner Pro S Special Limited Edition
// @namespace   Violentmonkey Scripts
// @match       *://sangtacviet.vip/truyen/*
// @match       *://sangtacviet.pro/truyen/*
// @match       *://sangtacviet.com/truyen/*
// @match       *://sangtacviet.xyz/truyen/*
// @match       *://sangtacviet.app/truyen/*
// @match       *://14.225.254.182/truyen/*
// @icon        https://i.ibb.co/mVNM0Ms4/419660.png
// @version     2.0.9
// @author      @playrough
// @description Clean and format text
// @license     MIT
// @grant       GM_addStyle
// @grant       GM_registerMenuCommand
// @grant       GM.setClipboard
// @run-at      document-start
// ==/UserScript==

(function () {
    "use strict";

    const CONFIG = {
        DOM: {
            nameInput: "#namewd",
            nameSettingBox: "#nsbox",
            contentBox: "#content-container .contentbox",
            configBox: "#configBox",
            settingBtn: "#btnshowns",
            highlightBtn: "#highlightBtn",
            settingIcon: ".fa-cogs.fas",
        },

        CLASSNAMES: {
            button85: "button-85",
            notifier: "cp-notifier",
            button: "cp-floating-btn",
        },

        ATTRIBUTES: {
            IS_NAME: "isname",
            EXTRA_ID_PREFIX: "exran",
        },

        ACTIONS_TO_RELOAD: [
            "addSuperName('hv','z')",
            "addSuperName('hv','f')",
            "addSuperName('hv','s')",
            "addSuperName('hv','l')",
            "addSuperName('hv','a')",
            "addSuperName('el')",
            "addSuperName('vp')",
            "addSuperName('kn')",
            "saveNS();excute();",
            "excute()",
        ],

        REGEX: {
            REVERSE_TRIM_TEXT: {
                pattern: /[\w\s\p{L}\p{M}]+/gu,
                replace: " $& ",
            },
            START_SIGNS: {
                pattern: /^\s*([^\w\s\p{L}\p{M}]+(?:\s+[^\w\s\p{L}\p{M}]+)*)(.*)$/u,
                replace: "$2",
            },
            END_SIGNS: {
                pattern: /^(.*?)([^\w\s\p{L}\p{M}]+(?:\s+[^\w\s\p{L}\p{M}]+)*)\s*$/u,
                replace: "$1",
            },
            ENDS: {
                pattern: /[.;:!?“【[]$/,
                replace: null,
            },
            CHINESE_UNICODE: {
                pattern: /[\u4e00-\u9fff\u3400-\u4dbf\u20000-\u2a6df\u2a700-\u2b73f\u2b740-\u2b81f\u2b820-\u2ceaf\u3300-\u33ff\ufe30-\ufe4f\uf900-\ufaff\U0002f800-\U0002fa1f]/g,
                replace: null,
            },
            NUMBER_COUNT_5: {
                pattern: /(?<!\d)\d{5,}(?!\d)/g,
                replace: null,
            },
            NUMBER_COMMA: {
                pattern: /\B(?=(\d{3})+(?!\d))/g,
                replace: " ",
            },
        },

        RULES: {
            OPEN_BRACKET: {
                pattern: /【/g,
                replace: "[",
            },
            CLOSE_BRACKET: {
                pattern: /】/g,
                replace: "]",
            },
            OPEN_ARROW: {
                pattern: /《/g,
                replace: "《",
            },
            CLOSE_ARROW: {
                pattern: /》/g,
                replace: "》",
            },
            ADD_MID_SPACE: {
                pattern: /([,.:;!?,。!?、;:])([?{‘“'"])/g,
                replace: "$1 $2",
            },
            ADD_PREVIOUS_SPACE: {
                pattern: /[》({<“‘]+/g,
                replace: " $&",
            },
            ADD_NEXT_SPACE: {
                pattern: /[《)}>”’,.:;!?,;#$^&%]+/g,
                replace: "$& ",
            },
            ADD_BOTH_SIDES_SPACE: {
                //·{3,}|(?<!·)·(?!·)|
                pattern: /[\-+*=≠~—→·⑩⑨⑧⑦⑥⑤④③②①⓪\[\]|<>{}]/g,
                replace: " $& ",
            },
            REMOVE_MULTI_BETWEEN_SPACE: {
                pattern: /([^\w\s\p{L}\p{M}])\s+([^\w\s\p{L}\p{M}])/gu,
                replace: "$1 $2",
            },
            REMOVE_PREVIOUS_SPACE: {
                pattern: /\s+([)”’,.:;!?,;#$^&%])/g,
                replace: "$1",
            },
            REMOVE_NEXT_SPACE: {
                pattern: /([(“‘])\s+/g,
                replace: "$1",
            },
            REMOVE_BOTH_SIDES_NUMBER_SPACE: {
                pattern: /(?<=\d)\s*([ .:])\s*(?=\d)/g,
                replace: "$1",
            },
        },

        MESSAGES: {
            CLEANED: "🎰 Cleaned & sorted!",
            COPY_SUCCESS: "📋 Name copied!",
            COPY_FAILED: "❌ Copy failed!",
            MODE_ON: "☕️ Highlight mode: On",
            MODE_OFF: "🍵 Highlight mode: Off",
        },

        CSS: `
            .listchapitem {
                border: none;
                padding: 10px 5px;
            }

            #namewd {
                padding: 15px;
                line-height: 1.8;
                height: 500px;
            }

            .fa-cogs.fas {
                color: #ffffff !important;
            }

            #configBox {
                right: 85px !important;
            }

            #btnshowns .fa-cogs.fas {
                color: white !important;
                font-size: 24px !important;
            }

            .button-85 {
                font-weight: bold;
                font-size: 12px;
                border: 1px solid rgb(103, 103, 103, 103);
                border-radius: 999px;
                width: 50px;
                height: 50px;
                background-color: #212121;
                cursor: pointer;
                color: white;
                transition: background-color ease-in-out 0.1s,
                    letter-spacing ease-in-out 0.1s, transform ease-in-out 0.1s;
            }

            .button-85:active {
                background-color: fuchsia;
                transform: scale(0.95);
            }

            .button-85:focus {
                outline: none;
            }

            .cp-notifier {
                position: fixed;
                bottom: 30px;
                left: 50%;
                transform: translateX(-50%) translateY(20px);
                width: auto;
                height: auto;
                padding: 10px 20px;
                border-radius: 12px;
                font-size: 12px;
                font-weight: 500;
                opacity: 0;
                pointer-events: none;
                backdrop-filter: blur(6px);
                z-index: 1001;
                transition: opacity 0.4s ease, transform 0.4s ease;
            }

            .cp-floating-btn {
                position: fixed;
                right: 16px;
                z-index: 1000;
            }
        `,
    };

    const app = {
        isHighlighted: true,
        domCache: {},

        init() {
            GM_addStyle(CONFIG.CSS);

            window.addEventListener("DOMContentLoaded", () => {
                const targetNode = document.querySelector(
                    CONFIG.DOM.contentBox
                );
                if (!targetNode) return;

                const observer = new MutationObserver(
                    (mutationsList, observer) => {
                        if (
                            document.querySelector(CONFIG.DOM.contentBox + " i")
                        ) {
                            observer.disconnect();
                            setTimeout(() => {
                                this.run();
                            }, 2000);
                        }
                    }
                );

                observer.observe(targetNode, {
                    childList: true,
                    subtree: true,
                });

                if (document.querySelector(CONFIG.DOM.contentBox + " i")) {
                    observer.disconnect();
                    setTimeout(() => {
                        this.run();
                    }, 2000);
                }
            });
        },

        registerMenuCommand() {
            GM_registerMenuCommand("🧹 Clean", () => this.format());
            GM_registerMenuCommand("🔁 Toggle Highlight", () =>
                this.switchHighlight()
            );
            GM_registerMenuCommand("📋 Copy name", () => this.copyName());
        },

        run() {
            console.clear();
            this.cacheDOMElements();
            this.setupAutoFormat();
            this.format();
            this.registerMenuCommand();

            // this.createUI();
        },

        cacheDOMElements() {
            this.domCache = {
                nameInput: document.querySelector(CONFIG.DOM.nameInput),
                contentBox: document.querySelector(CONFIG.DOM.contentBox),
                settingBtn: document.querySelector(CONFIG.DOM.settingBtn),
            };
        },

        createUI() {
            createButton("fixCleanBtn", "Clean", () => this.format(), 150);
            createButton("copyBtn", "Copy", () => this.copyName(), 85);
            createButton(
                "highlightBtn",
                "On",
                () => this.switchHighlight(),
                215
            );

            if (!this.domCache.settingBtn) return;
            this.domCache.settingBtn.classList.add(CONFIG.CLASSNAMES.button85);

            function createButton(id, text, onClick, bottom) {
                const btn = document.createElement("button");
                btn.id = id;
                btn.className = `${CONFIG.CLASSNAMES.button85} ${CONFIG.CLASSNAMES.button}`;
                btn.textContent = text;
                btn.onclick = onClick;
                btn.style.bottom = `${bottom}px`;

                document.body.appendChild(btn);
                return btn;
            }
        },

        setupAutoFormat() {
            CONFIG.ACTIONS_TO_RELOAD.forEach((action) => {
                const el = document.querySelector(`[onclick="${action}"]`);
                if (!el) return;
                el.addEventListener("click", this.format.bind(this));
            });
        },

        showNotify(message, duration = 2000) {
            const el = document.createElement("div");
            el.className = `${CONFIG.CLASSNAMES.button85} ${CONFIG.CLASSNAMES.notifier}`;
            el.textContent = message;

            document.body.appendChild(el);

            void el.offsetWidth;

            el.style.opacity = "1";
            el.style.transform = "translateX(-50%) translateY(0)";

            setTimeout(() => {
                el.style.opacity = "0";
                el.style.transform = "translateX(-50%) translateY(20px)";
                el.addEventListener("transitionend", () => el.remove());
            }, duration);
        },

        format() {
            if (!this.domCache.contentBox) return;

            this.removeEmptyITags();
            this.domCache.contentBox.normalize();

            this.processITags();
            this.processTextNodes();
            this.sort();

            this.showNotify(CONFIG.MESSAGES.CLEANED);
        },

        removeEmptyITags() {
            this.domCache.contentBox.querySelectorAll("i").forEach((i) => {
                if (!i.textContent.trim()) i.remove();
            });
        },

        processITags() {
            this.domCache.contentBox.querySelectorAll("i").forEach((i) => {
                separatorSigns(i);
                formalizeITag(i);
                toLowercase(i);
                capitalizeStart(i);

                if (!i.textContent.trim()) i.remove();
            });

            function separatorSigns(i) {
                if (!i.id?.startsWith("exran")) return;

                const parent = i.parentNode;
                let textContent = i.textContent;

                textContent = handleStartSign(i, textContent, parent);
                handleEndSign(i, textContent, parent);

                if (i.textContent.trim() === "") {
                    parent.removeChild(i);
                }
            }

            function handleStartSign(i, textContent, parent) {
                const startMatch = textContent.match(
                    CONFIG.REGEX.START_SIGNS.pattern
                );
                if (!startMatch) return textContent;

                const prevSibling = i.previousSibling;
                const isPrevTextNode = prevSibling?.nodeType === Node.TEXT_NODE;
                const textStart = startMatch[1];
                const remainingText = startMatch[2];

                if (isPrevTextNode && prevSibling.nodeValue.trim() === "") {
                    parent.replaceChild(
                        document.createTextNode(textStart),
                        prevSibling
                    );
                    i.textContent = remainingText;
                    return remainingText;
                }

                if (isPrevTextNode) {
                    prevSibling.nodeValue += textStart;
                    i.textContent = remainingText;
                    return remainingText;
                }

                i.insertAdjacentText("beforebegin", textStart);
                i.textContent = remainingText;
                return remainingText;
            }

            function handleEndSign(i, textContent, parent) {
                const endMatch = textContent.match(
                    CONFIG.REGEX.END_SIGNS.pattern
                );
                if (!endMatch) return textContent;

                const nextSibling = i.nextSibling;
                const isNextTextNode = nextSibling?.nodeType === Node.TEXT_NODE;
                const textEnd = endMatch[2];
                const remainingText = endMatch[1];

                if (isNextTextNode && nextSibling.nodeValue.trim() === "") {
                    parent.replaceChild(
                        document.createTextNode(textEnd),
                        nextSibling
                    );
                    i.textContent = remainingText;
                    return remainingText;
                }

                if (isNextTextNode) {
                    nextSibling.nodeValue = textEnd + nextSibling.nodeValue;
                    i.textContent = remainingText;
                    return remainingText;
                }

                i.insertAdjacentText("afterend", textEnd);
                i.textContent = remainingText;
                return remainingText;
            }

            function formalizeITag(i) {
                if (!i) return;
                const prev = i.previousSibling;
                const next = i.nextSibling;

                i.textContent = i.textContent.trim();

                if (prev && prev.nodeType === 1 && prev.tagName === "I") {
                    i.parentNode.insertBefore(document.createTextNode(" "), i);
                }

                if (next && next.nodeType === 1 && next.tagName === "I") {
                    i.parentNode.insertBefore(
                        document.createTextNode(" "),
                        next
                    );
                }
            }

            function toLowercase(i) {
                const shouldLowercase =
                    !i.hasAttribute(CONFIG.ATTRIBUTES.IS_NAME) &&
                    !i.id?.startsWith(CONFIG.ATTRIBUTES.EXTRA_ID_PREFIX);

                if (shouldLowercase) {
                    i.textContent = i.textContent.toLowerCase();
                }
            }

            function capitalizeStart(i) {
                const prevEl = i.previousElementSibling;
                const prevNode = i.previousSibling;

                const getText = (dom) => dom?.textContent?.trim() || "";

                const shouldCapitalize =
                    prevEl === null ||
                    prevEl.nodeName === "BR" ||
                    prevEl.nodeName === "HEADER" ||
                    CONFIG.REGEX.ENDS.pattern.test(getText(prevNode));

                if (!shouldCapitalize || !i.innerHTML) return;

                const txt = i.innerHTML;
                i.innerHTML = txt[0].toUpperCase() + txt.slice(1);
            }
        },

        processTextNodes() {
            const walker = document.createTreeWalker(
                this.domCache.contentBox,
                NodeFilter.SHOW_TEXT
            );

            while (walker.nextNode()) {
                normalizeText(walker.currentNode);
            }

            function normalizeText(node) {
                if (!node?.nodeValue?.trim()) return;

                let txt = node.nodeValue.trim();
                const originalText = node.nodeValue;

                if (isTextNodeOutsideITag(node)) {
                    const { pattern, replace } = CONFIG.REGEX.REVERSE_TRIM_TEXT;
                    txt = txt.replace(pattern, replace);
                }

                Object.keys(CONFIG.RULES).forEach((rule) => {
                    const { pattern, replace } = CONFIG.RULES[rule];
                    txt = txt.replace(pattern, replace);
                });

                txt = txt.replace(CONFIG.REGEX.NUMBER_COUNT_5.pattern, (s) =>
                    s.replace(
                        CONFIG.REGEX.NUMBER_COMMA.pattern,
                        CONFIG.REGEX.NUMBER_COMMA.replace
                    )
                );

                if (!isTextNodeOutsideITag(node)) {
                    txt = txt.trim();
                }

                if (txt !== originalText) {
                    node.nodeValue = txt;
                }
            }

            function isTextNodeOutsideITag(textNode) {
                return (
                    textNode.nodeType === Node.TEXT_NODE &&
                    textNode.parentElement.tagName !== "I"
                );
            }
        },

        copyName() {
            const text = this.domCache.nameInput?.value || "";
            GM.setClipboard(text);
            this.showNotify(CONFIG.MESSAGES.COPY_SUCCESS);
        },

        switchHighlight(forceOn = false) {
            if (forceOn) this.isHighlighted = true;

            const btn = document.querySelector(CONFIG.DOM.highlightBtn);
            if (btn) btn.textContent = this.isHighlighted ? "Off" : "On";

            const COLOR_DANGER = "oklch(81% 0.117 11.638)";
            const I_NAME = "i[" + CONFIG.ATTRIBUTES.IS_NAME + "]";

            this.domCache.contentBox.querySelectorAll(I_NAME).forEach((i) => {
                i.style.color = this.isHighlighted ? COLOR_DANGER : "";
                i.style.fontWeight = this.isHighlighted ? "bold" : "normal";
            });

            this.isHighlighted = !this.isHighlighted;
            this.showNotify(
                this.isHighlighted
                    ? CONFIG.MESSAGES.MODE_OFF
                    : CONFIG.MESSAGES.MODE_ON
            );
        },

        sort() {
            const originalText = this.domCache.nameInput.value.trim();
            if (!originalText) {
                console.log("No text found in input field");
                return;
            }

            const sortedText = sortNames(originalText);
            this.domCache.nameInput.value = sortedText;

            function sortNames(text) {
                const items = text
                    .split("\n")
                    .filter((line) => line.trim() && line.includes("="))
                    .map((line) => {
                        const [keyPart, ...valueParts] = line.split("=");
                        const key = keyPart.replace("$", "").trim();
                        const value = valueParts.join("=").trim();
                        return { key, value };
                    });

                const itemsWithStats = items.map((item) => {
                    const chineseCharCount = (
                        item.key.match(CONFIG.REGEX.CHINESE_UNICODE.pattern) ||
                        []
                    ).length;

                    const firstChar = item.value.charAt(0);
                    const isCapitalized =
                        firstChar === firstChar.toUpperCase() &&
                        firstChar !== firstChar.toLowerCase();

                    return {
                        ...item,
                        chineseCharCount,
                        caseType: isCapitalized ? "capitalized" : "lowercase",
                    };
                });

                itemsWithStats.sort((a, b) => {
                    if (a.chineseCharCount !== b.chineseCharCount) {
                        return a.chineseCharCount - b.chineseCharCount;
                    }
                    return a.value.localeCompare(b.value);
                });

                const groupedItems = itemsWithStats.reduce((acc, item) => {
                    acc[item.caseType] = acc[item.caseType] || [];
                    acc[item.caseType].push(item);
                    return acc;
                }, {});

                const capitalizedText = (groupedItems.capitalized || [])
                    .map((item) => `$${item.key}=${item.value}`)
                    .join("\n");

                const lowercaseText = (groupedItems.lowercase || [])
                    .map((item) => `$${item.key}=${item.value}`)
                    .join("\n");

                return `\n${capitalizedText}\n\n` + `\n${lowercaseText}`;
            }
        },
    };

    app.init();
})();