Milovana: Data Extractor

Extract data from teases

이 스크립트를 설치하려면 Tampermonkey, Greasemonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램을 설치해야 합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Userscripts와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 유저 스크립트 관리자 확장 프로그램이 필요합니다.

(이미 유저 스크립트 관리자가 설치되어 있습니다. 설치를 진행합니다!)

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

(이미 유저 스타일 관리자가 설치되어 있습니다. 설치를 진행합니다!)

// ==UserScript==
// @name        Milovana: Data Extractor
// @namespace   wompi72
// @author      wompi72
// @version     1.0.7
// @description Extract data from teases
// @match       https://milovana.com/*
// @grant       none
// @license     MIT
// ==/UserScript==

function displayJsonData(data) {
    function formatValue(v) {
        if (typeof v !== "number" || Math.abs(v) < 1000) {
            return v;
        }

        const thousands = Math.floor(Math.log10(Math.abs(v)) / 3);
        const base = Math.floor(v / 1000 ** thousands);

        return `${base}${"k".repeat(thousands)}`;
    }

    const displayData = Object.entries(data)
        .map(([k, v]) => `${k}: ${formatValue(v)}`)
        .join(", ");
    return displayData;
}

(function() {
    'use strict';

    class DataScraper {
        cached_data = {}

        init() {
            this.cached_data = JSON.parse(localStorage.getItem("TeaseDataData") || "{}")
        }
        downloadJson(teaseId, teaseTitle) {
            const jsonSourceUrl = this.getJsonSourceUrl(teaseId)
            fetch(jsonSourceUrl).then(res => res.blob()).then(blob => {
                const fileName = teaseTitle.replace(/[^a-zA-Z0-9]/g, "_").toLowerCase();
                const fullFileName = `${fileName}.json`;
                this._download(blob, fullFileName);
            });
        }
        downloadSimplifiedJson(teaseId, teaseTitle) {
            const jsonSourceUrl = this.getJsonSourceUrl(teaseId)
            fetch(jsonSourceUrl).then(res => res.json()).then(jsonData => {
                let parserClass;
                if ("galleries" in jsonData) {
                    parserClass = new EOSTeaseSimplifier()
                } else {
                    parserClass = new FlashTeaseSimplifier()
                }
                const simplifiedData = parserClass.parseData(jsonData);

                const fileName = teaseTitle.replace(/[^a-zA-Z0-9]/g, "_").toLowerCase();
                const fullFileName = `${fileName}_simplified.json`;
                const jsonString = JSON.stringify(simplifiedData, null, 4);
                const blob = new Blob([jsonString], { type: 'application/json' });
                this._download(blob, fullFileName);
            });
        }

        getSimplifiedJson(teaseId, callback) {
            const jsonSourceUrl = this.getJsonSourceUrl(teaseId)
            fetch(jsonSourceUrl).then(res => res.json()).then(jsonData => {
                let parserClass;
                if ("galleries" in jsonData) {
                    parserClass = new EOSTeaseSimplifier()
                } else {
                    parserClass = new FlashTeaseSimplifier()
                }
                callback(parserClass.parseData(jsonData));
            });
        }

        save_data(data, teaseId) {
            this.cached_data[teaseId] = data
            localStorage.setItem("TeaseDataData", JSON.stringify(this.cached_data))
        }
        get_data(teaseId) {
            return this.cached_data[teaseId] || null
        }

        async getTeaseSummary(teaseId) {
            const url = this.getJsonSourceUrl(teaseId);
            const res = await fetch(url);
            if (!res.ok) {
                console.error(res);
                return
            }
            let data = await res.json();
            let summary;
            if ("galleries" in data) {
                summary = new EOSTeaseSimplifier().summarizeData(data);
            } else {
                summary = new FlashTeaseSimplifier().summarizeData(data);
            }

            this.save_data(summary, teaseId)
            return summary;
        }

        getJsonSourceUrl(teaseId) {
            return `https://milovana.com/webteases/geteosscript.php?id=${teaseId}`;
        }

        _download(jsonData, fileName) {
            const url = window.URL.createObjectURL(jsonData);
            const a = document.createElement('a');
            a.href = url;
            a.download = fileName;
            document.body.appendChild(a);
            a.click();
            document.body.removeChild(a);
        }
    }

    const scraper = new DataScraper();
    scraper.init()

    const TEASE_TYPES = {
        EOS: "EOS",
        FLASH: "FLASH",
    }

    class DisplayData {
        display() {
            this.addCSS()
            document.querySelectorAll(".tease").forEach(teaseEl => {
                try {
                    this.addDisplay(teaseEl)
                } catch (error) {
                    console.error(`Error processing tease element: ${error.message}`);
                }
            })
        }

        addDisplay(teaseEl) {
            const bubbleEl = teaseEl.querySelector(".bubble");
            const titleEl = bubbleEl.querySelector("h1 a");
            const teaseId = titleEl.href.match(/id=(\d+)/)[1];
            const teaseTitle = titleEl.textContent;
            const teaseTypeImg = titleEl.querySelector("img");
            if (teaseTypeImg==null) return;

            let teaseType;
            if (teaseTypeImg.classList.contains("eosticon")) {
                teaseType = TEASE_TYPES.EOS
            } else if (teaseTypeImg.classList.contains("flashticon")) {
                teaseType = TEASE_TYPES.FLASH
            } else {
                return;
            }

            const parentContainerEl= this._createDiv(["flex-row", "data-buttons-container"], bubbleEl);
            const dataContainerEl = this._createDiv(["data-display"], parentContainerEl);
            const buttonContainerEl = this._createDiv(["data-display"], parentContainerEl);
            if (scraper.get_data(teaseId) != null) {
                this._displayData(scraper.get_data(teaseId), dataContainerEl)
            }

            this._createButton("Json", ["data-button", "tease_data_button"], buttonContainerEl, () => {
                scraper.downloadJson(teaseId, teaseTitle)
            });
            this._createButton("Simple", ["data-button", "tease_data_button"], buttonContainerEl, () => {
                scraper.downloadSimplifiedJson(teaseId, teaseTitle)
            });
            this._createButton("Summary", ["parse-data-button", "tease_data_button"], buttonContainerEl, async () => {
                buttonContainerEl.querySelector(".parse-data-button").disabled = true;
                const data = await scraper.getTeaseSummary(teaseId);
                this._displayData(data, dataContainerEl);
            });
            this._createButton("Viewer", ["flow-graph-button", "tease_data_button"], buttonContainerEl, async () => {
                scraper.getSimplifiedJson(teaseId, new ConnectionMapper().showSimplifiedTease)
            });
        }

        _createDiv(classes, parent) {
            const div = document.createElement("div");
            div.classList.add(...classes);
            parent.appendChild(div);
            return div;
        }
        _createButton(text, classes, parent, onClick) {
            const button = document.createElement("button");
            button.textContent = text;
            button.addEventListener("click", onClick);
            button.classList.add(...classes);
            parent.appendChild(button);
            return button;
        }

        addCSS() {
            const style = document.createElement('style');
            style.textContent = `
.tease .bubble {
    height: unset !important;
    min-height: 100px;
}
.tease .bubble .desc {
    min-height: 60px;
}
.tease .info .tags {
    margin-left: 155px;
}
.flex-row {
    display: flex;
}
.data-buttons-container {
    justify-content: space-between;
    height: 1.0rem;
    font-size: 0.5rem;
}
.tease_data_button  {
    background-color: #a36666;
    color: #ffffff;
    border: none;
    border-radius: 2px;
    padding: 2px 4px;
    cursor: pointer;
    transition: opacity 0.2s;
    margin: 0 2px;
}
#mv-sidebar button:hover {
    opacity: 0.9;
}
#mv-sidebar button:active {
    transform: translateY(1px);
}
.data-display {
    margin: auto 0;
}
    `
            document.head.appendChild(style);
        }

        _displayData(data, dataContainerEl) {
            dataContainerEl.innerText = displayJsonData(data);
        }
    }

    function isEmpty(value) {
        if (Array.isArray(value) && value.length === 0) return true;
        return value === null || value === undefined || value == "";
    }

    class EOSTeaseSimplifier {
        constructor() {
            this.htmlPattern = /<\/?(strong|span|p|em|b|i|u)(?:\s+[^>]*)?>/gi;
            this.scriptNewlinePattern = /(\\r)*(\\n)+/g;
        }

        parseData(rawData) {
            const pageData = { ...rawData.pages };

            let initScript = this.preParseInitScripts(rawData.init || "");
            if (!isEmpty(initScript)) {
                pageData["__init_scripts"] = initScript;
            }

            let galleries = rawData.galleries || "";
            if (!isEmpty(galleries)) {
                this.galleries = this.parse_galleries(galleries);
            } else {
                this.galleries = {};
            }

            const sortedEntries = Object.entries(pageData).sort((a, b) => {
                const naturalSort = (str) => str.replace(/\d+/g, (m) => m.padStart(10, '0'));
                return naturalSort(a[0]).localeCompare(naturalSort(b[0]));
            });

            const sortedPageData = Object.fromEntries(sortedEntries);

            return this.parsePages(sortedPageData);
        }

        parse_galleries(galleries) {
            const out = {};
            for (const [gallery_id, gallery_data] of Object.entries(galleries)) {
                out[gallery_id] = `${gallery_data.name} (Image not found in gallery)`

                for (const [i, image_data] of gallery_data.images.entries()) {
                    out[`${gallery_id}/${image_data.id}`] = `${gallery_data.name} (${i + 1})`
                }
            }
            return out;
        }

        parsePages(pages) {
            return this.parseElement(pages)
        }

        parseElement(inData) {
            if (Array.isArray(inData)) {
                return this.parseList(inData);
            } else if (inData !== null && typeof inData === 'object') {
                return this.parseDict(inData);
            } else {
                return inData;
            }
        }

        parseDict(inData) {
            if ("say" in inData) {
                let text = inData.say.label || "";
                return this.parseText(text);
            }

            if ("eval" in inData) {
                return `> SCRIPT:${inData.eval.script}`;
            }

            if ("image" in inData) {
                try {
                    const locator = inData.image.locator.replace(/^gallery:/, '');
                    if (this.galleries.hasOwnProperty(locator)) return `> Image: ${this.galleries[locator]}`;
                    const gallery_id = locator.split("/")[0];
                    if (this.galleries.hasOwnProperty(gallery_id)) return `> Image: ${this.galleries[gallery_id]}`;
                } catch (e) {
                    console.error(e);
                }
                return "Image";
            }

            if ("goto" in inData) {
                return `> GOTO:${inData.goto.target}`;
            }

            if ("timer" in inData) {
                const timerData = inData.timer;

                return this.parseTimer(timerData);
            }

            if ("if" in inData) {
                const ifData = inData.if;
                if (ifData.condition === "true" && isEmpty(ifData.elseCommands)) {
                    return this.parseElement(ifData.commands);
                }
                const out = {
                    [`If (${ifData.condition})`]: this.parseElement(ifData.commands)
                };
                if (ifData.elseCommands) {
                    out["else"] = this.parseElement(ifData.elseCommands);
                }
                return out;
            }

            if ("choice" in inData) {
                const options = inData.choice.options || [];
                return this.parseChoices(options);
            }

            if ("audio.play" in inData) {
                const suffix = "loops" in inData["audio.play"] && inData["audio.play"].loops > 0 ? ` (${inData["audio.play"].loops} loops)` : "";

                return `> Audio: ${inData["audio.play"].locator}${suffix}`;
            }
            if ("prompt" in inData) {
                return `> Prompt: ${inData.prompt.variable}`
            }

            const outData = {};
            for (const [key, value] of Object.entries(inData)) {
                outData[key] = this.parseElement(value);
            }
            return outData;
        }

        parseTimer(timerData) {
            let key = `> Timer ${timerData.duration}`;
            if (timerData.style) key += ` (${timerData.style})`;
            if (timerData.isAsync) key += " (async)";

            const commands = this.parseElement(timerData.commands || []);
            if (isEmpty(commands)) {
                return key;
            }
            if (commands.length === 1) {
                return `${key} -${commands[0]}`;
            }
            return {[key]: commands};
        }

        parseText(text) {
            let cleaned = text.replace(this.htmlPattern, '');
            return cleaned.replace(/&#39;/g, "'").replace(/&apos;/g, "'").replace(/&quot;/g, "'");
        }

        parseList(inData) {
            const out = [];
            for (const element of inData) {
                const parsed = this.parseElement(element);
                if (parsed === "Image" || isEmpty(parsed)) continue;
                out.push(parsed);
            }
            return out;
        }

        parseChoices(options) {
            const out = {};
            options.forEach(option => {
                out[`Choice: ${option.label}`] = this.parseElement(option.commands);
            });

            const values = Object.values(out);
            const labels = options.map(o => o.label).join(', ');

            if (values.length === 0 || values.every(v => isEmpty(v))) {
                return `> Choice: ${labels}`;
            }
            if (values.length === 1 && values[0].length === 1) {
                return `> Choice: ${labels} -${values[0]}`;
            }
            if (values.every(v => JSON.stringify(v) === JSON.stringify(values[0]))) {
                return {[`Choice: ${labels}`]: values[0]};
            }
            if (values.every(v => v.length === 1)) {
                const outList = []
                for (const [key, value] of Object.entries(out)) {
                    outList.push(`> ${key} -${value[0]}`);
                }
                return outList;
            }

            return out;
        }

        preParseInitScripts(script) {
            if (isEmpty(script)) return [];
            const normalized = script.replace(this.scriptNewlinePattern, "\n");
            return normalized.split("\n").filter(line => line.trim() !== "");
        }

        summarizeData(data) {
            const parsed = {
                pages: 0,
                words: 0,
                scriptWords: 0,
                meaningfulChoices: 0,
                storage: false,
            }
            for (const page of Object.values(data.pages)) {
                parsed.pages += 1;
                this._summarizePage(page, parsed);
            }
            this._summarizeScript(data.init || "", parsed)
            return parsed;
        }

        _summarizePage(page, parsed) {
            return this._summarizeData(page, parsed);
        }

        _summarizeData(inData, parsed) {
            if (Array.isArray(inData)) {
                for (const item of inData) {
                    this._summarizeData(item, parsed);
                }
                return;
            }
            if (typeof inData !== "object") return;
            this._summarizeDict(inData, parsed);
        }


        _summarizeDict(inData, parsed) {
            if (Object.hasOwn(inData, "say")) {
                parsed.words += inData.say.label.split(" ").length;
            } else if (Object.hasOwn(inData, "choice")) {
                this._summarizeBranches(inData.choice.options, parsed);
            } else if (Object.hasOwn(inData, "if")) {
                const ifData = [{
                    commands: inData.if.commands,
                }]
                if (Object.hasOwn(inData.if, "elseCommands")) {
                    ifData.push({
                        commands: inData.if.elseCommands,
                    })
                }
                this._summarizeBranches(ifData, parsed, false, inData.if.condition == "true");
            } else if (Object.hasOwn(inData, "eval")) {
                this._summarizeScript(inData.eval.script, parsed);
            }
        }
        _summarizeBranches(choices, parsed, checkAtLeastTwo=true, isNotMeaningful=false) {
            let meaningfulChoicesFound = false;
            for (const choice of choices) {
                this._summarizeData(choice.commands, parsed);
                if (!isNotMeaningful && meaningfulChoicesFound || (checkAtLeastTwo && choices.length < 2)) continue;
                if (this._hasRelevantChoice(choice.commands)) {
                    meaningfulChoicesFound = true;
                }
            }

            if (!isNotMeaningful && meaningfulChoicesFound) {
                parsed.meaningfulChoices += 1;
            }
        }

        _hasRelevantChoice(choiceResults) {
            for (const command of choiceResults) {
                if (Object.hasOwn(command, "goto") || Object.hasOwn(command, "eval")){
                    return  true;
                }
            }
            return false
        }

        _summarizeScript(script, parsed) {
            if (script.includes("teaseStorage")) {
                parsed.storage = true;
            }
            script = script.replace(/\r\n?|\n/g, "\n");

            // Remove comments while preserving string literals
            script = script.replace(
                /("(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'|`(?:\\.|[^`\\])*`)|\/\/.*|\/\*[\s\S]*?\*\//g,
                (match, stringLiteral) => (stringLiteral ? stringLiteral : "")
            );

            // Count words (alphanumeric + underscore)
            const words = script.match(/\b[A-Za-z0-9_]+\b/g);
            parsed.scriptWords += words ? words.length : 0;
        }

    }

    class FlashTeaseSimplifier extends EOSTeaseSimplifier {
        parsePages(pages) {
            const out = {};
            for (const [key, value] of Object.entries(pages)) {
                if (value.length > 1) {
                    out[key] = this.parseElement(value);
                } else {
                    out[key] = this.parseElement(value[0]);
                }
            }
            return out;
        }

        parseDict(inData) {
            if (isEmpty(inData)) {return null}

            if ("nyx.page" in inData) {
                const pageData = inData["nyx.page"];
                const parsedPage = [];
                if ("media" in pageData) {
                    parsedPage.push(
                        this.parseElement(pageData.media)
                    )
                }
                parsedPage.push(this.parseText(pageData.text));
                if ("action" in pageData) {
                    parsedPage.push(
                        this.parseElement(pageData.action)
                    )
                }
                return parsedPage;
            }

            if ("nyx.buttons" in inData) {
                const options = inData["nyx.buttons"] || [];
                return this.parseChoices(options);
            }

            if ("nyx.vert" in inData) {
                return this.parseElement(inData["nyx.vert"].elements);
            }

            if ("goto" in inData) {
                return `> GOTO:${inData.goto.target}`;
            }

            if ("nyx.timer" in inData) {
                const timerData = inData["nyx.timer"];
                try {
                    const durationStr = timerData.duration;
                    const ms = parseInt(durationStr);
                    timerData.duration = this.formatDurationMs(ms);
                } catch (e) {}

                return this.parseTimer(timerData);
            }

            if ("nyx.image" in inData) {
                const imageLocator = inData["nyx.image"];

                return `> Image: ${imageLocator.replace(/^file:/, '')}`;
            }

            const outData = {};
            for (const [key, value] of Object.entries(inData)) {
                outData[key] = this.parseElement(value);
            }
            return outData;
        }

        _summarizePage(page, parsed) {
            if (page.length > 1) {
                return this._summarizeData(page, parsed);
            } else {
                return this._summarizeData(page[0], parsed);
            }
        }

        _summarizeDict(inData, parsed) {
            if ("nyx.page" in inData) {
                const pageData = inData["nyx.page"];

                parsed.words += pageData.text.split(" ").length;
                if ("action" in pageData) {
                    this._summarizeDict(pageData.action, parsed);
                }
                return;
            }

            if ("nyx.buttons" in inData) {
                const options = this._summaryButtonOptions(inData["nyx.buttons"]);
                this._summarizeOptions(options, parsed);
            }
            if ("nyx.vert" in inData) {
                const options = []
                for (const element of inData["nyx.vert"].elements) {
                    if ("nyx.buttons" in element) {
                        options.push(...this._summaryButtonOptions(element["nyx.buttons"]));
                    }
                    if ("nyx.timer" in element) {
                        options.push(element["nyx.timer"].commands[0].goto.target)
                    }
                }
                this._summarizeOptions(options, parsed);
            }
        }

        _summaryButtonOptions(buttonOptions) {
            const options = []
            for (const option of buttonOptions) {
                options.push(option.commands[0].goto.target);
            }
            return options;
        }

        _summarizeOptions(options, parsed) {
            if (options.length <= 1) {
                return;
            }
            if (options.every(v => JSON.stringify(v) === JSON.stringify(options[0]))) {
                return;
            }
            parsed.meaningfulChoices += 1;
        }

        formatDurationMs(ms) {
            const totalSeconds = Math.floor(ms / 1000);
            const hours = Math.floor(totalSeconds / 3600);
            const minutes = Math.floor((totalSeconds % 3600) / 60);
            const seconds = totalSeconds % 60;

            const parts = [];
            if (hours > 0) parts.push(`${hours} hour${hours !== 1 ? 's' : ''}`);
            if (minutes > 0) parts.push(`${minutes} minute${minutes !== 1 ? 's' : ''}`);
            if (seconds > 0) parts.push(`${seconds} second${seconds !== 1 ? 's' : ''}`);

            return parts.length > 0 ? parts.join(' ') : '0 seconds';
        }
    }

    class ConnectionMapper {
        constructor() {
            this.modal = null;
            this.selectedCard = null;
            this.showAlternateArrows = false;
            this.showFullBodyInCards = false;
            this.showChronological = this.loadChronologicalPreference();
        }

        addCSS() {
            const style = document.createElement('style');
            style.textContent = `
            .cm-modal {
                position: fixed;
                top: 50%;
                left: 50%;
                transform: translate(-50%, -50%);
                width: 95vw;
                height: 95vh;
                background: #f2cfcf;
                border: 2px solid #6671a3;
                border-radius: 8px;
                z-index: 10000;
                display: flex;
                flex-direction: column;
                box-shadow: 0 8px 32px rgba(0,0,0,0.2);
            }
            .cm-header {
                background: #6671a3;
                color: white;
                padding: 12px 16px;
                border-bottom: 1px solid #555;
                display: flex;
                justify-content: space-between;
                align-items: center;
                font-weight: bold;
                gap: 20px;
            }
            .cm-header-controls {
                display: flex;
                align-items: center;
                gap: 12px;
            }
            .cm-checkbox {
                margin-right: 6px;
                cursor: pointer;
                accent-color: #a36666;
            }
            .cm-checkbox-label {
                color: white;
                font-size: 12px;
                margin: 0;
                cursor: pointer;
            }
            .cm-body {
                display: flex;
                flex: 1;
                overflow: hidden;
            }
            .cm-cards-panel {
                flex: 1;
                overflow-y: auto;
                padding: 16px;
                display: flex;
                flex-wrap: wrap;
                gap: 16px;
                align-content: flex-start;
                border-right: 2px solid #ddd;
            }
            .cm-cards-panel.chronological {
                display: flex;
                flex-direction: column;
                gap: 8px;
                align-content: unset;
                flex-wrap: nowrap;
            }
            .cm-card-row {
                display: flex;
                gap: 16px;
                flex-wrap: wrap;  
                justify-content: space-around;
                border-bottom: 2px solid #a36666;
            }
            .cm-details-panel {
                width: 50%;
                max-width: 40rem;
                padding: 16px;
                overflow-y: auto;
                border-left: 2px solid #ddd;
                display: flex;
                flex-direction: column;
            }
            .cm-full-body-mode .cm-details-panel {
                width: 8rem;
            }
            .cm-full-body-mode .cm-detail-section-body {
                display: none;
            }
            .cm-card {
                background: white;
                border: 2px solid #a36666;
                border-radius: 6px;
                min-width: 160px;
                cursor: pointer;
                transition: all 0.2s;
                overflow: hidden;
            }
            .cm-card:hover {
                box-shadow: 0 4px 12px rgba(0,0,0,0.15);
                transform: translateY(-2px);
            }
            .cm-card.selected {
                border-color: #6671a3;
                background: #6671a3;
                box-shadow: 0 0 8px rgba(102, 113, 163, 0.4);
                color: #dbdbdb;
            }
            .cm-card.connected {
                background: green;
                border-color: green;
                box-shadow: 0 0 6px rgba(212, 165, 116, 0.3);
                color: white;
            }
            .cm-card.connected-alt {
                background: yellowgreen;
                border-color: yellowgreen;
                box-shadow: 0 0 6px rgba(212, 165, 116, 0.3);
                color: white;
            }
            .cm-card-header {
                background: #eebfb8;
                padding: 5px;
                font-weight: bold;
                color: #333;
                text-align: center;
            }
            .cm-card-preview {
                padding: 8px;
                font-size: 11px;
                max-height: 60px;
                overflow: hidden;
                text-overflow: ellipsis;
            }
            .cm-card-preview.full-body {
                max-height: 300px;
                max-width: 30rem;
                overflow-y: auto;
                white-space: pre-wrap;
                word-break: break-word;
            }
            .cm-details-title {
                font-size: 18px;
                font-weight: bold;
                color: #333;
                margin-bottom: 12px;
                padding-bottom: 8px;
                border-bottom: 2px solid #a36666;
            }
            .cm-details-section {
                margin-bottom: 12px;
            }
            .cm-section-label {
                font-weight: bold;
                color: #333;
                font-size: 12px;
                margin-bottom: 4px;
            }
            .cm-section-content {
                background: #f5f5f5;
                padding: 8px;
                border-radius: 4px;
                font-size: 12px;
                line-height: 1.5;
            }
            .cm-link {
                color: #6671a3;
                cursor: pointer;
                text-decoration: underline;
                margin-right: 6px;
                transition: color 0.2s;
            }
            .cm-link:hover {
                color: #a36666;
            }
            .cm-missing {
                color: #d32f2f;
                font-weight: bold;
            }
            .cm-close-btn {
                background: #a36666;
                color: white;
                border: none;
                border-radius: 4px;
                padding: 6px 12px;
                cursor: pointer;
                font-size: 12px;
            }
            .cm-close-btn:hover {
                opacity: 0.9;
            }
        `;
            document.head.appendChild(style);
        }

        show(data) {
            this.data = data;
            this.addCSS();

            this.modal = document.createElement('div');
            this.modal.className = 'cm-modal';

            // Header
            const header = document.createElement('div');
            header.className = 'cm-header';

            const title = document.createElement('span');
            title.textContent = 'Viewer';

            const controls = document.createElement('div');
            controls.className = 'cm-header-controls';

            const checkboxContainer = document.createElement('label');
            checkboxContainer.className = 'cm-checkbox-label';
            const checkbox = document.createElement('input');
            checkbox.type = 'checkbox';
            checkbox.className = 'cm-checkbox';
            checkbox.addEventListener('change', (e) => {
                this.showAlternateArrows = e.target.checked;
                this.updateCardHighlights();
            });
            checkboxContainer.appendChild(checkbox);
            checkboxContainer.appendChild(document.createTextNode('Show Guessed Connections'));

            // Checkbox for showing full body in cards
            const fullBodyContainer = document.createElement('label');
            fullBodyContainer.className = 'cm-checkbox-label';
            const fullBodyCheckbox = document.createElement('input');
            fullBodyCheckbox.type = 'checkbox';
            fullBodyCheckbox.className = 'cm-checkbox';
            fullBodyCheckbox.addEventListener('change', (e) => {
                this.showFullBodyInCards = e.target.checked;
                if (this.showFullBodyInCards) {
                    this.modal.classList.add('cm-full-body-mode');
                } else {
                    this.modal.classList.remove('cm-full-body-mode');
                }
                this.updateAllCardPreviews();
            });
            fullBodyContainer.appendChild(fullBodyCheckbox);
            fullBodyContainer.appendChild(document.createTextNode('Show Full Body'));

            // Checkbox for chronological display
            const chronoContainer = document.createElement('label');
            chronoContainer.className = 'cm-checkbox-label';
            const chronoCheckbox = document.createElement('input');
            chronoCheckbox.type = 'checkbox';
            chronoCheckbox.className = 'cm-checkbox';
            chronoCheckbox.checked = this.showChronological;
            chronoCheckbox.addEventListener('change', (e) => {
                this.showChronological = e.target.checked;
                this.saveChronologicalPreference(this.showChronological);
                this.refreshCardsPanel();
            });
            chronoContainer.appendChild(chronoCheckbox);
            chronoContainer.appendChild(document.createTextNode('Chronological Display'));

            const closeBtn = document.createElement('button');
            closeBtn.className = 'cm-close-btn';
            closeBtn.textContent = 'Close';
            closeBtn.addEventListener('click', () => this.close());

            controls.appendChild(checkboxContainer);
            controls.appendChild(fullBodyContainer);
            controls.appendChild(chronoContainer);
            controls.appendChild(closeBtn);

            header.appendChild(title);
            header.appendChild(controls);
            this.modal.appendChild(header);

            // Body: two-panel layout
            const body = document.createElement('div');
            body.className = 'cm-body';

            // Left panel: Cards
            const cardsPanel = document.createElement('div');
            cardsPanel.className = 'cm-cards-panel';
            this.cardsPanel = cardsPanel;

            if (this.showChronological) {
                this.populateChronologicalCards(cardsPanel);
            } else {
                this.populateAlphabeticalCards(cardsPanel);
            }

            // Right panel: Details
            const detailsPanel = document.createElement('div');
            detailsPanel.className = 'cm-details-panel';
            this.detailsPanel = detailsPanel;

            body.appendChild(cardsPanel);
            body.appendChild(detailsPanel);
            this.modal.appendChild(body);

            document.body.appendChild(this.modal);

            // Select first card by default
            if (data.length > 0) {
                this.selectCard(data[0]);
            }
        }

        showSimplifiedTease(simplifiedTease) {
            const out = {}
            for (const [page, data] of Object.entries(simplifiedTease)) {
                if (data == null || typeof data[Symbol.iterator] !== "function") {
                    continue;
                }
                let json_data = [];

                for (const line of data) {
                    if (typeof line === 'string') {
                        json_data.push(line);
                    } else {
                        json_data.push(JSON.stringify(line, null, 2));
                    }
                }
                const data_str = json_data.join("\n")
                const page_parsed = {
                    id: page,
                    body: data_str,
                    connections_raw: [],
                    connections: [],
                    guessed_connections: [],
                    missing_connections: [],
                    incoming_connections: [],
                    incoming_guessed_connections: [],
                };
                for (const match of data_str.matchAll(/> GOTO:([^\s"]+)/g)) {
                    const redirect = match[1].trim();
                    if (Object.hasOwn(simplifiedTease, redirect)) {
                        page_parsed.connections.push(redirect);
                        continue;
                    }
                    const guessed_connections = []
                    if (redirect.includes("*") || redirect.includes("$")) {
                        const regex = new RegExp(redirect.replace(/\*/g, ".*").replace(/\$(.*?)\s/g, "(.*?)"));
                        for (const [key, value] of Object.entries(simplifiedTease)) {
                            if (regex.test(key)) {
                                guessed_connections.push(key);
                            }
                        }
                    }
                    if (guessed_connections.length > 0) {
                        page_parsed.guessed_connections.push(...guessed_connections);
                    } else {
                        page_parsed.missing_connections.push(redirect);
                    }
                }

                out[page_parsed.id] = page_parsed
            }
            for (const page_data of Object.values(out)) {
                for (const connection of page_data.connections) {
                    out[connection].incoming_connections.push(page_data.id);
                }
                for (const connection of page_data.guessed_connections) {
                    out[connection].incoming_guessed_connections.push(page_data.id);
                }
            }

            const out2 = []
            for (const page_data of Object.values(out)) {
                out2.push(page_data)
            }

            new ConnectionMapper().show(out2);
        }

        createCard(item) {
            const card = document.createElement('div');
            card.className = 'cm-card';
            card.dataset.id = item.id;

            const header = document.createElement('div');
            header.className = 'cm-card-header';
            header.textContent = `>${item.id}`;

            const preview = document.createElement('div');
            preview.className = 'cm-card-preview';

            // Calculate lines count
            const lineCount = item.body ? item.body.split('\n').length : 0;

            // Calculate connection counts
            const connections = item.connections ? item.connections.length : 0;
            const missed = item.missing_connections ? item.missing_connections.length : 0;
            const guessed = item.guessed_connections ? item.guessed_connections.length : 0;

            card.dataset.item = JSON.stringify(item);

            this.updateCardPreviewContent(preview, item, lineCount, connections, missed, guessed);

            card.appendChild(header);
            card.appendChild(preview);

            card.addEventListener('click', () => this.selectCard(item));

            return card;
        }

        updateCardPreviewContent(preview, item, lineCount, connections, missed, guessed) {
            if (this.showFullBodyInCards && item.body) {
                preview.className = 'cm-card-preview full-body';
                preview.textContent = item.body;
            } else {
                preview.className = 'cm-card-preview';
                preview.textContent = `lines: ${lineCount}, connections: ${connections}|${missed}|${guessed}`;
            }
        }

        updateAllCardPreviews() {
            document.querySelectorAll('.cm-card').forEach(card => {
                const item = JSON.parse(card.dataset.item);
                const preview = card.querySelector('.cm-card-preview');
                const lineCount = item.body ? item.body.split('\n').length : 0;
                const connections = item.connections ? item.connections.length : 0;
                const missed = item.missing_connections ? item.missing_connections.length : 0;
                const guessed = item.guessed_connections ? item.guessed_connections.length : 0;
                this.updateCardPreviewContent(preview, item, lineCount, connections, missed, guessed);
            });
        }

        selectCard(item, scrollIntoView=false) {
            document.querySelectorAll('.cm-card').forEach(c => {
                c.classList.remove('selected');
            });
            const selectedCardElement = document.querySelector(`[data-id="${item.id}"]`);
            selectedCardElement?.classList.add('selected');

            if (scrollIntoView) {
                selectedCardElement?.scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'nearest' });
            }

            this.selectedCard = item;
            this.updateDetailsPanel(item);
            this.updateCardHighlights();
        }

        updateCardHighlights() {
            if (!this.selectedCard) return;

            // Clear all connection highlights
            document.querySelectorAll('.cm-card').forEach(c => {
                c.classList.remove('connected', 'connected-alt');
            });

            // Highlight connected cards
            if (this.selectedCard.connections) {
                this.selectedCard.connections.forEach(id => {
                    document.querySelector(`[data-id="${id}"]`)?.classList.add('connected');
                });
            }

            // Highlight alternate connections if enabled
            if (this.showAlternateArrows && this.selectedCard.guessed_connections) {
                this.selectedCard.guessed_connections.forEach(id => {
                    document.querySelector(`[data-id="${id}"]`)?.classList.add('connected-alt');
                });
            }
        }

        updateDetailsPanel(item) {
            this.detailsPanel.innerHTML = '';

            const title = document.createElement('div');
            title.className = 'cm-details-title';
            title.textContent = item.id;
            this.detailsPanel.appendChild(title);

            if (item.body) {
                const bodySection = document.createElement('div');
                bodySection.className = 'cm-details-section cm-detail-section-body';
                const label = document.createElement('div');
                label.className = 'cm-section-label';
                label.textContent = 'Body';
                const content = document.createElement('div');
                content.className = 'cm-section-content';
                const lines = item.body.split('\n');
                lines.forEach((line, idx) => {
                    const lineEl = document.createElement('div');
                    const preservedLine = line.replace(/^ +/, match => '&nbsp;'.repeat(match.length));
                    lineEl.innerHTML = preservedLine;
                    lineEl.style.wordBreak = 'break-word';
                    lineEl.style.whiteSpace = 'pre-wrap';
                    content.appendChild(lineEl);
                });
                bodySection.appendChild(label);
                bodySection.appendChild(content);
                this.detailsPanel.appendChild(bodySection);
            }

            const createConnectionSection = (data, label, isRaw = false, isMissing = false) => {
                if (!data || data.length === 0) return;

                const section = document.createElement('div');
                section.className = 'cm-details-section';
                const labelEl = document.createElement('div');
                labelEl.className = 'cm-section-label';
                labelEl.textContent = label;
                const content = document.createElement('div');
                content.className = `cm-section-content${isMissing ? ' cm-missing' : ''}`;

                if (isRaw) {
                    content.textContent = data.join(', ');
                } else {
                    const links = data.map(id => {
                        const link = document.createElement('span');
                        link.className = 'cm-link';
                        link.textContent = id;
                        link.addEventListener('click', () => {
                            const targetItem = this.data.find(d => d.id === id);
                            if (targetItem) this.selectCard(targetItem, true);
                        });
                        return link;
                    });
                    links.forEach((link, idx) => {
                        content.appendChild(link);
                        if (idx < links.length - 1) content.appendChild(document.createTextNode(' '));
                    });
                }

                section.appendChild(labelEl);
                section.appendChild(content);
                this.detailsPanel.appendChild(section);
            };

            createConnectionSection.call(this, item.connections, 'Connections');

            createConnectionSection.call(this, item.guessed_connections, 'Guessed Connections');

            createConnectionSection.call(this, item.missing_connections, 'Missing Connections', false, true);

            createConnectionSection.call(this, item.incoming_connections, 'In: Connections');

            createConnectionSection.call(this, item.incoming_guessed_connections, 'In: Guessed Connections');

            createConnectionSection.call(this, item.connections_raw, 'Pattern (Raw)', true);
        }

        loadChronologicalPreference() {
            const stored = localStorage.getItem('cm-chronological-display');
            return stored ? JSON.parse(stored) : true;
        }

        saveChronologicalPreference(value) {
            localStorage.setItem('cm-chronological-display', JSON.stringify(value));
        }

        populateAlphabeticalCards(cardsPanel) {
            this.data.forEach(item => {
                const card = this.createCard(item);
                cardsPanel.appendChild(card);
            });
        }

        populateChronologicalCards(cardsPanel) {
            cardsPanel.classList.add('chronological');
            const sortedData = this.buildChronologicalOrder();

            sortedData.forEach(row => {
                const rowDiv = document.createElement('div');
                rowDiv.className = 'cm-card-row';

                row.forEach(item => {
                    const card = this.createCard(item);
                    rowDiv.appendChild(card);
                });

                cardsPanel.appendChild(rowDiv);
            });
        }

        buildChronologicalOrder() {
            const rows = [];
            const displayed = new Set();
            const allIds = new Set(this.data.map(item => item.id));

            // Find start page (no incoming connections)
            const startPage = this.data.find(item => item.id === "start");
            let startPages;
            if (!startPage) {
                startPages = this.data.filter(item =>
                    item.incoming_connections.length === 0 &&
                    item.incoming_guessed_connections.length === 0
                );
            } else {
                startPages = [startPage];
            }

            // If no start pages found, use all data
            if (startPages.length === 0) {
                startPages = this.data;
            }

            // Add start pages to first row
            const firstRow = startPages.map(p => p.id);
            rows.push(this.getItemsById(firstRow, displayed));
            firstRow.forEach(id => displayed.add(id));

            // Build subsequent rows based on connections
            while (displayed.size < this.data.length) {
                const nextRow = this.buildNextRow(displayed, allIds);

                if (nextRow.length === 0) {
                    // No more connections found, add remaining pages
                    const remaining = this.data.filter(item => !displayed.has(item.id));
                    if (remaining.length > 0) {
                        rows.push(remaining);
                        remaining.forEach(item => displayed.add(item.id));
                    }
                    break;
                }

                rows.push(nextRow);
                nextRow.forEach(item => displayed.add(item.id));
            }

            return rows;
        }

        buildNextRow(displayed, allIds) {
            const nextRow = [];
            const candidates = new Set();

            // Find all pages that connect from already-displayed pages
            for (const id of displayed) {
                const item = this.data.find(d => d.id === id);
                if (item) {
                    // Add connections
                    if (this.showAlternateArrows) {
                        // Include guessed connections
                        item.connections.forEach(connId => {
                            if (!displayed.has(connId) && allIds.has(connId)) {
                                candidates.add(connId);
                            }
                        });
                        item.guessed_connections.forEach(connId => {
                            if (!displayed.has(connId) && allIds.has(connId)) {
                                candidates.add(connId);
                            }
                        });
                    } else {
                        // Only confirmed connections
                        item.connections.forEach(connId => {
                            if (!displayed.has(connId) && allIds.has(connId)) {
                                candidates.add(connId);
                            }
                        });
                    }
                }
            }

            // Convert candidates to items
            candidates.forEach(id => {
                const item = this.data.find(d => d.id === id);
                if (item) {
                    nextRow.push(item);
                }
            });

            return nextRow;
        }

        getItemsById(ids, displayed) {
            return ids.map(id => {
                const item = this.data.find(d => d.id === id);
                displayed.add(id);
                return item;
            }).filter(item => item !== undefined);
        }

        refreshCardsPanel() {
            // Clear cards panel
            this.cardsPanel.innerHTML = '';
            this.cardsPanel.classList.remove('chronological');

            // Repopulate with new display mode
            if (this.showChronological) {
                this.populateChronologicalCards(this.cardsPanel);
            } else {
                this.populateAlphabeticalCards(this.cardsPanel);
            }

            // Maintain selected card
            if (this.selectedCard) {
                this.selectCard(this.selectedCard);
            }
        }

        close() {
            if (this.modal) {
                this.modal.remove();
                this.modal = null;
            }
        }
    }

    if (window.location.href.includes("webteases/") && !window.location.href.includes("showtease")) {
        new DisplayData().display()

        const observer = new MutationObserver(() => new DisplayData().display());
        observer.observe(document.querySelector("#tease_list"), { childList: true, subtree: false });
    }

    function sidebar_integration() {
        if (window.pageData.type !== window.TEASE_TYPES.eos) return;
        const section = window.sidebar.addSection("tease_data","Tease Data");
        const cached_data = scraper.get_data(window.pageData.id);

        const dataDisplay = window.sidebar.addText("Click on Summary to display tease data", section, ["width-100"])

        function _displayData(data) {
            const text = displayJsonData(data);
            dataDisplay.innerHTML = text.split(", ").join("<br>");
        }

        if (cached_data) {
            _displayData(cached_data)
        }

        const showButton = window.sidebar.addButton(cached_data ? "Refresh Summary" : "Summary", async () => {
            const new_data = await scraper.getTeaseSummary(window.pageData.id);
            _displayData(new_data);

            showButton.disabled = true;
        }, section)
        window.sidebar.addButton("Download Json", () => {
            scraper.downloadJson(window.pageData.id, window.pageData.title);
        }, section)
        window.sidebar.addButton("Download Simplified", () => {
            scraper.downloadSimplifiedJson(window.pageData.id, window.pageData.title);
        }, section)
        window.sidebar.addButton("Viewer", () => {
            scraper.getSimplifiedJson(window.pageData.id, new ConnectionMapper().showSimplifiedTease);
        }, section)
    }

    // Listen for sidebar ready event instead of using setTimeout
    window.addEventListener('milovana-sidebar-ready', sidebar_integration);

    // Fallback: if sidebar is already loaded (in case event fires before listener attaches)
    if (window.sidebar && window.location.href.includes("showtease")) {
        sidebar_integration();
    }
})();