User:Sam Sailor/Scripts/Headmaster.js

Note: After saving, you have to bypass your browser's cache to see the changes. Google Chrome, Firefox, Microsoft Edge and Safari: Hold down the ⇧ Shift key and click the Reload toolbar button. For details and instructions about other browsers, see Wikipedia:Bypass your cache.
/* <nowiki> */
/**
 * Headmaster v0.9.2
 * Scans for semicolon markup not used as description list terms.
 * Interactively converts pseudo-headings (MOS:PSEUDOHEAD) to bold or proper headings.
 * Detects and corrects MOS:BLANKLINE spacing issues
 *
 * @author Sam Sailor
 * @license CC-BY-SA-4.0 / GFDL
 * @link [[User:Sam_Sailor/Scripts/Headmaster.js]]
 */
(function() {
    if (window.Headmaster) return;
    const VERSION = "0.9.2";
    const defaultNS = [0, 118];
    const userNS = window.headmaster_ns || [];
    const config = {
        menu: window.headmaster_menu || 'p-cactions',
        enableSummary: window.headmaster_do_summary !== false,
        customSummary: window.headmaster_summary || "",
        allowedNamespaces: Array.from(new Set([...defaultNS, ...userNS])),
        autoScan: window.headmaster_auto_scan !== false,
        autoRun: window.headmaster_auto_run === true
    };
    const Headmaster = {
        VERSION: VERSION,
        DefaultSummary: "Converting [[MOS:PSEUDOHEAD|pseudo-headings]] {details}using [[User:Sam_Sailor/Scripts/Headmaster.js|Headmaster]]. Semicolon markup is reserved for [[MOS:DLIST|description lists]].",
        styleInjected: false,
        css: `
            #hm-panel { clear: both; border: 1px solid #a2a9b1; padding: 15px; background: #f8f9fa; margin-bottom: 1.5em; border-radius: 2px; box-shadow: 0 2px 4px rgba(0,0,0,0.1); }
            #hm-table { width: 100%; border-collapse: collapse; background: white; margin-top: 10px; }
            #hm-table th { background: #f2f2f2; border: 1px solid #a2a9b1; padding: 8px; text-align: left; }
            #hm-table td { border: 1px solid #a2a9b1; padding: 8px; vertical-align: middle; }
            .hm-context { font-family: 'Courier New', monospace; font-size: 0.9em; color: #54595d; background: #fdfdfd; }
            #hm-controls { display: flex; justify-content: space-between; align-items: center; margin-top: 15px; }
            #hm-controls button { margin-left: 10px; padding: 6px 16px; cursor: pointer; border-radius: 2px; }
            .hm-btn-apply { background: #36c; color: white; border: 1px solid #36c; font-weight: bold; }
            .hm-btn-apply:hover { background: #447ff5; }
            .hm-global-selector { font-size: 0.9em; color: #202122; background: #eaecf0; padding: 8px; border-radius: 2px; border: 1px solid #a2a9b1; }
            .hm-global-selector a { font-weight: bold; text-decoration: none; color: #36c; }
            .hm-global-selector a:hover { text-decoration: underline; }
            .hm-locate-btn { cursor: pointer; color: #36c; border: 1px solid #a2a9b1; background: #f8f9fa; padding: 2px 6px; font-size: 0.85em; border-radius: 2px; }
            .hm-locate-btn:hover { background: #fff; border-color: #36c; }
        `,
        setup() {
            if (!config.allowedNamespaces.includes(mw.config.get("wgNamespaceNumber"))) return;
            const action = mw.config.get("wgAction");
            this.addPortlet(action);
            if (action === 'view' && (config.autoScan || config.autoRun)) {
                this.silentScan();
            }
            if (mw.util.getParamValue('headmaster') === '1' && ['edit', 'submit'].includes(action)) {
                const params = new URLSearchParams(window.location.search);
                params.delete('headmaster');
                const searchString = params.toString();
                const newUrl = window.location.pathname + (searchString ? '?' + searchString : '');
                window.history.replaceState(null, '', newUrl);
                this.init();
            }
        },
        addPortlet(action) {
            const link = mw.util.addPortletLink(config.menu, '#', 'Headmaster', 'ca-hm', 'Interactively convert pseudo-headings');
            if (link) {
                $(link).on('click', (e) => {
                    e.preventDefault();
                    if (action === 'view') {
                        this.redirectToEdit();
                    } else if (['edit', 'submit'].includes(action)) {
                        this.init();
                    }
                });
            }
        },
        redirectToEdit() {
            window.location.href = mw.util.getUrl(mw.config.get("wgPageName"), {
                action: 'edit',
                headmaster: '1'
            });
        },
        silentScan() {
            new mw.Api().get({
                action: 'query',
                prop: 'revisions',
                titles: mw.config.get('wgPageName'),
                rvprop: 'content',
                rvlimit: 1,
                formatversion: 2
            }).done((data) => {
                const page = data.query.pages[0];
                if (!page || !page.revisions) return;
                const wikitext = page.revisions[0].content;
                const tasks = this.analyzeText(wikitext);
                if (tasks.length > 0) {
                    if (config.autoRun) {
                        this.redirectToEdit();
                    } else {
                        this.notifyDiscovery(tasks.length);
                    }
                }
            }).fail((err) => {
                mw.log.warn("Headmaster: silentScan failed", err);
            });
        },
        notifyDiscovery(count) {
            const $msg = $('<span>').text(`Headmaster: Found ${count} issues. `).append($('<a>').text('Run conversion').css({
                fontWeight: 'bold',
                cursor: 'pointer'
            }).on('click', () => this.redirectToEdit()));
            mw.notify($msg, {
                tag: 'hm-discovery',
                autoHide: false
            });
        },
        init() {
            if (!this.styleInjected) {
                mw.loader.addStyleTag(this.css);
                this.styleInjected = true;
            }
            const $textbox = $('#wpTextbox1');
            const wikitext = $textbox.textSelection('getContents');
            if (!wikitext) {
                mw.notify("Textbox is empty or not found.", {
                    type: 'error'
                });
                return;
            }
            const tasks = this.analyzeText(wikitext);
            if (tasks.length === 0) {
                mw.notify("No issues found.");
                return;
            }
            this.showUI(tasks, wikitext);
        },
        analyzeText(wikitext) {
            const lines = wikitext.split("\n");
            const tasks = [];
            let currentDepth = 2;
            for (let i = 0; i < lines.length; i++) {
                const line = lines[i].trim();
                const headingMatch = line.match(/^(={2,6})\s*(.+?)\s*\1\s*$/);
                if (headingMatch) {
                    currentDepth = headingMatch[1].length;
                    const prevLine = i > 0 ? lines[i - 1].trim() : null;
                    const isSmashed = prevLine !== null && prevLine !== "";
                    const isExtraSpace = i > 1 && lines[i - 1].trim() === "" && lines[i - 2].trim() === "";
                    if (isSmashed || isExtraSpace) {
                        tasks.push({
                            index: i,
                            type: 'WHITESPACE',
                            original: lines[i],
                            isSmashed: isSmashed,
                            isExtraSpace: isExtraSpace
                        });
                    }
                } else if (line.startsWith(';') && !line.startsWith(';;')) {
                    const nextLine = lines[i + 1] || "";
                    if (!nextLine.trim().startsWith(':')) {
                        const cleanText = line.substring(1).replace(/\s*:.*$/, "").trim();
                        const suggestedLevel = "=".repeat(Math.min(currentDepth + 1, 6));
                        tasks.push({
                            index: i,
                            type: 'PSEUDO',
                            original: line,
                            cleanText: cleanText,
                            context: nextLine.substring(0, 60).trim() + (nextLine.length > 60 ? "..." : ""),
                            suggestedLevel: suggestedLevel
                        });
                    }
                }
            }
            return tasks;
        },
        showUI(tasks, originalWikitext) {
            $('#hm-panel').remove();
            $('#editform').hide();
            const visibleTasks = tasks.filter(t => t.type === 'PSEUDO');
            const pseudoCount = visibleTasks.length;
            const whitespaceCount = tasks.filter(t => t.type === 'WHITESPACE').length;
            let statusMsg = "";
            const s = (n) => n !== 1 ? 's' : '';
            if (pseudoCount > 0) {
                statusMsg = `Found <strong>${pseudoCount}</strong> possible pseudo-heading${s(pseudoCount)}.`;
                if (whitespaceCount > 0) {
                    statusMsg += ` Also fixing <strong>${whitespaceCount}</strong> spacing issue${s(whitespaceCount)} quietly.`;
                }
            } else {
                statusMsg = `Did not find any pseudo-headings, but fixing <strong>${whitespaceCount}</strong> spacing issue${s(whitespaceCount)} quietly.`;
            }
            const tableRows = pseudoCount > 0 ? visibleTasks.map((task, i) => `
                <tr data-task-index="${task.index}">
                    <td><button class="hm-locate-btn" data-hm-i="${tasks.indexOf(task)}" title="Show in editor">Locate</button></td>
                    <td class="hm-context"><code>${mw.html.escape(task.original)}</code></td>
                    <td><label><input type="radio" name="hm-opt-${i}" value="keep"> Keep</label></td>
                    <td><label><input type="radio" name="hm-opt-${i}" value="bold" checked> <b>Bold</b></label></td>
                    <td><label><input type="radio" name="hm-opt-${i}" value="head"> Heading (${task.suggestedLevel.length})</label></td>
                    <td class="hm-context">${mw.html.escape(task.context || "(end of page)")}</td>
                </tr>
            `).join("") : `<tr><td colspan="6" style="text-align:center; padding: 2em; color: #54595d; background: #f8f9fa;">
                    No interactive heading changes detected. Only background spacing normalization will be applied.
                </td></tr>`;
            const $panel = $(`
                <div id="hm-panel">
                    <h3 style="margin-top:0">Headmaster v${this.VERSION}: Review pseudo-headings</h3>
                    <div class="hm-global-selector" ${pseudoCount === 0 ? 'style="display:none"' : ''}>
                        <strong>Bulk action:</strong> 
                        <a href="#" id="hm-all-keep">Set all to Keep</a> | 
                        <a href="#" id="hm-all-bold">Set all to Bold</a> | 
                        <a href="#" id="hm-all-head">Set all to Heading</a>
                    </div>
                    <table id="hm-table">
                        <thead>
                            <tr>
                                <th>Find</th>
                                <th>Source</th>
                                <th colspan="3">Action</th>
                                <th>Next line context</th>
                            </tr>
                        </thead>
                        <tbody>${tableRows}</tbody>
                    </table>
                    <div id="hm-controls">
                        <span>${statusMsg}</span>
                        <div>
                            <button id="hm-cancel" class="mw-ui-button">Cancel</button>
                            <button id="hm-apply" class="hm-btn-apply">Show changes (diff)</button>
                        </div>
                    </div>
                </div>
            `);
            $('#content').prepend($panel);
            window.scrollTo(0, 0);
            $('.hm-locate-btn').on('click', function() {
                $(document).off('mousedown.hmlocate');
                const idx = $(this).data('hm-i');
                const task = tasks[idx];
                const lines = originalWikitext.split('\n');
                let charOffset = 0;
                for (let j = 0; j < task.index; j++) {
                    charOffset += lines[j].length + 1;
                }
                $('#hm-panel').hide();
                $('#editform').show();
                const $textbox = $('#wpTextbox1');
                $textbox.textSelection('setSelection', {
                    start: charOffset,
                    end: charOffset + lines[task.index].length
                });
                $textbox.textSelection('scrollToCaretPosition');
                const locateNotify = mw.notify("Locating line " + (task.index + 1) + ". Click outside the editor to return.", {
                    tag: 'hm-locate',
                    type: 'success',
                    autoHide: false
                });
                setTimeout(() => {
                    $(document).on('mousedown.hmlocate', function(e) {
                        if (!$(e.target).closest('#editform, .mw-notification-area').length) {
                            $(document).off('mousedown.hmlocate');
                            Promise.resolve(locateNotify).then(n => n.close());
                            $('#editform').hide();
                            $('#hm-panel').show();
                        }
                    });
                }, 200);
            });
            $('#hm-all-keep').on('click', (e) => {
                e.preventDefault();
                $('input[value="keep"]').prop('checked', true);
            });
            $('#hm-all-bold').on('click', (e) => {
                e.preventDefault();
                $('input[value="bold"]').prop('checked', true);
            });
            $('#hm-all-head').on('click', (e) => {
                e.preventDefault();
                $('input[value="head"]').prop('checked', true);
            });
            $('#hm-cancel').on('click', () => {
                $('#hm-panel').remove();
                $('#editform').show();
            });
            $('#hm-apply').on('click', () => this.applyChanges(tasks, originalWikitext));
        },
        getProcessedSummary(existingSummary, bolds, heads, spaces) {
            if (!config.enableSummary) return existingSummary;
            let mySummary = "";
            let isDefault = false;
            if (config.customSummary) {
                mySummary = config.customSummary;
            } else {
                isDefault = true;
                if (bolds > 0 || heads > 0) {
                    const detailsArr = [];
                    if (bolds > 0) detailsArr.push(`${bolds} to bold`);
                    if (heads > 0) detailsArr.push(`${heads} to heading`);
                    const detailsStr = detailsArr.length > 0 ? `(${detailsArr.join(', ')}) ` : "";
                    mySummary = this.DefaultSummary.replace("{details}", detailsStr);
                    if (spaces > 0) {
                        mySummary += " Also handled [[MOS:BLANKLINE]].";
                    }
                } else if (spaces > 0) {
                    mySummary = "Handling [[MOS:BLANKLINE]] using [[User:Sam_Sailor/Scripts/Headmaster.js|Headmaster]].";
                }
            }
            if (!mySummary) return existingSummary;
            const trimmedOld = existingSummary.trim();
            if (!trimmedOld) return mySummary;
            if (/[.!?]$/.test(trimmedOld)) {
                return trimmedOld + " " + mySummary;
            } else {
                const finalSummary = isDefault ? mySummary.charAt(0).toLowerCase() + mySummary.slice(1) : mySummary;
                return trimmedOld + "; " + finalSummary;
            }
        },
        applyChanges(tasks, wikitext) {
            try {
                let lines = wikitext.split("\n");
                let bCount = 0,
                    hCount = 0,
                    wCount = 0;
                const pseudoTasks = tasks.filter(t => t.type === 'PSEUDO');
                for (let i = pseudoTasks.length - 1; i >= 0; i--) {
                    const task = pseudoTasks[i];
                    const idx = task.index;
                    const action = $('#hm-table tbody tr').filter(`[data-task-index="${idx}"]`).find('input:checked').val();
                    if (action === 'bold') {
                        let newValue = `'''${task.cleanText}'''`;
                        if (lines[idx + 1] !== undefined && lines[idx + 1] !== "") {
                            newValue += "\n";
                        }
                        lines[idx] = newValue;
                        bCount++;
                    } else if (action === 'head') {
                        const h = task.suggestedLevel;
                        let newValue = `${h} ${task.cleanText} ${h}`;
                        if (idx > 0 && lines[idx - 1].trim() !== "") {
                            newValue = "\n" + newValue;
                        }
                        lines[idx] = newValue;
                        hCount++;
                    }
                }
                const spaceTasks = tasks.filter(t => t.type === 'WHITESPACE');
                for (let i = spaceTasks.length - 1; i >= 0; i--) {
                    const task = spaceTasks[i];
                    const idx = task.index;
                    if (task.isSmashed) {
                        lines.splice(idx, 0, "");
                    } else if (task.isExtraSpace) {
                        let backIdx = idx - 1;
                        while (backIdx > 0 && lines[backIdx].trim() === "" && lines[backIdx - 1].trim() === "") {
                            lines.splice(backIdx, 1);
                            backIdx--;
                        }
                    }
                    wCount++;
                }
                if (bCount + hCount + wCount === 0) {
                    mw.notify("No changes selected.");
                    $('#hm-panel').remove();
                    $('#editform').show();
                    return;
                }
                $('#wpTextbox1').textSelection('setContents', lines.join("\n"));
                const $summary = $('#wpSummary');
                $summary.val(this.getProcessedSummary($summary.val(), bCount, hCount, wCount));
                $('#hm-panel').remove();
                $('#editform').show();
                mw.notify(`Headmaster: Fixed ${wCount} spacing issue(s) and converted ${bCount + hCount} heading(s).`);
                $('html, body').animate({
                    scrollTop: 0
                }, 'fast');
                $('#wpDiff').click();
            } catch (err) {
                $('#editform').show();
                mw.notify("Headmaster error: " + err.message, {
                    type: 'error'
                });
            }
        }
    };
    window.Headmaster = Headmaster;
    mw.loader.using(['mediawiki.util', 'mediawiki.api', 'jquery.textSelection'], () => {
        $(() => Headmaster.setup());
    });
})();
/* </nowiki> */
// [[Category:Wikipedia scripts|Headmaster]]