F-List / Ascendant

Adds [replace], recursive BBCode, lists, entity decoding, etc.

// ==UserScript==
// @name         F-List / Ascendant
// @namespace    http://tampermonkey.net/
// @version      1.1
// @description  Adds [replace], recursive BBCode, lists, entity decoding, etc.
// @author       Your Name
// @match        https://www.f-list.net/c/*
// @grant        none
// @run-at       document-idle
// ==/UserScript==

(function() {
    'use strict';

    function decodeHTMLEntitiesRecursive(text, maxDepth = 5) {
        if (typeof text !== 'string' || maxDepth <= 0) return text;
        const textarea = document.createElement('textarea');
        textarea.innerHTML = text;
        let decoded = textarea.value;
        if (decoded !== text) {
            return decodeHTMLEntitiesRecursive(decoded, maxDepth - 1);
        }
        return decoded;
    }

    function convertCustomBBCodeToHTML(bbcodeString) {
        let processedHtml = String(bbcodeString); // Ensure it's a string

        // Order of processing is important: simpler/content tags first, then containers.
        // Decode entities from the entire incoming string once, as it might be serialized HTML.
        // This is a bit broad. A more targeted decode happens when extracting content from regex.
        // processedHtml = decodeHTMLEntitiesRecursive(processedHtml);


        // --- Process Tables ---
        processedHtml = processedHtml.replace(/\[table class="([^"]+)"\]/gi, '<table class="$1">');
        processedHtml = processedHtml.replace(/\[table\]/gi, '<table>');
        processedHtml = processedHtml.replace(/\[\/table\]/gi, '</table>');
        processedHtml = processedHtml.replace(/\[tr\]/gi, '<tr>');
        processedHtml = processedHtml.replace(/\[\/tr\]/gi, '</tr>');
        processedHtml = processedHtml.replace(/\[th\]/gi, '<th>');
        processedHtml = processedHtml.replace(/\[\/th\]/gi, '</th>');
        processedHtml = processedHtml.replace(/\[td\]/gi, '<td>');
        processedHtml = processedHtml.replace(/\[\/td\]/gi, '</td>');

        // --- Process Lists (Basic) ---
        processedHtml = processedHtml.replace(/\[list\]/gi, '<ul>');
        processedHtml = processedHtml.replace(/\[\/list\]/gi, '</ul>');
        processedHtml = processedHtml.replace(/\[\*\]\s*([\s\S]*?)(?=\r?\n\s*\[\*\]|\r?\n\s*\[\/list\]|\[\/list\]|$)/gi, (match, itemContent) => {
            // Recursively process content of list items
            let decodedItemContent = decodeHTMLEntitiesRecursive(itemContent.trim());
            let processedItemHtml = convertCustomBBCodeToHTML(decodedItemContent);
            return `<li>${processedItemHtml}</li>`;
        });

        // --- Process Columns ---
        processedHtml = processedHtml.replace(/\[columns\]([\s\S]*?)\[\/columns\]/gi, (match, columnsContent) => {
            let innerColsHtml = columnsContent.replace(/\[col\]([\s\S]*?)\[\/col\]/gi, (colMatch, colBbcodeContent) => {
                let decodedColBbcodeContent = decodeHTMLEntitiesRecursive(colBbcodeContent.trim());
                let processedColHtml = convertCustomBBCodeToHTML(decodedColBbcodeContent);
                return `<div style="flex: 1; padding: 10px;">${processedColHtml}</div>`;
            });
            return `<div style="display: flex; width: 100%; gap: 15px;">${innerColsHtml}</div>`;
        });

        // --- Process Custom Collapses (BBCode to HTML) ---
        processedHtml = processedHtml.replace(/\[collapse(?:=([^\]]*))?\]([\s\S]*?)\[\/collapse\]/gi, (match, titleFromRegex, bbcodeContentWithinCollapse) => {
            let rawTitleAttribute = titleFromRegex;
            let displayTitle;
            if (typeof rawTitleAttribute === 'string') {
                displayTitle = decodeHTMLEntitiesRecursive(rawTitleAttribute);
            } else {
                displayTitle = 'Details';
            }
            const tempTitleDiv = document.createElement('div');
            tempTitleDiv.textContent = displayTitle;
            const sanitizedTitleForHeader = tempTitleDiv.innerHTML;

            let decodedBbcodeContent = decodeHTMLEntitiesRecursive(bbcodeContentWithinCollapse.trim());
            let processedContentHtml = convertCustomBBCodeToHTML(decodedBbcodeContent);

            return `<div class="tm-custom-collapse">
                        <div class="tm-custom-collapse-header">${sanitizedTitleForHeader}</div>
                        <div class="tm-custom-collapse-content" style="display: none;">${processedContentHtml}</div>
                    </div>`;
        });
        return processedHtml;
    }

    function attachCustomCollapseEventListeners(rootElement = document) {
        // (No changes)
        rootElement.querySelectorAll('.tm-custom-collapse-header:not(.tm-listener-attached)').forEach(header => {
            header.classList.add('tm-listener-attached');
            header.style.cursor = 'pointer';
            header.addEventListener('click', function(event) {
                event.stopPropagation(); event.preventDefault();
                const contentElement = this.nextElementSibling;
                if (contentElement && contentElement.classList.contains('tm-custom-collapse-content')) {
                    const isVisible = contentElement.style.display !== 'none';
                    contentElement.style.display = isVisible ? 'none' : 'block';
                    this.classList.toggle('tm-collapse-active', !isVisible);
                }
            });
        });
    }

    function processProfileContent() {
        const contentAreas = document.querySelectorAll('#tabs-1 .FormattedBlock, #tabs-2 .FormattedBlock');

        contentAreas.forEach(area => {
            // Clear old listeners before reprocessing an area
            area.querySelectorAll('.tm-listener-attached').forEach(el => {
                // A more robust way to remove listeners if we re-create elements often,
                // but for now, just removing the flag is part of the strategy.
                // If elements are fully replaced by area.innerHTML, listeners are gone anyway.
                el.classList.remove('tm-listener-attached');
            });

            let currentHtmlContent = area.innerHTML;

            // STEP 0: Handle [replace] functionality FIRST
            const replaceRegex = /\[replace\]([\s\S]*?)\[\/replace\]/i; // Case-insensitive
            const replaceMatch = currentHtmlContent.match(replaceRegex);

            if (replaceMatch && replaceMatch[1]) {
                // If [replace] is found, its content becomes the new base HTML for this area.
                // All other original content of the area is discarded.
                currentHtmlContent = replaceMatch[1].trim();
                // The [replace] tags themselves are removed by this assignment.
                // Note: The [replace] block itself is consumed and doesn't appear in the output.
            }
            // From now on, all operations use 'currentHtmlContent' which is either original or replaced.

            // --- Convert F-List Native Collapses to BBCode Text Nodes (within currentHtmlContent) ---
            // This step needs to operate on a DOM structure if we are to reliably get innerHTML of F-List blocks.
            // So, we'll parse currentHtmlContent, do the conversion, then serialize back.
            const tempDiv = document.createElement('div');
            tempDiv.innerHTML = currentHtmlContent;

            const convertFListCollapseToBBCodeNode = (fListHeader) => {
                // (No changes to this helper from v0.9)
                const fListBlock = fListHeader.querySelector(':scope > .CollapseBlock');
                const fListTitleSpan = fListHeader.querySelector(':scope > .CollapseHeaderText > span');
                if (fListBlock && fListTitleSpan) {
                    let rawTitle = fListTitleSpan.textContent || "";
                    let titleForBBCode;
                    if (rawTitle === " ") { titleForBBCode = " "; }
                    else if (rawTitle.trim() === "") { titleForBBCode = ""; }
                    else { titleForBBCode = rawTitle.trim(); }
                    const content = fListBlock.innerHTML;
                    let bbCodeString;
                    if (titleForBBCode === " ") { bbCodeString = `[collapse=${titleForBBCode}]`; }
                    else if (titleForBBCode) { bbCodeString = `[collapse=${titleForBBCode}]`; }
                    else { bbCodeString = `[collapse]`; }
                    bbCodeString += content + '[/collapse]';
                    return document.createTextNode(bbCodeString);
                }
                return null;
            };

            tempDiv.querySelectorAll('.CollapseHeader:not(.tm-custom-collapse-header)').forEach(fListHeader => {
                if (fListHeader.closest('.tm-custom-collapse')) return; // Avoid re-processing tm-custom-collapse internals
                const bbNode = convertFListCollapseToBBCodeNode(fListHeader);
                if (bbNode) fListHeader.replaceWith(bbNode);
            });
            currentHtmlContent = tempDiv.innerHTML; // Get back the string with F-List collapses as BBCode

            // --- Main BBCode string to Final HTML conversion ---
            const processedAreaHtml = convertCustomBBCodeToHTML(currentHtmlContent);

            // Set the fully processed HTML to the area.
            // This single assignment replaces the entire content of 'area'.
            if (area.innerHTML !== processedAreaHtml) {
                area.innerHTML = processedAreaHtml;
            }

            // --- Iterative DOM pass for F-List collapses newly rendered INSIDE tm-custom-collapse content ---
            // This part *must* run after area.innerHTML is set with processedAreaHtml,
            // because it operates on the live DOM structure created by the browser from processedAreaHtml.
            let iterations = 0; const maxIterations = 15;
            while (iterations < maxIterations) {
                // (No changes from v0.9 for this F-List native nested collapse handling, but it operates on 'area' now)
                iterations++; let workDoneThisIteration = false;
                const nestedFListHeaders = area.querySelectorAll('.tm-custom-collapse-content .CollapseHeader:not(.tm-custom-collapse-header)');
                if (nestedFListHeaders.length === 0) break;
                nestedFListHeaders.forEach(fListHeader => {
                    const fListBlock = fListHeader.querySelector(':scope > .CollapseBlock');
                    const fListTitleSpan = fListHeader.querySelector(':scope > .CollapseHeaderText > span');
                    if (fListBlock && fListTitleSpan) {
                        let rawTitle = fListTitleSpan.textContent || "";
                        let actualTitleText;
                        if (rawTitle === " ") { actualTitleText = " "; }
                        else if (rawTitle.trim() === "") { actualTitleText = 'Details'; }
                        else { actualTitleText = rawTitle.trim(); }
                        const content = fListBlock.innerHTML;
                        const tempTitleDivForHeader = document.createElement('div');
                        tempTitleDivForHeader.textContent = actualTitleText;
                        const sanitizedTitle = tempTitleDivForHeader.innerHTML;
                        const newTmCollapseHTML = `
                            <div class="tm-custom-collapse">
                                <div class="tm-custom-collapse-header">${sanitizedTitle}</div>
                                <div class="tm-custom-collapse-content" style="display: none;">${content}</div>
                            </div>`;
                        const tempNewCollapseContainer = document.createElement('div');
                        tempNewCollapseContainer.innerHTML = newTmCollapseHTML;
                        const newTmCollapseElement = tempNewCollapseContainer.firstElementChild;
                        if (newTmCollapseElement) {
                            fListHeader.replaceWith(newTmCollapseElement);
                            workDoneThisIteration = true;
                        } else { fListHeader.remove(); }
                    } else { fListHeader.remove(); }
                });
                if (!workDoneThisIteration) break;
            }
            if (iterations === maxIterations && area.querySelectorAll('.tm-custom-collapse-content .CollapseHeader:not(.tm-custom-collapse-header)').length > 0) {
                console.warn("F-List Enhanced BBCode: Max iterations for nested F-List collapses.");
            }

            // --- Remove "Quote:" text ---
            area.querySelectorAll('.QuoteHeader').forEach(quoteHeader => {
                if (quoteHeader.textContent.trim() === "Quote:") {
                    quoteHeader.textContent = '';
                }
            });

            // --- Attach event listeners to all custom collapses in the area ---
            attachCustomCollapseEventListeners(area);
        });
    }

    // --- Script Execution & Observer --- (No changes from v0.9)
    if (document.readyState === 'interactive' || document.readyState === 'complete') {
        processProfileContent();
    } else {
        document.addEventListener('DOMContentLoaded', processProfileContent);
    }
    const profileContentContainer = document.getElementById('Content');
    if (profileContentContainer) {
        const observer = new MutationObserver((mutationsList) => {
            let needsProcessing = false;
            for (const mutation of mutationsList) {
                if (mutation.type === 'childList' && mutation.addedNodes.length > 0) {
                    mutation.addedNodes.forEach(node => {
                        if (node.nodeType === Node.ELEMENT_NODE && (node.matches('.FormattedBlock, .ui-tabs-panel') || node.querySelector('.FormattedBlock'))) {
                            needsProcessing = true;
                        }
                    });
                } else if (mutation.type === 'attributes' && (mutation.target.matches('.ui-tabs-panel') || mutation.target.matches('.FormattedBlock'))) {
                    needsProcessing = true;
                }
                if (needsProcessing) break;
            }
            if (needsProcessing) { processProfileContent(); }
        });
        observer.observe(profileContentContainer, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });
    }

    // --- Stylesheet --- (No changes from v0.9)
    const styleSheet = document.createElement("style");
    styleSheet.type = "text/css";
    styleSheet.innerText = `
        .FormattedBlock table { border-collapse: collapse; width: 90%; margin: 15px auto; border: 1px solid #555; }
        .FormattedBlock th, .FormattedBlock td { border: 1px solid #444; padding: 8px; text-align: left; }
        .FormattedBlock th { background-color: #333; color: #eee; }
        .tm-custom-collapse { margin: 10px 0; border: 1px solid #4a4a4a; border-radius: 4px; overflow: hidden; background-color: #2c2c2c; }
        .tm-custom-collapse-header { background-color: #3a3a3a; color: #eee; padding: 10px 15px; cursor: pointer; user-select: none; transition: background-color 0.2s ease; }
        .tm-custom-collapse-header:hover { background-color: #454545; }
        .tm-custom-collapse-header.tm-collapse-active { background-color: #505050; }
        .tm-custom-collapse-content { padding: 15px; border-top: 1px solid #4a4a4a; color: #ddd; background-color: #2f2f2f; }
    `;
    document.head.appendChild(styleSheet);

})();